testguard-cli 0.5.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 (47) hide show
  1. package/CHANGELOG.md +255 -0
  2. package/README.md +275 -31
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +58 -1
  5. package/spec/README.md +1 -0
  6. package/spec/lib/validate.mjs +57 -1
  7. package/spec/schemas/baseline.schema.json +8 -0
  8. package/spec/schemas/common.schema.json +5 -1
  9. package/spec/schemas/evidence.schema.json +374 -63
  10. package/spec/schemas/replay.schema.json +85 -0
  11. package/spec/schemas/status.schema.json +256 -54
  12. package/src/admit/admit.mjs +17 -0
  13. package/src/baseline/baseline.mjs +31 -2
  14. package/src/brief/brief.mjs +71 -7
  15. package/src/claims/removed.mjs +161 -0
  16. package/src/cli.mjs +53 -10
  17. package/src/commands/admit.mjs +116 -0
  18. package/src/commands/baseline.mjs +17 -3
  19. package/src/commands/brief.mjs +13 -7
  20. package/src/commands/claims.mjs +35 -6
  21. package/src/commands/init.mjs +19 -6
  22. package/src/commands/mcp.mjs +12 -0
  23. package/src/commands/probe.mjs +11 -3
  24. package/src/commands/replay.mjs +63 -0
  25. package/src/commands/status.mjs +3 -1
  26. package/src/git.mjs +5 -0
  27. package/src/init/init.mjs +182 -29
  28. package/src/init/templates/SKILL.md +3 -3
  29. package/src/mcp/server.mjs +104 -0
  30. package/src/mcp/tools.mjs +114 -0
  31. package/src/probe/contention.mjs +60 -0
  32. package/src/probe/discover.mjs +12 -3
  33. package/src/probe/independence.mjs +59 -0
  34. package/src/probe/mocks.mjs +120 -0
  35. package/src/probe/probe.mjs +126 -18
  36. package/src/probe/rank.mjs +73 -15
  37. package/src/probe/runners/index.mjs +49 -5
  38. package/src/probe/runners/jest.mjs +3 -3
  39. package/src/probe/runners/playwright.mjs +133 -0
  40. package/src/probe/runners/shared.mjs +70 -13
  41. package/src/probe/runners/vitest.mjs +4 -3
  42. package/src/render.mjs +2 -2
  43. package/src/replay/label.mjs +64 -0
  44. package/src/replay/replay.mjs +309 -0
  45. package/src/scaffold/producers.mjs +217 -6
  46. package/src/scaffold/scaffold.mjs +9 -3
  47. package/src/status/status.mjs +47 -6
