testguard-cli 0.6.0 → 0.7.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 (43) hide show
  1. package/CHANGELOG.md +373 -0
  2. package/README.md +277 -27
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +112 -5
  5. package/spec/README.md +28 -10
  6. package/spec/lib/validate.mjs +84 -8
  7. package/spec/schemas/baseline.schema.json +7 -7
  8. package/spec/schemas/brief.schema.json +12 -12
  9. package/spec/schemas/calibration.schema.json +5 -5
  10. package/spec/schemas/claims.schema.json +122 -25
  11. package/spec/schemas/common.schema.json +127 -29
  12. package/spec/schemas/evidence.schema.json +169 -73
  13. package/spec/schemas/gate.schema.json +12 -12
  14. package/spec/schemas/ignore.schema.json +4 -4
  15. package/spec/schemas/replay.schema.json +10 -10
  16. package/spec/schemas/status.schema.json +25 -25
  17. package/src/cli.mjs +20 -5
  18. package/src/commands/claims.mjs +22 -2
  19. package/src/commands/probe.mjs +24 -4
  20. package/src/gate/changed.mjs +9 -2
  21. package/src/probe/attribution.mjs +123 -0
  22. package/src/probe/classify.mjs +18 -2
  23. package/src/probe/cost.mjs +145 -0
  24. package/src/probe/discover.mjs +14 -6
  25. package/src/probe/inject.mjs +41 -9
  26. package/src/probe/mocks.mjs +2 -0
  27. package/src/probe/probe.mjs +235 -52
  28. package/src/probe/progress.mjs +87 -0
  29. package/src/probe/pyimports.mjs +287 -0
  30. package/src/probe/rank.mjs +2 -0
  31. package/src/probe/runners/index.mjs +38 -11
  32. package/src/probe/runners/jest.mjs +13 -1
  33. package/src/probe/runners/playwright.mjs +9 -0
  34. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  35. package/src/probe/runners/python/_testguard_report.py +141 -0
  36. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  37. package/src/probe/runners/python.mjs +316 -0
  38. package/src/probe/runners/shared.mjs +10 -1
  39. package/src/probe/runners/vitest.mjs +13 -1
  40. package/src/replay/replay.mjs +3 -2
  41. package/src/scaffold/producers.python.mjs +209 -0
  42. package/src/scaffold/scaffold.mjs +86 -32
  43. package/src/util/glob.mjs +7 -1
@@ -7,6 +7,13 @@ import { fingerprint } from './fingerprint.mjs';
7
7
  const schemaDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'schemas');
8
8
 
9
9
  export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status', 'gate', 'replay']);
10
+ /**
11
+ * How a document says it tried to falsify its claims. Absent means
12
+ * `fault-injection`, so every document written before the field existed is
13
+ * held to exactly the rules it was written against.
14
+ */
15
+ export const methodOf = (x) => x?.method ?? 'fault-injection';
16
+ const isInjection = (x) => methodOf(x) === 'fault-injection';
10
17
  export const PASSING_VERDICTS = Object.freeze(new Set(['killed']));
11
18
 
12
19
  const ajv = new Ajv2020({ strict: true, allErrors: true });
@@ -15,7 +22,7 @@ for (const f of readdirSync(schemaDir).filter((n) => n.endsWith('.schema.json'))
15
22
  }
16
23
 
17
24
  const schemaFor = (kind) => {
18
- const v = ajv.getSchema(`urn:guard-spec:v1:${kind}`);
25
+ const v = ajv.getSchema(`urn:claimspec:v1:${kind}`);
19
26
  if (!v) throw new RangeError(`unknown Guard-spec kind: ${kind}`);
20
27
  return v;
21
28
  };
@@ -33,10 +40,15 @@ const semantic = {
33
40
  const p = `/claims/${ci}/faults/${fi}`;
34
41
  if (seenFault.has(f.id)) errors.push({ path: `${p}/id`, message: `duplicate fault id "${f.id}" in claim "${c.id}"` });
35
42
  seenFault.add(f.id);
36
- const occ = f.occurrence ?? 1;
37
- const hits = f.expectHits ?? 1;
38
- if (occ > hits) errors.push({ path: `${p}/occurrence`, message: `occurrence ${occ} exceeds expectHits ${hits}` });
39
- if (f.find === f.replace) errors.push({ path: `${p}/replace`, message: 'replace is identical to find; the fault is a no-op' });
43
+ // Anchor arithmetic is a property of fault injection. A probe that does
44
+ // not substitute text has no `find` to compare, and `undefined ===
45
+ // undefined` would report every one of them as a no-op.
46
+ if (isInjection(f)) {
47
+ const occ = f.occurrence ?? 1;
48
+ const hits = f.expectHits ?? 1;
49
+ if (occ > hits) errors.push({ path: `${p}/occurrence`, message: `occurrence ${occ} exceeds expectHits ${hits}` });
50
+ if (f.find === f.replace) errors.push({ path: `${p}/replace`, message: 'replace is identical to find; the fault is a no-op' });
51
+ }
40
52
  });
