testguard-cli 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.github/workflows/supply-chain.yml +2 -2
  2. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/CHANGELOG.md +47 -0
  3. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/PKG-INFO +24 -5
  4. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/README.md +23 -4
  5. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/action.yml +1 -1
  6. testguard_cli-0.1.2/cli/testguard.mjs +14 -0
  7. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/package-lock.json +2 -2
  8. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/package.json +1 -1
  9. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/packaging/homebrew/testguard.rb +2 -2
  10. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/pyproject.toml +1 -1
  11. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/GATE-SEMANTICS.md +2 -2
  12. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/schemas/common.schema.json +1 -1
  13. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/cli.mjs +7 -1
  14. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/commands/baseline.mjs +3 -1
  15. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/commands/claims.mjs +3 -2
  16. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/commands/probe.mjs +8 -3
  17. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/probe/probe.mjs +41 -10
  18. testguard_cli-0.1.2/src/probe/rank.mjs +130 -0
  19. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/probe/runner-vitest.mjs +61 -2
  20. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/probe/worktree.mjs +28 -11
  21. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/render.mjs +3 -2
  22. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/test/brief.test.mjs +11 -0
  23. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/test/classify.test.mjs +3 -0
  24. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/test/probe.fixture.test.mjs +35 -0
  25. testguard_cli-0.1.2/test/rank-aliases.test.mjs +35 -0
  26. testguard_cli-0.1.2/test/runner-command.test.mjs +34 -0
  27. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/test/worktree.test.mjs +28 -1
  28. testguard_cli-0.1.1/cli/testguard.mjs +0 -6
  29. testguard_cli-0.1.1/src/probe/rank.mjs +0 -51
  30. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.gitattributes +0 -0
  31. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  32. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  33. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  34. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.github/dependabot.yml +0 -0
  35. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.github/scripts/install-smoke.mjs +0 -0
  36. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.github/scripts/sync-release-version.mjs +0 -0
  37. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.github/workflows/auto-merge.yml +0 -0
  38. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.github/workflows/ci.yml +0 -0
  39. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.github/workflows/release.yml +0 -0
  40. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.github/workflows/scheduled-release.yml +0 -0
  41. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.gitignore +0 -0
  42. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.npmignore +0 -0
  43. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.npmrc +0 -0
  44. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/.pre-commit-hooks.yaml +0 -0
  45. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/AGENTS.md +0 -0
  46. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/CLAUDE.md +0 -0
  47. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/CODE_OF_CONDUCT.md +0 -0
  48. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/CONTRIBUTING.md +0 -0
  49. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/LICENSE +0 -0
  50. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/PRIVACY.md +0 -0
  51. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/SECURITY.md +0 -0
  52. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/SUPPORT.md +0 -0
  53. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/bench/README.md +0 -0
  54. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/fixtures/known-answer/.gitignore +0 -0
  55. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/fixtures/known-answer/README.md +0 -0
  56. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/fixtures/known-answer/expected.json +0 -0
  57. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/fixtures/known-answer/package.json +0 -0
  58. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/fixtures/known-answer/src/export.mjs +0 -0
  59. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/fixtures/known-answer/src/redact.mjs +0 -0
  60. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/fixtures/known-answer/test/flaky.test.mjs +0 -0
  61. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/fixtures/known-answer/test/redact.test.mjs +0 -0
  62. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/fixtures/known-answer/testguard.claims.json +0 -0
  63. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/fixtures/known-answer/vitest.config.mjs +0 -0
  64. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/README.md +0 -0
  65. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/examples/baseline.json +0 -0
  66. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/examples/brief.json +0 -0
  67. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/examples/calibration.json +0 -0
  68. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/examples/claims.json +0 -0
  69. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/examples/evidence.json +0 -0
  70. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/examples/ignore.json +0 -0
  71. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/baseline.bad-fingerprint-key.json +0 -0
  72. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/baseline.zero-count.json +0 -0
  73. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/brief.text-without-heading.json +0 -0
  74. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/brief.unknown-verdict-key.json +0 -0
  75. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/calibration.p-outside-ci.json +0 -0
  76. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/calibration.positives-exceed-n.json +0 -0
  77. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/claims.absolute-path.json +0 -0
  78. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/claims.duplicate-fault-id.json +0 -0
  79. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/claims.missing-provenance.json +0 -0
  80. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/claims.noop-fault.json +0 -0
  81. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/claims.occurrence-exceeds-hits.json +0 -0
  82. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/claims.parent-traversal.json +0 -0
  83. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/evidence.flaky-defender-without-flakiness.json +0 -0
  84. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/evidence.killed-by-non-assertion.json +0 -0
  85. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/evidence.killed-on-red-baseline.json +0 -0
  86. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/evidence.killed-without-n-runs.json +0 -0
  87. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/evidence.rank-extra-property.json +0 -0
  88. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/evidence.unknown-verdict.json +0 -0
  89. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/evidence.unverifiable-without-reason.json +0 -0
  90. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/evidence.wrong-fingerprint.json +0 -0
  91. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/ignore.missing-reason.json +0 -0
  92. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/invalid/ignore.short-reason.json +0 -0
  93. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/conformance/schemas.test.mjs +0 -0
  94. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/lib/fingerprint.mjs +0 -0
  95. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/lib/validate.mjs +0 -0
  96. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/schemas/baseline.schema.json +0 -0
  97. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/schemas/brief.schema.json +0 -0
  98. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/schemas/calibration.schema.json +0 -0
  99. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/schemas/claims.schema.json +0 -0
  100. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/schemas/evidence.schema.json +0 -0
  101. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/spec/schemas/ignore.schema.json +0 -0
  102. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/baseline/baseline.mjs +0 -0
  103. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/brief/brief.mjs +0 -0
  104. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/claims/annotations.mjs +0 -0
  105. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/claims/load.mjs +0 -0
  106. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/commands/brief.mjs +0 -0
  107. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/evidence/writer.mjs +0 -0
  108. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/git.mjs +0 -0
  109. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/probe/classify.mjs +0 -0
  110. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/probe/inject.mjs +0 -0
  111. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/util/glob.mjs +0 -0
  112. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/src/util/hash.mjs +0 -0
  113. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/test/annotations.test.mjs +0 -0
  114. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/test/baseline.test.mjs +0 -0
  115. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/test/glob.test.mjs +0 -0
  116. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/test/inject.test.mjs +0 -0
  117. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/test/runner-vitest.test.mjs +0 -0
  118. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/test/writer.test.mjs +0 -0
  119. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/testguard.claims.json +0 -0
  120. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/testguard_cli/__init__.py +0 -0
  121. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/testguard_cli/wrapper.py +0 -0
  122. {testguard_cli-0.1.1 → testguard_cli-0.1.2}/vitest.config.mjs +0 -0