@@ -0,0 +1,85 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:guard-spec:v1:replay",
4
+ "title": "Guard spec v1 — bug replay",
5
+ "description": "Would this suite have caught the bugs that actually escaped? For each fix commit in a range: revert only the source to its parent, remove the test the fix shipped, run what remains. `blind` means the suite was blind to a bug that really shipped. Unlike an injected fault, a replayed bug is ground truth — a human already confirmed it was a defect — which is what makes this the oracle a fault model is calibrated against.",
6
+ "type": "object",
7
+ "required": ["schemaVersion", "tool", "run", "records"],
8
+ "properties": {
9
+ "$schema": { "type": "string" },
10
+ "schemaVersion": { "const": 1 },
11
+ "tool": { "$ref": "urn:guard-spec:v1:common#/$defs/tool" },
12
+ "run": {
13
+ "type": "object",
14
+ "required": ["id", "startedAt", "range", "confirmRuns"],
15
+ "properties": {
16
+ "id": { "type": "string", "minLength": 1, "maxLength": 128 },
17
+ "startedAt": { "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime" },
18
+ "finishedAt": { "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime" },
19
+ "range": {
20
+ "description": "The commit range searched for fix commits, as given.",
21
+ "type": "string",
22
+ "minLength": 1,
23
+ "maxLength": 256
24
+ },
25
+ "runner": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string" }, "version": { "type": "string" } }, "additionalProperties": false },
26
+ "confirmRuns": { "type": "integer", "minimum": 1 },
27
+ "candidates": {
28
+ "description": "Fix commits found in the range before de-duplication by patch-id.",
29
+ "type": "integer",
30
+ "minimum": 0
31
+ },
32
+ "duplicates": {
33
+ "description": "Commits dropped as the same patch under another sha. A dual-branch topology carries the same fix two or three times; counting it twice corrupts the corpus.",
34
+ "type": "integer",
35
+ "minimum": 0
36
+ }
37
+ },
38
+ "additionalProperties": false
39
+ },
40
+ "records": {
41
+ "type": "array",
42
+ "items": {
43
+ "type": "object",
44
+ "required": ["commit", "patchId", "verdict", "source", "removedTests", "runs"],
45
+ "properties": {
46
+ "commit": { "$ref": "urn:guard-spec:v1:common#/$defs/gitSha" },
47
+ "patchId": {
48
+ "description": "`git patch-id --stable` of the fix: the identity of the CHANGE rather than of the commit. A dual-branch topology carries the same fix under several shas, and counting it twice corrupts the corpus. Hex, 40 characters for a SHA-1 repository and 64 for a SHA-256 one; the commit sha when git could not compute one.",
49
+ "type": "string",
50
+ "pattern": "^[a-f0-9]{40}(?:[a-f0-9]{24})?$"
51
+ },
52
+ "subject": { "description": "The fix commit's subject line, for a human reading the corpus.", "type": "string", "maxLength": 512 },
53
+ "verdict": {
54
+ "description": "`caught` = a remaining test failed by assertion on the reverted source, every run. `blind` = the suite stayed green on known-broken code. `nocover` = no test exercises the reverted files at all, which is worse than blind. `unverifiable` = nothing could be concluded: the revert did not apply, the suite could not load, it timed out, or the commit only ADDS source (`no-prior-version` \u2014 there is no earlier behaviour to have been blind to, so it is not a bug the suite could have caught and it never enters a calibration). `flaky` = the runs disagreed, so nothing can be concluded.",
55
+ "enum": ["caught", "blind", "nocover", "unverifiable", "flaky"]
56
+ },
57
+ "reason": { "type": "string", "maxLength": 256 },
58
+ "source": {
59
+ "description": "Source files reverted to the fix's parent.",
60
+ "type": "array",
61
+ "minItems": 1,
62
+ "items": { "$ref": "urn:guard-spec:v1:common#/$defs/repoPath" }
63
+ },
64
+ "removedTests": {
65
+ "description": "Test files the fix shipped, removed before the run: the fix's own test proves nothing about what the suite knew before it.",
66
+ "type": "array",
67
+ "items": { "$ref": "urn:guard-spec:v1:common#/$defs/repoPath" }
68
+ },
69
+ "ranTests": { "type": "integer", "minimum": 0 },
70
+ "faultClass": {
71
+ "description": "The injected-fault class this real diff most resembles, derived mechanically from the diff shape. This is the join key between ground truth and a fault model: it is what lets P(this fault class is a real bug) be computed.",
72
+ "$ref": "urn:guard-spec:v1:common#/$defs/faultClass"
73
+ },
74
+ "runs": {
75
+ "type": "array",
76
+ "minItems": 1,
77
+ "items": { "$ref": "urn:guard-spec:v1:evidence#/$defs/testRun" }
78
+ }
79
+ },
80
+ "additionalProperties": false
81
+ }
82
+ }
83
+ },
84
+ "additionalProperties": false
85
+ }
@@ -1,27 +1,72 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "urn:guard-spec:v1:status",
4
- "title": "Guard spec v1 — status",
4
+ "title": "Guard spec v1 \u2014 status",
5
5
  "description": "The single machine-readable answer to 'where is this project and what should happen next'. Every human rendering (CLI text, agent brief, skill instructions) derives from this document so they cannot disagree. Computed from the claims file, the evidence, the baseline and the current working tree; never from a cached verdict alone.",
6
6
  "type": "object",
