testguard-cli 0.16.0 → 0.18.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 (67) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +169 -14
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +316 -0
  5. package/spec/README.md +27 -2
  6. package/spec/lib/authoring-input.mjs +40 -0
  7. package/spec/lib/origins.mjs +56 -0
  8. package/spec/lib/validate.mjs +123 -1
  9. package/spec/schemas/annotations.schema.json +65 -0
  10. package/spec/schemas/authoring-input.schema.json +67 -0
  11. package/spec/schemas/brief.schema.json +2 -0
  12. package/spec/schemas/claims.schema.json +2 -8
  13. package/spec/schemas/common.schema.json +73 -0
  14. package/spec/schemas/evidence.schema.json +40 -10
  15. package/spec/schemas/status.schema.json +6 -2
  16. package/src/brief/brief.mjs +11 -4
  17. package/src/claims/annotation-placement.mjs +357 -0
  18. package/src/claims/annotations.mjs +61 -2
  19. package/src/cli.mjs +41 -6
  20. package/src/commands/admit.mjs +13 -0
  21. package/src/commands/annotations.mjs +67 -0
  22. package/src/commands/claims.mjs +8 -4
  23. package/src/commands/probe.mjs +50 -7
  24. package/src/commands/replay.mjs +7 -0
  25. package/src/commands/scaffold.mjs +49 -2
  26. package/src/commands/sweep.mjs +7 -0
  27. package/src/evidence/writer.mjs +12 -4
  28. package/src/git.mjs +4 -4
  29. package/src/init/templates/SKILL.md +35 -0
  30. package/src/probe/attribution.mjs +10 -0
  31. package/src/probe/contention.mjs +20 -2
  32. package/src/probe/cost.mjs +6 -1
  33. package/src/probe/inject.mjs +2 -6
  34. package/src/probe/origin-policy.mjs +69 -0
  35. package/src/probe/policy-freshness.mjs +74 -0
  36. package/src/probe/probe.mjs +49 -24
  37. package/src/probe/runners/discovery.mjs +4 -0
  38. package/src/probe/runners/index.mjs +3 -1
  39. package/src/probe/runners/jest.mjs +2 -2
  40. package/src/probe/runners/lifecycle.mjs +58 -0
  41. package/src/probe/runners/node/entry.mjs +11 -0
  42. package/src/probe/runners/node/report.mjs +66 -0
  43. package/src/probe/runners/node/runner.mjs +42 -0
  44. package/src/probe/runners/node-test.mjs +130 -0
  45. package/src/probe/runners/playwright.mjs +6 -2
  46. package/src/probe/runners/python.mjs +10 -4
  47. package/src/probe/runners/shared.mjs +14 -7
  48. package/src/probe/runners/vitest.mjs +3 -3
  49. package/src/probe/universe.mjs +47 -0
  50. package/src/probe/worktree.mjs +3 -1
  51. package/src/render.mjs +17 -1
  52. package/src/replay/replay.mjs +8 -4
  53. package/src/scaffold/admission.mjs +117 -0
  54. package/src/scaffold/append.mjs +41 -0
  55. package/src/scaffold/apply-append.mjs +71 -0
  56. package/src/scaffold/fix-input.mjs +119 -0
  57. package/src/scaffold/fix-paths.mjs +90 -0
  58. package/src/scaffold/intent.mjs +2 -0
  59. package/src/scaffold/open-append.mjs +44 -0
  60. package/src/scaffold/plan-append.mjs +37 -0
  61. package/src/scaffold/plan-intent-input.mjs +38 -0
  62. package/src/scaffold/prepare-append.mjs +46 -0
  63. package/src/scaffold/request.mjs +30 -0
  64. package/src/scaffold/scaffold.mjs +23 -10
  65. package/src/status/status.mjs +34 -9
  66. package/src/sweep/pool.mjs +33 -0
  67. package/src/sweep/sweep.mjs +9 -4
@@ -4,10 +4,13 @@ import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fingerprint } from './fingerprint.mjs';
6
6
  import { reproduces, decimals, MAX_PLACES } from './wilson.mjs';
7
+ import { isDeepStrictEqual } from 'node:util';
8
+ import { recordedOriginSummary } from './origins.mjs';
9
+ import { authoringInputErrors } from './authoring-input.mjs';
7
10
 
8
11
  const schemaDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'schemas');
9
12
 
