commit a4632842f0967ec071b1284939449e818d43f815 Author: Paul Hitt Date: Mon Sep 28 14:47:11 2026 -0400 First public release Co-Authored-By: Claude Opus 5.5 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..dbb0173 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env +.env.* +!.env.example +.npmrc +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..ab1f693 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## 0.2.0 - 2026-09-28 + +First public release under MIT. + +- `scoreDimension()`, `scoreProduct()` and `scoreSuite()` apply a + five-dimension readiness rubric with hard caps and six bands. +- The lowest open cap on a dimension wins; an unknown cap id throws. +- A score above 70 with no evidence is held at 70 and reported as NOT + ASSESSED. +- The overall score is the weakest dimension; the average is reported + alongside and labelled as such. +- Suite-wide caps apply before the tier-weighted average. +- A cap can cite the evidence that establishes it; an uncited cap is still + applied and the report says so. +- `productReport()`, `suiteReport()`, `formatProduct()` and `formatSuite()` + produce an evidence-record report. +- Worked example in `examples/cap-by-worst-defect.ts`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..218c280 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +Issues and merge requests are welcome at +https://gitlab.com/extant2000/sustain-score. + +## Ground rules + +- A test that cannot fail proves nothing. If you fix a bug, add a test and + check that it fails against the unfixed code before you submit. +- Measure, don't assume. Two modules with the same line count can still be + different programs. +- Keep dependencies minimal. Every new runtime dependency needs a reason. +- Explain why in comments, not what. The what is already in the code. +- A breaking change needs a major version bump and a note in CHANGELOG.md. + +## 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..496fedb --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# SustainScore + +A production-readiness score that separates demo-ware from software that is +ready to hand over, with the rubric's caps enforced in code. + +SustainScore encodes a five-dimension readiness rubric (security, bugs, +compliance, consistency, usability) with six bands from Prototype to Mature +and a table of hard caps. You supply the scorer's raw judgement per dimension, +the defects that are currently open, and the evidence. It returns scores that +apply the rubric's anti-inflation rules mechanically. + +## Why + +A rubric kept as prose gets applied charitably. A scorer can write down, in +the same row, that an open defect caps Security at 45 and still record 70. +Nothing in a document stops that. Encoding the rubric does. + +The worked example in `examples/cap-by-worst-defect.ts` scores a fictional +product whose security review notes an exposed API key that was never +rotated. Run it with `npx tsx examples/cap-by-worst-defect.ts`: + +``` +Hand-scored vs rubric-enforced + + security raw 70 -> 45 Beta <- CAPPED by sec.leaked-secret (45) + bugs raw 68 -> 68 Launch-capable, with debt + compliance raw 73 -> 73 Launch-capable, with debt + consistency raw 68 -> 68 Launch-capable, with debt + usability raw 68 -> 68 Launch-capable, with debt + + hand-scored overall : ~69 + enforced overall : 45 (Beta) + weakest : security + average : 64.4 (reported, never the headline) + delta : -24 +``` + +A 24-point overstatement and a band change, from one cap a human reader +skipped. + +## What it enforces + +| Rule | Enforcement | +|---|---| +| Cap by the worst defect | The lowest open cap on a dimension wins. One serious hole is not averaged away by ten strengths. | +| Evidence required above 70 | A score above 70 with no cited evidence is held at 70 and reported as NOT ASSESSED, not as a pass. Unverified is treated as absent. | +| Overall is the lowest dimension | `overall` is the minimum. The average is reported alongside it and labelled so it cannot be read as the headline. | +| Suite caps apply before averaging | A shared platform flaw hits every product. Averaging first would let healthy products dilute it. | + +An unknown cap id throws. Dropping a mistyped cap without a word would +inflate the score, which is the exact failure this library prevents. A cap +assigned to the wrong dimension also throws, and so does a product with a +missing dimension. + +Caps can cite what establishes them: `openCaps` takes a bare id or +`{ id, evidence }`. A cap that cites nothing is still applied, and the report +says it cited nothing. An unproven cap is a smaller error than an unapplied +one. + +The full cap table is exported as `CAP_RULES`, the bands as `BANDS`. + +## Usage + +```ts +import { scoreProduct, scoreSuite, formatProduct } from '@extant2000/sustain-score' + +const result = scoreProduct({ + product: 'example-app', + tier: 'A', + dimensions: { + security: { raw: 70, openCaps: ['sec.leaked-secret'], evidence: [{ ref: 'docs/security-review.md:14' }] }, + bugs: { raw: 68, evidence: [{ ref: 'test/api.test.ts' }] }, + compliance: { raw: 73, evidence: [{ ref: 'docs/compliance-checklist.md' }] }, + consistency: { raw: 68, evidence: [{ ref: 'shared/ui/' }] }, + usability: { raw: 68, evidence: [{ ref: 'reports/accessibility-scan.json' }] }, + }, +}) + +result.overall // 45, the lowest dimension +result.weakest // 'security' +result.band.name // 'Beta' + +console.log(formatProduct(result)) +``` + +`scoreSuite(products, { suiteCaps })` rolls several products up with tier +weighting (A = 2, B = 1.5, C = 1). `productReport()` and `suiteReport()` +return a `CapabilityReport` from `@extant2000/evidence-record`, and +`formatProduct()` and `formatSuite()` render one. Evidence can be the +evidence-record shape or a plain `{ ref, note }`, where a `path:line` ref +becomes a file citation. + +To rank several scored products by what to fix next, see +`@extant2000/portfolio-advisor`. + +## Install + +``` +npm install @extant2000/sustain-score +``` + +## License + +MIT. Copyright (c) 2026 Extant 2000 LLC. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5fd4f2c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security policy + +## Reporting a vulnerability + +Please report vulnerabilities privately by email to security@extant2000.com. +Do not open a public issue or merge request for a security problem. + +Include the affected version, a description of the issue, and steps or a test +that reproduce it if you have them. + +We aim to acknowledge every report within 5 business days. diff --git a/examples/cap-by-worst-defect.ts b/examples/cap-by-worst-defect.ts new file mode 100644 index 0000000..def54e3 --- /dev/null +++ b/examples/cap-by-worst-defect.ts @@ -0,0 +1,66 @@ +/** + * Worked example: cap by the worst defect. + * + * A hand-scored assessment of a fictional product. The scorer rated Security + * at 70 and noted, in the same row, an open defect that the rubric caps at 45 + * (a secret that was exposed and never rotated). A human reading prose can be + * charitable and skip the cap. The library cannot: the lowest open cap wins, + * and the product's overall score is its weakest dimension, not the average. + * + * Run: npx tsx examples/cap-by-worst-defect.ts + */ +import { scoreProduct, type DimensionAssessment, type DimensionKey } from '../src/index.js' + +/** The overall score a hand-scored summary might report: roughly the average. */ +const HAND_SCORED_OVERALL = 69 + +const dimensions: Record = { + security: { + raw: 70, + // The scorer named this cap and still wrote 70. + openCaps: [{ + id: 'sec.leaked-secret', + evidence: [{ ref: 'docs/security-review.md:14', note: 'exposed API key not yet rotated' }], + }], + evidence: [{ ref: 'docs/security-review.md' }], + bindingConstraint: 'exposed API key not yet rotated', + nextAction: 'rotate the key and redeploy', + }, + bugs: { + raw: 68, + evidence: [{ ref: 'test/api.test.ts' }], + bindingConstraint: 'API handlers lack behaviour tests', + }, + compliance: { + raw: 73, + evidence: [{ ref: 'docs/compliance-checklist.md' }], + }, + consistency: { + raw: 68, + evidence: [{ ref: 'shared/ui/' }], + }, + usability: { + raw: 68, + evidence: [{ ref: 'reports/accessibility-scan.json' }], + bindingConstraint: 'accessibility findings not yet triaged', + }, +} + +const result = scoreProduct({ product: 'example-app', tier: 'A', dimensions }) + +console.log('Hand-scored vs rubric-enforced\n') +for (const d of result.dimensions) { + const flag = d.boundBy ? ` <- CAPPED by ${d.boundBy.id} (${d.boundBy.max})` : '' + console.log( + ` ${d.dimension.padEnd(12)} raw ${String(d.raw).padStart(3)} -> ${String(d.score).padStart(3)} ${d.band.name}${flag}`, + ) +} +console.log(`\n hand-scored overall : ~${HAND_SCORED_OVERALL}`) +console.log(` enforced overall : ${result.overall} (${result.band.name})`) +console.log(` weakest : ${result.weakest}`) +console.log(` average : ${result.average} (reported, never the headline)`) +console.log(` delta : ${result.overall - HAND_SCORED_OVERALL}`) + +if (result.overall < HAND_SCORED_OVERALL) { + console.log('\n The hand-scored number is higher than the rubric permits.') +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..aebb990 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1504 @@ +{ + "name": "@extant2000/sustain-score", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@extant2000/sustain-score", + "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..441eb2f --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "@extant2000/sustain-score", + "version": "0.2.0", + "private": false, + "description": "Maturity score separating demo-ware from transition-ready.", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "files": [ + "dist", + "src", + "examples", + "README.md", + "LICENSE" + ], + "repository": { + "type": "git", + "url": "https://gitlab.com/extant2000/sustain-score.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/sustain-score", + "bugs": { + "url": "https://gitlab.com/extant2000/sustain-score/-/issues" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a1aa7db --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export * from './rubric.js' +export * from './score.js' +export * from './report.js' diff --git a/src/report.ts b/src/report.ts new file mode 100644 index 0000000..29b37fb --- /dev/null +++ b/src/report.ts @@ -0,0 +1,97 @@ +import { + type CapabilityReport, + type Finding, + type FormatOptions, + evidence as ev, + formatReport as renderReport, +} from '@extant2000/evidence-record' +import type { DimensionScore, ProductScore, SuiteScore } from './score.js' +import { EVIDENCE_THRESHOLD } from './rubric.js' + +/** + * One finding per dimension, carrying what set the number. + * + * The previous output said `CAPPED sec.leaked-secret` and stopped there. A + * reader could not tell whether the cap was still warranted, which is how a + * cap ends up recorded but not applied. A cap now cites what establishes it, + * and a cap that cites nothing says so. + */ +function findingFor(product: string, d: DimensionScore): Finding { + const id = `${product}.${d.dimension}` + + if (d.boundBy) { + return { + id, + summary: `${d.dimension} capped at ${d.score} (assessed ${d.raw}) — ${d.boundBy.description}`, + determination: 'fail', + severity: d.score < 50 ? 'critical' : 'high', + detail: d.nextAction ?? d.bindingConstraint, + evidence: d.boundBy.evidence.length > 0 + ? d.boundBy.evidence + // Loud rather than silent: an uncited cap is still applied, because + // the cap being wrong is a better failure than the cap being skipped. + : [ev.document(`Cap rule ${d.boundBy.id}`, undefined, undefined, + 'No evidence was recorded for this cap. It is applied anyway — an unproven cap is a smaller error than an unapplied one.')], + } + } + + if (d.evidenceWithheld) { + return { + id, + summary: `${d.dimension} held at ${EVIDENCE_THRESHOLD} (assessed ${d.raw}) — no evidence was cited`, + determination: 'not-assessed', + severity: 'high', + detail: `A score above ${EVIDENCE_THRESHOLD} requires evidence. Unverified is treated as absent, so the assessed value was not granted.`, + evidence: [], + } + } + + return { + id, + summary: `${d.dimension} scored ${d.score} (${d.band.name})`, + determination: 'pass', + severity: 'info', + detail: d.bindingConstraint, + evidence: d.evidence.length > 0 + ? d.evidence + : [ev.document(`${d.dimension} assessment`, undefined, undefined, + `At or below ${EVIDENCE_THRESHOLD}, evidence is not required by the rubric.`)], + } +} + +/** Convert one product's score into the portfolio-standard report shape. */ +export function productReport(p: ProductScore): CapabilityReport { + return { + capability: 'SustainScore', + scope: `${p.product} (${p.tier}) — ${p.dimensions.length} dimensions`, + examined: p.dimensions.length, + findings: p.dimensions.map(d => findingFor(p.product, d)), + notes: [ + `overall ${p.overall} (${p.band.name}), set by the weakest dimension: ${p.weakest}`, + // Printed second and labelled, so it cannot be mistaken for the headline. + `average ${p.average} — reported because the rubric asks for it, never as the headline`, + ], + } +} + +/** Convert a suite roll-up into the portfolio-standard report shape. */ +export function suiteReport(s: SuiteScore): CapabilityReport { + return { + capability: 'SustainScore', + scope: `${s.products.length} products, weighted by tier`, + examined: s.products.reduce((n, p) => n + p.dimensions.length, 0), + findings: s.products.flatMap(p => p.dimensions.map(d => findingFor(p.product, d))), + notes: [ + `suite overall ${s.overall} (${s.band.name}), set by the weakest dimension: ${s.weakest}`, + `average ${s.average} — suite-wide caps are applied BEFORE averaging, so healthy products cannot dilute a shared flaw`, + ], + } +} + +export function formatProduct(p: ProductScore, opts: FormatOptions = {}): string { + return renderReport(productReport(p), opts) +} + +export function formatSuite(s: SuiteScore, opts: FormatOptions = {}): string { + return renderReport(suiteReport(s), opts) +} diff --git a/src/rubric.ts b/src/rubric.ts new file mode 100644 index 0000000..f852681 --- /dev/null +++ b/src/rubric.ts @@ -0,0 +1,118 @@ +/** + * Production-Readiness Rubric v1.0 — encoded. + * + * A rubric kept as prose and applied by hand gets applied charitably. A scorer + * can write down a defect that caps a dimension at 45 and still record 70 in + * the same row. Encoding the rubric is not a formatting exercise: a charitable + * scorer is the failure mode this module removes. + */ + +export type BandName = + | 'Prototype' | 'Alpha' | 'Beta' + | 'Launch-capable, with debt' | 'Production' | 'Mature' + +export interface Band { + name: BandName + min: number + max: number + meaning: string +} + +/** Identical for every dimension. Ordered low → high. */ +export const BANDS: readonly Band[] = Object.freeze([ + { name: 'Prototype', min: 0, max: 19, + meaning: 'Unsafe or broken in ordinary use. Would harm a user or the business if strangers used it today.' }, + { name: 'Alpha', min: 20, max: 39, + meaning: 'Works on the happy path. Known defects that actively harm users or expose the company. Not sellable.' }, + { name: 'Beta', min: 40, max: 59, + meaning: 'Genuinely usable, but ≥1 gap that blocks charging money or inviting scrutiny.' }, + { name: 'Launch-capable, with debt', min: 60, max: 74, + meaning: 'No blocking defect. Real, catalogued debt a competitor or auditor would notice.' }, + { name: 'Production', min: 75, max: 89, + meaning: 'No known blockers. Controls exist AND are verified working. Failures are monitored and recoverable.' }, + { name: 'Mature', min: 90, max: 100, + meaning: 'Independently verified (external audit, pen test, or a real incident survived). Evidence, not assertion.' }, +]) + +export function bandFor(score: number): Band { + const s = Math.max(0, Math.min(100, Math.round(score))) + // Walk high → low so the first match is the highest band the score reaches. + for (let i = BANDS.length - 1; i >= 0; i--) { + const b = BANDS[i]! + if (s >= b.min) return b + } + return BANDS[0]! +} + +export type DimensionKey = + | 'security' | 'bugs' | 'compliance' | 'consistency' | 'usability' + +export const DIMENSIONS: readonly DimensionKey[] = Object.freeze([ + 'security', 'bugs', 'compliance', 'consistency', 'usability', +]) + +export interface CapRule { + /** Stable id so a scorecard can reference which cap bound a score. */ + id: string + dimension: DimensionKey + /** Ceiling this defect imposes on its dimension. */ + max: number + description: string +} + +/** Every hard cap in rubric v1.0, verbatim. */ +export const CAP_RULES: readonly CapRule[] = Object.freeze([ + // Dimension 1 — SECURITY + { id: 'sec.cross-tenant', dimension: 'security', max: 30, + description: 'Any live cross-tenant data exposure' }, + { id: 'sec.leaked-secret', dimension: 'security', max: 45, + description: 'Any secret in the repo, in logs, or known-leaked and unrotated' }, + { id: 'sec.no-restore', dimension: 'security', max: 70, + description: 'No verified backup restore' }, + { id: 'sec.no-dep-monitoring', dimension: 'security', max: 75, + description: 'No dependency-vulnerability monitoring' }, + // Dimension 2 — BUGS + { id: 'bugs.data-loss', dimension: 'bugs', max: 35, + description: 'Any known defect causing silent data loss or corruption' }, + { id: 'bugs.takes-money-no-deliver', dimension: 'bugs', max: 40, + description: "Any user-facing flow that takes money and doesn't deliver" }, + { id: 'bugs.no-error-monitoring', dimension: 'bugs', max: 60, + description: 'No error monitoring in production' }, + { id: 'bugs.smoke-only', dimension: 'bugs', max: 70, + description: 'Smoke-tests-only (tests assert files exist, not behavior)' }, + // Dimension 3 — COMPLIANCE + { id: 'comp.false-claim', dimension: 'compliance', max: 50, + description: 'Any live false or unsubstantiated public claim' }, + { id: 'comp.missing-registration', dimension: 'compliance', max: 60, + description: 'Any missing registration that is legally required NOW' }, + { id: 'comp.legal-contradicts-code', dimension: 'compliance', max: 65, + description: 'Legal pages that contradict code behavior' }, + // Dimension 4 — CONSISTENCY + { id: 'cons.per-repo-fix', dimension: 'consistency', max: 65, + description: 'Same critical bug class needing a separate fix per repo' }, + { id: 'cons.docs-contradict', dimension: 'consistency', max: 70, + description: 'Docs/CLAUDE.md materially contradicting the code' }, + // Dimension 5 — USABILITY + { id: 'use.keyboard-inoperable', dimension: 'usability', max: 55, + description: 'Keyboard-inoperable or unlabeled primary flow' }, + { id: 'use.contrast', dimension: 'usability', max: 65, + description: 'Body-copy contrast below AA' }, + { id: 'use.no-empty-error-states', dimension: 'usability', max: 70, + description: 'No empty/error states on primary screens' }, +]) + +const CAPS_BY_ID = new Map(CAP_RULES.map(c => [c.id, c])) + +export function capRule(id: string): CapRule | undefined { + return CAPS_BY_ID.get(id) +} + +/** + * Score at or above which evidence is mandatory (anti-inflation rule 5: + * "Evidence required above 70"). + */ +export const EVIDENCE_THRESHOLD = 70 + +/** Revenue/exposure weights for the suite average. */ +export const TIER_WEIGHTS = Object.freeze({ A: 2, B: 1.5, C: 1 }) +export type Tier = keyof typeof TIER_WEIGHTS diff --git a/src/score.ts b/src/score.ts new file mode 100644 index 0000000..8423e15 --- /dev/null +++ b/src/score.ts @@ -0,0 +1,267 @@ +import { + DIMENSIONS, + EVIDENCE_THRESHOLD, + TIER_WEIGHTS, + bandFor, + capRule, + type Band, + type DimensionKey, + type Tier, +} from './rubric.js' + +import { type Evidence as SourcedEvidence, evidence as ev } from '@extant2000/evidence-record' + +/** + * The original shape: a free-text reference. + * + * @deprecated Pass `@extant2000/evidence-record` evidence instead — `ref` is a + * string a reader has to trust, and this library's own rubric penalises + * unverified claims under "Unverified = absent". Still accepted, and + * converted: `path:42` becomes a file citation, anything else a document one. + */ +export interface Evidence { + /** file:line, a passing test name, a live check, or a production query. */ + ref: string + note?: string +} + +export type EvidenceLike = Evidence | SourcedEvidence + +/** Normalise either shape into a citation the shared renderer can print. */ +export function toSourcedEvidence(e: EvidenceLike): SourcedEvidence { + if ('source' in e) return e + const m = /^(.+):(\d+)$/.exec(e.ref) + return m + ? ev.file(m[1]!, Number(m[2]), undefined, e.note) + : ev.document(e.ref, undefined, undefined, e.note) +} + +/** + * A cap that is currently open, and what establishes that. + * + * The bare-string form is what the first version accepted, and it is exactly + * the gap this release closes: the output said `CAPPED sec.leaked-secret` + * without citing anything, so a reader had no way to check whether the cap + * was still warranted, which is how a cap goes unapplied in the first place. + */ +export type CapClaim = string | { id: string, evidence?: EvidenceLike[] } + +export function capId(c: CapClaim): string { + return typeof c === 'string' ? c : c.id +} + +function capEvidence(c: CapClaim): SourcedEvidence[] { + return typeof c === 'string' ? [] : (c.evidence ?? []).map(toSourcedEvidence) +} + +export interface DimensionAssessment { + /** The scorer's judgement BEFORE caps are applied. */ + raw: number + /** Cap rules whose defect is currently open, ideally with what proves it. */ + openCaps?: CapClaim[] + /** Required to hold a final score above EVIDENCE_THRESHOLD. */ + evidence?: EvidenceLike[] + /** What is holding the score down. Free text, carried through. */ + bindingConstraint?: string + /** Single highest-leverage action to raise it. */ + nextAction?: string +} + +export interface DimensionScore { + dimension: DimensionKey + raw: number + score: number + band: Band + /** + * The cap that actually bound the score, if any — with whatever established + * it, so "CAPPED sec.leaked-secret" is checkable rather than asserted. + */ + boundBy?: { id: string, max: number, description: string, evidence: SourcedEvidence[] } + /** Citations supporting the raw judgement, normalised. */ + evidence: SourcedEvidence[] + /** True when the score was reduced to EVIDENCE_THRESHOLD for lack of evidence. */ + evidenceWithheld: boolean + bindingConstraint?: string + nextAction?: string +} + +/** + * Score one dimension, applying the anti-inflation rules mechanically. + * + * Rule 3 — cap by the WORST defect: the lowest open cap wins. "One serious hole + * is not averaged away by ten strengths." + * + * Rule 5 — evidence required above 70: a score that cannot cite evidence is + * reduced to the threshold rather than rejected, so an un-evidenced assessment + * still produces a usable (and honest) number. + * + * These two are the whole point. Applied by hand, rule 3 is the one that gets + * skipped: a scorer notes an open defect that caps Security at 45 and still + * records 70. + */ +export function scoreDimension( + dimension: DimensionKey, + input: DimensionAssessment, +): DimensionScore { + const raw = clamp(input.raw) + let score = raw + let boundBy: DimensionScore['boundBy'] + + for (const claim of input.openCaps ?? []) { + const id = capId(claim) + const rule = capRule(id) + if (!rule) throw new Error(`Unknown cap rule: ${id}`) + if (rule.dimension !== dimension) { + throw new Error(`Cap ${id} belongs to ${rule.dimension}, not ${dimension}`) + } + if (rule.max < score) { + score = rule.max + boundBy = { + id: rule.id, + max: rule.max, + description: rule.description, + evidence: capEvidence(claim), + } + } + } + + const evidence = (input.evidence ?? []).map(toSourcedEvidence) + const hasEvidence = evidence.length > 0 + let evidenceWithheld = false + if (score > EVIDENCE_THRESHOLD && !hasEvidence) { + score = EVIDENCE_THRESHOLD + evidenceWithheld = true + } + + return { + dimension, + raw, + score, + band: bandFor(score), + boundBy, + evidence, + evidenceWithheld, + bindingConstraint: input.bindingConstraint, + nextAction: input.nextAction, + } +} + +export interface ProductAssessment { + product: string + tier: Tier + dimensions: Record +} + +export interface ProductScore { + product: string + tier: Tier + weight: number + dimensions: DimensionScore[] + /** The headline number: the LOWEST dimension, not the average. */ + overall: number + /** Reported alongside, never instead of, `overall`. */ + average: number + band: Band + /** Which dimension set the overall score. */ + weakest: DimensionKey +} + +/** + * Score one product. + * + * The overall score is the MINIMUM across dimensions — "a product is only as + * shippable as its weakest dimension." The average is reported too, because the + * rubric says to, but it is deliberately not the headline: averaging is how a + * single disqualifying hole disappears behind four healthy numbers. + */ +export function scoreProduct(input: ProductAssessment): ProductScore { + const dimensions = DIMENSIONS.map(d => { + const a = input.dimensions[d] + if (!a) throw new Error(`Missing assessment for dimension: ${d}`) + return scoreDimension(d, a) + }) + + let weakest = dimensions[0]! + for (const d of dimensions) if (d.score < weakest.score) weakest = d + + const overall = weakest.score + const average = round(dimensions.reduce((s, d) => s + d.score, 0) / dimensions.length) + + return { + product: input.product, + tier: input.tier, + weight: TIER_WEIGHTS[input.tier], + dimensions, + overall, + average, + band: bandFor(overall), + weakest: weakest.dimension, + } +} + +export interface SuiteOptions { + /** + * Cap ids that apply suite-wide (a shared platform flaw hits everything). + * Applied BEFORE averaging, per the rubric's scoring procedure — averaging + * first would let healthy products dilute a flaw that affects them all. + */ + suiteCaps?: CapClaim[] +} + +export interface SuiteScore { + products: ProductScore[] + /** Weighted average per dimension, suite caps applied first. */ + dimensions: Record + /** Lowest suite dimension score. The headline. */ + overall: number + average: number + band: Band + weakest: DimensionKey +} + +export function scoreSuite( + inputs: ProductAssessment[], + options: SuiteOptions = {}, +): SuiteScore { + if (inputs.length === 0) throw new Error('scoreSuite requires at least one product') + const products = inputs.map(scoreProduct) + + const dimensions = {} as Record + for (const d of DIMENSIONS) { + let totalWeight = 0 + let acc = 0 + for (const p of products) { + const ds = p.dimensions.find(x => x.dimension === d)! + // Suite-wide cap applied to each product's score BEFORE it enters the + // weighted mean. + let s = ds.score + for (const claim of options.suiteCaps ?? []) { + const id = capId(claim) + const rule = capRule(id) + if (rule && rule.dimension === d && rule.max < s) s = rule.max + } + acc += s * p.weight + totalWeight += p.weight + } + dimensions[d] = round(acc / totalWeight) + } + + let weakest: DimensionKey = DIMENSIONS[0]! + for (const d of DIMENSIONS) if (dimensions[d] < dimensions[weakest]) weakest = d + + const overall = dimensions[weakest] + const average = round( + DIMENSIONS.reduce((s, d) => s + dimensions[d], 0) / DIMENSIONS.length, + ) + + return { products, dimensions, overall, average, band: bandFor(overall), weakest } +} + +function clamp(n: number): number { + if (!Number.isFinite(n)) throw new Error(`Score must be a finite number, got ${n}`) + return Math.max(0, Math.min(100, Math.round(n))) +} + +function round(n: number): number { + return Math.round(n * 10) / 10 +} diff --git a/test/sustain-score.test.ts b/test/sustain-score.test.ts new file mode 100644 index 0000000..707b479 --- /dev/null +++ b/test/sustain-score.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'vitest' +import { + BANDS, + CAP_RULES, + DIMENSIONS, + bandFor, + scoreDimension, + scoreProduct, + scoreSuite, + type DimensionAssessment, + type DimensionKey, + type ProductAssessment, + formatProduct, + productReport, +} from '../src/index.js' +import { validateReport } from '@extant2000/evidence-record' + +const ev = [{ ref: 'test/foo.test.ts:12' }] + +/** A product scoring `n` on every dimension, with evidence. */ +function flat(product: string, n: number, tier: 'A' | 'B' | 'C' = 'B'): ProductAssessment { + const dimensions = {} as Record + for (const d of DIMENSIONS) dimensions[d] = { raw: n, evidence: ev } + return { product, tier, dimensions } +} + +/** A healthy product with one dimension replaced, for isolating its output. */ +function assessmentWith(dim: DimensionKey, a: DimensionAssessment): ProductAssessment { + const p = flat('app', 60) + p.dimensions[dim] = a + return p +} + +describe('bandFor', () => { + it('maps the documented boundaries', () => { + expect(bandFor(0).name).toBe('Prototype') + expect(bandFor(19).name).toBe('Prototype') + expect(bandFor(20).name).toBe('Alpha') + expect(bandFor(40).name).toBe('Beta') + expect(bandFor(60).name).toBe('Launch-capable, with debt') + expect(bandFor(75).name).toBe('Production') + expect(bandFor(90).name).toBe('Mature') + expect(bandFor(100).name).toBe('Mature') + }) + + it('covers 0-100 with no gaps', () => { + for (let s = 0; s <= 100; s++) expect(bandFor(s)).toBeDefined() + }) + + it('has contiguous non-overlapping bands', () => { + for (let i = 1; i < BANDS.length; i++) { + expect(BANDS[i]!.min).toBe(BANDS[i - 1]!.max + 1) + } + }) +}) + +describe('scoreDimension — anti-inflation rule 3 (cap by worst defect)', () => { + it('CATCHES THE CHARITABLE SCORE: an unrotated leaked secret caps Security at 45', () => { + // A hand scorer can note that a known-leaked, unrotated secret caps the + // dimension at 45 and still record ~70. Charity is not available here. + const r = scoreDimension('security', { + raw: 70, + openCaps: ['sec.leaked-secret'], + evidence: ev, + }) + expect(r.raw).toBe(70) + expect(r.score).toBe(45) + expect(r.band.name).toBe('Beta') + expect(r.boundBy?.id).toBe('sec.leaked-secret') + }) + + it('applies the LOWEST open cap, not the first or last', () => { + const r = scoreDimension('security', { + raw: 90, + openCaps: ['sec.no-dep-monitoring', 'sec.cross-tenant', 'sec.no-restore'], + evidence: ev, + }) + expect(r.score).toBe(30) + expect(r.boundBy?.id).toBe('sec.cross-tenant') + }) + + it('leaves a score below the cap untouched', () => { + const r = scoreDimension('security', { + raw: 25, openCaps: ['sec.leaked-secret'], evidence: ev, + }) + expect(r.score).toBe(25) + expect(r.boundBy).toBeUndefined() + }) + + it('rejects a cap belonging to another dimension', () => { + expect(() => scoreDimension('bugs', { raw: 80, openCaps: ['sec.leaked-secret'] })) + .toThrow(/belongs to security/) + }) + + it('rejects an unknown cap id rather than ignoring it', () => { + // Silently ignoring a typo'd cap would inflate the score — the exact + // failure mode this library exists to prevent. + expect(() => scoreDimension('security', { raw: 80, openCaps: ['sec.tpyo'] })) + .toThrow(/Unknown cap rule/) + }) +}) + +describe('scoreDimension — anti-inflation rule 5 (evidence above 70)', () => { + it('withholds an un-evidenced score above the threshold', () => { + const r = scoreDimension('bugs', { raw: 85 }) + expect(r.score).toBe(70) + expect(r.evidenceWithheld).toBe(true) + }) + + it('allows an evidenced score above the threshold', () => { + const r = scoreDimension('bugs', { raw: 85, evidence: ev }) + expect(r.score).toBe(85) + expect(r.evidenceWithheld).toBe(false) + }) + + it('does not touch an un-evidenced score at or below the threshold', () => { + const r = scoreDimension('bugs', { raw: 70 }) + expect(r.score).toBe(70) + expect(r.evidenceWithheld).toBe(false) + }) + + it('an empty evidence array is not evidence', () => { + expect(scoreDimension('bugs', { raw: 85, evidence: [] }).score).toBe(70) + }) +}) + +describe('scoreProduct', () => { + it('reports the LOWEST dimension as overall, not the average', () => { + const a = flat('app', 80) + a.dimensions.security = { raw: 40, evidence: ev } + const r = scoreProduct(a) + expect(r.overall).toBe(40) + expect(r.weakest).toBe('security') + expect(r.average).toBe(72) // (40+80*4)/5 + expect(r.band.name).toBe('Beta') + }) + + it('a single capped dimension pins the whole product', () => { + // Four strong dimensions must not average away one disqualifying hole. + const a = flat('api', 88) + a.dimensions.security = { raw: 88, openCaps: ['sec.cross-tenant'], evidence: ev } + const r = scoreProduct(a) + expect(r.overall).toBe(30) + expect(r.band.name).toBe('Alpha') + }) + + it('assigns the tier weight', () => { + expect(scoreProduct(flat('x', 80, 'A')).weight).toBe(2) + expect(scoreProduct(flat('x', 80, 'B')).weight).toBe(1.5) + expect(scoreProduct(flat('x', 80, 'C')).weight).toBe(1) + }) + + it('throws on a missing dimension rather than scoring a partial product', () => { + const a = flat('x', 80) + delete (a.dimensions as Partial>).usability + expect(() => scoreProduct(a)).toThrow(/Missing assessment/) + }) + + it('carries the binding constraint and next action through', () => { + const a = flat('x', 80) + a.dimensions.bugs = { + raw: 68, evidence: ev, + bindingConstraint: 'server/api/** handlers untested', + nextAction: 'behaviour-asserting coverage', + } + const bugs = scoreProduct(a).dimensions.find(d => d.dimension === 'bugs')! + expect(bugs.bindingConstraint).toBe('server/api/** handlers untested') + expect(bugs.nextAction).toBe('behaviour-asserting coverage') + }) +}) + +describe('scoreSuite', () => { + it('weights by tier', () => { + // A-tier (2) at 90, C-tier (1) at 60 → (90*2 + 60*1) / 3 = 80 + const r = scoreSuite([flat('big', 90, 'A'), flat('small', 60, 'C')]) + expect(r.dimensions.security).toBe(80) + }) + + it('applies a suite-wide cap BEFORE averaging', () => { + // Averaging first would let healthy products dilute a flaw affecting all + // of them: (90+90)/2 = 90, capped after → 90. Capping first → 45. + const r = scoreSuite( + [flat('a', 90, 'A'), flat('b', 90, 'A')], + { suiteCaps: ['sec.leaked-secret'] }, + ) + expect(r.dimensions.security).toBe(45) + }) + + it('leads with the lowest suite dimension', () => { + const a = flat('a', 85) + a.dimensions.consistency = { raw: 63, evidence: ev } + const r = scoreSuite([a]) + expect(r.overall).toBe(63) + expect(r.weakest).toBe('consistency') + }) + + it('throws on an empty suite instead of returning a flattering zero', () => { + expect(() => scoreSuite([])).toThrow(/at least one product/) + }) +}) + +describe('rubric integrity', () => { + it('every cap rule has a unique id', () => { + const ids = CAP_RULES.map(c => c.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('every cap belongs to a known dimension and sits in 0-100', () => { + for (const c of CAP_RULES) { + expect(DIMENSIONS).toContain(c.dimension) + expect(c.max).toBeGreaterThanOrEqual(0) + expect(c.max).toBeLessThanOrEqual(100) + } + }) + + it('every dimension carries at least one cap', () => { + for (const d of DIMENSIONS) { + expect(CAP_RULES.some(c => c.dimension === d)).toBe(true) + } + }) +}) + +describe('conformance with the evidence-record standard', () => { + const withCapEvidence = scoreDimension('security', { + raw: 70, + openCaps: [{ + id: CAP_RULES.find(c => c.dimension === 'security')!.id, + evidence: [{ ref: 'docs/secrets-review.md:12', note: 'exposed key, never rotated' }], + }], + }) + + it('a cap cites what establishes it', () => { + // The gap this closes: "CAPPED sec.leaked-secret" with nothing behind it. + expect(withCapEvidence.boundBy!.evidence).toHaveLength(1) + const text = formatProduct(scoreProduct(assessmentWith('security', { + raw: 70, + openCaps: [{ + id: CAP_RULES.find(c => c.dimension === 'security')!.id, + evidence: [{ ref: 'docs/secrets-review.md:12', note: 'never rotated' }], + }], + })), { evidence: 'full' }) + expect(text).toContain('docs/secrets-review.md:12') + expect(text).toContain('never rotated') + }) + + it('still applies a cap that cites nothing, and says it cited nothing', () => { + // An unproven cap is a smaller error than an unapplied one. + const bare = scoreDimension('security', { + raw: 90, + openCaps: [CAP_RULES.find(c => c.dimension === 'security')!.id], + }) + expect(bare.score).toBeLessThan(90) + const text = formatProduct(scoreProduct(assessmentWith('security', { + raw: 90, + openCaps: [CAP_RULES.find(c => c.dimension === 'security')!.id], + })), { evidence: 'full' }) + expect(text).toContain('No evidence was recorded for this cap') + }) + + it('converts a legacy path:line ref into a real citation', () => { + const d = scoreDimension('security', { raw: 50, evidence: [{ ref: 'server/api/x.ts:9' }] }) + expect(d.evidence[0]!.source).toMatchObject({ kind: 'file', path: 'server/api/x.ts', line: 9 }) + }) + + it('a score withheld for lack of evidence is not-assessed, not a pass', () => { + const p = scoreProduct(assessmentWith('security', { raw: 95 })) + const f = productReport(p).findings.find(x => x.id.endsWith('.security'))! + expect(f.determination).toBe('not-assessed') + expect(formatProduct(p)).toContain('no evidence was cited') + }) + + it('every conclusion carries a citation', () => { + expect(validateReport(productReport(scoreProduct(assessmentWith('security', { raw: 60 }))))).toEqual([]) + }) + + it('labels the average so it cannot be read as the headline', () => { + const text = formatProduct(scoreProduct(assessmentWith('security', { raw: 60 }))) + expect(text).toContain('never as the headline') + expect(text).toContain('set by the weakest dimension') + }) +}) + +describe('band rendering', () => { + it('prints the band name, not a stringified object', () => { + const text = formatProduct(scoreProduct(flat('app', 80))) + expect(text).not.toContain('[object Object]') + expect(text).toContain('Production') + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9f56c30 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", + "declaration": true, "outDir": "dist", "rootDir": "src", + "strict": true, "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}