testguard-cli 0.4.0 → 0.6.0

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 (51) hide show
  1. package/CHANGELOG.md +291 -0
  2. package/README.md +321 -27
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +109 -1
  5. package/spec/README.md +4 -2
  6. package/spec/lib/validate.mjs +75 -1
  7. package/spec/schemas/baseline.schema.json +8 -0
  8. package/spec/schemas/brief.schema.json +11 -1
  9. package/spec/schemas/common.schema.json +32 -1
  10. package/spec/schemas/evidence.schema.json +374 -63
  11. package/spec/schemas/gate.schema.json +52 -0
  12. package/spec/schemas/replay.schema.json +85 -0
  13. package/spec/schemas/status.schema.json +269 -45
  14. package/src/admit/admit.mjs +17 -0
  15. package/src/baseline/baseline.mjs +31 -2
  16. package/src/brief/brief.mjs +96 -8
  17. package/src/claims/removed.mjs +161 -0
  18. package/src/cli.mjs +69 -12
  19. package/src/commands/admit.mjs +116 -0
  20. package/src/commands/baseline.mjs +17 -3
  21. package/src/commands/brief.mjs +26 -12
  22. package/src/commands/claims.mjs +35 -6
  23. package/src/commands/gate.mjs +47 -0
  24. package/src/commands/init.mjs +19 -6
  25. package/src/commands/mcp.mjs +12 -0
  26. package/src/commands/probe.mjs +12 -3
  27. package/src/commands/replay.mjs +63 -0
  28. package/src/commands/status.mjs +4 -1
  29. package/src/gate/changed.mjs +222 -0
  30. package/src/git.mjs +5 -0
  31. package/src/init/init.mjs +182 -29
  32. package/src/init/templates/SKILL.md +21 -3
  33. package/src/mcp/server.mjs +104 -0
  34. package/src/mcp/tools.mjs +114 -0
  35. package/src/probe/contention.mjs +60 -0
  36. package/src/probe/discover.mjs +12 -3
  37. package/src/probe/independence.mjs +59 -0
  38. package/src/probe/mocks.mjs +120 -0
  39. package/src/probe/probe.mjs +126 -18
  40. package/src/probe/rank.mjs +73 -15
  41. package/src/probe/runners/index.mjs +49 -5
  42. package/src/probe/runners/jest.mjs +3 -3
  43. package/src/probe/runners/playwright.mjs +133 -0
  44. package/src/probe/runners/shared.mjs +70 -13
  45. package/src/probe/runners/vitest.mjs +4 -3
  46. package/src/render.mjs +2 -2
  47. package/src/replay/label.mjs +64 -0
  48. package/src/replay/replay.mjs +309 -0
  49. package/src/scaffold/producers.mjs +217 -6
  50. package/src/scaffold/scaffold.mjs +9 -3
  51. package/src/status/status.mjs +88 -7
@@ -6,7 +6,7 @@ import { fingerprint } from './fingerprint.mjs';
6
6
 
7
7
  const schemaDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'schemas');
8
8
 
9
- export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status']);
9
+ export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status', 'gate', 'replay']);
10
10
  export const PASSING_VERDICTS = Object.freeze(new Set(['killed']));
11
11
 
12
12
  const ajv = new Ajv2020({ strict: true, allErrors: true });
@@ -47,6 +47,8 @@ const semantic = {
47
47
  const n = doc.run.confirmRuns;
48
48
  if (n < 3 && doc.run.provisional !== true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is below 3; the run must declare provisional: true` });
49
49
  if (n >= 3 && doc.run.provisional === true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is confirmed; provisional must be absent or false` });