41
53
  });
42
54
  return errors;
@@ -44,9 +56,15 @@ const semantic = {
44
56
 
45
57
  evidence(doc) {
46
58
  const errors = [];
59
+ const injection = isInjection(doc.run);
47
60
  const n = doc.run.confirmRuns;
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
- if (n >= 3 && doc.run.provisional === true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is confirmed; provisional must be absent or false` });
61
+ // N-run agreement is what fault injection means by "confirmed". A method
62
+ // that does not re-run anything has no N, and a rule about one would be a
63
+ // rule about a number that is not there.
64
+ if (injection) {
65
+ if (n < 3 && doc.run.provisional !== true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is below 3; the run must declare provisional: true` });
66
+ if (n >= 3 && doc.run.provisional === true) errors.push({ path: '/run/provisional', message: `confirmRuns ${n} is confirmed; provisional must be absent or false` });
67
+ }
50
68
  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
69
  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' });
52
70
  doc.records.forEach((r, i) => {
@@ -54,6 +72,23 @@ const semantic = {
54
72
  const expected = fingerprint({ claimId: r.claim.id, subjectId: r.subject.id, file: r.subject.file ?? '', verdict: r.verdict });
55
73
  if (r.fingerprint !== expected) errors.push({ path: `${p}/fingerprint`, message: `fingerprint does not match spec derivation (expected ${expected})` });
56
74
 
75
+ // What the schema no longer requires, the validator still does — for the
76
+ // one method that means it. Fault injection without defenders, inputs or
77
+ // runs is not a leaner document, it is a verdict with nothing behind it;
78
+ // relaxing the schema was to let a scanner conform, never to let an
79
+ // injecting tool stop showing its work.
80
+ if (injection) {
81
+ for (const k of ['defenders', 'inputs']) {
82
+ if (!r[k]) errors.push({ path: `${p}/${k}`, message: `fault-injection requires ${k} on every record` });
83
+ }
84
+ for (const k of ['baselineRuns', 'probeRuns']) {
85
+ if (!Array.isArray(r.detail[k])) errors.push({ path: `${p}/detail/${k}`, message: `fault-injection requires detail.${k} on every record` });
86
+ }
87
+ }
88
+ // Every rule below reads those fields, so a record that is already
89
+ // malformed must not also throw on the way to being reported.
90
+ if (!injection || !r.defenders || !r.inputs || !Array.isArray(r.detail.baselineRuns) || !Array.isArray(r.detail.probeRuns)) return;
91
+
57
92
  const { baselineRuns, probeRuns } = r.detail;
58
93
  if (r.verdict === 'killed' || r.verdict === 'survived') {
59
94
  if (baselineRuns.length !== n) errors.push({ path: `${p}/detail/baselineRuns`, message: `${r.verdict} requires exactly confirmRuns (${n}) baseline runs, got ${baselineRuns.length}` });
@@ -99,11 +134,52 @@ const semantic = {
99
134
  }
100
135
  for (const s of r.defenders.signals ?? []) {
101
136
  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` });
137
+ // An attribute patch (Python) leaves the file a defender, so it belongs
138
+ // to `resolved` and not to `mocking`; every other signal is about a file
139
+ // that replaced the module and must be listed as mocking it.
140
+ if (s.signal === 'target-attribute-patched') {
141
+ if (!s.reason) errors.push({ path: `${p}/defenders/signals`, message: `target-attribute-patched on ${s.file} requires the patched attributes as its reason` });
142
+ if (!r.defenders.resolved.includes(s.file)) errors.push({ path: `${p}/defenders/signals`, message: `${s.file} patches attributes of the subject but is not a resolved defender` });
143
+ } else if (!(r.defenders.mocking ?? []).includes(s.file)) {
144
+ errors.push({ path: `${p}/defenders/signals`, message: `${s.file} carries a mock signal but is not listed in defenders.mocking` });
145
+ }
146
+ }
147
+ // A module the defenders never imported cannot have been killed by them.
148
+ if (r.detail.targetNotImported && r.verdict === 'killed') {
149
+ errors.push({ path: `${p}/detail/targetNotImported`, message: 'the subject was never imported, so the defenders cannot have killed the fault' });
150
+ }
151
+ // The control is only ever charged on a would-be survivor, so a `reached`
152
+ // record must be one, and `not-reached` must have become unverifiable.
153
+ if (r.detail.negativeControl === 'reached' && r.verdict !== 'survived') {
154
+ errors.push({ path: `${p}/detail/negativeControl`, message: `negativeControl "reached" belongs to a survivor, not to ${r.verdict}` });
155
+ }
156
+ if (r.detail.negativeControl === 'not-reached' && !(r.verdict === 'unverifiable' && r.detail.reason === 'subject-not-executed')) {
157
+ errors.push({ path: `${p}/detail/negativeControl`, message: 'negativeControl "not-reached" requires verdict unverifiable with reason subject-not-executed' });
158
+ }
159
+ if (r.detail.reason === 'subject-not-executed' && r.detail.negativeControl !== 'not-reached') {
160
+ errors.push({ path: `${p}/detail/reason`, message: 'subject-not-executed requires the negativeControl that established it' });
161
+ }
162
+ // Python's import provenance and the general control answer the same
163
+ // question. A document where they disagree describes a run that cannot
164
+ // have happened.
165
+ if (r.detail.targetNotImported && r.detail.negativeControl === 'reached') {
166
+ errors.push({ path: `${p}/detail/negativeControl`, message: 'the subject was never imported, so the negative control cannot have reached it' });
103
167
  }
104
168
  if (r.verdict === 'unverifiable' && !r.detail.reason) {
105
169
  errors.push({ path: `${p}/detail/reason`, message: 'unverifiable requires a reason (e.g. anchor-missing, anchor-ambiguous)' });
106
170
  }
171
+ // `probe-error` is the one reason that names nothing on its own: it says
172
+ // the run threw, not what threw. Without the message the record is a
173
+ // dead end for anyone reading the document instead of rerunning it.
174
+ if (r.detail.reason === 'probe-error' && !r.detail.message) {
175
+ errors.push({ path: `${p}/detail/message`, message: 'probe-error requires a message naming what threw' });
176
+ }
177
+ // In-place is the mode where a vanished target is the user's own file
178
+ // gone; it raises rather than records. A document claiming otherwise
179
+ // describes a run that cannot have happened.
180
+ if (r.detail.restoreSkipped && doc.run.mode === 'in-place') {
181
+ errors.push({ path: `${p}/detail/restoreSkipped`, message: 'restoreSkipped cannot occur in in-place mode, where a missing target is raised' });
182
+ }
107
183
  });
108
184
  return errors;
109
185
  },
@@ -1,27 +1,27 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "urn:guard-spec:v1:baseline",
4
- "title": "Guard spec v1 — baseline",
3
+ "$id": "urn:claimspec:v1:baseline",
4
+ "title": "claimspec v1 — baseline",
5
5
  "description": "Frozen fingerprints of findings that existed when the baseline was taken. A finding whose fingerprint appears here is suppressed up to `count` occurrences; everything else is new and gates. Only non-passing verdicts are ever fingerprinted.",
6
6
  "type": "object",
7
7
  "required": ["schemaVersion", "tool", "createdAt", "fingerprints"],
8
8
  "properties": {
9
9
  "$schema": { "type": "string" },
10
10
  "schemaVersion": { "const": 1 },
11
- "tool": { "$ref": "urn:guard-spec:v1:common#/$defs/tool" },
12
- "createdAt": { "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime" },
13
- "head": { "$ref": "urn:guard-spec:v1:common#/$defs/gitSha" },
11
+ "tool": { "$ref": "urn:claimspec:v1:common#/$defs/tool" },
12
+ "createdAt": { "$ref": "urn:claimspec:v1:common#/$defs/isoDateTime" },
13
+ "head": { "$ref": "urn:claimspec:v1:common#/$defs/gitSha" },
14
14
  "dirty": {
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
18
  "snapshot": {
19
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"
20
+ "$ref": "urn:claimspec:v1:common#/$defs/gitSha"
21
21
  },
22
22
  "restampedAt": {
23
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"
24
+ "$ref": "urn:claimspec:v1:common#/$defs/isoDateTime"
25
25
  },
26
26
  "fingerprints": {
27
27
  "type": "object",
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "urn:guard-spec:v1:brief",
4
- "title": "Guard spec v1 — agent brief",
3
+ "$id": "urn:claimspec:v1:brief",
4
+ "title": "claimspec v1 — agent brief",
5
5
  "description": "What to tell an agent before it writes code. `text` is the rendered block for an additionalContext payload; `items` is the same content structured so other tools can compose briefs. Ranked, capped, and never a single score.",
6
6
  "type": "object",
7
7
  "required": ["schemaVersion", "tool", "generatedAt", "heading", "summary", "items", "text"],
8
8
  "properties": {
9
9
  "$schema": { "type": "string" },
10
10
  "schemaVersion": { "const": 1 },
11
- "tool": { "$ref": "urn:guard-spec:v1:common#/$defs/tool" },
12
- "generatedAt": { "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime" },
13
- "head": { "$ref": "urn:guard-spec:v1:common#/$defs/gitSha" },
11
+ "tool": { "$ref": "urn:claimspec:v1:common#/$defs/tool" },
12
+ "generatedAt": { "$ref": "urn:claimspec:v1:common#/$defs/isoDateTime" },
13
+ "head": { "$ref": "urn:claimspec:v1:common#/$defs/gitSha" },
14
14
  "heading": {
15
15
  "description": "The markdown heading that opens `text`, e.g. `## TEST BLINDSPOT CONTEXT`.",
16
16
  "type": "string",
@@ -51,7 +51,7 @@
51
51
  "required": ["ref", "files"],
52
52
  "properties": {
53
53
  "ref": { "type": "string", "minLength": 1, "maxLength": 256 },
54
- "files": { "type": "array", "minItems": 1, "items": { "$ref": "urn:guard-spec:v1:common#/$defs/uncoveredFile" } }
54
+ "files": { "type": "array", "minItems": 1, "items": { "$ref": "urn:claimspec:v1:common#/$defs/uncoveredFile" } }
55
55
  },
56
56
  "additionalProperties": false
57
57
  },
@@ -62,13 +62,13 @@
62
62
  "type": "object",
63
63
  "required": ["fingerprint", "claimId", "statement", "verdict", "severity", "isNew"],
64
64
  "properties": {
65
- "fingerprint": { "$ref": "urn:guard-spec:v1:common#/$defs/sha256" },
66
- "claimId": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
67
- "subjectId": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
65
+ "fingerprint": { "$ref": "urn:claimspec:v1:common#/$defs/sha256" },
66
+ "claimId": { "$ref": "urn:claimspec:v1:common#/$defs/identifier" },
67
+ "subjectId": { "$ref": "urn:claimspec:v1:common#/$defs/identifier" },
68
68
  "statement": { "type": "string", "minLength": 1, "maxLength": 2000 },
69
- "verdict": { "$ref": "urn:guard-spec:v1:common#/$defs/verdict" },
70
- "severity": { "$ref": "urn:guard-spec:v1:common#/$defs/severity" },
71
- "file": { "$ref": "urn:guard-spec:v1:common#/$defs/repoPath" },
69
+ "verdict": { "$ref": "urn:claimspec:v1:common#/$defs/verdict" },
70
+ "severity": { "$ref": "urn:claimspec:v1:common#/$defs/severity" },
71
+ "file": { "$ref": "urn:claimspec:v1:common#/$defs/repoPath" },
72
72
  "rank": { "type": "number" },
73
73
  "hint": {
74
74
  "description": "One line the agent can act on, e.g. which assertion shape left the gap.",
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "urn:guard-spec:v1:calibration",
4
- "title": "Guard spec v1 — calibration",
3
+ "$id": "urn:claimspec:v1:calibration",
4
+ "title": "claimspec v1 — calibration",
5
5
  "description": "P(finding is real) per bucket, with a confidence interval and the sample size behind it. A label without n is a vibe; this is the format that stops it being one.",
6
6
  "type": "object",
7
7
  "required": ["schemaVersion", "tool", "computedAt", "method", "confidence", "bucketBy", "source", "buckets"],
8
8
  "properties": {
9
9
  "$schema": { "type": "string" },
10
10
  "schemaVersion": { "const": 1 },
11
- "tool": { "$ref": "urn:guard-spec:v1:common#/$defs/tool" },
12
- "computedAt": { "$ref": "urn:guard-spec:v1:common#/$defs/isoDateTime" },
11
+ "tool": { "$ref": "urn:claimspec:v1:common#/$defs/tool" },
12
+ "computedAt": { "$ref": "urn:claimspec:v1:common#/$defs/isoDateTime" },
13
13
  "method": {
14
14
  "description": "Interval method. `wilson` is the only method defined in v1.",
15
15
  "enum": ["wilson"]
@@ -17,7 +17,7 @@
17
17
  "confidence": { "type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1 },
18
18
  "bucketBy": {
19
19
  "description": "The field findings are bucketed on, e.g. `faultClass` or `severity`.",
20
- "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
20
+ "$ref": "urn:claimspec:v1:common#/$defs/identifier"
21
21
  },
22
22
  "source": {
23
23
  "description": "Where the ground-truth labels came from.",
@@ -1,27 +1,43 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "urn:guard-spec:v1:claims",
4
- "title": "Guard spec v1 — claims file",
3
+ "$id": "urn:claimspec:v1:claims",
4
+ "title": "claimspec v1 \u2014 claims file",
5
5
  "description": "What must be true, and how to try to falsify it. A claim is a statement the project makes; each fault is one concrete way that statement could be false, expressed as a deterministic source change. This file is code: its `replace` strings execute under the test runner. Treat it with the same review discipline as source.",
6
6
  "type": "object",
7
- "required": ["schemaVersion", "claims"],
7
+ "required": [
8
+ "schemaVersion",
9
+ "claims"
10
+ ],
8
11
  "properties": {
9
- "$schema": { "type": "string" },
10
- "schemaVersion": { "const": 1 },
12
+ "$schema": {
13
+ "type": "string"
14
+ },
15
+ "schemaVersion": {
16
+ "const": 1
17
+ },
11
18
  "claims": {
12
19
  "type": "array",
13
- "items": { "$ref": "#/$defs/claim" }
20
+ "items": {
21
+ "$ref": "#/$defs/claim"
22
+ }
14
23
  }
15
24
  },
16
25
  "additionalProperties": false,
17
26
  "$defs": {
18
27
  "claim": {
19
28
  "type": "object",
20
- "required": ["id", "statement", "source", "severity", "producedBy", "faults"],
29
+ "required": [
30
+ "id",
31
+ "statement",
32
+ "source",
33
+ "severity",
34
+ "producedBy",
35
+ "faults"
36
+ ],
21
37
  "properties": {
22
38
  "id": {
23
39
  "description": "Stable identifier. Renaming it changes every fingerprint derived from it.",
24
- "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
40
+ "$ref": "urn:claimspec:v1:common#/$defs/identifier"
25
41
  },
26
42
  "statement": {
27
43
  "description": "The claim in plain language, as the project states it.",
@@ -32,46 +48,82 @@
32
48
  "source": {
33
49
  "description": "Where the claim comes from. Spec- and ADR-sourced claims are human-governed; annotation- and comment-sourced claims may have been written by the same agent that wrote the tests, and should be ranked accordingly.",
34
50
  "type": "object",
35
- "required": ["kind"],
51
+ "required": [
52
+ "kind"
53
+ ],
36
54
  "properties": {
37
- "kind": { "enum": ["spec", "adr", "annotation", "comment", "manual"] },
38
- "ref": { "type": "string", "maxLength": 512 }
55
+ "kind": {
56
+ "enum": [
57
+ "spec",
58
+ "adr",
59
+ "annotation",
60
+ "comment",
61
+ "manual"
62
+ ]
63
+ },
64
+ "ref": {
65
+ "type": "string",
66
+ "maxLength": 512
67
+ }
39
68
  },
40
69
  "additionalProperties": false
41
70
  },
42
- "severity": { "$ref": "urn:guard-spec:v1:common#/$defs/severity" },
43
- "producedBy": { "$ref": "urn:guard-spec:v1:common#/$defs/provenance" },
71
+ "severity": {
72
+ "$ref": "urn:claimspec:v1:common#/$defs/severity"
73
+ },
74
+ "producedBy": {
75
+ "$ref": "urn:claimspec:v1:common#/$defs/provenance"
76
+ },
44
77
  "defendedBy": {
45
78
  "description": "Globs (project-relative) for the test files that supposedly defend this claim. Empty or absent means: discover defenders as the test files that import each fault's target file (relative paths and resolved aliases). If nothing resolves, the verdict is `nocover`.",
46
79
  "type": "array",
47
- "items": { "type": "string", "minLength": 1 },
80
+ "items": {
81
+ "type": "string",
82
+ "minLength": 1
83
+ },
48
84
  "uniqueItems": true
49
85
  },
50
86
  "faults": {
51
87
  "type": "array",
52
88
  "minItems": 1,
53
- "items": { "$ref": "#/$defs/fault" }
89
+ "items": {
90
+ "$ref": "#/$defs/fault"
91
+ }
54
92
  },
55
93
  "tags": {
56
94
  "type": "array",
57
- "items": { "$ref": "urn:guard-spec:v1:common#/$defs/identifier" },
95
+ "items": {
96
+ "$ref": "urn:claimspec:v1:common#/$defs/identifier"
97
+ },
58
98
  "uniqueItems": true
59
99
  }
60
100
  },
61
101
  "additionalProperties": false
62
102
  },
63
103
  "fault": {
64
- "description": "One concrete way the claim could be false. Applied as an exact-string substitution; the schema is producer-agnostic — a human, an operator, an agent, or a derivation may all emit this shape.",
104
+ "description": "One concrete way the claim could be false, and how a tool would try to make it so. `method` says which; it defaults to `fault-injection`, whose shape is an exact-string substitution and is unchanged. Producer-agnostic: a human, an operator, an agent or a derivation may all emit this shape, and every artifact says which.",
65
105
  "type": "object",
66
- "required": ["id", "description", "faultClass", "file", "find", "replace", "producedBy"],
106
+ "required": [
107
+ "id",
108
+ "description",
109
+ "producedBy"
110
+ ],
67
111
  "properties": {
68
112
  "id": {
69
113
  "description": "Unique within the claim.",
70
- "$ref": "urn:guard-spec:v1:common#/$defs/identifier"
114
+ "$ref": "urn:claimspec:v1:common#/$defs/identifier"
115
+ },
116
+ "description": {
117
+ "type": "string",
118
+ "minLength": 1,
119
+ "maxLength": 2000
120
+ },
121
+ "faultClass": {
122
+ "$ref": "urn:claimspec:v1:common#/$defs/faultClass"
123
+ },
124
+ "file": {
125
+ "$ref": "urn:claimspec:v1:common#/$defs/repoPath"
71
126
  },
72
- "description": { "type": "string", "minLength": 1, "maxLength": 2000 },
73
- "faultClass": { "$ref": "urn:guard-spec:v1:common#/$defs/faultClass" },
74
- "file": { "$ref": "urn:guard-spec:v1:common#/$defs/repoPath" },
75
127
  "find": {
76
128
  "description": "Exact text to locate in `file`. Must occur exactly `expectHits` times or the fault is `unverifiable`.",
77
129
  "type": "string",
@@ -90,14 +142,59 @@
90
142
  "default": 1
91
143
  },
92
144
  "expectHits": {
93
- "description": "How many times `find` must occur in `file`. Any other count means the anchor has rotted or is ambiguous → `unverifiable`, never a silent skip.",
145
+ "description": "How many times `find` must occur in `file`. Any other count means the anchor has rotted or is ambiguous \u2192 `unverifiable`, never a silent skip.",
94
146
  "type": "integer",
95
147
  "minimum": 1,
96
148
  "default": 1
97
149
  },
98
- "producedBy": { "$ref": "urn:guard-spec:v1:common#/$defs/provenance" }
150
+ "producedBy": {
151
+ "$ref": "urn:claimspec:v1:common#/$defs/provenance"
152
+ },
153
+ "method": {
154
+ "$ref": "urn:claimspec:v1:common#/$defs/method"
155
+ }
99
156
  },
100
- "additionalProperties": false
157
+ "additionalProperties": false,
158
+ "allOf": [
159
+ {
160
+ "if": {
161
+ "anyOf": [
162
+ {
163
+ "properties": {
164
+ "method": {
165
+ "const": "fault-injection"
166
+ }
167
+ },
168
+ "required": [
169
+ "method"
170
+ ]
171
+ },
172
+ {
173
+ "not": {
174
+ "required": [
175
+ "method"
176
+ ]
177
+ }
178
+ }
179
+ ]
180
+ },
181
+ "then": {
182
+ "$comment": "The default method. Absent `method` means fault-injection, so every claims file written before the field existed is held to exactly the same requirements as before. The property stubs are what ajv's strict mode needs to accept `required` here; the real definitions stay above.",
183
+ "properties": {
184
+ "faultClass": true,
185
+ "file": true,
186
+ "find": true,
187
+ "replace": true
188
+ },
189
+ "required": [
190
+ "faultClass",
191
+ "file",
192
+ "find",
193
+ "replace"
194
+ ]
195
+ }
196
+ }
197
+ ]
101
198
  }
102
199
  }
103
200
  }