10
- export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status', 'gate', 'replay', 'sweep', 'concerns']);
13
+ export const KINDS = Object.freeze(['claims', 'evidence', 'baseline', 'ignore', 'calibration', 'brief', 'status', 'gate', 'replay', 'sweep', 'concerns', 'annotations', 'authoring-input']);
11
14
  /**
12
15
  * How a document says it tried to falsify its claims. Absent means
13
16
  * `fault-injection`, so every document written before the field existed is
@@ -46,8 +49,113 @@ const nestedErrors = (nested = [], prefix = '/nested', occupied = []) => {
46
49
  return errors;
47
50
  };
48
51
 
52
+ // A projected summary is not source authority. Only evidence retains the
53
+ // complete claim projections needed to reproduce each kind and mixed count.
54
+ function originPolicyErrors(kind, doc) {
55
+ const errors = [];
56
+ const policies = [[doc.originPolicy, '/originPolicy'], [kind === 'status' ? doc.run?.originPolicy : undefined, '/run/originPolicy']];
57
+ const identities = (rows) => rows.map((r) => JSON.stringify([r.claimId, r.faultId])).sort();
58
+ for (const [p, path] of policies) {
59
+ if (!p) continue;
60
+ const fail = (message) => errors.push({ path, message });
61
+ for (const field of ['eligibleKinds', 'ineligibleClaims', 'unavailableReasons']) {
62
+ if (!isDeepStrictEqual(p[field], [...p[field]].sort())) fail(`${field} must be normalized in sorted order`);
63
+ }
64
+ const failures = p.ineligibleClaims.length + p.nonKilledFaults.length;
65
+ const expectedState = p.unavailableReasons.length ? 'unavailable' : failures ? 'failed' : 'passed';
66
+ if (p.state !== expectedState) fail('policy state must match refusal reasons and unsuppressed failures');
67
+ if (p.state !== 'unavailable' && (!p.claims || !p.faults)) fail('an evaluated policy requires a nonempty claim and fault universe');
68
+ if (p.ineligibleClaims.length > p.claims) fail('ineligible claims exceed the current claim denominator');
69
+ if (p.state !== 'unavailable' && p.nonKilledFaults.length > p.faults) fail('non-killed faults exceed the complete fault denominator');
70
+ if (kind === 'evidence') {
71
+ const actual = [...new Set(doc.records.filter((r) => r.verdict !== 'killed').map((r) => JSON.stringify([r.claim.id, r.subject.id])))].sort();
72
+ if (!isDeepStrictEqual(identities(p.nonKilledFaults), actual)) fail('policy must retain every actual non-killed fault identity');
73
+ if (p.state !== 'unavailable') {
74
+ const claims = new Set(doc.records.map((r) => r.claim.id));
75
+ const faults = new Set(doc.records.map((r) => JSON.stringify([r.claim.id, r.subject.id])));
76
+ if (p.claims !== claims.size || p.faults !== faults.size || faults.size !== doc.records.length) fail('evaluated policy denominators require complete unique recorded identities');
77
+ const ineligible = [...new Set(doc.records.filter((r) => !p.eligibleKinds.includes(r.claim.source.kind)).map((r) => r.claim.id))].sort();
78
+ if (!isDeepStrictEqual(p.ineligibleClaims, ineligible)) fail('policy eligibility must match recorded declarations');
79
+ if (!isInjection(doc.run) || doc.run.provisional || doc.run.confirmRuns < 3) fail('evaluated policy requires confirmed fault-injection evidence');
80
+ if (recordedOriginSummary(doc.records).claims.mixed) fail('evaluated policy cannot use mixed recorded origins');
81
+ }
82
+ }
83
+ if (kind === 'status' && (p.claims !== doc.counts.claims || p.faults !== doc.counts.faults)) fail('policy denominators must match current status claims and faults');
84
+ if (kind === 'brief' && p.state !== 'unavailable' && (p.claims !== doc.summary.claims || p.faults !== Object.values(doc.summary.byVerdict).reduce((a, b) => a + b, 0))) fail('recorded policy denominators must match the uncapped brief summary');
85
+ }
86
+ if (kind === 'status' && doc.run) {
87
+ const p = doc.run.originPolicy;
88
+ const expected = p?.state === 'unavailable' ? 2 : p?.state === 'failed' || doc.run.newSinceBaseline > 0 ? 1 : 0;
89
+ if (p && doc.run.exitCode !== expected) errors.push({ path: '/run/exitCode', message: 'exit code must preserve policy refusal/failure before baseline findings' });
90
+ if (!p && doc.run.exitCode === 2) errors.push({ path: '/run/exitCode', message: 'completed probe exit 2 requires an unavailable origin policy' });
91
+ if (p && (doc.run.scope || doc.run.provisional)) errors.push({ path: '/run/originPolicy', message: 'origin policy cannot certify a partial or provisional invocation' });
92
+ }
93
+ return errors;
94
+ }
95
+
96
+ function originErrors(kind, doc) {
97
+ if (!doc.origins) return [];
98
+ const errors = [], o = doc.origins;
99
+ const fail = (message) => errors.push({ path: '/origins', message });
100
+ const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0);
101
+ if (sum(o.claims.byKind) + o.claims.mixed !== o.claims.total) fail('claim origin buckets plus mixed must equal the distinct claim total');
102
+ if (o.records && sum(o.records.byKind) !== o.records.total) fail('record origin buckets must equal the record total');
103
+ if (kind === 'status') {
104
+ if (o.basis !== 'declared' || o.records || o.claims.mixed !== 0) fail('status origins must describe unmixed current declarations without recorded counts');
105
+ if (o.claims.total !== doc.counts.claims) fail('declared origin total must equal current claim count');
106
+ } else {
107
+ if (o.basis !== 'recorded' || !o.records) fail('evidence and brief origins require recorded basis and record counts');
108
+ if (kind === 'evidence' && !isDeepStrictEqual(o, recordedOriginSummary(doc.records))) fail('recorded origins must reproduce every actual record and distinct claim declaration');
109
+ if (kind === 'brief' && (o.claims.total !== doc.summary.claims || o.records?.total !== sum(doc.summary.byVerdict))) fail('brief origin totals must match the complete summary, not capped items');
110
+ }
111
+ return errors;
112
+ }
113
+
49
114
  // Rules JSON Schema cannot express. Each returns an array of {path, message}.
50
115
  const semantic = {
116
+ 'authoring-input': authoringInputErrors,
117
+ annotations(doc) {
118
+ const errors = [];
119
+ const fail = (path, message) => errors.push({ path, message });
120
+ const selected = new Set(doc.selected), seenFiles = new Set(), seenIds = new Set();
121
+ for (const [bucket, entries] of [['targets', doc.targets], ['refused', doc.refused]]) {
122
+ for (const [i, entry] of entries.entries()) {
123
+ if (seenFiles.has(entry.file)) fail(`/${bucket}/${i}/file`, 'target must occur in exactly one admission bucket');
124
+ if (bucket === 'targets' && (entry.file.includes('\\') || /^[A-Za-z]:/.test(entry.file) || entry.file.split('/').some((part) => !part || part === '.' || part === '..'))) fail(`/${bucket}/${i}/file`, 'admitted target must be a canonical relative path');
125
+ seenFiles.add(entry.file);
126
+ for (const id of entry.claimIds) {
127
+ if (!selected.has(id)) fail(`/${bucket}/${i}/claimIds`, 'target claim must be selected');
128
+ seenIds.add(id);
129
+ }
130
+ }
131
+ }
132
+ if (doc.selected.some((id) => !seenIds.has(id))) fail('/selected', 'every selected claim must have a target or explicit refusal');
133
+ if (doc.mode === 'preview') {
134
+ if (doc.outcome) fail('/outcome', 'preview never has a write outcome');
135
+ if (doc.state !== (doc.refused.length ? 'refused' : 'preview')) fail('/state', 'preview state must reflect all admission refusals');
136
+ return errors;
137
+ }
138
+ if (!doc.outcome) { fail('/outcome', 'apply requires a write outcome'); return errors; }
139
+ if (doc.refused.length && doc.state !== 'refused') fail('/state', 'any admission refusal blocks all writes');
140
+ const o = doc.outcome;
141
+ const adds = doc.targets.filter((t) => t.action === 'add').map((t) => t.file);
142
+ const unchanged = doc.targets.filter((t) => t.action === 'unchanged').map((t) => t.file);
143
+ const same = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
144
+ if (o.touched.some((file) => !adds.includes(file))) fail('/outcome/touched', 'only admitted add targets can be touched');
145
+ if (o.changed.some((file) => !o.touched.includes(file))) fail('/outcome/changed', 'verified changes must be touched');
146
+ if (!same(o.unchanged, unchanged)) fail('/outcome/unchanged', 'unchanged files must match unchanged targets');
147
+ for (const [i, failure] of o.failed.entries()) {
148
+ if (failure.file !== null && !doc.targets.some((t) => t.file === failure.file)) fail(`/outcome/failed/${i}/file`, 'failed file must be an admitted target or null');
149
+ }
150
+ if (doc.state === 'applied') {
151
+ if (doc.refused.length || o.failed.length || !same(o.changed, adds) || !same(o.touched, adds) || !o.recoveryDir || o.lockRelease?.released !== true) fail('/state', 'applied requires every admitted target verified, no refusals/failures, recovery and released owned lock');
152
+ } else if (doc.state === 'refused') {
153
+ if (o.touched.length || o.changed.length || (!doc.refused.length && !o.failed.length)) fail('/state', 'refused requires no touched files and an explicit refusal/failure');
154
+ } else if (doc.state === 'partial') {
155
+ if (!o.touched.length || !o.failed.length || !o.recoveryDir) fail('/state', 'partial requires touched files, explicit failure and retained recovery');
156
+ } else fail('/state', 'apply cannot be a preview');
157
+ return errors;
158
+ },
51
159
  claims(doc) {
52
160
  const errors = [];
53
161
  const seenClaim = new Set();
@@ -75,6 +183,14 @@ const semantic = {
75
183
 
76
184
  evidence(doc) {
77
185
  const errors = [];
186
+ if (doc.run.runner?.source === 'builtin' && (doc.run.runner.name !== 'node-test' || !/^(?:[2-9]\d|[1-9]\d{2,})\.\d+\.\d+(?:[-+].+)?$/.test(doc.run.runner.version ?? ''))) {
187
+ errors.push({ path: '/run/runner', message: 'builtin source requires node-test and its actual Node version (20 or newer)' });
188
+ }
189
+ if (doc.run.measurements) {
190
+ const m = doc.run.measurements;
191
+ if (Math.abs(m.elapsedMs - m.runnerMs - m.overheadMs) > 0.001) errors.push({ path: '/run/measurements', message: 'elapsedMs must equal runnerMs plus overheadMs' });
192
+ }
193
+ if (doc.run.serial && doc.run.workers !== undefined && doc.run.workers !== 1) errors.push({ path: '/run/workers', message: 'serial execution requires one worker' });
78
194
  const injection = isInjection(doc.run);
79
195
  const n = doc.run.confirmRuns;
80
196
  // N-run agreement is what fault injection means by "confirmed". A method
@@ -270,6 +386,10 @@ const semantic = {
270
386
 
271
387
  status(doc) {
272
388
  const errors = [];
389
+ if (doc.cost?.measurements) {
390
+ const m = doc.cost.measurements;
391
+ if (Math.abs(m.elapsedMs - m.runnerMs - m.overheadMs) > 0.001) errors.push({ path: '/cost/measurements', message: 'elapsedMs must equal runnerMs plus overheadMs' });
392
+ }
273
393
  if (doc.changes?.nested) {
274
394
  errors.push(...nestedErrors(doc.changes.nested, '/changes/nested', doc.changes.uncovered.map((file) => file.file)));
275
395
  const nestedCount = doc.changes.nested.reduce((count, entry) => count + entry.files.length, 0);
@@ -472,5 +592,7 @@ export function validate(kind, doc) {
472
592
  };
473
593
  }
474
594
  const errors = semantic[kind] ? semantic[kind](doc) : [];
595
+ errors.push(...originErrors(kind, doc));
596
+ errors.push(...originPolicyErrors(kind, doc));
475
597
  return { ok: errors.length === 0, errors };
476
598
  }
@@ -0,0 +1,65 @@
1
+ {
2
+ "$id": "urn:claimspec:v1:annotations",
3
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
4
+ "title": "claimspec v1 — annotation authoring",
5
+ "description": "File-level authoring only. Not verification or intent authentication. No source contents or reusable approval token.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schemaVersion", "tool", "mode", "state", "selected", "targets", "refused"],
9
+ "properties": {
10
+ "schemaVersion": { "const": 1 },
11
+ "tool": { "$ref": "urn:claimspec:v1:common#/$defs/tool" },
12
+ "mode": { "enum": ["preview", "apply"] },
13
+ "state": { "enum": ["preview", "applied", "refused", "partial"] },
14
+ "selected": { "$ref": "#/$defs/ids" },
15
+ "targets": {
16
+ "type": "array",
17
+ "items": {
18
+ "type": "object", "additionalProperties": false, "required": ["file", "claimIds", "action"],
19
+ "properties": {
20
+ "file": { "$ref": "urn:claimspec:v1:common#/$defs/repoPath" },
21
+ "claimIds": { "type": "array", "$ref": "#/$defs/ids", "minItems": 1 },
22
+ "action": { "enum": ["add", "unchanged"] }
23
+ }
24
+ }
25
+ },
26
+ "refused": {
27
+ "type": "array",
28
+ "items": {
29
+ "type": "object", "additionalProperties": false, "required": ["file", "claimIds", "reason"],
30
+ "properties": {
31
+ "file": { "type": "string", "minLength": 1 },
32
+ "claimIds": { "type": "array", "$ref": "#/$defs/ids", "minItems": 1 },
33
+ "reason": { "enum": ["unsupported-claim-id", "unsupported-method", "unsafe-path", "excluded-target", "unsupported-source", "symlink-target", "not-regular-file", "nested-project", "source-too-large", "target-missing", "target-unreadable", "target-changed", "invalid-encoding", "mixed-newlines", "plan-source-budget", "proposed-source-too-large", "anchor-missing", "anchor-ambiguous"] }
34
+ }
35
+ }
36
+ },
37
+ "outcome": {
38
+ "type": "object", "additionalProperties": false, "required": ["changed", "touched", "unchanged", "failed"],
39
+ "properties": {
40
+ "changed": { "$ref": "#/$defs/files" },
41
+ "touched": { "$ref": "#/$defs/files" },
42
+ "unchanged": { "$ref": "#/$defs/files" },
43
+ "failed": {
44
+ "type": "array", "items": {
45
+ "type": "object", "additionalProperties": false, "required": ["file", "stage", "reason"],
46
+ "properties": {
47
+ "file": { "type": ["string", "null"] },
48
+ "stage": { "enum": ["preparation", "all-target-preflight", "started-journal", "target-preflight", "source-write", "write-verification", "completion-journal", "lock-release", "result-journal"] },
49
+ "reason": { "type": "string", "minLength": 1 }
50
+ }
51
+ }
52
+ },
53
+ "recoveryDir": { "type": "string", "minLength": 1 },
54
+ "lockRelease": {
55
+ "type": "object", "additionalProperties": false, "required": ["released"],
56
+ "properties": { "released": { "type": "boolean" }, "reason": { "type": "string", "minLength": 1 } }
57
+ }
58
+ }
59
+ }
60
+ },
61
+ "$defs": {
62
+ "ids": { "type": "array", "uniqueItems": true, "items": { "$ref": "urn:claimspec:v1:common#/$defs/identifier" } },
63
+ "files": { "type": "array", "uniqueItems": true, "items": { "$ref": "urn:claimspec:v1:common#/$defs/repoPath" } }
64
+ }
65
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "$id": "urn:claimspec:v1:authoring-input",
3
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
4
+ "title": "claimspec v1 — read-only authoring input",
5
+ "description": "Metadata-only input handoff. Not claims, verification, freshness authority or authenticated intent. No source text or absolute roots.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schemaVersion", "tool", "purpose", "verification", "next", "input"],
9
+ "properties": {
10
+ "schemaVersion": { "const": 1 },
11
+ "tool": { "$ref": "urn:claimspec:v1:common#/$defs/tool" },
12
+ "purpose": { "const": "authoring-input" },
13
+ "verification": { "const": "not-performed" },
14
+ "next": { "const": "supply-independent-intent" },
15
+ "input": { "oneOf": [{ "$ref": "#/$defs/document" }, { "$ref": "#/$defs/fix" }] }
16
+ },
17
+ "$defs": {
18
+ "objectId": { "type": "string", "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$" },
19
+ "document": {
20
+ "type": "object", "additionalProperties": false,
21
+ "required": ["kind", "file", "hash", "bytes"],
22
+ "properties": {
23
+ "kind": { "const": "document" },
24
+ "file": { "$ref": "urn:claimspec:v1:common#/$defs/repoPath" },
25
+ "hash": { "$ref": "urn:claimspec:v1:common#/$defs/sha256" },
26
+ "bytes": { "type": "integer", "minimum": 0, "maximum": 2097152 }
27
+ }
28
+ },
29
+ "fix": {
30
+ "type": "object", "additionalProperties": false,
31
+ "required": ["kind", "commit", "parent", "subject", "scope", "paths", "counts"],
32
+ "properties": {
33
+ "kind": { "const": "fix" },
34
+ "commit": { "$ref": "#/$defs/objectId" },
35
+ "parent": { "$ref": "#/$defs/objectId" },
36
+ "subject": { "type": "string", "maxLength": 1024 },
37
+ "scope": { "enum": ["project-root", "selected-nested-project"] },
38
+ "paths": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/path" } },
39
+ "counts": {
40
+ "type": "object", "additionalProperties": false,
41
+ "required": ["total", "supported", "deleted", "unsupported", "excluded"],
42
+ "properties": {
43
+ "total": { "$ref": "#/$defs/count" },
44
+ "supported": { "$ref": "#/$defs/count" },
45
+ "deleted": { "$ref": "#/$defs/count" },
46
+ "unsupported": { "$ref": "#/$defs/count" },
47
+ "excluded": { "$ref": "#/$defs/count" }
48
+ }
49
+ }
50
+ }
51
+ },
52
+ "count": { "type": "integer", "minimum": 0, "maximum": 256 },
53
+ "path": {
54
+ "type": "object", "additionalProperties": false,
55
+ "required": ["file", "status", "oldMode", "newMode", "oldId", "newId", "disposition"],
56
+ "properties": {
57
+ "file": { "$ref": "urn:claimspec:v1:common#/$defs/repoPath" },
58
+ "status": { "enum": ["A", "M", "D", "T"] },
59
+ "oldMode": { "type": "string", "pattern": "^[0-7]{6}$" },
60
+ "newMode": { "type": "string", "pattern": "^[0-7]{6}$" },
61
+ "oldId": { "$ref": "#/$defs/objectId" },
62
+ "newId": { "$ref": "#/$defs/objectId" },
63
+ "disposition": { "enum": ["supported", "deleted", "unsupported"] }
64
+ }
65
+ }
66
+ }
67
+ }
@@ -6,6 +6,8 @@
6
6
  "type": "object",
7
7
  "required": ["schemaVersion", "tool", "generatedAt", "heading", "summary", "items", "text"],
8
8
  "properties": {
9
+ "originPolicy": { "$ref": "urn:claimspec:v1:common#/$defs/originPolicy" },
10
+ "origins": { "$ref": "urn:claimspec:v1:common#/$defs/originSummary" },
9
11
  "$schema": { "type": "string" },
10
12
  "schemaVersion": { "const": 1 },
11
13
  "tool": { "$ref": "urn:claimspec:v1:common#/$defs/tool" },
@@ -46,20 +46,14 @@
46
46
  "maxLength": 2000
47
47
  },
48
48
  "source": {
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.",
49
+ "description": "Declared origin of claim intent. A source label or opaque reference does not authenticate independence, chronology or correctness. Producer provenance and measured test independence are separate.",
50
50
  "type": "object",
51
51
  "required": [
52
52
  "kind"
53
53
  ],
54
54
  "properties": {
55
55
  "kind": {
56
- "enum": [
57
- "spec",
58
- "adr",
59
- "annotation",
60
- "comment",
61
- "manual"
62
- ]
56
+ "$ref": "urn:claimspec:v1:common#/$defs/claimSourceKind"
63
57
  },
64
58
  "ref": {
65
59
  "type": "string",
@@ -4,6 +4,79 @@
4
4
  "title": "claimspec v1 \u2014 shared definitions",
5
5
  "description": "Definitions shared by every Guard-spec format. Referenced as urn:claimspec:v1:common#/$defs/<name>.",
6
6
  "$defs": {
7
+ "originPolicy": {
8
+ "description": "An explicitly selected declared-origin policy, not authenticated independence. Stored results describe their run, not current freshness authority.",
9
+ "type": "object",
10
+ "required": ["state", "eligibleKinds", "claims", "faults", "ineligibleClaims", "nonKilledFaults", "unavailableReasons"],
11
+ "properties": {
12
+ "state": { "enum": ["passed", "failed", "unavailable"] },
13
+ "eligibleKinds": { "type": "array", "minItems": 1, "maxItems": 10, "uniqueItems": true, "items": { "$ref": "#/$defs/claimSourceKind" } },
14
+ "claims": { "$ref": "#/$defs/originCount" },
15
+ "faults": { "$ref": "#/$defs/originCount" },
16
+ "ineligibleClaims": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/identifier" } },
17
+ "nonKilledFaults": {
18
+ "type": "array", "uniqueItems": true,
19
+ "items": {
20
+ "type": "object", "required": ["claimId", "faultId"],
21
+ "properties": { "claimId": { "$ref": "#/$defs/identifier" }, "faultId": { "$ref": "#/$defs/identifier" } },
22
+ "additionalProperties": false
23
+ }
24
+ },
25
+ "unavailableReasons": {
26
+ "type": "array", "uniqueItems": true,
27
+ "items": { "enum": ["invalid-claims", "invalid-evidence", "empty-universe", "stale-inputs", "provisional-evidence", "incompatible-method", "missing-record", "extra-record", "duplicate-record", "claim-metadata-mismatch", "fault-mismatch"] }
28
+ }
29
+ },
30
+ "additionalProperties": false
31
+ },
32
+ "originCount": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 },
33
+ "originCounts": {
34
+ "type": "object",
35
+ "required": ["spec", "adr", "annotation", "comment", "manual", "doc", "bug", "incident", "review", "inferred"],
36
+ "properties": {
37
+ "spec": { "$ref": "#/$defs/originCount" },
38
+ "adr": { "$ref": "#/$defs/originCount" },
39
+ "annotation": { "$ref": "#/$defs/originCount" },
40
+ "comment": { "$ref": "#/$defs/originCount" },
41
+ "manual": { "$ref": "#/$defs/originCount" },
42
+ "doc": { "$ref": "#/$defs/originCount" },
43
+ "bug": { "$ref": "#/$defs/originCount" },
44
+ "incident": { "$ref": "#/$defs/originCount" },
45
+ "review": { "$ref": "#/$defs/originCount" },
46
+ "inferred": { "$ref": "#/$defs/originCount" }
47
+ },
48
+ "additionalProperties": false
49
+ },
50
+ "originSummary": {
51
+ "description": "Declared origins, not authenticated independence. Distinct claim identities and actual recorded projections have separate denominators. Evidence validation recomputes this from every record; projected status/brief documents can only bind arithmetic and totals.",
52
+ "type": "object",
53
+ "required": ["basis", "claims"],
54
+ "properties": {
55
+ "basis": { "enum": ["declared", "recorded"] },
56
+ "claims": {
57
+ "type": "object", "required": ["total", "byKind", "mixed"],
58
+ "properties": {
59
+ "total": { "$ref": "#/$defs/originCount" },
60
+ "byKind": { "$ref": "#/$defs/originCounts" },
61
+ "mixed": { "$ref": "#/$defs/originCount" }
62
+ },
63
+ "additionalProperties": false
64
+ },
65
+ "records": {
66
+ "type": "object", "required": ["total", "byKind"],
67
+ "properties": {
68
+ "total": { "$ref": "#/$defs/originCount" },
69
+ "byKind": { "$ref": "#/$defs/originCounts" }
70
+ },
71
+ "additionalProperties": false
72
+ }
73
+ },
74
+ "additionalProperties": false
75
+ },
76
+ "claimSourceKind": {
77
+ "description": "Declared origin of claim intent, not authenticated independence or an evidence-strength order. References remain opaque; producer provenance and measured test independence are separate.",
78
+ "enum": ["spec", "adr", "annotation", "comment", "manual", "doc", "bug", "incident", "review", "inferred"]
79
+ },
7
80
  "nestedProject": {
8
81
  "description": "Changed files delegated to a separately validated descendant TestGuard project. This is not coverage or evidence; run that project's gate separately.",
9
82
  "type": "object",
@@ -11,6 +11,8 @@
11
11
  "records"
12
12
  ],
13
13
  "properties": {
14
+ "originPolicy": { "$ref": "urn:claimspec:v1:common#/$defs/originPolicy" },
15
+ "origins": { "$ref": "urn:claimspec:v1:common#/$defs/originSummary" },
14
16
  "$schema": {
15
17
  "type": "string"
16
18
  },
@@ -79,10 +81,11 @@
79
81
  "maxLength": 64
80
82
  },
81
83
  "source": {
82
- "description": "`project`: the runner package resolved from the project and its own bin script ran. `path`: no such package in the project; an executable of that name on PATH ran instead (a legitimate monorepo setup, but not the project's pinned version).",
84
+ "description": "`project`: the runner package resolved from the project. `path`: an executable on PATH ran instead. `builtin`: the explicitly selected node-test engine uses the Node executable running TestGuard; its version is required.",
83
85
  "enum": [
84
86
  "project",
85
- "path"
87
+ "path",
88
+ "builtin"
86
89
  ]
87
90
  }
88
91
  },
@@ -168,6 +171,39 @@
168
171
  },
169
172
  "method": {
170
173
  "$ref": "urn:claimspec:v1:common#/$defs/method"
174
+ },
175
+ "measurements": {
176
+ "type": "object",
177
+ "required": [
178
+ "elapsedMs",
179
+ "runnerMs",
180
+ "overheadMs",
181
+ "runnerInvocations"
182
+ ],
183
+ "properties": {
184
+ "elapsedMs": {
185
+ "type": "number",
186
+ "minimum": 0
187
+ },
188
+ "runnerMs": {
189
+ "type": "number",
190
+ "minimum": 0
191
+ },
192
+ "overheadMs": {
193
+ "type": "number",
194
+ "minimum": 0
195
+ },
196
+ "runnerInvocations": {
197
+ "type": "integer",
198
+ "minimum": 0
199
+ }
200
+ },
201
+ "additionalProperties": false
202
+ },
203
+ "workers": {
204
+ "type": "integer",
205
+ "minimum": 1,
206
+ "description": "Requested built-in runner worker ceiling; Python is serial. Absent for custom commands, whose concurrency is user-managed."
171
207
  }
172
208
  },
173
209
  "additionalProperties": false,
@@ -309,13 +345,7 @@
309
345
  ],
310
346
  "properties": {
311
347
  "kind": {
312
- "enum": [
313
- "spec",
314
- "adr",
315
- "annotation",
316
- "comment",
317
- "manual"
318
- ]
348
+ "$ref": "urn:claimspec:v1:common#/$defs/claimSourceKind"
319
349
  },
320
350
  "ref": {
321
351
  "type": "string",
@@ -664,7 +694,7 @@
664
694
  "additionalProperties": false
665
695
  },
666
696
  "reusedFrom": {
667
- "description": "Run id whose verdict was reused because `inputs` were unchanged.",
697
+ "description": "Run id whose verdict was reused because fault, defender and recorded claim metadata inputs were unchanged. A changed claim statement, severity, source or producer requires a fresh probe.",
668
698
  "$ref": "urn:claimspec:v1:common#/$defs/identifier"
669
699
  },
670
700
  "rank": {
@@ -13,6 +13,8 @@
13
13
  "counts"
14
14
  ],
15
15
  "properties": {
16
+ "originPolicy": { "$ref": "urn:claimspec:v1:common#/$defs/originPolicy" },
17
+ "origins": { "$ref": "urn:claimspec:v1:common#/$defs/originSummary" },
16
18
  "$schema": {
17
19
  "type": "string"
18
20
  },
@@ -157,12 +159,13 @@
157
159
  "type": "object",
158
160
  "required": ["id", "evidence", "provisional", "records", "newSinceBaseline", "exitCode"],
159
161
  "properties": {
162
+ "originPolicy": { "$ref": "urn:claimspec:v1:common#/$defs/originPolicy" },
160
163
  "id": { "type": "string", "minLength": 1, "maxLength": 128 },
161
164
  "evidence": { "type": "string", "minLength": 1, "maxLength": 4096 },
162
165
  "provisional": { "type": "boolean" },
163
166
  "records": { "type": "integer", "minimum": 0 },
164
167
  "newSinceBaseline": { "type": "integer", "minimum": 0 },
165
- "exitCode": { "enum": [0, 1] },
168
+ "exitCode": { "enum": [0, 1, 2] },
166
169
  "scope": {
167
170
  "type": "object",
168
171
  "required": ["requestedClaims", "requestedCount", "probedClaims", "probedCount"],
@@ -191,6 +194,7 @@
191
194
  "type": "object",
192
195
  "required": ["totalMs", "totalRuns", "reusedRecords", "records", "claims", "defenders", "sharedDefenders"],
193
196
  "properties": {
197
+ "measurements": {"type": "object", "required": ["elapsedMs", "runnerMs", "overheadMs", "runnerInvocations"], "properties": {"elapsedMs": {"type": "number", "minimum": 0}, "runnerMs": {"type": "number", "minimum": 0}, "overheadMs": {"type": "number", "minimum": 0}, "runnerInvocations": {"type": "integer", "minimum": 0}}, "additionalProperties": false},
194
198
  "totalMs": { "type": "number", "minimum": 0 },
195
199
  "totalRuns": { "type": "integer", "minimum": 0 },
196
200
  "reusedRecords": { "type": "integer", "minimum": 0 },
@@ -301,7 +305,7 @@
301
305
  }
302
306
  },
303
307
  "notes": {
304
- "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.",
308
+ "description": "Informational lines that never change state or next verification action: e.g. annotation-link advice (not verification or authenticated provenance), or a baseline snapshot with the way to re-stamp it.",
305
309
  "type": "array",
306
310
  "items": { "type": "string", "minLength": 1, "maxLength": 512 }
307
311
  },
@@ -1,5 +1,6 @@
1
1
  import { gate } from '../baseline/baseline.mjs';
2
- import { coAuthorshipWarning, summarize, formatVerdict } from '../render.mjs';
2
+ import { coAuthorshipWarning, summarize, formatVerdict, renderOrigins, renderOriginPolicy } from '../render.mjs';
3
+ import { recordedOriginSummary } from '../../spec/lib/origins.mjs';
3
4
 
4
5
  export const HEADING = '## TEST BLINDSPOT CONTEXT';
5
6
  /** First line of every markdown brief, so a poster can find and update its own note instead of adding another. */