@@ -17,8 +17,8 @@ permissions:
17
17
  jobs:
18
18
  osv-scan-pr:
19
19
  if: github.event_name == 'pull_request'
20
- uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
20
+ uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
21
21
 
22
22
  osv-scan-scheduled:
23
23
  if: github.event_name != 'pull_request'
24
- uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
24
+ uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.2] - 2026-09-17
11
+
12
+ From a field report on a real codebase (63 test files, 458 tests, 39 faults).
13
+
14
+ ### Fixed
15
+
16
+ - **A runner that cannot be resolved was reported as `FLAKY-DEFENDER`.** In
17
+ the scratch worktree, a symlinked `node_modules` (the sibling/auto-worktree
18
+ layout) was invisible, vitest failed to load, and the load error was
19
+ classified as flaky tests — blaming the wrong party. Symlinked
20
+ `node_modules` are now linked to their resolved target; the runner is
21
+ checked before any verdict and an unresolvable one is a precondition
22
+ failure (exit 2) with the fix in the message; defenders that fail to
23
+ *load* during a baseline are `UNVERIFIABLE` (`defenders-failed-to-load`),
24
+ never flaky.
25
+ - **Blast radius ignored path aliases.** `tsconfig`/`jsconfig` `paths` (with
26
+ `baseUrl` and relative `extends`) and `package.json#imports` are resolved,
27
+ so a module imported 83 times via `@/…` no longer ranks as if nothing
28
+ imported it. Bare package specifiers remain ignored, as documented.
29
+ - Summary said "N unproven claims" when N counted faults; it now reports
30
+ unproven faults *and* the distinct claims they belong to.
31
+ - `testguard claims` never showed which claims carry a `@claim` annotation;
32
+ it now reports the count and marks each annotated row.
33
+ - Piping output to a closed reader (`testguard claims | head`) no longer
34
+ crashes with an `EPIPE` stack trace.
35
+
36
+ ### Added
37
+
38
+ - `--claim <ID,ID>` probes only the named claims and writes
39
+ `.testguard/evidence-partial.json`, keeping the canonical evidence intact —
40
+ turns a fix-loop iteration from minutes into seconds.
41
+ - `--runner-cmd "<cmd>"` with `{files}` and `{out}` placeholders for
42
+ monorepos, custom configs and other package managers.
43
+ - `--node-modules <dir>` (or `TESTGUARD_NODE_MODULES`) to link a specific
44
+ `node_modules` into the scratch worktree.
45
+
46
+ ### Changed
47
+
48
+ - The `--in-place` precondition message says what it means: only fault
49
+ target files must be clean; test files may be dirty.
50
+ - `testguard baseline` prints the two `.gitignore` lines for the regenerated
51
+ files instead of leaving it to the README.
52
+ - With no baseline, the ranked block is not printed a second time under the
53
+ per-fault stream.
54
+ - `--quiet` is documented precisely.
55
+ - Staged Homebrew formula carries the sha256 of the published 0.1.1 tarball.
56
+
10
57
  ## [0.1.1] - 2026-09-17
11
58
 
12
59
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: testguard-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Proves a test suite actually defends the claims a project makes: injects the faults those claims forbid and reports every one the tests miss. Python wrapper for the Node.js CLI (requires Node.js 20+).
5
5
  Project-URL: Homepage, https://github.com/raccioly/testguard
6
6
  Project-URL: Documentation, https://github.com/raccioly/testguard#readme
@@ -59,6 +59,15 @@ on known-broken code. The largest gap was a compliance-critical path with
59
59
  100% coverage, where the one assertion that mattered used
60
60
  `expect.objectContaining({...})` and omitted the field carrying the data.
61
61
 