7
- "required": ["schemaVersion", "tool", "generatedAt", "state", "next", "counts"],
7
+ "required": [
8
+ "schemaVersion",
9
+ "tool",
10
+ "generatedAt",
11
+ "state",
12
+ "next",
13
+ "counts"
14
+ ],
8
15
  "properties": {
9
- "$schema": { "type": "string" },
10
- "schemaVersion": { "const": 1 },
11
- "tool": { "$ref": "urn:guard-spec:v1:common#/$defs/tool" },
12
- "generatedAt": { "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime" },
16
+ "$schema": {
17
+ "type": "string"
18
+ },
19
+ "schemaVersion": {
20
+ "const": 1
21
+ },
22
+ "tool": {
23
+ "$ref": "urn:guard-spec:v1:common#/$defs/tool"
24
+ },
25
+ "generatedAt": {
26
+ "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime"
27
+ },
13
28
  "state": {
14
29
  "description": "no-claims: no claims file. unclaimed-changes: the change measured against a reference touches source files that carry no claim and no excusing ignore entry (only computed when a reference is known; it precedes every evidence state because the claim is written before more code). unprobed: claims but no confirmed evidence. evidence-stale: evidence exists but a claim, fault, target or defender changed since. provisional-only: only sub-N evidence exists. unproven: current evidence has findings not covered by the baseline. clean: every finding is killed or baselined.",
15
- "enum": ["no-claims", "unclaimed-changes", "unprobed", "evidence-stale", "provisional-only", "unproven", "clean"]
30
+ "enum": [
31
+ "no-claims",
32
+ "unclaimed-changes",
33
+ "unprobed",
34
+ "evidence-stale",
35
+ "provisional-only",
36
+ "unproven",
37
+ "clean"
38
+ ]
16
39
  },
17
40
  "next": {
18
41
  "description": "The one action that moves the project forward. `command` is a shell line when the action is a tool command, or an instruction when it is not (write-test, claim for a test file).",
19
42
  "type": "object",
20
- "required": ["action", "command", "why"],
43
+ "required": [
44
+ "action",
45
+ "command",
46
+ "why"
47
+ ],
21
48
  "properties": {
22
- "action": { "enum": ["scaffold", "claim", "probe", "write-test", "baseline", "review-fault-change", "none"] },
23
- "command": { "type": "string", "minLength": 1, "maxLength": 1024 },
24
- "why": { "type": "string", "minLength": 1, "maxLength": 2000 },
49
+ "action": {
50
+ "enum": [
51
+ "scaffold",
52
+ "claim",
53
+ "probe",
54
+ "write-test",
55
+ "baseline",
56
+ "review-fault-change",
57
+ "none"
58
+ ]
59
+ },
60
+ "command": {
61
+ "type": "string",
62
+ "minLength": 1,
63
+ "maxLength": 1024
64
+ },
65
+ "why": {
66
+ "type": "string",
67
+ "minLength": 1,
68
+ "maxLength": 2000
69
+ },
25
70
  "file": {
26
71
  "description": "For claim (and for scaffold when a change is known): the first uncovered changed file the action is about.",
27
72
  "$ref": "urn:guard-spec:v1:common#/$defs/repoPath"
@@ -29,38 +74,91 @@
29
74
  "target": {
30
75
  "description": "For write-test / review-fault-change: the finding the action is about.",
31
76
  "type": "object",
32
- "required": ["claimId", "subjectId"],
77
+ "required": [
78
+ "claimId",
79
+ "subjectId"
80
+ ],
33
81
  "properties": {
34
- "claimId": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
35
- "subjectId": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
36
- "file": { "$ref": "urn:guard-spec:v1:common#/$defs/repoPath" },
37
- "verdict": { "$ref": "urn:guard-spec:v1:common#/$defs/verdict" }
82
+ "claimId": {
83
+ "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
84
+ },
85
+ "subjectId": {
86
+ "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
87
+ },
88
+ "file": {
89
+ "$ref": "urn:guard-spec:v1:common#/$defs/repoPath"
90
+ },
91
+ "verdict": {
92
+ "$ref": "urn:guard-spec:v1:common#/$defs/verdict"
93
+ }
38
94
  },
39
95
  "additionalProperties": false
40
96
  }
41
97
  },
42
98
  "additionalProperties": false
43
99
  },
44
- "provisional": { "type": "boolean" },
100
+ "provisional": {
101
+ "type": "boolean"
102
+ },
45
103
  "counts": {
46
104
  "type": "object",
47
- "required": ["claims", "faults"],
105
+ "required": [
106
+ "claims",
107
+ "faults"
108
+ ],
48
109
  "properties": {
49
- "claims": { "type": "integer", "minimum": 0 },
50
- "faults": { "type": "integer", "minimum": 0 },
110
+ "claims": {
111
+ "type": "integer",
112
+ "minimum": 0
113
+ },
114
+ "faults": {
115
+ "type": "integer",
116
+ "minimum": 0
117
+ },
51
118
  "byVerdict": {
52
119
  "type": "object",
53
- "propertyNames": { "enum": ["killed", "survived", "nocover", "unverifiable", "timeout", "fault-invalid", "flaky-defender"] },
54
- "additionalProperties": { "type": "integer", "minimum": 0 }
120
+ "propertyNames": {
121
+ "enum": [
122
+ "killed",
123
+ "survived",
124
+ "nocover",
125
+ "unverifiable",
126
+ "timeout",
127
+ "fault-invalid",
128
+ "flaky-defender"
129
+ ]
130
+ },
131
+ "additionalProperties": {
132
+ "type": "integer",
133
+ "minimum": 0
134
+ }
135
+ },
136
+ "new": {
137
+ "type": "integer",
138
+ "minimum": 0
55
139
  },
56
- "new": { "type": "integer", "minimum": 0 },
57
- "baselined": { "type": "integer", "minimum": 0 }
140
+ "killedCoAuthored": { "description": "Kills whose defender was last touched by the same change (or author) as the code it guards — L3 independence. A signal, not a gate.", "type": "integer", "minimum": 0 },
141
+ "flakyDefenderSets": { "description": "Distinct defender sets that failed at least once on unmodified source.", "type": "integer", "minimum": 0 },
142
+ "worstFlakeRate": { "description": "Highest observed failures/runs on unmodified source across all records, 0–1. A number, so \"unstable\" stops being an adjective.", "type": "number", "minimum": 0, "maximum": 1 },
143
+ "baselined": {
144
+ "type": "integer",
145
+ "minimum": 0
146
+ }
58
147
  },
59
148
  "additionalProperties": false
60
149
  },
61
150
  "stale": {
62
151
  "description": "Why the evidence no longer describes the working tree. Empty when it does.",
63
152
  "type": "array",
153
+ "items": {
154
+ "type": "string",
155
+ "minLength": 1,
156
+ "maxLength": 512
157
+ }
158
+ },
159
+ "notes": {
160
+ "description": "Informational lines that never change `state`: e.g. a baseline frozen from a snapshot whose head predates the commit that carries its tests, with the way to re-stamp it.",
161
+ "type": "array",
64
162
  "items": { "type": "string", "minLength": 1, "maxLength": 512 }
65
163
  },
66
164
  "changedFaults": {
@@ -68,12 +166,24 @@
68
166
  "type": "array",
69
167
  "items": {
70
168
  "type": "object",
71
- "required": ["claimId", "subjectId", "previousVerdict"],
169
+ "required": [
170
+ "claimId",
171
+ "subjectId",
172
+ "previousVerdict"
173
+ ],
72
174
  "properties": {
73
- "claimId": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
74
- "subjectId": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
75
- "previousVerdict": { "$ref": "urn:guard-spec:v1:common#/$defs/verdict" },
76
- "file": { "$ref": "urn:guard-spec:v1:common#/$defs/repoPath" }
175
+ "claimId": {
176
+ "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
177
+ },
178
+ "subjectId": {
179
+ "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
180
+ },
181
+ "previousVerdict": {
182
+ "$ref": "urn:guard-spec:v1:common#/$defs/verdict"
183
+ },
184
+ "file": {
185
+ "$ref": "urn:guard-spec:v1:common#/$defs/repoPath"
186
+ }
77
187
  },
78
188
  "additionalProperties": false
79
189
  }
@@ -84,17 +194,43 @@
84
194
  "maxItems": 50,
85
195
  "items": {
86
196
  "type": "object",
87
- "required": ["fingerprint", "claimId", "subjectId", "verdict", "severity", "isNew"],
197
+ "required": [
198
+ "fingerprint",
199
+ "claimId",
200
+ "subjectId",
201
+ "verdict",
202
+ "severity",
203
+ "isNew"
204
+ ],
88
205
  "properties": {
89
- "fingerprint": { "$ref": "urn:guard-spec:v1:common#/$defs/sha256" },
90
- "claimId": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
91
- "subjectId": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
92
- "verdict": { "$ref": "urn:guard-spec:v1:common#/$defs/verdict" },
93
- "reason": { "type": "string", "maxLength": 256 },
94
- "severity": { "$ref": "urn:guard-spec:v1:common#/$defs/severity" },
95
- "file": { "$ref": "urn:guard-spec:v1:common#/$defs/repoPath" },
96
- "rank": { "type": "number" },
97
- "isNew": { "type": "boolean" }
206
+ "fingerprint": {
207
+ "$ref": "urn:guard-spec:v1:common#/$defs/sha256"
208
+ },
209
+ "claimId": {
210
+ "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
211
+ },
212
+ "subjectId": {
213
+ "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
214
+ },
215
+ "verdict": {
216
+ "$ref": "urn:guard-spec:v1:common#/$defs/verdict"
217
+ },
218
+ "reason": {
219
+ "type": "string",
220
+ "maxLength": 256
221
+ },
222
+ "severity": {
223
+ "$ref": "urn:guard-spec:v1:common#/$defs/severity"
224
+ },
225
+ "file": {
226
+ "$ref": "urn:guard-spec:v1:common#/$defs/repoPath"
227
+ },
228
+ "rank": {
229
+ "type": "number"
230
+ },
231
+ "isNew": {
232
+ "type": "boolean"
233
+ }
98
234
  },
99
235
  "additionalProperties": false
100
236
  }
@@ -102,30 +238,96 @@
102
238
  "changes": {
103
239
  "description": "Claim coverage of the change since `ref`, present only when a reference was given (--changed) or detected from CI. Uncovered files come first in every rendering: the claim is written before more code. Same per-file shapes as the gate document.",
104
240
  "type": "object",
105
- "required": ["ref", "base", "changed", "evaluated", "excluded", "uncovered", "reliedOn", "expired"],
241
+ "required": [
242
+ "ref",
243
+ "base",
244
+ "changed",
245
+ "evaluated",
246
+ "excluded",
247
+ "uncovered",
248
+ "reliedOn",
249
+ "expired"
250
+ ],
106
251
  "properties": {
107
- "ref": { "type": "string", "minLength": 1, "maxLength": 256 },
108
- "base": { "$ref": "urn:guard-spec:v1:common#/$defs/gitSha" },
109
- "includeDirty": { "type": "boolean" },
110
- "changed": { "type": "integer", "minimum": 0 },
111
- "evaluated": { "type": "integer", "minimum": 0 },
112
- "excluded": { "type": "integer", "minimum": 0 },
113
- "uncovered": { "type": "array", "items": { "$ref": "urn:guard-spec:v1:common#/$defs/uncoveredFile" } },
114
- "reliedOn": { "type": "array", "items": { "$ref": "urn:guard-spec:v1:common#/$defs/ignoreReliance" } },
115
- "expired": { "type": "array", "items": { "$ref": "urn:guard-spec:v1:common#/$defs/ignoreReliance" } }
252
+ "ref": {
253
+ "type": "string",
254
+ "minLength": 1,
255
+ "maxLength": 256
256
+ },
257
+ "base": {
258
+ "$ref": "urn:guard-spec:v1:common#/$defs/gitSha"
259
+ },
260
+ "includeDirty": {
261
+ "type": "boolean"
262
+ },
263
+ "changed": {
264
+ "type": "integer",
265
+ "minimum": 0
266
+ },
267
+ "evaluated": {
268
+ "type": "integer",
269
+ "minimum": 0
270
+ },
271
+ "excluded": {
272
+ "type": "integer",
273
+ "minimum": 0
274
+ },
275
+ "uncovered": {
276
+ "type": "array",
277
+ "items": {
278
+ "$ref": "urn:guard-spec:v1:common#/$defs/uncoveredFile"
279
+ }
280
+ },
281
+ "reliedOn": {
282
+ "type": "array",
283
+ "items": {
284
+ "$ref": "urn:guard-spec:v1:common#/$defs/ignoreReliance"
285
+ }
286
+ },
287
+ "expired": {
288
+ "type": "array",
289
+ "items": {
290
+ "$ref": "urn:guard-spec:v1:common#/$defs/ignoreReliance"
291
+ }
292
+ }
116
293
  },
117
294
  "additionalProperties": false
118
295
  },
119
296
  "paths": {
120
297
  "type": "object",
121
298
  "properties": {
122
- "claims": { "type": "string" },
123
- "ignore": { "type": "string" },
124
- "evidence": { "type": "string" },
125
- "provisionalEvidence": { "type": "string" },
126
- "baseline": { "type": "string" }
299
+ "claims": {
300
+ "type": "string"
301
+ },
302
+ "ignore": {
303
+ "type": "string"
304
+ },
305
+ "evidence": {
306
+ "type": "string"
307
+ },
308
+ "provisionalEvidence": {
309
+ "type": "string"
310
+ },
311
+ "baseline": {
312
+ "type": "string"
313
+ }
127
314
  },
128
315
  "additionalProperties": false
316
+ },
317
+ "evidenceSource": {
318
+ "description": "Whether the evidence read was the project's own (`local`) or supplied with --evidence, e.g. fetched from CI (`provided`).",
319
+ "enum": [
320
+ "local",
321
+ "provided"
322
+ ]
323
+ },
324
+ "evidenceHead": {
325
+ "description": "The commit the evidence describes (its snapshot when the working tree was probed). Present whenever evidence was read.",
326
+ "$ref": "urn:guard-spec:v1:common#/$defs/gitSha"
327
+ },
328
+ "head": {
329
+ "description": "HEAD of the working tree the status was computed in, when resolvable. Differs from evidenceHead when the evidence came from elsewhere.",
330
+ "$ref": "urn:guard-spec:v1:common#/$defs/gitSha"
129
331
  }
130
332
  },
131
333
  "additionalProperties": false
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The admission decision. Pure: records in, verdict out.
3
+ *
4
+ * A test is admitted only when every probed fault of its claim is `killed`
5
+ * — Gate A (green on unmodified HEAD, N/N) and Gate B (fails with the fault
6
+ * applied, N/N) are both inside `killed` by construction. Anything else
7
+ * blocks, and the first blocking record in gate order is named so the agent
8
+ * knows which fault to work on.
9
+ */
10
+ const ORDER = ['survived', 'nocover', 'unverifiable', 'fault-invalid', 'timeout', 'flaky-defender'];
11
+
12
+ export function decideAdmission(records) {
13
+ if (records.length === 0) return { admitted: false, blocking: null };
14
+ const admitted = records.every((r) => r.verdict === 'killed');
15
+ const blocking = admitted ? null : [...records].filter((r) => r.verdict !== 'killed').sort((a, b) => ORDER.indexOf(a.verdict) - ORDER.indexOf(b.verdict))[0];
16
+ return { admitted, blocking };
17
+ }
@@ -1,13 +1,42 @@
1
1
  const SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
2
2
 
3
- /** Freeze every non-passing finding. `killed` is never debt, so it is never fingerprinted. */
3
+ /**
4
+ * Freeze every non-passing finding. `killed` is never debt, so it is never
5
+ * fingerprinted. When the evidence came from a working-tree snapshot, the
6
+ * baseline says so (`snapshot`): its `head` is then the parent of the commit
7
+ * that will carry the tests, and `baseline --restamp` moves it once a clean
8
+ * probe of that commit reproduces the same fingerprints.
9
+ */
4
10
  export function buildBaseline(evidence, { createdAt = new Date().toISOString() } = {}) {
5
11
  const fingerprints = {};
6
12
  for (const r of evidence.records) {
7
13
  if (r.verdict === 'killed') continue;
8
14
  fingerprints[r.fingerprint] = (fingerprints[r.fingerprint] ?? 0) + 1;
9
15
  }
10
- return { schemaVersion: 1, tool: evidence.tool, createdAt, head: evidence.run.repo.head, dirty: evidence.run.repo.dirty, fingerprints };
16
+ const repo = evidence.run.repo;
17
+ return { schemaVersion: 1, tool: evidence.tool, createdAt, head: repo.head, dirty: repo.dirty, ...(repo.snapshot ? { snapshot: repo.snapshot } : {}), fingerprints };
18
+ }
19
+
20
+ /** Same fingerprints, same counts. */
21
+ export const sameFingerprints = (a, b) => {
22
+ const ka = Object.keys(a).sort();
23
+ const kb = Object.keys(b).sort();
24
+ return ka.length === kb.length && ka.every((k, i) => k === kb[i] && a[k] === b[k]);
25
+ };
26
+
27
+ /**
28
+ * Re-stamp a baseline's `head`/`dirty` from a later, CLEAN probe that
29
+ * reproduced exactly the same fingerprints. A frozen contract is never
30
+ * silently rewritten: different fingerprints, a dirty tree or a snapshot run
31
+ * are refused with the reason, and the caller re-probes and re-baselines.
32
+ */
33
+ export function restampBaseline(baseline, evidence, { restampedAt = new Date().toISOString() } = {}) {
34
+ const repo = evidence.run.repo;
35
+ if (repo.snapshot || repo.dirty) return { ok: false, reason: `the evidence is from a ${repo.snapshot ? 'working-tree snapshot' : 'dirty tree'}; re-stamping needs a clean probe of the commit that carries the tests` };
36
+ const current = buildBaseline(evidence).fingerprints;
37
+ if (!sameFingerprints(baseline.fingerprints, current)) return { ok: false, reason: 'the current evidence does not reproduce the baseline\'s fingerprints; re-probe and run `testguard baseline` to freeze the new set instead' };
38
+ const { snapshot, ...rest } = baseline;
39
+ return { ok: true, baseline: { ...rest, head: repo.head, dirty: false, restampedAt } };
11
40
  }
12
41
 
13
42
  /**