@@ -72,10 +73,12 @@ export function buildBrief(evidence, baseline, { max = 20, generatedAt = new Dat
72
73
  new: g.new.length + g.belowFloor.length,
73
74
  baselined: g.baselined.length,
74
75
  };
76
+ const origins = recordedOriginSummary(evidence.records);
75
77
  const kills = evidence.records.filter((r) => r.verdict === 'killed');
76
78
  const coAuthored = kills.filter((r) => r.detail.independence?.class === 'co-authored').length;
77
79
  const unclaimed = changes?.uncovered?.length ? { ref: changes.ref, files: changes.uncovered } : undefined;
78
- const doc = { schemaVersion: 1, tool: evidence.tool, generatedAt, head: evidence.run.repo.head, heading: HEADING, summary, ...(next ? { next: { action: next.action, command: next.command, why: next.why } } : {}), ...(unclaimed ? { unclaimed } : {}), items, text: '' };
80
+ const doc = { schemaVersion: 1, tool: evidence.tool, generatedAt, head: evidence.run.repo.head, heading: HEADING, summary, origins, ...(next ? { next: { action: next.action, command: next.command, why: next.why } } : {}), ...(unclaimed ? { unclaimed } : {}), items, text: '' };
81
+ if (evidence.originPolicy) doc.originPolicy = structuredClone(evidence.originPolicy);
79
82
  doc.text = renderBriefText({ ...doc, provisional: Boolean(evidence.run.provisional), install }, { hasBaseline: Boolean(baseline), total: evidence.records.length, independence: kills.length ? { coAuthored, kills: kills.length } : undefined });
80
83
  return doc;
81
84
  }