62
+ A second, independent run on a different AI-authored codebase (63 test
63
+ files, 458 tests, 24 hand-written security claims, 39 faults): **21 of 39
64
+ faults survived a fully green suite — 9 of them critical.** Super-admin
65
+ gating, membership checks, cookie flags and the whole authorization callback
66
+ could be disabled without a single test noticing. One test file had
67
+ re-implemented the authorization logic *inside the test* and asserted
68
+ against the copy: fifteen green tests, zero detection. After wrapper-level
69
+ tests were written against the survivors, 39/39 were killed.
70
+
62
71
  ## Install
63
72
 
64
73
  | How | Command |
@@ -67,7 +76,7 @@ on known-broken code. The largest gap was a compliance-critical path with
67
76
  | npm | `npm i -D testguard-cli` then `npx testguard probe` |
68
77
  | pip | `pip install testguard-cli` then `testguard probe` (needs Node ≥ 20) |
69
78
  | Homebrew | `brew tap raccioly/tap && brew install testguard` |
70
- | GitHub Action | `uses: raccioly/testguard@v0.1.1` — see [`action.yml`](./action.yml) |
79
+ | GitHub Action | `uses: raccioly/testguard@v0.1.2` — see [`action.yml`](./action.yml) |
71
80
  | pre-commit | `repo: https://github.com/raccioly/testguard`, hooks `testguard-claims`, `testguard-probe` |
72
81
 
73
82
  ## How it works
@@ -84,7 +93,7 @@ npx testguard-cli brief # tell the agent where the suite is blind, before
84
93
  deterministic source change that would make the statement false. Every
85
94
  claim and every fault records who produced it. `testguard claims`
86
95
  validates the file and reports drift against `@claim <ID>` annotations in
87
- source.
96
+ source. Test files are deliberately not scanned — a claim asserted by a test is the authorship trap the tool exists for — and annotation ids must contain a hyphen so prose is never mistaken for one.
88
97
  2. **Probe** confirms the defenders are green N times unmodified, applies
89
98
  each fault in a scratch git worktree (your tree is never touched), runs
90
99
  the defenders N times, re-runs survivors against the whole suite with
@@ -101,8 +110,18 @@ npx testguard-cli brief # tell the agent where the suite is blind, before
101
110
  | `FLAKY-DEFENDER` | the defenders are not reliably green, or disagreed across runs |
102
111
 
103
112
  Never a single score. Findings are ranked by severity, claim provenance
104
- and blast radius, and written to `.testguard/evidence.json` validated
105
- against the spec before it is written.
113
+ and blast radius (relative imports, `tsconfig` path aliases and
114
+ `package.json#imports` are resolved; bare package names are not), and
115
+ written to `.testguard/evidence.json` — validated against the spec before
116
+ it is written.
117
+
118
+ Practical loop: first pass `--no-escalate` (escalation re-runs the whole
119
+ suite N times per survivor); iterate on one claim with
120
+ `--claim <ID> --in-place` — only the files faults are applied to must be
121
+ committed, test files may be dirty; final pass with defaults. A custom
122
+ runner (`pnpm --filter`, a specific config) goes in
123
+ `--runner-cmd "<cmd> {files} … {out}"`; if the scratch worktree cannot
124
+ see your `node_modules`, pass `--node-modules <dir>`.
106
125
  3. **Baseline** freezes every non-passing fingerprint. Later probes suppress
107
126
  what was already known and exit non-zero only on what is new. Claims whose
108
127
  source and defenders are unchanged reuse their prior verdict, so a probe
@@ -35,6 +35,15 @@ on known-broken code. The largest gap was a compliance-critical path with
35
35
  100% coverage, where the one assertion that mattered used
36
36
  `expect.objectContaining({...})` and omitted the field carrying the data.
37
37
 
38
+ A second, independent run on a different AI-authored codebase (63 test
39
+ files, 458 tests, 24 hand-written security claims, 39 faults): **21 of 39
40
+ faults survived a fully green suite — 9 of them critical.** Super-admin
41
+ gating, membership checks, cookie flags and the whole authorization callback
42
+ could be disabled without a single test noticing. One test file had
43
+ re-implemented the authorization logic *inside the test* and asserted
44
+ against the copy: fifteen green tests, zero detection. After wrapper-level
45
+ tests were written against the survivors, 39/39 were killed.
46
+
38
47
  ## Install
39
48
 
40
49
  | How | Command |
@@ -43,7 +52,7 @@ on known-broken code. The largest gap was a compliance-critical path with
43
52
  | npm | `npm i -D testguard-cli` then `npx testguard probe` |
44
53
  | pip | `pip install testguard-cli` then `testguard probe` (needs Node ≥ 20) |
45
54
  | Homebrew | `brew tap raccioly/tap && brew install testguard` |
46
- | GitHub Action | `uses: raccioly/testguard@v0.1.1` — see [`action.yml`](./action.yml) |
55
+ | GitHub Action | `uses: raccioly/testguard@v0.1.2` — see [`action.yml`](./action.yml) |
47
56
  | pre-commit | `repo: https://github.com/raccioly/testguard`, hooks `testguard-claims`, `testguard-probe` |
48
57
 
49
58
  ## How it works
@@ -60,7 +69,7 @@ npx testguard-cli brief # tell the agent where the suite is blind, before
60
69
  deterministic source change that would make the statement false. Every
61
70
  claim and every fault records who produced it. `testguard claims`
62
71
  validates the file and reports drift against `@claim <ID>` annotations in
