commit 7640ad722caeb280f670963f2e147f8f2e303031 Author: Paul Hitt Date: Mon Sep 28 14:46:17 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..871809c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## 0.1.2 - 2026-09-28 + +First public release under MIT. + +- `Determination`: `pass`, `fail`, `not-applicable` and `not-assessed`. + `rollUp([])` returns `not-assessed`, never `pass`. +- `Evidence`: file, record, command, measurement and document sources, with + `cite()` and `citeVerbose()` for rendering. Measurements carry their sample + count. +- `describeMeasurement()`: reads a zero-sample or uncomputable measurement as + such. +- `CapabilityReport`: `overall()` derives the verdict, and `examined === 0` + forces `not-assessed`. +- `validateReport()`: flags conclusions that carry no evidence. +- `formatReport()` and `formatRollUp()`: worst-first rendering with citations + inline. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..da2041f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +Issues and merge requests are welcome at +https://gitlab.com/extant2000/evidence-record. + +## 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..1f55a12 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# @extant2000/evidence-record + +A small vocabulary for checks that have to show their work: four-way +determinations, cited evidence, and reports that derive their own verdict. + +Zero runtime dependencies. Pure functions. Ships ESM and CommonJS. + +## Why it exists + +Automated checks tend to fail their readers in the same few ways. This package +gives every check one shared way to avoid them. + +1. **A boolean hides the case that matters most.** `passed: true` can mean "we + looked and it held" or "there was nothing to look at". Those need to stay + apart, so a determination has four values, and three of them are not a + pass. +2. Findings that do not say where they came from ask the reader to take them + on trust. Every finding here carries `Evidence`: a file and line, a database + row, a command, a measurement, or a document. +3. A measurement without its sample count is not evidence. An availability of + 100% over zero samples and one over 8,640 samples are different claims, so + the sample count travels with the number. +4. A report with zero findings is not a pass unless something was examined. + The verdict is computed from the report, and `examined === 0` always gives + `not-assessed`. + +The unflattering outputs stay unflattering. A missing value is not a pass, and +nothing in this package turns one into a pass. + +## Determinations + +```ts +type Determination = 'pass' | 'fail' | 'not-applicable' | 'not-assessed' +``` + +`rollUp` combines several. Any fail wins. An empty list is `not-assessed`, +because `[].every(...)` being true is the trap this type closes. One unassessed +item keeps the roll-up at `not-assessed`, so healthy items cannot dilute it. + +```ts +rollUp([]) // 'not-assessed' +rollUp(['pass', 'not-assessed']) // 'not-assessed' +rollUp(['pass', 'pass', 'fail']) // 'fail' +``` + +`label(d)` gives a short badge (`MET`, `NOT MET`, `N/A`, `NOT ASSESSED`). +`describe(d, subject)` gives a sentence that still makes sense when pasted into +an email on its own. + +## Evidence + +```ts +import { evidence, cite, describeMeasurement } from '@extant2000/evidence-record' + +evidence.file('docker-compose.yml', 42, 'ports: ["2222:22"]') +evidence.record('requirements', 'a1b2c3', 'parent_id', 'null') +evidence.command('docker compose config', 0) +evidence.measurement('uptime', null, 0, { unit: '%', window: '2026-07' }) +evidence.document('NIST SP 800-53 Rev 5', 'AC-2') +``` + +`cite()` renders each kind in the form people already read it in: `path:line` +for source, `table#id (field)` for rows, `$ command → exit 0` for commands. +`citeVerbose()` adds the excerpt and note. `describeMeasurement()` reads the +null case out loud: "No samples for Availability in 2026-07; it cannot be +computed." + +## Reports + +```ts +import { evidence, overall, validateReport, formatReport } from '@extant2000/evidence-record' + +const report = { + capability: 'PortCheck', + scope: '12 compose services', + examined: 12, + findings: [ + { id: 'f1', summary: 'SSH published on a host port', determination: 'fail', + severity: 'critical', evidence: [evidence.file('docker-compose.yml', 42, 'ports: ["2222:22"]')] }, + { id: 'f2', summary: 'Admin UI bound to loopback only', determination: 'pass', + severity: 'info', evidence: [evidence.file('docker-compose.yml', 88)] }, + ], +} + +overall(report) // 'fail', computed from the findings +validateReport(report) // [] when every conclusion carries a citation +formatReport(report) // worst first, one citation per finding +``` + +Output of `formatReport(report)`: + +``` +PortCheck — 12 compose services + NOT MET · 12 items examined · 2 findings + +✗ CRITICAL SSH published on a host port [NOT MET] + evidence: docker-compose.yml:42 +◦ info Admin UI bound to loopback only [MET] + evidence: docker-compose.yml:88 +``` + +The same report with nothing examined: + +``` +PortCheck — no services found + NOT ASSESSED · 0 items examined · 0 findings + + PortCheck's scope was not assessed. This is not a pass — an unassessed control is not an implemented control. +``` + +`validateReport` flags a finding that reaches `pass`, `fail` or +`not-applicable` with no evidence, plus duplicate ids and empty summaries. A +`not-assessed` finding may have no evidence, since having nothing to cite is +what it reports. `formatReport` accepts `{ evidence: 'inline' | 'full' | 'none', +minSeverity }`. `formatRollUp(reports)` gives one line per report and a +combined verdict, where an unrun report pulls the total to `not-assessed`. + +## Conformance + +A check built on this package conforms when its own tests assert that: + +- `validateReport(report)` is empty for every report it can produce; +- an empty input produces `not-assessed`, and the rendered output contains + neither `✓` nor the word "clean"; +- every `fail` finding carries at least one `Evidence`; +- the rendered output of a failure names a file, a row, or a command. + +## Install + +``` +npm install @extant2000/evidence-record +``` + +## License + +MIT. Copyright (c) 2026 Extant 2000 LLC. 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/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fb78fc4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1497 @@ +{ + "name": "@extant2000/evidence-record", + "version": "0.1.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@extant2000/evidence-record", + "version": "0.1.2", + "license": "MIT", + "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/@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..2f0d6d5 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "@extant2000/evidence-record", + "version": "0.1.2", + "private": false, + "description": "Shared evidence and determination vocabulary for checks that must show their work.", + "license": "MIT", + "author": "Extant 2000 LLC", + "type": "module", + "main": "./dist-cjs/index.js", + "files": [ + "dist", + "dist-cjs", + "src", + "README.md", + "LICENSE" + ], + "repository": { + "type": "git", + "url": "https://gitlab.com/extant2000/evidence-record.git" + }, + "homepage": "https://gitlab.com/extant2000/evidence-record", + "bugs": { + "url": "https://gitlab.com/extant2000/evidence-record/-/issues" + }, + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist-cjs/index.js" + } + }, + "scripts": { + "build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist-cjs/package.json', JSON.stringify({type:'commonjs'}))\"", + "test": "vitest run", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + }, + "module": "./dist/index.js" +} diff --git a/src/determination.ts b/src/determination.ts new file mode 100644 index 0000000..bf121b6 --- /dev/null +++ b/src/determination.ts @@ -0,0 +1,80 @@ +/** + * The four things a check can conclude — and the three of them that are not + * a pass. + * + * A boolean cannot carry this. `passed: true` collapses "we checked and it + * held" together with "there was nothing to check", and those are the two + * cases a reviewer most needs kept apart. An earlier version of these + * libraries said VACUOUS for the second, which is precise and unreadable; this + * replaces the jargon without softening the meaning. + */ +export type Determination = + /** Something was examined against the criterion, and it held. */ + | 'pass' + /** Something was examined against the criterion, and it did not hold. */ + | 'fail' + /** The criterion was examined and genuinely does not apply here. */ + | 'not-applicable' + /** Nothing was examined. Never a pass. */ + | 'not-assessed' + +/** + * One-word label for a column or badge. + * + * `not-assessed` deliberately does NOT get a neutral word like "skipped" — + * "skipped" reads as a decision someone made, and this is usually an absence + * nobody noticed. + */ +export function label(d: Determination): string { + switch (d) { + case 'pass': return 'MET' + case 'fail': return 'NOT MET' + case 'not-applicable': return 'N/A' + case 'not-assessed': return 'NOT ASSESSED' + } +} + +/** + * A full sentence a reviewer can read without a glossary. + * + * `subject` names what was being checked, so the sentence stands alone when + * it is lifted out of the report and pasted into an email — which is how + * these findings actually travel. + */ +export function describe(d: Determination, subject = 'This item'): string { + switch (d) { + case 'pass': + return `${subject} was checked and met the criterion.` + case 'fail': + return `${subject} was checked and did not meet the criterion.` + case 'not-applicable': + return `${subject} was examined and the criterion does not apply to it.` + case 'not-assessed': + return `${subject} was not assessed. This is not a pass — an unassessed control is not an implemented control.` + } +} + +/** True only for the one determination that is genuinely a clean result. */ +export function isPass(d: Determination): boolean { + return d === 'pass' +} + +/** + * Roll several determinations into one. + * + * Order matters and is deliberate: + * any fail → fail + * nothing examined → not-assessed (NOT a pass; this is the vacuous case) + * everything N/A → not-applicable + * otherwise → pass + * + * The empty list returns `not-assessed` rather than `pass`. `[].every(...)` + * being true is the exact trap this type exists to close. + */ +export function rollUp(ds: Determination[]): Determination { + if (ds.some(d => d === 'fail')) return 'fail' + if (ds.length === 0) return 'not-assessed' + if (ds.some(d => d === 'not-assessed')) return 'not-assessed' + if (ds.every(d => d === 'not-applicable')) return 'not-applicable' + return 'pass' +} diff --git a/src/evidence.ts b/src/evidence.ts new file mode 100644 index 0000000..39cfaf4 --- /dev/null +++ b/src/evidence.ts @@ -0,0 +1,157 @@ +/** + * Where a finding came from. + * + * The rule this type exists to enforce: a finding names the artefact that + * produced it. SideDoor already did this — it prints the service, the port + * binding and the compose file — and it is noticeably the most useful of these + * outputs to read. A result that is asserted without a source leaves the + * reader to take it on trust, and an unverified claim should count as absent. + */ + +/** A location in source or configuration. */ +export interface FileEvidence { + kind: 'file' + path: string + /** 1-indexed. Omitted when the finding is about the file as a whole. */ + line?: number + /** The matched text. Keep it short — one line, trimmed. */ + excerpt?: string +} + +/** A row in a database. */ +export interface RecordEvidence { + kind: 'record' + table: string + id: string + /** The column that carried the value the finding turns on. */ + field?: string + value?: string +} + +/** Output of a command that was actually run. */ +export interface CommandEvidence { + kind: 'command' + command: string + exitCode?: number + excerpt?: string +} + +/** + * A number that was measured, together with how much data stood behind it. + * + * `samples` is not decoration. An availability of 100% over zero samples and + * an availability of 100% over 8,640 samples are different claims, and only + * one of them is evidence. + */ +export interface MeasurementEvidence { + kind: 'measurement' + metric: string + /** Null when it could not be computed. Never substitute a flattering default. */ + value: number | null + unit?: string + samples: number + /** e.g. "2026-07-01 → 2026-07-31". */ + window?: string +} + +/** A written source: a policy, a standard, a control catalogue. */ +export interface DocumentEvidence { + kind: 'document' + title: string + section?: string + url?: string +} + +export type EvidenceSource = + | FileEvidence + | RecordEvidence + | CommandEvidence + | MeasurementEvidence + | DocumentEvidence + +export interface Evidence { + source: EvidenceSource + /** ISO 8601. When the observation was made, if the caller tracks it. */ + observedAt?: string + /** One line on why this artefact supports the finding. */ + note?: string +} + +/** + * A short, copy-pasteable citation — the thing a reader scans for. + * + * Formats follow the conventions each kind is already read in: `path:line` + * for source (clickable in most terminals), `table#id` for rows. + */ +export function cite(e: Evidence): string { + const s = e.source + switch (s.kind) { + case 'file': + return s.line ? `${s.path}:${s.line}` : s.path + case 'record': + return s.field ? `${s.table}#${s.id} (${s.field})` : `${s.table}#${s.id}` + case 'command': + return s.exitCode === undefined ? `$ ${s.command}` : `$ ${s.command} → exit ${s.exitCode}` + case 'measurement': { + const v = s.value === null ? 'not computable' : `${s.value}${s.unit ?? ''}` + const n = `${s.samples} sample${s.samples === 1 ? '' : 's'}` + return s.window ? `${s.metric} = ${v} (${n}, ${s.window})` : `${s.metric} = ${v} (${n})` + } + case 'document': + return s.section ? `${s.title} § ${s.section}` : s.title + } +} + +/** Longer form: the citation plus whatever context was attached to it. */ +export function citeVerbose(e: Evidence): string[] { + const out = [cite(e)] + const s = e.source + if ((s.kind === 'file' || s.kind === 'command') && s.excerpt) out.push(` ${s.excerpt.trim()}`) + if (s.kind === 'record' && s.value !== undefined) out.push(` value: ${s.value}`) + if (e.note) out.push(` ${e.note}`) + return out +} + +/** Convenience constructors, so call sites stay one line. */ +export const evidence = { + file(path: string, line?: number, excerpt?: string, note?: string): Evidence { + return { source: { kind: 'file', path, line, excerpt }, note } + }, + record(table: string, id: string, field?: string, value?: string, note?: string): Evidence { + return { source: { kind: 'record', table, id, field, value }, note } + }, + command(command: string, exitCode?: number, excerpt?: string, note?: string): Evidence { + return { source: { kind: 'command', command, exitCode, excerpt }, note } + }, + measurement( + metric: string, + value: number | null, + samples: number, + opts: { unit?: string, window?: string, note?: string } = {}, + ): Evidence { + return { + source: { kind: 'measurement', metric, value, samples, unit: opts.unit, window: opts.window }, + note: opts.note, + } + }, + document(title: string, section?: string, url?: string, note?: string): Evidence { + return { source: { kind: 'document', title, section, url }, note } + }, +} + +/** + * Read a measurement out loud, including the case where there is nothing to + * read. + * + * `uptime null, met=false` was correct and unreadable. A reader should not + * have to know that zero samples is not 100% in order to understand the line. + */ +export function describeMeasurement(s: MeasurementEvidence): string { + if (s.samples === 0) { + return `No samples for ${s.metric}${s.window ? ` in ${s.window}` : ''}; it cannot be computed.` + } + if (s.value === null) { + return `${s.metric} could not be computed from the ${s.samples} available sample${s.samples === 1 ? '' : 's'}.` + } + return `${s.metric} is ${s.value}${s.unit ?? ''} over ${s.samples} sample${s.samples === 1 ? '' : 's'}${s.window ? ` (${s.window})` : ''}.` +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..268635a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,22 @@ +export type { Determination } from './determination.js' +export { label, describe, isPass, rollUp } from './determination.js' + +export type { + Evidence, + EvidenceSource, + FileEvidence, + RecordEvidence, + CommandEvidence, + MeasurementEvidence, + DocumentEvidence, +} from './evidence.js' +export { cite, citeVerbose, evidence, describeMeasurement } from './evidence.js' + +export type { + Severity, + Finding, + CapabilityReport, + ReportProblem, + FormatOptions, +} from './report.js' +export { overall, validateReport, formatReport, formatRollUp } from './report.js' diff --git a/src/report.ts b/src/report.ts new file mode 100644 index 0000000..f63b017 --- /dev/null +++ b/src/report.ts @@ -0,0 +1,197 @@ +import { type Determination, label, describe, rollUp } from './determination.js' +import { type Evidence, cite, citeVerbose } from './evidence.js' + +export type Severity = 'critical' | 'high' | 'medium' | 'info' + +const RANK: Record = { critical: 0, high: 1, medium: 2, info: 3 } + +export interface Finding { + /** + * Stable within one report only. These identify a row in THIS run, not a + * durable record — do not persist them as entity keys. + */ + id: string + /** One line, in the reader's language, not the checker's. */ + summary: string + determination: Determination + severity: Severity + /** + * What produced this finding. Required, and allowed to be empty only for + * `not-assessed` — where the whole point is that nothing was observed. + */ + evidence: Evidence[] + /** Optional paragraph: why it matters, what to do. */ + detail?: string +} + +export interface CapabilityReport { + /** The capability that produced this, e.g. "SideDoor". */ + capability: string + /** What was examined, in a phrase: "49 compose services on the build host". */ + scope: string + /** How many items were actually examined. Zero forces `not-assessed`. */ + examined: number + findings: Finding[] + /** Free-form lines printed under the summary — caveats, exclusions. */ + notes?: string[] +} + +/** + * The report's overall determination, derived rather than asserted. + * + * Deriving it closes the gap where a capability reports zero findings and a + * reader infers a pass. Zero findings over zero examined items is + * `not-assessed`, and it says so. + */ +export function overall(r: CapabilityReport): Determination { + if (r.examined === 0) return 'not-assessed' + if (r.findings.length === 0) return 'pass' + return rollUp(r.findings.map(f => f.determination)) +} + +export interface ReportProblem { + findingId: string + problem: string +} + +/** + * Check the report against the standard before it is shown to anyone. + * + * This is the enforcement half of "evidence attached, not asserted". A + * capability that concludes something without naming what it looked at fails + * here, in its own test suite, rather than in front of a reviewer. + */ +export function validateReport(r: CapabilityReport): ReportProblem[] { + const problems: ReportProblem[] = [] + const seen = new Set() + + for (const f of r.findings) { + if (seen.has(f.id)) problems.push({ findingId: f.id, problem: 'duplicate finding id' }) + seen.add(f.id) + + if (f.determination !== 'not-assessed' && f.evidence.length === 0) { + problems.push({ + findingId: f.id, + problem: `determination "${f.determination}" carries no evidence — an asserted result is not a verified one`, + }) + } + if (!f.summary.trim()) { + problems.push({ findingId: f.id, problem: 'empty summary' }) + } + } + + if (r.examined > 0 && r.findings.length === 0 && r.notes?.length === 0) { + // Not a problem, but worth surfacing: a clean run should still say what + // it looked at, and `scope` is the only place that lives. + if (!r.scope.trim()) problems.push({ findingId: '(report)', problem: 'clean run with no scope described' }) + } + + return problems +} + +export interface FormatOptions { + /** + * `inline` — one citation per finding (default; fits a CI log) + * `full` — every citation, with excerpts and notes + * `none` — findings only, for a summary email + */ + evidence?: 'inline' | 'full' | 'none' + /** Drop findings below this severity. Defaults to showing everything. */ + minSeverity?: Severity +} + +/** + * Render a report for a terminal, a CI log, or an email. + * + * Worst first, so a critical never hides under a page of info lines, and the + * unflattering cases stay unflattering: a not-assessed run leads with the + * fact that nothing was checked instead of a green tick. + */ +export function formatReport(r: CapabilityReport, opts: FormatOptions = {}): string { + const mode = opts.evidence ?? 'inline' + const floor = opts.minSeverity ? RANK[opts.minSeverity] : RANK.info + const verdict = overall(r) + const out: string[] = [] + + out.push(`${r.capability} — ${r.scope}`) + out.push(` ${label(verdict)} · ${r.examined} item${r.examined === 1 ? '' : 's'} examined · ${r.findings.length} finding${r.findings.length === 1 ? '' : 's'}`) + + if (verdict === 'not-assessed' && r.examined === 0) { + out.push('') + out.push(` ${describe('not-assessed', `${r.capability}'s scope`)}`) + for (const n of r.notes ?? []) out.push(` ${n}`) + return out.join('\n') + } + + for (const n of r.notes ?? []) out.push(` ${n}`) + + const shown = r.findings + .filter(f => RANK[f.severity] <= floor) + .sort((a, b) => RANK[a.severity] - RANK[b.severity] || a.id.localeCompare(b.id)) + + if (shown.length > 0) out.push('') + + for (const f of shown) { + const tag = f.severity === 'critical' ? '✗ CRITICAL' + : f.severity === 'high' ? '✗ HIGH ' + : f.severity === 'medium' ? '· MEDIUM ' + : '◦ info ' + out.push(`${tag} ${f.summary} [${label(f.determination)}]`) + if (f.detail) out.push(` ${f.detail}`) + + if (mode === 'none') continue + + if (f.evidence.length === 0) { + // A not-assessed finding has nothing to cite by definition — that IS + // its content, and labelling it a missing citation would read as a + // defect in the report rather than the gap it is reporting. + // + // Anything else with no evidence is loud on purpose: a conclusion with + // nothing behind it should look worse than one with a citation. + out.push(f.determination === 'not-assessed' + ? ' (nothing was observed)' + : ' (no evidence attached)') + continue + } + if (mode === 'inline') { + out.push(` evidence: ${cite(f.evidence[0]!)}${f.evidence.length > 1 ? ` (+${f.evidence.length - 1} more)` : ''}`) + } else { + for (const e of f.evidence) { + for (const line of citeVerbose(e)) out.push(` ${line}`) + } + } + } + + if (shown.length === 0 && r.findings.length > 0) { + out.push('') + out.push(` ${r.findings.length} finding(s) below the ${opts.minSeverity} threshold, not shown.`) + } + if (r.findings.length === 0 && verdict === 'pass') { + out.push('') + // Scope-neutral on purpose: "49 compose services was checked" is the + // grammar you get from interpolating a count into a sentence, and the + // count is already on the line above. + out.push('✓ Every item examined met the criterion.') + } + + return out.join('\n') +} + +/** + * Roll several capabilities into one portfolio line. + * + * The same asymmetry as the per-report case: a capability that was never run + * must not average away as a pass, so it drags the roll-up to not-assessed + * rather than being skipped. + */ +export function formatRollUp(reports: CapabilityReport[]): string { + const out: string[] = [] + const verdict = rollUp(reports.map(overall)) + out.push(`Portfolio — ${reports.length} capabilit${reports.length === 1 ? 'y' : 'ies'} · ${label(verdict)}`) + for (const r of reports) { + const v = overall(r) + const fails = r.findings.filter(f => f.determination === 'fail').length + out.push(` ${label(v).padEnd(12)} ${r.capability.padEnd(18)} ${r.examined} examined, ${fails} not met`) + } + return out.join('\n') +} diff --git a/test/evidence-record.test.ts b/test/evidence-record.test.ts new file mode 100644 index 0000000..5f184cc --- /dev/null +++ b/test/evidence-record.test.ts @@ -0,0 +1,186 @@ +import { describe as suite, it, expect } from 'vitest' +import { + type CapabilityReport, + cite, + citeVerbose, + describe as describeDetermination, + describeMeasurement, + evidence, + formatReport, + formatRollUp, + label, + overall, + rollUp, + validateReport, +} from '../src/index.js' + +suite('determination', () => { + it('never renders a non-pass as a pass', () => { + expect(label('not-assessed')).toBe('NOT ASSESSED') + expect(label('not-applicable')).toBe('N/A') + expect(describeDetermination('not-assessed')).toContain('not a pass') + }) + + it('rolls an empty set up to not-assessed, not pass', () => { + // [].every(...) === true is the exact trap this closes. + expect(rollUp([])).toBe('not-assessed') + }) + + it('lets one failure dominate', () => { + expect(rollUp(['pass', 'pass', 'fail'])).toBe('fail') + }) + + it('does not let an unassessed item average away', () => { + expect(rollUp(['pass', 'not-assessed'])).toBe('not-assessed') + }) + + it('reports all-N/A as N/A rather than pass', () => { + expect(rollUp(['not-applicable', 'not-applicable'])).toBe('not-applicable') + }) +}) + +suite('citations', () => { + it('cites source as path:line so a terminal can open it', () => { + expect(cite(evidence.file('docker-compose.yml', 42, 'ports: ["2222:22"]'))).toBe('docker-compose.yml:42') + }) + + it('cites a row as table#id with the deciding column', () => { + expect(cite(evidence.record('requirements', 'a1b2c3', 'parent_id', 'null'))) + .toBe('requirements#a1b2c3 (parent_id)') + }) + + it('carries sample count into the citation, because 100% of nothing is not 100%', () => { + const none = evidence.measurement('uptime', null, 0, { unit: '%', window: '2026-07' }) + const real = evidence.measurement('uptime', 98.3, 8640, { unit: '%', window: '2026-07' }) + expect(cite(none)).toContain('not computable') + expect(cite(none)).toContain('0 samples') + expect(cite(real)).toContain('98.3%') + expect(cite(real)).toContain('8640 samples') + }) + + it('reads a zero-sample measurement as uncomputable, not as a value', () => { + const s = evidence.measurement('Availability', null, 0, { window: '2026-07' }).source + expect(describeMeasurement(s as never)).toBe( + 'No samples for Availability in 2026-07; it cannot be computed.', + ) + }) + + it('includes excerpt and note in the verbose form', () => { + const lines = citeVerbose(evidence.file('server/api/x.ts', 7, 'const q = sql(raw)', 'unparameterised')) + expect(lines[0]).toBe('server/api/x.ts:7') + expect(lines.join('\n')).toContain('const q = sql(raw)') + expect(lines.join('\n')).toContain('unparameterised') + }) +}) + +const clean: CapabilityReport = { + capability: 'SideDoor', + scope: '49 compose services', + examined: 49, + findings: [], +} + +suite('report', () => { + it('derives not-assessed from an empty scan instead of reporting clean', () => { + const empty: CapabilityReport = { capability: 'SideDoor', scope: 'nothing', examined: 0, findings: [] } + expect(overall(empty)).toBe('not-assessed') + const out = formatReport(empty) + expect(out).toContain('NOT ASSESSED') + expect(out).not.toContain('✓') + }) + + it('reports a genuine clean run as a pass', () => { + expect(overall(clean)).toBe('pass') + expect(formatReport(clean)).toContain('✓') + }) + + it('rejects a conclusion with no evidence behind it', () => { + const asserted: CapabilityReport = { + capability: 'CertPack', + scope: '3 controls', + examined: 3, + findings: [{ id: 'f1', summary: 'AC-2 implemented', determination: 'pass', severity: 'info', evidence: [] }], + } + const problems = validateReport(asserted) + expect(problems).toHaveLength(1) + expect(problems[0]!.problem).toContain('no evidence') + }) + + it('allows a not-assessed finding to carry no evidence', () => { + const unassessed: CapabilityReport = { + capability: 'CertPack', + scope: '3 controls', + examined: 3, + findings: [{ id: 'f1', summary: 'AC-2', determination: 'not-assessed', severity: 'high', evidence: [] }], + } + expect(validateReport(unassessed)).toHaveLength(0) + }) + + it('catches duplicate finding ids', () => { + const dup: CapabilityReport = { + capability: 'X', scope: 's', examined: 2, + findings: [ + { id: 'f1', summary: 'a', determination: 'pass', severity: 'info', evidence: [evidence.file('a.ts')] }, + { id: 'f1', summary: 'b', determination: 'pass', severity: 'info', evidence: [evidence.file('b.ts')] }, + ], + } + expect(validateReport(dup).some(p => p.problem === 'duplicate finding id')).toBe(true) + }) + + it('shows a missing citation loudly rather than silently', () => { + const r: CapabilityReport = { + capability: 'X', scope: 's', examined: 1, + findings: [{ id: 'f1', summary: 'something is wrong', determination: 'fail', severity: 'high', evidence: [] }], + } + expect(formatReport(r)).toContain('(no evidence attached)') + }) + + it('does not label an unassessed finding as a missing citation', () => { + // Having nothing to cite IS the content of a not-assessed finding. + // Calling it a missing citation reads as a defect in the report rather + // than the gap the report is describing. + const r: CapabilityReport = { + capability: 'X', scope: 's', examined: 1, + findings: [{ id: 'f1', summary: 'rule covered nothing', determination: 'not-assessed', severity: 'medium', evidence: [] }], + } + const out = formatReport(r) + expect(out).toContain('(nothing was observed)') + expect(out).not.toContain('(no evidence attached)') + }) + + it('sorts worst first so a critical cannot hide under info lines', () => { + const r: CapabilityReport = { + capability: 'SideDoor', scope: '2 services', examined: 2, + findings: [ + { id: 'f2', summary: 'loopback bind', determination: 'pass', severity: 'info', evidence: [evidence.file('a.yml', 3)] }, + { id: 'f1', summary: 'SSH published on a host port', determination: 'fail', severity: 'critical', evidence: [evidence.file('b.yml', 9, '2222:22')] }, + ], + } + const out = formatReport(r) + expect(out.indexOf('SSH published')).toBeLessThan(out.indexOf('loopback bind')) + expect(overall(r)).toBe('fail') + }) + + it('prints every citation in full mode', () => { + const r: CapabilityReport = { + capability: 'X', scope: 's', examined: 1, + findings: [{ + id: 'f1', summary: 'drift', determination: 'fail', severity: 'high', + evidence: [evidence.file('a.ts', 1), evidence.file('b.ts', 2)], + }], + } + expect(formatReport(r, { evidence: 'inline' })).toContain('(+1 more)') + const full = formatReport(r, { evidence: 'full' }) + expect(full).toContain('a.ts:1') + expect(full).toContain('b.ts:2') + }) +}) + +suite('roll-up', () => { + it('does not let healthy capabilities dilute an unrun one', () => { + const unrun: CapabilityReport = { capability: 'UnrunCheck', scope: 'none', examined: 0, findings: [] } + const out = formatRollUp([clean, unrun]) + expect(out).toContain('NOT ASSESSED') + expect(rollUp([overall(clean), overall(unrun)])).toBe('not-assessed') + }) +}) diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..0d99093 --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "dist-cjs", + "declaration": false + } +} 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"] +}