50
+ if (doc.run.runners && doc.run.runner && !doc.run.runners.some((x) => x.name === doc.run.runner.name)) errors.push({ path: '/run/runners', message: 'runners must include the project runner named in run.runner' });
51
+ if (doc.run.repo.ignoredDirty && doc.run.repo.snapshot) errors.push({ path: '/run/repo/ignoredDirty', message: 'a working-tree snapshot has no ignored dirty files: the tree was probed as it is' });
50
52
  doc.records.forEach((r, i) => {
51
53
  const p = `/records/${i}`;
52
54
  const expected = fingerprint({ claimId: r.claim.id, subjectId: r.subject.id, file: r.subject.file ?? '', verdict: r.verdict });
@@ -74,9 +76,31 @@ const semantic = {
74
76
  if (r.verdict === 'nocover' && !r.defenders.nocover) {
75
77
  errors.push({ path: `${p}/defenders/nocover`, message: 'nocover verdict requires defenders.nocover = true' });
76
78
  }
79
+ if (r.detail.flakeRate) {
80
+ const { runs, failures } = r.detail.flakeRate;
81
+ if (failures > runs) errors.push({ path: `${p}/detail/flakeRate`, message: `flakeRate failures (${failures}) exceed runs (${runs})` });
82
+ if (failures > 0 && r.verdict !== 'flaky-defender') errors.push({ path: `${p}/detail/flakeRate`, message: 'a defender that failed on unmodified source makes the verdict flaky-defender' });
83
+ }
84
+ if (r.detail.independence) {
85
+ if (r.verdict !== 'killed') errors.push({ path: `${p}/detail/independence`, message: 'independence is a property of a kill; only killed records carry it' });
86
+ const { class: cls, defenderCommit, targetCommit } = r.detail.independence;
87
+ if (cls !== 'unknown' && !(defenderCommit && targetCommit)) errors.push({ path: `${p}/detail/independence`, message: `independence class ${cls} requires both defenderCommit and targetCommit` });
88
+ }
77
89
  if (r.detail.undeclaredKillers && r.detail.reason !== 'killed-by-undeclared-tests') {
78
90
  errors.push({ path: `${p}/detail/undeclaredKillers`, message: 'undeclaredKillers is only meaningful with reason killed-by-undeclared-tests' });
79
91
  }
92
+ if (r.defenders.byRunner) {
93
+ const resolved = new Set(r.defenders.resolved);
94
+ for (const [runner, files] of Object.entries(r.defenders.byRunner)) for (const f of files) if (!resolved.has(f)) errors.push({ path: `${p}/defenders/byRunner/${runner}`, message: `${f} ran under ${runner} but is not a resolved defender` });
95
+ if (Object.keys(r.defenders.byRunner).length < 2) errors.push({ path: `${p}/defenders/byRunner`, message: 'byRunner is only meaningful when more than one runner ran the defenders' });
96
+ }
97
+ if (r.defenders.mocking && r.defenders.discovered) {
98
+ for (const f of r.defenders.mocking) if (r.defenders.resolved.includes(f)) errors.push({ path: `${p}/defenders/mocking`, message: `${f} mocks the subject and was discovered; it cannot also be a resolved defender` });
99
+ }
100
+ for (const s of r.defenders.signals ?? []) {
101
+ if (s.signal === 'unasserted-annotated' && !s.reason) errors.push({ path: `${p}/defenders/signals`, message: `unasserted-annotated on ${s.file} requires the annotation's reason` });
102
+ if (!(r.defenders.mocking ?? []).includes(s.file)) errors.push({ path: `${p}/defenders/signals`, message: `${s.file} carries a mock signal but is not listed in defenders.mocking` });
103
+ }
80
104
  if (r.verdict === 'unverifiable' && !r.detail.reason) {
81
105
  errors.push({ path: `${p}/detail/reason`, message: 'unverifiable requires a reason (e.g. anchor-missing, anchor-ambiguous)' });
82
106
  }
@@ -99,8 +123,58 @@ const semantic = {
99
123
  status(doc) {
100
124
  const errors = [];
101
125
  if (['write-test', 'review-fault-change'].includes(doc.next.action) && !doc.next.target) errors.push({ path: '/next/target', message: `${doc.next.action} requires a target` });
126
+ if (doc.next.action === 'claim' && !doc.next.file) errors.push({ path: '/next/file', message: 'claim requires the file the claim is about' });
102
127
  if (doc.state === 'no-claims' && doc.counts.claims !== 0) errors.push({ path: '/counts/claims', message: 'no-claims with a non-zero claim count' });
103
128
  if (doc.state === 'clean' && (doc.counts.new ?? 0) > 0) errors.push({ path: '/state', message: 'clean with new findings' });
129
+ if (doc.state === 'unclaimed-changes' && !(doc.changes && doc.changes.uncovered.length > 0)) errors.push({ path: '/changes/uncovered', message: 'unclaimed-changes requires at least one uncovered changed file' });
130
+ if (doc.state === 'unclaimed-changes' && doc.next.action !== 'claim') errors.push({ path: '/next/action', message: 'unclaimed-changes requires next.action = claim' });
131
+ if (doc.evidenceSource && !doc.evidenceHead) errors.push({ path: '/evidenceHead', message: 'evidence was read, so the commit it describes must be recorded' });
132
+ if (doc.changes && doc.changes.uncovered.length > 0 && !['no-claims', 'unclaimed-changes'].includes(doc.state)) errors.push({ path: '/state', message: 'uncovered changed files are hidden behind a later state; unclaimed-changes precedes every evidence state' });
133
+ return errors;
134
+ },
135
+
136
+ replay(doc) {
137
+ const errors = [];
138
+ const n = doc.run.confirmRuns;
139
+ const seen = new Set();
140
+ doc.records.forEach((r, i) => {
141
+ const p = `/records/${i}`;
142
+ // One patch, one row: a dual-branch topology carries the same fix under
143
+ // two or three shas, and counting it twice corrupts the corpus the
144
+ // calibration is computed from.
145
+ if (seen.has(r.patchId)) errors.push({ path: `${p}/patchId`, message: `duplicate patch-id ${r.patchId.slice(0, 12)}: the same fix counted twice` });
146
+ seen.add(r.patchId);
147
+ if (['caught', 'blind'].includes(r.verdict) && r.runs.length !== n) {
148
+ errors.push({ path: `${p}/runs`, message: `${r.verdict} requires exactly confirmRuns (${n}) runs, got ${r.runs.length}` });
149
+ }
150
+ // Only a test body rejecting the reverted source counts as caught —
151
+ // the same rule as `killed`, for the same reason.
152
+ if (r.verdict === 'caught' && !r.runs.every((x) => x.outcome === 'fail' && (x.assertionFailures ?? 0) > 0)) {
153
+ errors.push({ path: `${p}/runs`, message: 'caught requires every run to fail with at least one assertion failure' });
154
+ }
155
+ if (r.verdict === 'blind' && !r.runs.every((x) => x.outcome === 'pass')) {
156
+ errors.push({ path: `${p}/runs`, message: 'blind requires every run to pass: the suite stayed green on known-broken code' });
157
+ }
158
+ if (r.verdict === 'flaky' && !(r.runs.some((x) => x.outcome === 'pass') && r.runs.some((x) => x.outcome === 'fail'))) {
159
+ errors.push({ path: `${p}/runs`, message: 'flaky requires runs that disagree' });
160
+ }
161
+ if (r.verdict === 'nocover' && r.ranTests) errors.push({ path: `${p}/ranTests`, message: 'nocover means no test exercises the reverted files; it cannot have run tests' });
162
+ if (r.verdict === 'unverifiable' && !r.reason) errors.push({ path: `${p}/reason`, message: 'unverifiable requires a reason (e.g. revert-did-not-apply, suite-failed-to-load)' });
163
+ });
164
+ return errors;
165
+ },
166
+
167
+ gate(doc) {
168
+ const errors = [];
169
+ if (doc.evaluated !== doc.covered.length + doc.uncovered.length) errors.push({ path: '/evaluated', message: `evaluated (${doc.evaluated}) must equal covered (${doc.covered.length}) + uncovered (${doc.uncovered.length})` });
170
+ if (doc.changed !== doc.evaluated + doc.excluded.length) errors.push({ path: '/changed', message: `changed (${doc.changed}) must equal evaluated (${doc.evaluated}) + excluded (${doc.excluded.length})` });
171
+ if (doc.uncovered.length > 0 && doc.exitCode !== 1) errors.push({ path: '/exitCode', message: 'an uncovered changed file must exit 1; the gate never passes over unclaimed code' });
172
+ if (doc.uncovered.length === 0 && doc.exitCode === 1 && !(doc.strict && doc.changed > 0 && doc.evaluated === 0)) errors.push({ path: '/exitCode', message: 'exit 1 without uncovered files is only valid under --strict when a non-empty change evaluated nothing' });
173
+ doc.covered.forEach((c, i) => {
174
+ if (c.by === 'ignore' && !c.pattern) errors.push({ path: `/covered/${i}/pattern`, message: 'a file covered by an ignore entry must name the pattern that excused it' });
175
+ if (c.by !== 'ignore' && !c.claimIds?.length) errors.push({ path: `/covered/${i}/claimIds`, message: `a file covered by a ${c.by} must name the claim(s)` });
176
+ });
177
+ for (const e of doc.expired) if (!e.expires) errors.push({ path: '/expired', message: `expired entry "${e.pattern}" has no expires instant` });
104
178
  return errors;
105
179
  },
106
180
 
@@ -15,6 +15,14 @@
15
15
  "description": "Whether the working tree had uncommitted changes when the evidence behind this baseline was taken.",
16
16
  "type": "boolean"
17
17
  },
18
+ "snapshot": {
19
+ "description": "When the evidence behind this baseline was a working-tree snapshot: that throwaway commit. `head` is then the parent of the commit that will carry the tests; `baseline --restamp` moves `head` once a clean probe reproduces the same fingerprints.",
20
+ "$ref": "urn:guard-spec:v1:common#/$defs/gitSha"
21
+ },
22
+ "restampedAt": {
23
+ "description": "Set when `head` was moved by `baseline --restamp` (same fingerprints, clean tree). `createdAt` keeps the original freeze time.",
24
+ "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime"
25
+ },
18
26
  "fingerprints": {
19
27
  "type": "object",
20
28
  "propertyNames": { "pattern": "^[a-f0-9]{64}$" },
@@ -39,12 +39,22 @@
39
39
  "type": "object",
40
40
  "required": ["action", "command", "why"],
41
41
  "properties": {
42
- "action": { "enum": ["scaffold", "probe", "write-test", "baseline", "review-fault-change", "none"] },
42
+ "action": { "enum": ["scaffold", "claim", "probe", "write-test", "baseline", "review-fault-change", "none"] },
43
43
  "command": { "type": "string", "minLength": 1, "maxLength": 1024 },
44
44
  "why": { "type": "string", "minLength": 1, "maxLength": 2000 }
45
45
  },
46
46
  "additionalProperties": false
47
47
  },
48
+ "unclaimed": {
49
+ "description": "Changed files that carry no claim, when the brief was built with a change reference. Rendered before the findings: an agent writes the claim before more code.",
50
+ "type": "object",
51
+ "required": ["ref", "files"],
52
+ "properties": {
53
+ "ref": { "type": "string", "minLength": 1, "maxLength": 256 },
54
+ "files": { "type": "array", "minItems": 1, "items": { "$ref": "urn:guard-spec:v1:common#/$defs/uncoveredFile" } }
55
+ },
56
+ "additionalProperties": false
57
+ },
48
58
  "items": {
49
59
  "type": "array",
50
60
  "maxItems": 50,
@@ -43,7 +43,7 @@
43
43
  ]
44
44
  },
45
45
  "faultClass": {
46
- "description": "Taxonomy of the injected change. Required on every fault so calibration (fault class → real-bug predictiveness) can be computed later.",
46
+ "description": "Taxonomy of the injected change. Required on every fault so calibration (fault class → real-bug predictiveness) can be computed later. `field-dropped` = a field removed from a persisted/transmitted object, allow-list, schema or merge; `argument-swapped` = a call kept with a parameter-derived argument replaced by a trivial value; `element-removed` = a UI element no longer rendered; `handler-dropped` = an event handler no longer wired.",
47
47
  "enum": [
48
48
  "guard-removed",
49
49
  "condition-forced",
@@ -53,6 +53,10 @@
53
53
  "call-removed",
54
54
  "return-altered",
55
55
  "exception-swallowed",
56
+ "field-dropped",
57
+ "argument-swapped",
58
+ "element-removed",
59
+ "handler-dropped",
56
60
  "other"
57
61
  ]
58
62
  },
@@ -77,6 +81,33 @@
77
81
  },
78
82
  "additionalProperties": false
79
83
  },