63
- source.
72
+ source. Test files are deliberately not scanned — a claim asserted by a test is the authorship trap the tool exists for — and annotation ids must contain a hyphen so prose is never mistaken for one.
64
73
  2. **Probe** confirms the defenders are green N times unmodified, applies
65
74
  each fault in a scratch git worktree (your tree is never touched), runs
66
75
  the defenders N times, re-runs survivors against the whole suite with
@@ -77,8 +86,18 @@ npx testguard-cli brief # tell the agent where the suite is blind, before
77
86
  | `FLAKY-DEFENDER` | the defenders are not reliably green, or disagreed across runs |
78
87
 
79
88
  Never a single score. Findings are ranked by severity, claim provenance
80
- and blast radius, and written to `.testguard/evidence.json` validated
81
- against the spec before it is written.
89
+ and blast radius (relative imports, `tsconfig` path aliases and
90
+ `package.json#imports` are resolved; bare package names are not), and
91
+ written to `.testguard/evidence.json` — validated against the spec before
92
+ it is written.
93
+
94
+ Practical loop: first pass `--no-escalate` (escalation re-runs the whole
95
+ suite N times per survivor); iterate on one claim with
96
+ `--claim <ID> --in-place` — only the files faults are applied to must be
97
+ committed, test files may be dirty; final pass with defaults. A custom
98
+ runner (`pnpm --filter`, a specific config) goes in
99
+ `--runner-cmd "<cmd> {files} … {out}"`; if the scratch worktree cannot
100
+ see your `node_modules`, pass `--node-modules <dir>`.
82
101
  3. **Baseline** freezes every non-passing fingerprint. Later probes suppress
83
102
  what was already known and exit non-zero only on what is new. Claims whose
84
103
  source and defenders are unchanged reuse their prior verdict, so a probe
@@ -38,7 +38,7 @@ inputs:
38
38
  version:
39
39
  description: 'testguard-cli version to run'
40
40
  required: false
41
- default: '0.1.1'
41
+ default: '0.1.2'
42
42
  node-version:
43
43
  description: 'Node.js version (20+)'
44
44
  required: false
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.mjs';
3
+
4
+ // `testguard … | head` closes stdout early; that is not an error worth a stack trace.
5
+ for (const stream of [process.stdout, process.stderr]) {
6
+ stream.on('error', (err) => {
7
+ if (err.code === 'EPIPE') process.exit(0);
8
+ throw err;
9
+ });
10
+ }
11
+
12
+ main(process.argv.slice(2)).then((code) => {
13
+ process.exitCode = code;
14
+ });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "testguard-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "testguard-cli",
9
- "version": "0.1.1",
9
+ "version": "0.1.2",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "ajv": "8.20.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testguard-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Proves a test suite defends the claims a project makes: injects the faults those claims forbid and reports every one the tests fail to detect.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,8 +13,8 @@
13
13
  class Testguard < Formula
14
14
  desc "Proves a test suite defends the claims a project makes"
15
15
  homepage "https://github.com/raccioly/testguard"
16
- url "https://registry.npmjs.org/testguard-cli/-/testguard-cli-0.1.1.tgz"
17
- sha256 "fb7de3fa96886ca55a860bb6b2ed03dea1a6df13fb9fa7ad1bb12d860f9a971a"
16
+ url "https://registry.npmjs.org/testguard-cli/-/testguard-cli-0.1.2.tgz"
17
+ sha256 "385d69f9d3c153b934d9c1cb6a2c754eb9b221b0a8c0ba8b805858384d2d4678"
18
18
  license "MIT"
19
19
 
20
20
  depends_on "node"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "testguard-cli"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Proves a test suite actually defends the claims a project makes: injects the faults those claims forbid and reports every one the tests miss. Python wrapper for the Node.js CLI (requires Node.js 20+)."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -12,7 +12,7 @@ The verdict set is closed. Only one value is a pass.
12
12
  | `killed` | Fault applied; every one of N probe runs failed with a genuine assertion failure, on a defender set that was green N/N unmodified. | no |
13
13
  | `survived` | Fault applied; every one of N probe runs passed. The claim is **unproven**. | **yes** |
14
14
  | `nocover` | No defending test exists: the declared globs resolve to nothing, or no test imports the subject. Worse than `survived` — nothing was even tried. | **yes** |
15
- | `unverifiable` | The fault could not be applied: anchor missing or ambiguous. Carries a `reason`. This is a loud, gating verdict — a claim that cannot be probed is not "skipped", it is undefended until someone re-authors the fault. | **yes** |
15
+ | `unverifiable` | The claim could not be probed: the fault's anchor is missing or ambiguous, or its defenders failed to load (`defenders-failed-to-load`). Carries a `reason`. A loud, gating verdict — a claim that cannot be probed is not "skipped", it is undefended until someone fixes the fault or the defenders. Never confused with `flaky-defender`, which requires tests that *ran*. | **yes** |
16
16
  | `timeout` | Probe run exceeded its budget. Not counted as a kill; the pessimistic reading is the safe one because flakiness biases the metric optimistically. | **yes** |
17
17
  | `fault-invalid` | The replacement does not load or compile. A bad fault, not a detection. | **yes** |
18
18
  | `flaky-defender` | Defenders were not green N/N on unmodified source (`defenders-not-green`), or the N probe runs disagreed with each other (`inconsistent-probe`). Either way no verdict about the fault can be trusted; fix the defenders first. | **yes** |