@@ -89,6 +92,8 @@ export function renderBriefText(brief, { hasBaseline, total, independence }) {
89
92
  `testguard ${brief.tool.version}${brief.install ? ` (${brief.install})` : ''}${brief.head ? ` @ ${brief.head.slice(0, 12)}` : ''} — ${brief.summary.claims} claims, ${total} faults probed, ${unproven} unproven` +
90
93
  (unproven === 0 ? '.' : hasBaseline ? ` (${brief.summary.new} new since baseline).` : ' (no baseline; everything is new).'),
91
94
  ];
95
+ if (brief.origins) lines.push(renderOrigins(brief.origins));
96
+ if (brief.originPolicy) lines.push(renderOriginPolicy(brief.originPolicy));
92
97
  // Unclaimed changes come before everything else: the claim is written
93
98
  // before more code, and TestGuard is silent about unclaimed code otherwise.
94
99
  if (brief.unclaimed) {
@@ -101,7 +106,7 @@ export function renderBriefText(brief, { hasBaseline, total, independence }) {
101
106
  if (independence?.coAuthored) lines.push('', coAuthorshipWarning(independence.coAuthored, independence.kills));
102
107
  if (brief.next) lines.push('', `NEXT [${brief.next.action}]: ${brief.next.command}`, ` why: ${brief.next.why}`);
103
108
  if (brief.items.length === 0) {
104
- lines.push('', 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.');
109
+ lines.push('', unproven === 0 ? 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.' : `${unproven} unproven faults remain; no items shown at the current cap. See the full evidence file.`);
105
110
  return lines.join('\n') + '\n';
106
111
  }
107
112
  lines.push(
@@ -149,6 +154,8 @@ export function renderBriefMarkdown(brief, { hasBaseline, total, install } = {})
149
154
  if (brief.provisional) lines.push('**PROVISIONAL** — fewer than three confirmation runs; nothing below is confirmed. Re-probe with `--confirm 3`.', '');
150
155
  lines.push(`\`testguard ${brief.tool.version}${install ? ` (${install})` : ''}\`${brief.head ? ` @ \`${brief.head.slice(0, 12)}\`` : ''} — **${brief.summary.claims}** claims, **${total}** faults probed, **${unproven}** unproven` +
151
156
  (unproven === 0 ? '.' : hasBaseline ? ` (**${brief.summary.new}** new since baseline).` : ' (no baseline; everything is new).'));
157
+ if (brief.origins) lines.push('', renderOrigins(brief.origins));
158
+ if (brief.originPolicy) lines.push('', renderOriginPolicy(brief.originPolicy));
152
159
  if (brief.unclaimed) {
153
160
  const n = brief.unclaimed.files.length;
154
161
  lines.push('', `### Unclaimed changes since \`${brief.unclaimed.ref}\``, '', `${n} changed file${n === 1 ? '' : 's'} carr${n === 1 ? 'ies' : 'y'} no claim. State the claim first; nothing below can see this code.`, '');
@@ -156,7 +163,7 @@ export function renderBriefMarkdown(brief, { hasBaseline, total, install } = {})
156
163
  }
157
164
  if (brief.next) lines.push('', `**Next** \`[${brief.next.action}]\`: \`${brief.next.command}\``, '', `> ${brief.next.why}`);
158
165
  if (brief.items.length === 0) {
159
- lines.push('', 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.');
166
+ lines.push('', unproven === 0 ? 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.' : `${unproven} unproven faults remain; no items shown at the current cap. See the full evidence file.`);
160
167
  return lines.join('\n') + '\n';
161
168
  }
162
169
  lines.push('', 'Where the test suite is blind, ranked. A **SURVIVED** fault means its defenders stayed green while the claim was false. Do not close these by asserting current behaviour; write a test that fails on the described fault and passes on HEAD.', '');