84
+ "uncoveredFile": {
85
+ "description": "A changed source or test file that no claim covers and no unexpired ignore entry excuses. Shared by the gate and status documents.",
86
+ "type": "object",
87
+ "required": ["file", "kind", "suggestion"],
88
+ "properties": {
89
+ "file": { "$ref": "#/$defs/repoPath" },
90
+ "kind": { "enum": ["source", "test"] },
91
+ "nearestClaimId": {
92
+ "description": "The claim whose fault files share the longest directory prefix with this file — where to attach, not a verdict.",
93
+ "$ref": "#/$defs/identifier"
94
+ },
95
+ "suggestion": { "type": "string", "minLength": 1, "maxLength": 1024 }
96
+ },
97
+ "additionalProperties": false
98
+ },
99
+ "ignoreReliance": {
100
+ "description": "An ignore entry the gate relied on (or would have, had it not expired), with the changed files it matched.",
101
+ "type": "object",
102
+ "required": ["pattern", "reason", "files"],
103
+ "properties": {
104
+ "pattern": { "type": "string", "minLength": 1, "maxLength": 512 },
105
+ "reason": { "type": "string", "minLength": 8, "maxLength": 2000 },
106
+ "expires": { "$ref": "#/$defs/isoDateTime" },
107
+ "files": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/repoPath" } }
108
+ },
109
+ "additionalProperties": false
110
+ },
80
111
  "annotation": {
81
112
  "description": "Strictly additive. An annotation never changes, suppresses, or drops a finding; it only attaches context to it.",
82
113
  "type": "object",