From 2a979970abda6c0017fc83d6a7af10f40b451595 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 28 Sep 2026 14:54:59 -0400 Subject: [PATCH] First public release Co-Authored-By: Claude Opus 5.5 --- .editorconfig | 12 + .gitignore | 9 + .gitlab-ci.yml | 6 + CHANGELOG.md | 15 + CONTRIBUTING.md | 22 + LICENSE | 21 + README.md | 98 +++ SECURITY.md | 6 + package-lock.json | 1504 ++++++++++++++++++++++++++++++++++++++ package.json | 43 ++ src/coverage.ts | 94 +++ src/gaps.ts | 101 +++ src/index.ts | 6 + src/report.ts | 129 ++++ src/score.ts | 66 ++ src/types.ts | 119 +++ test/trace-proof.test.ts | 191 +++++ tsconfig.json | 13 + 18 files changed, 2455 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/coverage.ts create mode 100644 src/gaps.ts create mode 100644 src/index.ts create mode 100644 src/report.ts create mode 100644 src/score.ts create mode 100644 src/types.ts create mode 100644 test/trace-proof.test.ts create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f15441a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6cf0b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env* +!.env.example +.npmrc +dist/ +dist-cjs/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..e5a37d2 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,6 @@ +stages: [test] + +test: + stage: test + image: node:22-alpine + script: [npm ci, npm run build, npm test] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6cc947d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## 0.2.0 - 2026-09-28 + +First public release under MIT. + +- `computeCoverage()` computes traceability coverage from loaded rows. A + requirement with no verifications is never counted as verified. +- `detectGaps()` finds orphaned requirements, missing tests and risks, + suspect links and failed verifications. +- `qualityScore()` gives a weighted composite score and grade, with + injectable weights and thresholds. +- `toReport()` and `formatTrace()` produce an `@extant2000/evidence-record` + report that cites the row behind every gap and splits the score into its + inputs. An empty requirement set is not assessed. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fc102ba --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,22 @@ +# Contributing + +Issues and merge requests are welcome at +https://gitlab.com/extant2000/trace-proof. + +## Ground rules + +- **A test that cannot fail proves nothing.** If you fix a bug, add a test + that fails without your fix, and check that it does fail before you submit. +- Measure, do not assume. Two modules with the same line count can be + different programs. +- Keep dependencies minimal. Every new dependency needs a reason. +- Explain why in comments, not what. + +## Before you open a merge request + +Run both of these and make sure they pass: + +``` +npm run build +npm test +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34e49b1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Extant 2000 LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd13037 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# TraceProof + +Builds a requirements-to-evidence traceability matrix from rows you load, and +reports the gaps and a quality score that leads back to those rows. It is +used in Hitt Hosting products. + +## What it does and why + +You load requirements, their links to verifications, risks, parents and +stakeholder needs, and the verification items themselves, from whatever +database you use. TraceProof takes the rows as plain objects and computes: + +- coverage: how many requirements are traced to a test, verified, linked to a + risk, linked to a parent and linked to a stakeholder need; +- gaps: orphaned requirements, requirements with no test or no risk, suspect + links, and failed verifications; +- a composite quality score from 0 to 100 with a letter grade. + +A few rules keep the numbers from flattering the programme. + +**A requirement with no verifications is not verified.** `[].every(...)` is +`true`, so a naive check reports a requirement with no evidence at all as +fully verified. A requirement counts as verified only when it has at least one +verification and every one of them is `Passed` or `Waived`. + +A requirement linked to a test both through a junction table and through a +foreign key on the verification item is counted once. + +Absent evidence and evidence of failure are different claims. A missing test, +risk or parent is reported as not assessed. A failed verification is a +failure, and it is the only critical gap. + +An empty requirement set is not assessed, not 0% with grade F. Zero next to an +F reads as "this programme traced nothing" when the truth is that nothing was +loaded. It is the same error as reporting 100% uptime from zero samples, in +the other direction. + +A composite score is easy to assert and hard to check. The report splits it +into its four inputs, each with the count of requirements it was computed +over, so a reader can redo the arithmetic. Every gap cites the row that +produced it, for example `requirements#`, with the table name set by +`requirementTable` and `verificationTable`. + +## Scoring + +The default weights are traced to a test 30%, verified 30%, linked to a risk +20% and linked to a parent 20%. Grades are A from 90, B from 75, C from 60 and +D from 40. `qualityScore()` accepts other weights and thresholds for a +different accreditation regime. Weights are not normalised: if they do not +sum to 1, the score will not span 0 to 100, which is a caller error worth +seeing. `toReport()` uses the default weights. + +## Usage + +```ts +import { computeCoverage, detectGaps, qualityScore, formatTrace, type TraceInput } from '@extant2000/trace-proof' + +const input: TraceInput = { + requirements: [ + { id: 'r1', req_number: 'SYS-001', title: 'Deorbit within 25 years', parent_id: 'p1' }, + { id: 'r2', req_number: 'SYS-002', title: 'Survive launch loads', parent_id: 'p1' }, + ], + requirementVerifications: [{ requirement_id: 'r1' }], + requirementRisks: [{ requirement_id: 'r1' }, { requirement_id: 'r2' }], + verificationItems: [{ id: 'v1', requirement_id: 'r1', status: 'Passed', method: 'Test' }], +} + +const coverage = computeCoverage(input) // pct_test 50, pct_verified 50, ... +const { gaps } = detectGaps(input) // SYS-002 has no test +const score = qualityScore(coverage) // { score: 70, grade: 'C', ... } + +console.log(formatTrace(input, { requirementTable: 'requirements' })) +``` + +The score lines from that output: + +``` +· MEDIUM traced to a test: 1 of 2 (50%) [NOT MET] +· MEDIUM verified: 1 of 2 (50%) [NOT MET] +◦ info linked to a parent: 2 of 2 (100%) [MET] +◦ info linked to a risk: 2 of 2 (100%) [MET] +``` + +The report also lists SYS-002 as not assessed for having no linked test, and +notes the composite score of 70/100, grade C. + +Gap ids (`gap-0`, `gap-1`, ...) are positional and only stable within one +call. Do not store them as entity keys. + +## Install + +``` +npm install @extant2000/trace-proof +``` + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b88987e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,6 @@ +# Security + +Please report vulnerabilities privately by email to security@extant2000.com. +Do not open a public issue for a security problem. + +We aim to acknowledge every report within 5 business days. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e20c1c4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1504 @@ +{ + "name": "@extant2000/trace-proof", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@extant2000/trace-proof", + "version": "0.2.0", + "license": "MIT", + "dependencies": { + "@extant2000/evidence-record": "^0.1.2" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@extant2000/evidence-record": { + "version": "0.1.2", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..916ee8e --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "@extant2000/trace-proof", + "version": "0.2.0", + "private": false, + "description": "Links requirements to the evidence that they are met.", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "files": [ + "dist", + "src", + "README.md", + "LICENSE" + ], + "repository": { + "type": "git", + "url": "https://gitlab.com/extant2000/trace-proof.git" + }, + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "test": "vitest run", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + }, + "dependencies": { + "@extant2000/evidence-record": "^0.1.2" + }, + "author": "Extant 2000 LLC", + "homepage": "https://gitlab.com/extant2000/trace-proof", + "bugs": { + "url": "https://gitlab.com/extant2000/trace-proof/-/issues" + } +} diff --git a/src/coverage.ts b/src/coverage.ts new file mode 100644 index 0000000..00a212f --- /dev/null +++ b/src/coverage.ts @@ -0,0 +1,94 @@ +import type { Coverage, TraceInput } from './types.js' + +/** Requirement ids that have at least one verification linked, by either route. */ +function testedRequirementIds(input: TraceInput): Set { + const ids = new Set() + // Two routes exist to the same relationship: an explicit junction table and + // an FK on the verification item. Both count as "traced to test", and a + // requirement linked by both must not be double-counted — hence a Set. + for (const rv of input.requirementVerifications ?? []) ids.add(rv.requirement_id) + for (const vi of input.verificationItems ?? []) { + if (vi.requirement_id) ids.add(vi.requirement_id) + } + return ids +} + +function idsFrom(rows: { requirement_id: string }[] | undefined): Set { + const ids = new Set() + for (const r of rows ?? []) ids.add(r.requirement_id) + return ids +} + +/** + * A requirement counts as verified only when it has at least one verification + * AND every one of them is Passed or Waived. + * + * The "at least one" clause matters: without it, a requirement with zero + * verifications vacuously satisfies `every()` and would be reported as + * verified — the most flattering possible reading of no evidence at all. + */ +function verifiedRequirementCount(input: TraceInput): number { + const byRequirement = new Map() + for (const vi of input.verificationItems ?? []) { + if (!vi.requirement_id) continue + const list = byRequirement.get(vi.requirement_id) + if (list) list.push(vi.status) + else byRequirement.set(vi.requirement_id, [vi.status]) + } + + let verified = 0 + for (const statuses of byRequirement.values()) { + if (statuses.length > 0 && statuses.every(s => s === 'Passed' || s === 'Waived')) { + verified++ + } + } + return verified +} + +const EMPTY_COVERAGE: Coverage = { + total_requirements: 0, + traced_to_parent: 0, + traced_to_test: 0, + traced_to_risk: 0, + verified: 0, + with_stakeholder_need: 0, + pct_parent: 0, + pct_test: 0, + pct_risk: 0, + pct_verified: 0, + pct_stakeholder: 0, +} + +/** + * Compute traceability coverage over an already-loaded set of rows. + * + * Pure: no I/O, no clock, no database. Given the same rows it returns the same + * numbers, which is what makes a coverage figure auditable. + */ +export function computeCoverage(input: TraceInput): Coverage { + const requirements = input.requirements ?? [] + const total = requirements.length + if (total === 0) return { ...EMPTY_COVERAGE } + + const withParent = requirements.filter(r => r.parent_id).length + const tested = testedRequirementIds(input) + const risked = idsFrom(input.requirementRisks) + const stakeholder = idsFrom(input.stakeholderNeedRequirements) + const verified = verifiedRequirementCount(input) + + const pct = (n: number) => Math.round((n / total) * 100) + + return { + total_requirements: total, + traced_to_parent: withParent, + traced_to_test: tested.size, + traced_to_risk: risked.size, + verified, + with_stakeholder_need: stakeholder.size, + pct_parent: pct(withParent), + pct_test: pct(tested.size), + pct_risk: pct(risked.size), + pct_verified: pct(verified), + pct_stakeholder: pct(stakeholder.size), + } +} diff --git a/src/gaps.ts b/src/gaps.ts new file mode 100644 index 0000000..4424c39 --- /dev/null +++ b/src/gaps.ts @@ -0,0 +1,101 @@ +import type { Gap, RequirementRow, TraceInput } from './types.js' + +function displayId(req: RequirementRow): string { + return req.req_number || req.id.slice(0, 8) +} + +function idsFrom(rows: { requirement_id: string }[] | undefined): Set { + const ids = new Set() + for (const r of rows ?? []) ids.add(r.requirement_id) + return ids +} + +/** + * Detect traceability gaps: orphaned requirements, missing coverage, suspect + * links, and failed verifications. + * + * Pure. Gap ids are positional (`gap-0`, `gap-1`, …) and therefore stable only + * within a single call — they identify a row in THIS report, not a durable + * record. Do not persist them as if they were entity keys. + */ +export function detectGaps(input: TraceInput): { gaps: Gap[], total: number } { + const gaps: Gap[] = [] + const requirements = input.requirements ?? [] + if (requirements.length === 0) return { gaps, total: 0 } + + const tested = new Set() + for (const rv of input.requirementVerifications ?? []) tested.add(rv.requirement_id) + for (const vi of input.verificationItems ?? []) { + if (vi.requirement_id) tested.add(vi.requirement_id) + } + + const risked = idsFrom(input.requirementRisks) + const suspect = idsFrom(input.suspectLinks) + + let idx = 0 + const push = (g: Omit) => { gaps.push({ id: `gap-${idx++}`, ...g }) } + + for (const req of requirements) { + const base = { + entity_type: 'requirement' as const, + entity_id: req.id, + display_id: displayId(req), + title: req.title || 'Untitled', + } + + // A top-level requirement legitimately has no parent, so this is reported + // as minor: it flags "check the hierarchy", not "this is wrong". + if (!req.parent_id) { + push({ + ...base, + gap_type: 'orphaned', + severity: 'minor', + description: 'No parent requirement — may be an orphan or missing hierarchy link', + }) + } + + if (!tested.has(req.id)) { + push({ + ...base, + gap_type: 'no_test', + severity: 'major', + description: 'No verification item or test linked to this requirement', + }) + } + + if (!risked.has(req.id)) { + push({ + ...base, + gap_type: 'no_risk', + severity: 'minor', + description: 'No risk assessment linked to this requirement', + }) + } + + if (suspect.has(req.id)) { + push({ + ...base, + gap_type: 'suspect', + severity: 'major', + description: 'Has suspect verification links that need review', + }) + } + } + + // Failed verifications are the only critical gap: everything above is + // absent evidence, this is evidence that the thing does not work. + for (const vi of input.verificationItems ?? []) { + if (vi.status !== 'Failed') continue + push({ + entity_type: 'verification', + entity_id: vi.id, + display_id: vi.id.slice(0, 8), + title: `${vi.method || 'Verification'} — Failed`, + gap_type: 'failed_verification', + severity: 'critical', + description: 'Verification item has failed status', + }) + } + + return { gaps, total: gaps.length } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1ad488f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,6 @@ +export * from './types.js' +export { computeCoverage } from './coverage.js' +export { detectGaps } from './gaps.js' +export { qualityScore, DEFAULT_WEIGHTS, DEFAULT_THRESHOLDS } from './score.js' +export { toReport, formatTrace, displayIdOf } from './report.js' +export type { ReportOptions } from './report.js' diff --git a/src/report.ts b/src/report.ts new file mode 100644 index 0000000..0017d5c --- /dev/null +++ b/src/report.ts @@ -0,0 +1,129 @@ +import { + type CapabilityReport, + type Finding, + type FormatOptions, + type Severity, + evidence, + formatReport as renderReport, +} from '@extant2000/evidence-record' +import type { Coverage, Gap, QualityScore, RequirementRow, TraceInput } from './types.js' +import { computeCoverage } from './coverage.js' +import { detectGaps } from './gaps.js' +import { qualityScore } from './score.js' + +export interface ReportOptions { + /** + * Table name used in citations, so a finding reads + * `mission_requirements#a1b2c3` and can be looked up. Defaults to the generic + * name because this library is deliberately not tied to one schema. + */ + requirementTable?: string + verificationTable?: string +} + +const SEVERITY: Record = { + critical: 'critical', + major: 'high', + minor: 'info', +} + +/** + * Cite the row that produced a gap. + * + * This is the gap the first version left open: gaps knew their `entity_id` + * but nothing rendered it, so a reader saw "quality 50/100, grade D" with no + * route back to the requirements that produced the number. + */ +function gapFinding(g: Gap, opts: ReportOptions): Finding { + const table = g.entity_type === 'requirement' + ? (opts.requirementTable ?? 'requirements') + : (opts.verificationTable ?? 'verification_items') + + return { + id: g.id, + summary: `${g.display_id} — ${g.title}: ${g.description}`, + // A failed verification is evidence the thing does not work. Everything + // else here is absent evidence, which is a different claim and gets the + // determination that says so. + determination: g.gap_type === 'failed_verification' ? 'fail' : 'not-assessed', + severity: SEVERITY[g.severity], + evidence: [evidence.record(table, g.entity_id, undefined, g.display_id, g.gap_type)], + } +} + +/** + * Turn each component of the composite score into a citable measurement. + * + * A composite number is the easiest thing in a report to assert and the + * hardest to check. Splitting it back into its four inputs — each carrying + * the requirement count it was computed over — makes it arithmetic a reader + * can redo rather than a figure they have to accept. + */ +function scoreFindings(c: Coverage, q: QualityScore): Finding[] { + const n = c.total_requirements + const parts: [string, number, number, string][] = [ + ['traced to a test', c.pct_test, c.traced_to_test, 'traced'], + ['verified', c.pct_verified, c.verified, 'verified'], + ['linked to a risk', c.pct_risk, c.traced_to_risk, 'risk'], + ['linked to a parent', c.pct_parent, c.traced_to_parent, 'parent'], + ] + + return parts.map(([label, pct, count, key]) => ({ + id: `score.${key}`, + summary: `${label}: ${count} of ${n} (${pct}%)`, + determination: pct === 100 ? 'pass' : 'fail', + severity: pct >= 75 ? 'info' : pct >= 40 ? 'medium' : 'high', + detail: `Contributes to the composite score of ${q.score}/100, grade ${q.grade}.`, + evidence: [evidence.measurement(label, pct, n, { unit: '%' })], + })) +} + +/** + * Build the portfolio-standard report for one traceability scope. + * + * An empty requirement set is `not-assessed`, not 0%. `computeCoverage` + * correctly returns zeros for no rows, but a zero percentage rendered next to + * a grade F reads as "this programme traced nothing", when the truth is that + * nothing was loaded. Same class of error as reporting 100% uptime from zero + * samples, in the opposite direction. + */ +export function toReport(input: TraceInput, opts: ReportOptions = {}): CapabilityReport { + const coverage = computeCoverage(input) + const { gaps } = detectGaps(input) + const q = qualityScore(coverage) + const n = coverage.total_requirements + + if (n === 0) { + return { + capability: 'TraceProof', + scope: 'no requirements loaded', + examined: 0, + findings: [], + notes: ['No requirements were loaded. This is not a score of zero — nothing was traced because nothing was read.'], + } + } + + return { + capability: 'TraceProof', + scope: `${n} requirement${n === 1 ? '' : 's'}`, + examined: n, + findings: [...scoreFindings(coverage, q), ...gaps.map(g => gapFinding(g, opts))], + notes: [ + `composite quality ${q.score}/100, grade ${q.grade}`, + `weights: traced 30%, verified 30%, risk 20%, parent 20% — reweight per accreditation regime`, + ], + } +} + +/** Render a traceability scope for a terminal, CI log or evidence package. */ +export function formatTrace( + input: TraceInput, + opts: ReportOptions & FormatOptions = {}, +): string { + return renderReport(toReport(input, opts), opts) +} + +/** Convenience: the display id used in citations, exported for callers. */ +export function displayIdOf(req: RequirementRow): string { + return req.req_number || req.id.slice(0, 8) +} diff --git a/src/score.ts b/src/score.ts new file mode 100644 index 0000000..772d297 --- /dev/null +++ b/src/score.ts @@ -0,0 +1,66 @@ +import type { + Coverage, + GradeThresholds, + QualityGrade, + QualityScore, + QualityWeights, +} from './types.js' + +/** Default weights. Sum to 1. */ +export const DEFAULT_WEIGHTS: QualityWeights = { + traced: 0.3, + verified: 0.3, + risk: 0.2, + parent: 0.2, +} + +export const DEFAULT_THRESHOLDS: GradeThresholds = { A: 90, B: 75, C: 60, D: 40 } + +function grade(score: number, t: GradeThresholds): QualityGrade { + if (score >= t.A) return 'A' + if (score >= t.B) return 'B' + if (score >= t.C) return 'C' + if (score >= t.D) return 'D' + return 'F' +} + +/** + * Composite 0–100 traceability quality score with a letter grade. + * + * Takes a `Coverage` object rather than fetching one, so reusing this + * arithmetic never needs a network round-trip, auth replay and JSON + * encode/decode just to multiply four numbers. + * + * Weights and thresholds are injectable so a different accreditation regime can + * reweight without forking. Weights are NOT normalised: if they do not sum to + * 1 the score simply will not span 0–100, which is a caller error worth + * surfacing loudly rather than silently correcting. + */ +export function qualityScore( + coverage: Pick, + weights: QualityWeights = DEFAULT_WEIGHTS, + thresholds: GradeThresholds = DEFAULT_THRESHOLDS, +): QualityScore { + const traced = coverage.pct_test + const verified = coverage.pct_verified + const riskCov = coverage.pct_risk + const parentCov = coverage.pct_parent + + const score = Math.round( + traced * weights.traced + + verified * weights.verified + + riskCov * weights.risk + + parentCov * weights.parent, + ) + + return { + score, + breakdown: { + traced_pct: traced, + verified_pct: verified, + risk_coverage_pct: riskCov, + parent_coverage_pct: parentCov, + }, + grade: grade(score, thresholds), + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3769b43 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,119 @@ +/** + * Row shapes TraceProof operates on. + * + * Deliberately structural, not tied to any ORM or database client: the caller + * loads rows however it likes and hands them over. That keeps the scoring and + * gap-detection logic testable without standing up a database, and lets a + * non-Supabase system use the same matrix. + */ + +/** A requirement in the traceability tree. */ +export interface RequirementRow { + id: string + /** Null means no parent — reported as an orphan gap. */ + parent_id?: string | null + /** Human-facing identifier (e.g. "SYS-014"). Falls back to a short id. */ + req_number?: string | null + title?: string | null +} + +/** A verification item (test, analysis, inspection, demonstration). */ +export interface VerificationItemRow { + id: string + requirement_id?: string | null + /** 'Passed' | 'Failed' | 'Waived' | anything else the caller uses. */ + status: string + method?: string | null +} + +/** A junction row linking a requirement to something else. */ +export interface RequirementLinkRow { + requirement_id: string +} + +/** + * Everything needed to compute coverage and gaps for one traceability scope + * (a mission, program, product line — whatever the caller scopes by). + */ +export interface TraceInput { + requirements: RequirementRow[] + /** requirement → verification junction rows. */ + requirementVerifications?: RequirementLinkRow[] + /** requirement → risk junction rows. */ + requirementRisks?: RequirementLinkRow[] + /** stakeholder need → requirement junction rows. */ + stakeholderNeedRequirements?: RequirementLinkRow[] + /** Verification items carrying an FK back to a requirement. */ + verificationItems?: VerificationItemRow[] + /** Links flagged as suspect and needing review. */ + suspectLinks?: RequirementLinkRow[] +} + +export interface Coverage { + total_requirements: number + traced_to_parent: number + traced_to_test: number + traced_to_risk: number + verified: number + with_stakeholder_need: number + pct_parent: number + pct_test: number + pct_risk: number + pct_verified: number + pct_stakeholder: number +} + +export type GapType = + | 'orphaned' + | 'no_test' + | 'no_risk' + | 'suspect' + | 'failed_verification' + +export type GapSeverity = 'minor' | 'major' | 'critical' + +export interface Gap { + id: string + entity_type: 'requirement' | 'verification' + entity_id: string + display_id: string + title: string + gap_type: GapType + severity: GapSeverity + description: string +} + +export type QualityGrade = 'A' | 'B' | 'C' | 'D' | 'F' + +export interface QualityScore { + score: number + breakdown: { + traced_pct: number + verified_pct: number + risk_coverage_pct: number + parent_coverage_pct: number + } + grade: QualityGrade +} + +/** + * Weights for the composite quality score. Must sum to 1. + * + * Extracted as a parameter rather than baked in: an accreditation regime that + * cares more about verification than hierarchy should be able to say so + * without forking the scorer. + */ +export interface QualityWeights { + traced: number + verified: number + risk: number + parent: number +} + +/** Grade cutoffs, inclusive lower bounds, highest first. */ +export interface GradeThresholds { + A: number + B: number + C: number + D: number +} diff --git a/test/trace-proof.test.ts b/test/trace-proof.test.ts new file mode 100644 index 0000000..1e99a6a --- /dev/null +++ b/test/trace-proof.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { computeCoverage, detectGaps, qualityScore, toReport, formatTrace } from '../src/index.js' +import { overall, validateReport } from '@extant2000/evidence-record' +import type { TraceInput } from '../src/index.js' + +const req = (id: string, parent?: string | null) => ({ + id, parent_id: parent ?? null, req_number: `R-${id}`, title: `Requirement ${id}`, +}) + +describe('computeCoverage', () => { + it('returns an all-zero shape for no requirements', () => { + const c = computeCoverage({ requirements: [] }) + expect(c.total_requirements).toBe(0) + expect(c.pct_verified).toBe(0) + }) + + it('counts a requirement traced by junction OR by verification FK, without double counting', () => { + const input: TraceInput = { + requirements: [req('a'), req('b')], + requirementVerifications: [{ requirement_id: 'a' }], + verificationItems: [{ id: 'v1', requirement_id: 'a', status: 'Passed' }], + } + // 'a' is linked by both routes but must count once. + expect(computeCoverage(input).traced_to_test).toBe(1) + }) + + it('treats a requirement with zero verifications as NOT verified', () => { + // Guards the vacuous-every() trap: [].every(...) is true, which would + // report a requirement with no evidence at all as fully verified. + const input: TraceInput = { requirements: [req('a')], verificationItems: [] } + expect(computeCoverage(input).verified).toBe(0) + }) + + it('requires EVERY linked verification to pass or be waived', () => { + const mixed: TraceInput = { + requirements: [req('a')], + verificationItems: [ + { id: 'v1', requirement_id: 'a', status: 'Passed' }, + { id: 'v2', requirement_id: 'a', status: 'Failed' }, + ], + } + expect(computeCoverage(mixed).verified).toBe(0) + + const allGood: TraceInput = { + requirements: [req('a')], + verificationItems: [ + { id: 'v1', requirement_id: 'a', status: 'Passed' }, + { id: 'v2', requirement_id: 'a', status: 'Waived' }, + ], + } + expect(computeCoverage(allGood).verified).toBe(1) + }) + + it('computes percentages against the requirement total', () => { + const input: TraceInput = { + requirements: [req('a', 'root'), req('b'), req('c'), req('d')], + } + expect(computeCoverage(input).pct_parent).toBe(25) + }) +}) + +describe('detectGaps', () => { + it('reports nothing for an empty requirement set', () => { + expect(detectGaps({ requirements: [] })).toEqual({ gaps: [], total: 0 }) + }) + + it('flags an untested, unrisked, parentless requirement three ways', () => { + const { gaps } = detectGaps({ requirements: [req('a')] }) + expect(gaps.map(g => g.gap_type).sort()).toEqual(['no_risk', 'no_test', 'orphaned']) + }) + + it('rates a failed verification critical and everything else lower', () => { + const { gaps } = detectGaps({ + requirements: [req('a', 'root')], + requirementVerifications: [{ requirement_id: 'a' }], + requirementRisks: [{ requirement_id: 'a' }], + verificationItems: [{ id: 'v1', requirement_id: 'a', status: 'Failed', method: 'Test' }], + }) + expect(gaps).toHaveLength(1) + expect(gaps[0]!.gap_type).toBe('failed_verification') + expect(gaps[0]!.severity).toBe('critical') + }) + + it('flags suspect links', () => { + const { gaps } = detectGaps({ + requirements: [req('a', 'root')], + requirementVerifications: [{ requirement_id: 'a' }], + requirementRisks: [{ requirement_id: 'a' }], + suspectLinks: [{ requirement_id: 'a' }], + }) + expect(gaps.map(g => g.gap_type)).toEqual(['suspect']) + }) + + it('falls back to a short id when req_number is absent', () => { + const { gaps } = detectGaps({ requirements: [{ id: 'abcdef1234567890' }] }) + expect(gaps[0]!.display_id).toBe('abcdef12') + }) +}) + +describe('qualityScore', () => { + it('applies the documented 0.3/0.3/0.2/0.2 weighting', () => { + const s = qualityScore({ pct_test: 100, pct_verified: 100, pct_risk: 0, pct_parent: 0 }) + expect(s.score).toBe(60) + expect(s.grade).toBe('C') + }) + + it('grades the boundaries inclusively', () => { + const at = (n: number) => qualityScore({ + pct_test: n, pct_verified: n, pct_risk: n, pct_parent: n, + }).grade + expect(at(90)).toBe('A') + expect(at(75)).toBe('B') + expect(at(60)).toBe('C') + expect(at(40)).toBe('D') + expect(at(39)).toBe('F') + }) + + it('accepts custom weights for a different accreditation regime', () => { + const s = qualityScore( + { pct_test: 0, pct_verified: 100, pct_risk: 0, pct_parent: 0 }, + { traced: 0, verified: 1, risk: 0, parent: 0 }, + ) + expect(s.score).toBe(100) + expect(s.grade).toBe('A') + }) + + it('round-trips from computeCoverage output', () => { + const coverage = computeCoverage({ + requirements: [req('a', 'root')], + requirementVerifications: [{ requirement_id: 'a' }], + requirementRisks: [{ requirement_id: 'a' }], + verificationItems: [{ id: 'v1', requirement_id: 'a', status: 'Passed' }], + }) + expect(qualityScore(coverage).score).toBe(100) + }) +}) + +describe('report — the score has to lead back to the rows', () => { + const input = { + requirements: [ + { id: 'r1-uuid-aaaa', req_number: 'SYS-001', title: 'Deorbit within 25 years', parent_id: 'p1' }, + { id: 'r2-uuid-bbbb', req_number: 'SYS-002', title: 'Survive launch loads' }, + ], + verificationItems: [ + { id: 'v1-uuid-cccc', requirement_id: 'r1-uuid-aaaa', status: 'Failed', method: 'Test' }, + ], + } + + it('cites the requirement row behind every gap', () => { + // The gap this closes: "quality 50/100, grade D" with no route back to + // the requirements that produced it. + const text = formatTrace(input, { requirementTable: 'mission_requirements', evidence: 'full' }) + expect(text).toContain('mission_requirements#r2-uuid-bbbb') + expect(text).toContain('SYS-002') + }) + + it('cites the verification row behind a failed verification', () => { + const text = formatTrace(input, { verificationTable: 'mission_verification_items', evidence: 'full' }) + expect(text).toContain('mission_verification_items#v1-uuid-cccc') + }) + + it('breaks the composite score into checkable measurements', () => { + const r = toReport(input) + const traced = r.findings.find(f => f.id === 'score.traced')! + expect(traced.evidence[0]!.source).toMatchObject({ kind: 'measurement', samples: 2 }) + expect(formatTrace(input)).toContain('composite quality') + }) + + it('treats an empty requirement set as not-assessed, not as a score of zero', () => { + // computeCoverage correctly returns zeros, but 0% next to grade F reads + // as "traced nothing" when the truth is "loaded nothing". + const r = toReport({ requirements: [] }) + expect(overall(r)).toBe('not-assessed') + const text = formatTrace({ requirements: [] }) + expect(text).toContain('NOT ASSESSED') + expect(text).toContain('nothing was traced because nothing was read') + expect(text).not.toContain('grade F') + }) + + it('separates absent evidence from evidence of failure', () => { + const r = toReport(input) + const failed = r.findings.find(f => f.summary.includes('Failed'))! + expect(failed.determination).toBe('fail') + const noTest = r.findings.find(f => f.summary.includes('No verification item'))! + expect(noTest.determination).toBe('not-assessed') + }) + + it('every conclusion carries a citation', () => { + expect(validateReport(toReport(input))).toEqual([]) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..24c52b6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}