@@ -81,7 +81,7 @@ evidence; they simply do not turn CI red.
81
81
  |---|---|
82
82
  | `0` | No new gating findings at or above the severity floor. |
83
83
  | `1` | At least one new gating finding. |
84
- | `2` | Precondition failed: defenders not green, working tree dirty for a target file, runner not found, claims file invalid. Nothing was probed. |
84
+ | `2` | Precondition failed: test runner not resolvable (e.g. no `node_modules` linked into the scratch worktree), working tree dirty for a fault target file, claims file invalid, no commits. Nothing was probed. |
85
85
  | `3` | Usage or configuration error. |
86
86
 
87
87
  A tool must never exit `0` because it had nothing to check. If the claims
@@ -31,7 +31,7 @@
31
31
  "enum": ["critical", "high", "medium", "low"]
32
32
  },
33
33
  "verdict": {
34
- "description": "Closed set. Only `killed` is a pass. Every other verdict gates by default (see GATE-SEMANTICS.md).",
34
+ "description": "Closed set. Only `killed` is a pass. Every other verdict gates by default (see GATE-SEMANTICS.md). `unverifiable` covers a fault that could not be applied (anchor missing/ambiguous) and a claim whose defenders failed to load (`defenders-failed-to-load`).",
35
35
  "enum": [
36
36
  "killed",
37
37
  "survived",
@@ -28,10 +28,13 @@ probe
28
28
  --baseline <path> baseline to gate against (default: <dir>/.testguard/baseline.json if present)
29
29
  --severity <level> gate only at or above (default: low)
30
30
  --ref <commit> probe this commit in the scratch worktree (default: HEAD)
31
+ --claim <ID,ID> probe only these claims; writes .testguard/evidence-partial.json
32
+ --runner-cmd "<cmd>" custom runner; must contain {files} and {out}, e.g. "pnpm vitest run {files} --reporter=json --outputFile={out}"
33
+ --node-modules <dir> node_modules to link into the scratch worktree (or TESTGUARD_NODE_MODULES)
31
34
  --in-place mutate the working tree instead of a scratch worktree
32
35
  --no-escalate do not re-run survivors against the whole suite
33
36
  --no-reuse re-probe claims whose inputs have not changed
34
- --quiet summary only
37
+ --quiet suppress the per-fault stream and ranked block; print only the summary and evidence path
35
38
 
36
39
  claims --json
37
40
  baseline --evidence <path> --out <path>
@@ -58,6 +61,9 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
58
61
  severity: { type: 'string', default: 'low' },
59
62
  max: { type: 'string', default: '20' },
60
63
  ref: { type: 'string', default: 'HEAD' },
64
+ claim: { type: 'string' },
65
+ 'runner-cmd': { type: 'string' },
66
+ 'node-modules': { type: 'string' },
61
67
  'in-place': { type: 'boolean', default: false },
62
68
  'no-escalate': { type: 'boolean', default: false },
63
69
  'no-reuse': { type: 'boolean', default: false },
@@ -16,6 +16,8 @@ export async function baselineCommand({ projectDir, values }, io) {
16
16
  writeSpecDoc('baseline', outPath, baseline);
17
17
  const n = Object.values(baseline.fingerprints).reduce((a, b) => a + b, 0);
18
18
  io.out(`baseline: ${n} unproven finding${n === 1 ? '' : 's'} frozen at ${baseline.head.slice(0, 12)} → ${outPath}`);
19
- io.out('Commit this file. From now on only new findings gate.');
19
+ io.out('Commit this file; from now on only new findings gate. Ignore the regenerated ones — add to .gitignore:');
20
+ io.out(' .testguard/evidence.json');
21
+ io.out(' .testguard/brief.json');
20
22
  return 0;
21
23
  }
@@ -12,12 +12,13 @@ export async function claimsCommand({ projectDir, values }, io) {
12
12
  if (values.json) {
13
13
  io.out(JSON.stringify({ path, claims, annotations, drift }, null, 2));
14
14
  } else {
15
- io.out(`${claims.claims.length} claims in ${path}`);
15
+ const annotated = new Set(drift.annotated);
16
+ io.out(`${claims.claims.length} claims in ${path} — ${annotated.size} carry a @claim annotation in source (test files are not scanned)`);
16
17
  io.out('');
17
18
  for (const c of claims.claims) {
18
19
  const defenders = resolveDefenders(projectDir, c.defendedBy);
19
20
  const cover = defenders.length ? `${defenders.length} defender${defenders.length === 1 ? '' : 's'}` : 'NO DEFENDER';
20
- io.out(`${c.id.padEnd(14)} ${c.severity.padEnd(8)} ${c.source.kind.padEnd(10)} ${String(c.faults.length).padStart(2)} fault${c.faults.length === 1 ? ' ' : 's'} ${cover.padEnd(12)} ${c.statement}`);
21
+ io.out(`${annotated.has(c.id) ? '@ ' : ' '}${c.id.padEnd(14)} ${c.severity.padEnd(8)} ${c.source.kind.padEnd(10)} ${String(c.faults.length).padStart(2)} fault${c.faults.length === 1 ? ' ' : 's'} ${cover.padEnd(12)} ${c.statement}`);
21
22
  }
22
23
  if (drift.undeclared.length || drift.stale.length) io.out('');
23
24
  for (const a of drift.undeclared) io.out(`UNDECLARED @claim ${a.id} at ${a.file}:${a.line} has no entry in the claims file — a claim with no fault model`);
@@ -21,7 +21,9 @@ export async function probeCommand({ projectDir, values, version }, io) {
21
21
  io.err('claims file declares no claims; nothing to verify');
22
22
  return 2;
23
23
  }
24
- const outPath = values.out ? resolve(values.out) : evidencePath(projectDir);
24
+ const only = values.claim ? values.claim.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
25
+ // A --claim run is partial evidence; keep it away from the canonical file unless --out says otherwise.
26
+ const outPath = values.out ? resolve(values.out) : only ? join(projectDir, '.testguard', 'evidence-partial.json') : evidencePath(projectDir);
25
27
  const previous = !values['no-reuse'] && existsSync(outPath) ? readSpecDoc('evidence', outPath) : undefined;
26
28
  const basePath = values.baseline ? resolve(values.baseline) : baselinePath(projectDir);
27
29
  const baseline = existsSync(basePath) ? readSpecDoc('baseline', basePath) : undefined;
@@ -34,6 +36,9 @@ export async function probeCommand({ projectDir, values, version }, io) {
34
36
  budgetMs,
35
37
  mode: values['in-place'] ? 'in-place' : 'worktree',
36
38
  ref: values.ref,
39
+ runnerCommand: values['runner-cmd'],
40
+ nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
41
+ only,
37
42
  escalate: !values['no-escalate'],
38
43
  toolVersion: version,
39
44
  onProgress: values.quiet ? undefined : (r) => io.out(renderRecord(r) + (r.reusedFrom ? ' (reused)' : '')),
@@ -41,13 +46,13 @@ export async function probeCommand({ projectDir, values, version }, io) {
41
46
  writeSpecDoc('evidence', outPath, evidence);
42
47
 
43
48
  const g = gate(evidence.records, baseline, { severityFloor: values.severity });
44
- if (!values.quiet) {
49
+ if (!values.quiet && baseline) {
45
50
  io.out('');
46
51
  const tag = (r) => (g.new.includes(r) ? '[NEW] ' : g.baselined.includes(r) ? '[baseline] ' : '[below floor] ');
47
52
  for (const r of sortForReport(evidence.records).filter((x) => x.verdict !== 'killed')) io.out(' ' + tag(r) + renderRecord(r));
48
53
  }
49
54
  io.out('');
50
55
  io.out(renderSummary(evidence.records) + (baseline ? ` ${g.new.length} new since baseline, ${g.baselined.length} baselined.` : ' No baseline.'));
51
- io.out(`evidence: ${outPath}`);
56
+ io.out(`evidence: ${outPath}${only ? ` (partial: --claim ${only.join(',')}; not the canonical evidence file)` : ''}`);
52
57
  return g.new.length > 0 ? 1 : 0;
53
58
  }
@@ -12,7 +12,7 @@ import { fingerprint } from '../../spec/lib/fingerprint.mjs';
12
12
 
13
13
  const isKill = (r) => r.outcome === 'fail' && r.assertionFailures > 0;
14
14
 
15
- function runnerVersion(projectDir) {
15
+ function readRunnerVersion(projectDir) {
16
16
  try {
17
17
  return JSON.parse(readFileSync(createRequire(join(projectDir, 'noop.js')).resolve('vitest/package.json'), 'utf8')).version;
18
18
  } catch {
@@ -31,6 +31,9 @@ export async function probe({
31
31
  mode = 'worktree',
32
32
  ref = 'HEAD',
33
33
  budgetMs = 120_000,
34
+ runnerCommand,
35
+ nodeModules,
36
+ only,
34
37
  escalate = true,
35
38
  scratchBase,
36
39
  toolVersion = '0.0.0',
@@ -45,21 +48,39 @@ export async function probe({
45
48
 
46
49
  const targets = [...new Set(claims.claims.flatMap((c) => c.faults.map((f) => relative(root, join(projectDir, f.file)))))];
47
50
  if (mode === 'in-place' && isDirty(root, targets)) {
48
- throw new PreconditionError(`uncommitted changes in target files (${targets.join(', ')}); commit or stash first, or drop --in-place`);
51
+ throw new PreconditionError(`uncommitted changes in fault target files (${targets.join(', ')}); commit or stash them, or drop --in-place. Only the files faults are applied to must be clean — test files may be dirty, which is what makes --in-place usable while writing tests.`);
52
+ }
53
+ const selected = only ? new Set(only) : null;
54
+ if (selected) {
55
+ const known = new Set(claims.claims.map((c) => c.id));
56
+ const unknown = [...selected].filter((id) => !known.has(id));
57
+ if (unknown.length) throw new PreconditionError(`--claim: unknown claim id(s) ${unknown.join(', ')}`);
49
58
  }
50
59
 
51
60
  const startedAt = new Date().toISOString();
52
- const iso = mode === 'worktree' ? createScratch({ repoRoot: root, projectDir, ref, scratchBase }) : inPlace({ repoRoot: root, projectDir });
61
+ const iso = mode === 'worktree' ? createScratch({ repoRoot: root, projectDir, ref, scratchBase, nodeModules }) : inPlace({ repoRoot: root, projectDir });
53
62
  const records = [];
63
+ let runnerVersion;
54
64
  try {
65
+ const commandTemplate = runnerCommand ? vitest.parseCommandTemplate(runnerCommand) : undefined;
66
+ if (!commandTemplate) {
67
+ // An unresolvable runner is a precondition failure, not a flaky defender.
68
+ const check = await vitest.checkRunner({ projectDir: iso.projectDir });
69
+ if (!check.ok) {
70
+ throw new PreconditionError(`test runner is not resolvable in the ${mode === 'worktree' ? 'scratch worktree' : 'project'} (${check.message}). ` +
71
+ (mode === 'worktree' ? 'No usable node_modules was linked: pass --node-modules <path>, or run with --in-place.' : 'Install dependencies first.'));
72
+ }
73
+ runnerVersion = check.version;
74
+ }
55
75
  const allTests = vitest.listTestFiles(iso.projectDir);
56
76
  const baselineCache = new Map();
57
77
  const prior = previous && previous.run.confirmRuns === confirmRuns
58
78
  ? new Map(previous.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]))
59
79
  : new Map();
60
- const runDefenders = (files) => vitest.runVitest({ projectDir: iso.projectDir, files, budgetMs });
80
+ const runDefenders = (files) => vitest.runVitest({ projectDir: iso.projectDir, files, budgetMs, commandTemplate });
61
81
 
62
82
  for (const claim of claims.claims) {
83
+ if (selected && !selected.has(claim.id)) continue;
63
84
  const defenders = vitest.resolveDefenders(iso.projectDir, claim.defendedBy);
64
85
  for (const fault of claim.faults) {
65
86
  const record = await probeOne({ claim, fault, defenders, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, prior: prior.get(`${claim.id}/${fault.id}`), priorRunId: previous?.run.id });
@@ -79,7 +100,7 @@ export async function probe({
79
100
  startedAt,
80
101
  finishedAt: new Date().toISOString(),
81
102
  repo: { head, dirty: isDirty(root) },
82
- runner: { name: vitest.name, ...(runnerVersion(projectDir) ? { version: runnerVersion(projectDir) } : {}) },
103
+ runner: { name: vitest.name, ...((runnerVersion ?? readRunnerVersion(projectDir)) ? { version: runnerVersion ?? readRunnerVersion(projectDir) } : {}) },
83
104
  confirmRuns,
84
105
  mode,
85
106
  },
@@ -111,14 +132,24 @@ async function probeOne({ claim, fault, defenders, allTests, iso, confirmRuns, e
111
132
  const key = defenders.join('\n');
112
133
  if (!baselineCache.has(key)) {
113
134
  const runs = [];
135
+ let loadMessage;
114
136
  for (let i = 0; i < confirmRuns; i++) {
115
- const { run } = await runDefenders(defenders);
116
- runs.push(run);
117
- if (run.outcome !== 'pass') break;
137
+ const res = await runDefenders(defenders);
138
+ runs.push(res.run);
139
+ if (res.run.outcome !== 'pass') {
140
+ loadMessage = res.loadMessage;
141
+ break;
142
+ }
118
143
  }
119
- baselineCache.set(key, runs);
144
+ baselineCache.set(key, { runs, loadMessage });
145
+ }
146
+ const baseline = baselineCache.get(key);
147
+ detail.baselineRuns = baseline.runs;
148
+ if (baseline.runs.some((r) => r.outcome === 'error')) {
149
+ // The defenders did not load. That is not flakiness and not a verdict
150
+ // about the fault; the claim cannot be probed until they do.
151
+ anchor = { status: 'defenders-failed-to-load', hits: anchor.hits, expected: anchor.expected, message: baseline.loadMessage };
120
152
  }
121
- detail.baselineRuns = baselineCache.get(key);
122
153
 
123
154
  if (detail.baselineRuns.every((r) => r.outcome === 'pass') && detail.baselineRuns.length === confirmRuns) {
124
155
  const mutation = applyFault(iso.projectDir, fault);
@@ -0,0 +1,130 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { dirname, join, resolve, extname } from 'node:path';
3
+ import { walk } from '../util/glob.mjs';
4
+
5
+ const aliasCache = new Map();
6
+
7
+ /** Minimal JSONC: strip comments and trailing commas, as tsconfig allows. */
8
+ function parseJsonc(text) {
9
+ return JSON.parse(text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1'));
10
+ }
11
+
12
+ /**
13
+ * Alias rules for a project: tsconfig/jsconfig `compilerOptions.paths` (with
14
+ * `baseUrl`, following a relative `extends` a few levels) and package.json
15
+ * `imports`. Bare specifiers that match no rule are ignored, as documented.
16
+ */
17
+ export function loadAliases(projectDir) {
18
+ if (aliasCache.has(projectDir)) return aliasCache.get(projectDir);
19
+ const rules = [];
20
+ const toRule = (pattern, targets, base) => {
21
+ const [prefix, suffix = ''] = pattern.split('*');
22
+ rules.push({ prefix, suffix, wildcard: pattern.includes('*'), targets: targets.map((t) => resolve(base, t)) });
23
+ };
24
+
25
+ let cfgPath = ['tsconfig.json', 'jsconfig.json'].map((f) => join(projectDir, f)).find(existsSync);
26
+ let baseUrl;
27
+ let paths;
28
+ for (let hop = 0; cfgPath && hop < 5; hop++) {
29
+ let cfg;
30
+ try {
31
+ cfg = parseJsonc(readFileSync(cfgPath, 'utf8'));
32
+ } catch {
33
+ break;
34
+ }
35
+ const co = cfg.compilerOptions ?? {};
36
+ if (baseUrl === undefined && co.baseUrl !== undefined) baseUrl = resolve(dirname(cfgPath), co.baseUrl);
37
+ if (paths === undefined && co.paths !== undefined) paths = { dir: dirname(cfgPath), map: co.paths };
38
+ const ext = typeof cfg.extends === 'string' && cfg.extends.startsWith('.') ? cfg.extends : null;
39
+ cfgPath = ext ? resolve(dirname(cfgPath), ext.endsWith('.json') ? ext : ext + '.json') : null;
40
+ }
41
+ if (paths) {
42
+ const base = baseUrl ?? paths.dir;
43
+ for (const [pattern, targets] of Object.entries(paths.map)) if (Array.isArray(targets)) toRule(pattern, targets, base);
44
+ }
45
+
46
+ const pkgPath = join(projectDir, 'package.json');
47
+ if (existsSync(pkgPath)) {
48
+ try {
49
+ const imports = JSON.parse(readFileSync(pkgPath, 'utf8')).imports ?? {};
50
+ for (const [pattern, target] of Object.entries(imports)) {
51
+ const t = typeof target === 'string' ? target : target?.default ?? target?.import ?? target?.node;
52
+ if (typeof t === 'string' && t.startsWith('.')) toRule(pattern, [t], projectDir);
53
+ }
54
+ } catch {}
55
+ }
56
+ aliasCache.set(projectDir, rules);
57
+ return rules;
58
+ }
59
+
60
+ /** Absolute candidate bases an aliased specifier could mean, or [] if it matches no rule. */
61
+ function aliasCandidates(specifier, rules) {
62
+ const out = [];
63
+ for (const r of rules) {
64
+ if (r.wildcard) {
65
+ if (specifier.startsWith(r.prefix) && specifier.endsWith(r.suffix) && specifier.length >= r.prefix.length + r.suffix.length) {
66
+ const star = specifier.slice(r.prefix.length, specifier.length - r.suffix.length);
67
+ for (const t of r.targets) out.push(t.replace('*', star));
68
+ }
69
+ } else if (specifier === r.prefix) {
70
+ out.push(...r.targets);
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ const SOURCE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx']);
77
+ const IMPORT_RE = /(?:from\s*|import\s*\(?\s*|require\s*\(\s*)['"]([^'"]+)['"]/g;
78
+ const TEST_RE = /\.(test|spec)\.[cm]?[jt]sx?$/;
79
+
80
+ const SEVERITY_WEIGHT = { critical: 8, high: 4, medium: 2, low: 1 };
81
+ // A claim written by the same agent that wrote the tests carries less
82
+ // independent evidence than one from a human-governed spec.
83
+ const SOURCE_WEIGHT = { spec: 1, adr: 1, manual: 0.9, annotation: 0.75, comment: 0.75 };
84
+
85
+ function expandBase(base) {
86
+ const candidates = [base, ...[...SOURCE_EXT].map((e) => base + e), ...[...SOURCE_EXT].map((e) => join(base, 'index' + e))];
87
+ const stripped = base.replace(/\.(js|mjs|cjs)$/, '');
88
+ candidates.push(stripped + '.ts', stripped + '.mts', stripped + '.tsx');
89
+ return candidates;
90
+ }
91
+
92
+ function resolvesTo(fromFile, specifier, targetAbs, aliases) {
93
+ const bases = specifier.startsWith('.') ? [resolve(dirname(fromFile), specifier)] : aliasCandidates(specifier, aliases);
94
+ return bases.some((b) => expandBase(b).includes(targetAbs));
95
+ }
96
+
97
+ /**
98
+ * Number of non-test source files that import `targetRel`: relative specifiers,
99
+ * tsconfig/jsconfig `paths` aliases and package.json `imports` are resolved;
100
+ * bare package specifiers are not. Direct imports only.
101
+ */
102
+ export function blastRadius(projectDir, targetRel) {
103
+ const targetAbs = resolve(projectDir, targetRel);
104
+ const aliases = loadAliases(projectDir);
105
+ let count = 0;
106
+ for (const rel of walk(projectDir)) {
107
+ if (!SOURCE_EXT.has(extname(rel)) || TEST_RE.test(rel) || rel === targetRel) continue;
108
+ const abs = join(projectDir, rel);
109
+ let src;
110
+ try {
111
+ src = readFileSync(abs, 'utf8');
112
+ } catch {
113
+ continue;
114
+ }
115
+ for (const m of src.matchAll(IMPORT_RE)) {
116
+ if (resolvesTo(abs, m[1], targetAbs, aliases)) {
117
+ count++;
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ return count;
123
+ }
124
+
125
+ /** Additive ordering only. Never touches the verdict. */
126
+ export function rank({ severity, sourceKind, blast }) {
127
+ const score = SEVERITY_WEIGHT[severity] * (SOURCE_WEIGHT[sourceKind] ?? 0.9) * (1 + Math.log2(1 + blast));
128
+ return { score: Number(score.toFixed(3)), blastRadius: blast, tier: severity };
129
+ }
130
+