@cleocode/contracts 2026.5.110 → 2026.5.112

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 (31) hide show
  1. package/dist/__tests__/evidence-atom-schema.test.d.ts +16 -0
  2. package/dist/__tests__/evidence-atom-schema.test.d.ts.map +1 -0
  3. package/dist/__tests__/evidence-atom-schema.test.js +394 -0
  4. package/dist/__tests__/evidence-atom-schema.test.js.map +1 -0
  5. package/dist/evidence-atom-schema.d.ts +407 -0
  6. package/dist/evidence-atom-schema.d.ts.map +1 -0
  7. package/dist/evidence-atom-schema.js +505 -0
  8. package/dist/evidence-atom-schema.js.map +1 -0
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/invariants/__tests__/registry.test.d.ts +14 -0
  14. package/dist/invariants/__tests__/registry.test.d.ts.map +1 -0
  15. package/dist/invariants/__tests__/registry.test.js +113 -0
  16. package/dist/invariants/__tests__/registry.test.js.map +1 -0
  17. package/dist/invariants/adr-073-saga.d.ts +32 -0
  18. package/dist/invariants/adr-073-saga.d.ts.map +1 -0
  19. package/dist/invariants/adr-073-saga.js +155 -0
  20. package/dist/invariants/adr-073-saga.js.map +1 -0
  21. package/dist/invariants/index.d.ts +159 -0
  22. package/dist/invariants/index.d.ts.map +1 -0
  23. package/dist/invariants/index.js +109 -0
  24. package/dist/invariants/index.js.map +1 -0
  25. package/package.json +2 -2
  26. package/src/__tests__/evidence-atom-schema.test.ts +499 -0
  27. package/src/evidence-atom-schema.ts +628 -0
  28. package/src/index.ts +39 -0
  29. package/src/invariants/__tests__/registry.test.ts +136 -0
  30. package/src/invariants/adr-073-saga.ts +167 -0
  31. package/src/invariants/index.ts +213 -0
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Zod schema + gate-to-atom mapping for the ADR-051 evidence atom grammar.
3
+ *
4
+ * `cleo verify <task-id> --evidence "<atoms>"` accepts a semicolon-delimited
5
+ * list of evidence atoms. Each atom carries a kind prefix and a payload. The
6
+ * grammar lives in three places today:
7
+ *
8
+ * 1. {@link EvidenceAtomSchema} — Zod discriminated union covering every
9
+ * parseable atom shape (this file).
10
+ * 2. {@link GATE_EVIDENCE_REQUIREMENTS} — gate → satisfying atom-combination
11
+ * map (this file).
12
+ * 3. `packages/core/src/tasks/evidence.ts` — runtime parser + filesystem /
13
+ * git / tool validators. The runtime parser delegates to
14
+ * {@link parseEvidenceString} below for the syntactic split.
15
+ *
16
+ * The schema is exposed for LLM agents and IDE tooling that want to validate
17
+ * an evidence string client-side BEFORE invoking `cleo verify`. The CLI
18
+ * runtime continues to perform the full filesystem-aware validation pass
19
+ * because Zod cannot check that a commit SHA is reachable or that a file
20
+ * exists on disk.
21
+ *
22
+ * The atom shapes here mirror the `ParsedAtom` union in
23
+ * `packages/core/src/tasks/evidence.ts` exactly. Behavior parity with the
24
+ * pre-T10337 ad-hoc parser is required — see the T10337 test suite for the
25
+ * full parity matrix.
26
+ *
27
+ * @task T10337
28
+ * @saga T10326
29
+ * @adr ADR-051
30
+ */
31
+
32
+ import { z } from 'zod';
33
+
34
+ import type { VerificationGate } from './task.js';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Per-atom Zod schemas
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * `commit:<sha>` atom — references a git commit by full or short SHA.
42
+ *
43
+ * Format: `commit:<7-40 hex chars>`. The schema accepts any 7-40-character
44
+ * lowercase hex SHA; the runtime validator additionally checks reachability
45
+ * from the task branch and the AC-file content-intersect rule (T9245).
46
+ *
47
+ * @task T832
48
+ */
49
+ export const commitAtomSchema = z.object({
50
+ kind: z.literal('commit'),
51
+ sha: z.string().regex(/^[0-9a-f]{7,40}$/i, 'commit sha must be 7-40 hex characters'),
52
+ });
53
+
54
+ /**
55
+ * `files:<p1,p2,...>` atom — list of file paths that the task touched.
56
+ *
57
+ * Format: `files:<comma-separated paths>`. The runtime validator additionally
58
+ * stats each path and records its sha256.
59
+ *
60
+ * @task T832
61
+ */
62
+ export const filesAtomSchema = z.object({
63
+ kind: z.literal('files'),
64
+ paths: z.array(z.string().min(1)).min(1, 'files atom requires at least one path'),
65
+ });
66
+
67
+ /**
68
+ * `test-run:<path>` atom — path to a structured test runner JSON output
69
+ * (vitest, jest, pytest, cargo-nextest, etc.).
70
+ *
71
+ * Format: `test-run:<absolute or project-relative path>`. The runtime validator
72
+ * loads the file, parses the JSON, and rejects when failedTests > 0 or
73
+ * totalTests === 0.
74
+ *
75
+ * @task T832
76
+ */
77
+ export const testRunAtomSchema = z.object({
78
+ kind: z.literal('test-run'),
79
+ path: z.string().min(1, 'test-run atom requires a non-empty path'),
80
+ });
81
+
82
+ /**
83
+ * `tool:<name>` atom — runs a project-resolved canonical tool and accepts
84
+ * exit-code 0.
85
+ *
86
+ * Format: `tool:<canonical name or legacy alias>`. Canonical names: `test`,
87
+ * `build`, `lint`, `typecheck`, `audit`, `security-scan`. Legacy aliases
88
+ * (`pnpm-test`, `tsc`, `biome`, `cargo-test`, `pytest`, …) still resolve via
89
+ * the runtime resolver (ADR-061 / T1534).
90
+ *
91
+ * @task T832
92
+ * @task T1534
93
+ */
94
+ export const toolAtomSchema = z.object({
95
+ kind: z.literal('tool'),
96
+ tool: z.string().min(1, 'tool atom requires a non-empty tool name'),
97
+ });
98
+
99
+ /**
100
+ * `url:<href>` atom — soft evidence pointing to an external artifact (docs,
101
+ * dashboard, etc.).
102
+ *
103
+ * Format: `url:<http(s)://...>`. The runtime validator requires the
104
+ * `http://` or `https://` scheme.
105
+ *
106
+ * @task T832
107
+ */
108
+ export const urlAtomSchema = z.object({
109
+ kind: z.literal('url'),
110
+ url: z
111
+ .string()
112
+ .min(1)
113
+ .regex(/^https?:\/\//, 'url atom must start with http:// or https://'),
114
+ });
115
+
116
+ /**
117
+ * `note:<text>` atom — free-form note used for soft evidence and waivers.
118
+ *
119
+ * Format: `note:<1-512 chars>`. The runtime validator caps the note at 512
120
+ * characters to keep the gate-evidence record bounded.
121
+ *
122
+ * @task T832
123
+ */
124
+ export const noteAtomSchema = z.object({
125
+ kind: z.literal('note'),
126
+ note: z
127
+ .string()
128
+ .min(1, 'note atom must be non-empty')
129
+ .max(512, 'note atom is too long (max 512 chars)'),
130
+ });
131
+
132
+ /**
133
+ * `decision:<id>` atom — references a `brain_decisions` row that IS the
134
+ * canonical artifact for a decision-only task. Satisfies the `implemented`
135
+ * gate when combined with `files:` pointing to a research note or with
136
+ * `note:` (T1875).
137
+ *
138
+ * Format: `decision:<id>` (e.g. `decision:D-arch-001`). The runtime validator
139
+ * looks up the row and requires `confirmation_state` ∈ {`accepted`,
140
+ * `proposed`}.
141
+ *
142
+ * @task T1875
143
+ */
144
+ export const decisionAtomSchema = z.object({
145
+ kind: z.literal('decision'),
146
+ decisionId: z.string().min(1, 'decision atom requires a non-empty decision ID'),
147
+ });
148
+
149
+ /**
150
+ * `pr:<number>` atom — references a GitHub PR by number. Satisfies BOTH
151
+ * `testsPassed` and `qaPassed` simultaneously when the PR is MERGED and every
152
+ * required-workflow check is green (T9764). Extended in T9838 to satisfy
153
+ * `implemented` because the merge commit IS the landing artifact.
154
+ *
155
+ * Format: `pr:<positive integer>` (e.g. `pr:357`).
156
+ *
157
+ * @task T9764
158
+ * @task T9838
159
+ */
160
+ export const prAtomSchema = z.object({
161
+ kind: z.literal('pr'),
162
+ prNumber: z.number().int().positive('pr atom requires a positive integer PR number'),
163
+ });
164
+
165
+ /**
166
+ * `loc-drop:<fromLines>:<toLines>` atom — proves that a migrated engine shed
167
+ * lines. Required for the `implemented` gate when the task carries the
168
+ * `engine-migration` label (T1604).
169
+ *
170
+ * Format: `loc-drop:<non-negative integer>:<non-negative integer>`.
171
+ * The schema admits any non-negative pair; the runtime validator additionally
172
+ * requires `fromLines > 0` and `toLines <= fromLines`.
173
+ *
174
+ * @task T1604
175
+ */
176
+ export const locDropAtomSchema = z.object({
177
+ kind: z.literal('loc-drop'),
178
+ fromLines: z.number().int().nonnegative('loc-drop fromLines must be ≥ 0'),
179
+ toLines: z.number().int().nonnegative('loc-drop toLines must be ≥ 0'),
180
+ });
181
+
182
+ /**
183
+ * `callsite-coverage:<symbol>:<sourcePath>` atom — proves that an exported
184
+ * symbol has ≥1 production callsite outside its own source file, test files,
185
+ * and dist directories. Required for the `implemented` gate when the task
186
+ * carries the `callsite-coverage` label (T1605).
187
+ *
188
+ * @task T1605
189
+ */
190
+ export const callsiteCoverageAtomSchema = z.object({
191
+ kind: z.literal('callsite-coverage'),
192
+ symbolName: z.string().min(1, 'callsite-coverage atom requires a non-empty symbolName'),
193
+ relativeSourcePath: z
194
+ .string()
195
+ .min(1, 'callsite-coverage atom requires a non-empty relativeSourcePath'),
196
+ });
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Discriminated union
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /**
203
+ * Zod discriminated union covering every parseable evidence atom prefix.
204
+ *
205
+ * Use `EvidenceAtomSchema.safeParse(atom)` to validate a single atom client-
206
+ * side BEFORE invoking `cleo verify`. The runtime parser in
207
+ * `packages/core/src/tasks/evidence.ts` performs filesystem/git/tool checks
208
+ * on top of this syntactic schema.
209
+ *
210
+ * Atom kinds:
211
+ * - `commit` — git commit SHA (T832)
212
+ * - `files` — touched file paths (T832)
213
+ * - `test-run` — structured test runner JSON (T832)
214
+ * - `tool` — project-resolved tool exit code (T832 / T1534)
215
+ * - `url` — soft external pointer (T832)
216
+ * - `note` — free-form note (T832)
217
+ * - `decision` — brain_decisions row reference (T1875)
218
+ * - `pr` — merged-PR retroactive proof (T9764 / T9838)
219
+ * - `loc-drop` — engine-migration LOC reduction (T1604)
220
+ * - `callsite-coverage` — exported-symbol production callsite (T1605)
221
+ *
222
+ * @task T10337
223
+ * @adr ADR-051
224
+ */
225
+ export const EvidenceAtomSchema = z.discriminatedUnion('kind', [
226
+ commitAtomSchema,
227
+ filesAtomSchema,
228
+ testRunAtomSchema,
229
+ toolAtomSchema,
230
+ urlAtomSchema,
231
+ noteAtomSchema,
232
+ decisionAtomSchema,
233
+ prAtomSchema,
234
+ locDropAtomSchema,
235
+ callsiteCoverageAtomSchema,
236
+ ]);
237
+
238
+ /**
239
+ * Inferred TypeScript type for a parsed evidence atom.
240
+ *
241
+ * This is the pre-validation shape emitted by {@link parseEvidenceString}
242
+ * — distinct from the post-validation `EvidenceAtom` in `./task.ts` which
243
+ * carries fields populated by the runtime validator (sha256, exitCode, etc.).
244
+ *
245
+ * @task T10337
246
+ */
247
+ export type EvidenceAtom = z.infer<typeof EvidenceAtomSchema>;
248
+
249
+ /** Discriminant union of every atom kind accepted by {@link EvidenceAtomSchema}. */
250
+ export type EvidenceAtomKind = EvidenceAtom['kind'];
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Gate-to-atom requirements map
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /**
257
+ * Per-gate evidence requirement spec.
258
+ *
259
+ * Each entry of `oneOf` is a satisfying atom-combination — the gate is
260
+ * satisfied IFF EVERY kind listed in at least ONE of the combinations is
261
+ * present among the supplied atoms.
262
+ *
263
+ * A single-element combination `['pr']` means "one `pr:` atom alone
264
+ * satisfies the gate". A two-element combination `['commit', 'files']` means
265
+ * "both a `commit:` atom AND a `files:` atom must be present".
266
+ *
267
+ * @task T10337
268
+ * @adr ADR-051 §2.3
269
+ */
270
+ export interface GateEvidenceRequirement {
271
+ /** Alternative satisfying combinations — ANY one is sufficient. */
272
+ oneOf: ReadonlyArray<ReadonlyArray<EvidenceAtomKind>>;
273
+ }
274
+
275
+ /**
276
+ * Mapping from each {@link VerificationGate} to its satisfying atom
277
+ * combinations. Mirrors the legacy `GATE_EVIDENCE_MINIMUMS` table in
278
+ * `packages/core/src/tasks/evidence.ts` exactly — behavior parity required
279
+ * by T10337.
280
+ *
281
+ * ## Why two alternatives for `implemented`?
282
+ *
283
+ * The `implemented` gate accepts six different evidence shapes:
284
+ *
285
+ * - `[commit, files]` — standard: commit SHA + touched file list.
286
+ * - `[commit, note]` — deletion-safe: commit SHA + descriptive note
287
+ * (when the implementation deleted files there
288
+ * are none left to anchor `files:`).
289
+ * - `[decision, files]` — decision-only task: brain_decisions row +
290
+ * research-note file (T1875).
291
+ * - `[decision, note]` — decision-only task without research note.
292
+ * - `[pr]` — merged-PR retroactive proof (T9838 extension
293
+ * of T9764).
294
+ *
295
+ * ## Why is `qaPassed` listed as `['tool']` not `['tool', 'tool']`?
296
+ *
297
+ * The pre-T10337 runtime enforced "at least one atom of kind `tool` is
298
+ * present" — it did NOT count distinct tool names. The convention `tool:lint`
299
+ * + `tool:typecheck` is documented in AGENTS.md as best-practice but the
300
+ * gate is satisfied by a single tool atom (e.g. `tool:test` when test
301
+ * already runs lint + typecheck pre-flight). Behavior parity requires that
302
+ * single-tool atoms continue to pass.
303
+ *
304
+ * @task T10337
305
+ * @adr ADR-051 §2.3
306
+ */
307
+ export const GATE_EVIDENCE_REQUIREMENTS: Readonly<
308
+ Record<VerificationGate, GateEvidenceRequirement>
309
+ > = Object.freeze({
310
+ implemented: {
311
+ oneOf: [
312
+ ['commit', 'files'],
313
+ ['commit', 'note'],
314
+ ['decision', 'files'],
315
+ ['decision', 'note'],
316
+ ['pr'],
317
+ ],
318
+ },
319
+ testsPassed: { oneOf: [['test-run'], ['tool'], ['pr']] },
320
+ qaPassed: { oneOf: [['tool'], ['pr']] },
321
+ documented: { oneOf: [['files'], ['url']] },
322
+ securityPassed: { oneOf: [['tool'], ['note']] },
323
+ cleanupDone: { oneOf: [['note']] },
324
+ nexusImpact: { oneOf: [['tool'], ['note']] },
325
+ });
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Helpers
329
+ // ---------------------------------------------------------------------------
330
+
331
+ /**
332
+ * Result of {@link validateEvidenceForGate}.
333
+ *
334
+ * Success carries no extra payload (the caller already holds the atom list).
335
+ * Failure carries a human-readable message in the legacy
336
+ * `Gate '<gate>' requires evidence: [<combo>] OR [<combo>]` format so error
337
+ * surfaces (E_EVIDENCE_INSUFFICIENT) read identically across the CLI and any
338
+ * client-side validator built on top of this schema.
339
+ */
340
+ export type EvidenceValidationResult =
341
+ | { readonly ok: true }
342
+ | { readonly ok: false; readonly message: string };
343
+
344
+ /**
345
+ * Format the satisfying-combinations spec for a gate as the legacy
346
+ * `[commit AND files] OR [pr]` string used in CLI error messages.
347
+ *
348
+ * Exposed so consumers can render the same hint text without re-computing it.
349
+ *
350
+ * @task T10337
351
+ */
352
+ export function formatGateRequirement(gate: VerificationGate): string {
353
+ const requirement = GATE_EVIDENCE_REQUIREMENTS[gate];
354
+ return requirement.oneOf.map((set) => `[${set.join(' AND ')}]`).join(' OR ');
355
+ }
356
+
357
+ /**
358
+ * Check whether a set of parsed atoms satisfies the requirement spec for
359
+ * `gate`.
360
+ *
361
+ * Returns `{ ok: true }` when ANY combination in `GATE_EVIDENCE_REQUIREMENTS[gate].oneOf`
362
+ * is fully present in `atoms` (matched by kind). Returns
363
+ * `{ ok: false, message }` otherwise — the message matches the legacy
364
+ * `checkGateEvidenceMinimum()` format byte-for-byte so error surfaces do not
365
+ * regress.
366
+ *
367
+ * Atoms must already be parsed (via {@link parseEvidenceString} or
368
+ * {@link EvidenceAtomSchema}); the helper does NOT re-validate the atom
369
+ * payload. Use {@link EvidenceAtomSchema.safeParse} first if the input came
370
+ * from an untrusted source.
371
+ *
372
+ * @param gate - Verification gate being checked.
373
+ * @param atoms - Already-parsed evidence atoms.
374
+ * @returns Pass / fail with a human-readable failure message.
375
+ *
376
+ * @example
377
+ * ```ts
378
+ * const atoms = parseEvidenceString('commit:abc1234;files:src/foo.ts');
379
+ * validateEvidenceForGate('implemented', atoms); // { ok: true }
380
+ * validateEvidenceForGate('testsPassed', atoms);
381
+ * // { ok: false, message: "Gate 'testsPassed' requires evidence: [test-run] OR [tool] OR [pr]" }
382
+ * ```
383
+ *
384
+ * @task T10337
385
+ * @adr ADR-051 §2.3
386
+ */
387
+ export function validateEvidenceForGate(
388
+ gate: VerificationGate,
389
+ atoms: ReadonlyArray<{ kind: EvidenceAtomKind }>,
390
+ ): EvidenceValidationResult {
391
+ const requirement = GATE_EVIDENCE_REQUIREMENTS[gate];
392
+ if (!requirement) {
393
+ // Unknown gate — defensive default: accept (preserves legacy "no minimum"
394
+ // behavior from checkGateEvidenceMinimum when the gate had no entry).
395
+ return { ok: true };
396
+ }
397
+ for (const combination of requirement.oneOf) {
398
+ const satisfied = combination.every((kind) => atoms.some((a) => a.kind === kind));
399
+ if (satisfied) return { ok: true };
400
+ }
401
+ return {
402
+ ok: false,
403
+ message: `Gate '${gate}' requires evidence: ${formatGateRequirement(gate)}`,
404
+ };
405
+ }
406
+
407
+ /**
408
+ * Error class thrown by {@link parseEvidenceString} when the input is
409
+ * malformed. Carries a {@link reason} suitable for the CLI `fix:` hint.
410
+ *
411
+ * Pure data — no dependency on `@cleocode/contracts/errors` (which would
412
+ * pull the CleoError import surface into every consumer that just wants
413
+ * syntactic validation).
414
+ *
415
+ * @task T10337
416
+ */
417
+ export class EvidenceParseError extends Error {
418
+ /** Hint text suitable for an `fix:` message in the CLI surface. */
419
+ public readonly fix: string;
420
+
421
+ constructor(message: string, fix: string) {
422
+ super(message);
423
+ this.name = 'EvidenceParseError';
424
+ this.fix = fix;
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Parse the raw CLI `--evidence` string into structured atoms.
430
+ *
431
+ * Syntax:
432
+ * ```
433
+ * evidence-list := atom (';' atom)*
434
+ * atom := kind ':' payload
435
+ * ```
436
+ *
437
+ * The payload format depends on `kind`:
438
+ *
439
+ * - `files` — comma-separated paths
440
+ * - `loc-drop` — `<fromLines>:<toLines>` (two integers)
441
+ * - `callsite-coverage` — `<symbolName>:<relativeSourcePath>`
442
+ * - `pr` — positive integer
443
+ * - everything else — opaque payload string until the next `;`
444
+ *
445
+ * A `state:MERGED` modifier may follow a `pr:<num>` atom and is consumed
446
+ * in-place (no separate atom is emitted) — see T9838. Any other usage of
447
+ * `state:` is rejected.
448
+ *
449
+ * Whitespace surrounding the `;` separators and around `kind`/`payload` is
450
+ * trimmed. Empty chunks are skipped (consecutive `;;` is tolerated).
451
+ *
452
+ * @param raw - Raw `--evidence` string.
453
+ * @returns Parsed atoms ready for {@link validateEvidenceForGate} or for
454
+ * the runtime validator in `packages/core/src/tasks/evidence.ts`.
455
+ * @throws {@link EvidenceParseError} when the input is malformed.
456
+ *
457
+ * @example
458
+ * ```ts
459
+ * parseEvidenceString('commit:abc1234;files:src/foo.ts,src/bar.ts;tool:lint');
460
+ * // [
461
+ * // { kind: 'commit', sha: 'abc1234' },
462
+ * // { kind: 'files', paths: ['src/foo.ts', 'src/bar.ts'] },
463
+ * // { kind: 'tool', tool: 'lint' },
464
+ * // ]
465
+ * ```
466
+ *
467
+ * @task T10337
468
+ * @adr ADR-051
469
+ */
470
+ export function parseEvidenceString(raw: string): EvidenceAtom[] {
471
+ if (!raw || typeof raw !== 'string') {
472
+ throw new EvidenceParseError(
473
+ 'Evidence string is empty',
474
+ "Pass evidence like '--evidence commit:<sha>;files:<path>;...'",
475
+ );
476
+ }
477
+ const chunks = raw
478
+ .split(';')
479
+ .map((s) => s.trim())
480
+ .filter(Boolean);
481
+ if (chunks.length === 0) {
482
+ throw new EvidenceParseError(
483
+ 'Evidence string contained no atoms',
484
+ "Pass evidence like '--evidence commit:<sha>;files:<path>;...'",
485
+ );
486
+ }
487
+
488
+ const atoms: EvidenceAtom[] = [];
489
+ for (const chunk of chunks) {
490
+ const colon = chunk.indexOf(':');
491
+ if (colon < 1 || colon === chunk.length - 1) {
492
+ throw new EvidenceParseError(
493
+ `Malformed evidence atom: "${chunk}" (expected <kind>:<payload>)`,
494
+ 'Each atom must be of form "<kind>:<payload>" separated by ";".',
495
+ );
496
+ }
497
+ const kind = chunk.slice(0, colon).trim();
498
+ const payload = chunk.slice(colon + 1).trim();
499
+ switch (kind) {
500
+ case 'commit':
501
+ atoms.push({ kind: 'commit', sha: payload });
502
+ break;
503
+ case 'files':
504
+ atoms.push({
505
+ kind: 'files',
506
+ paths: payload
507
+ .split(',')
508
+ .map((p) => p.trim())
509
+ .filter(Boolean),
510
+ });
511
+ break;
512
+ case 'test-run':
513
+ atoms.push({ kind: 'test-run', path: payload });
514
+ break;
515
+ case 'tool':
516
+ atoms.push({ kind: 'tool', tool: payload });
517
+ break;
518
+ case 'url':
519
+ atoms.push({ kind: 'url', url: payload });
520
+ break;
521
+ case 'note':
522
+ atoms.push({ kind: 'note', note: payload });
523
+ break;
524
+ case 'decision': {
525
+ if (!payload) {
526
+ throw new EvidenceParseError(
527
+ `decision atom requires a non-empty decision ID in "${chunk}"`,
528
+ 'Use format: decision:<decisionId> e.g. decision:D-arch-001',
529
+ );
530
+ }
531
+ atoms.push({ kind: 'decision', decisionId: payload });
532
+ break;
533
+ }
534
+ case 'pr': {
535
+ const prNumber = Number(payload);
536
+ if (!Number.isInteger(prNumber) || prNumber <= 0) {
537
+ throw new EvidenceParseError(
538
+ `pr atom requires a positive integer PR number, got "${payload}" in "${chunk}"`,
539
+ 'Use format: pr:<number> e.g. pr:357',
540
+ );
541
+ }
542
+ atoms.push({ kind: 'pr', prNumber });
543
+ break;
544
+ }
545
+ case 'loc-drop': {
546
+ const firstColon = payload.indexOf(':');
547
+ if (firstColon < 1 || firstColon === payload.length - 1) {
548
+ throw new EvidenceParseError(
549
+ `Malformed loc-drop atom: "${chunk}" (expected loc-drop:<fromLines>:<toLines>)`,
550
+ 'Use format: loc-drop:<fromLines>:<toLines> e.g. loc-drop:1200:800',
551
+ );
552
+ }
553
+ const fromRaw = payload.slice(0, firstColon).trim();
554
+ const toRaw = payload.slice(firstColon + 1).trim();
555
+ const fromLines = Number(fromRaw);
556
+ const toLines = Number(toRaw);
557
+ if (!Number.isInteger(fromLines) || fromLines < 0) {
558
+ throw new EvidenceParseError(
559
+ `loc-drop: fromLines must be a non-negative integer, got "${fromRaw}"`,
560
+ 'Use format: loc-drop:<fromLines>:<toLines> e.g. loc-drop:1200:800',
561
+ );
562
+ }
563
+ if (!Number.isInteger(toLines) || toLines < 0) {
564
+ throw new EvidenceParseError(
565
+ `loc-drop: toLines must be a non-negative integer, got "${toRaw}"`,
566
+ 'Use format: loc-drop:<fromLines>:<toLines> e.g. loc-drop:1200:800',
567
+ );
568
+ }
569
+ atoms.push({ kind: 'loc-drop', fromLines, toLines });
570
+ break;
571
+ }
572
+ case 'callsite-coverage': {
573
+ const colonIdx = payload.indexOf(':');
574
+ if (colonIdx < 1 || colonIdx === payload.length - 1) {
575
+ throw new EvidenceParseError(
576
+ `Malformed callsite-coverage atom: "${chunk}" (expected callsite-coverage:<symbolName>:<relativeSourcePath>)`,
577
+ 'Use format: callsite-coverage:<symbolName>:<relativeSourcePath> e.g. callsite-coverage:myFn:packages/core/src/myFn.ts',
578
+ );
579
+ }
580
+ const symbolName = payload.slice(0, colonIdx).trim();
581
+ const relativeSourcePath = payload.slice(colonIdx + 1).trim();
582
+ if (!symbolName) {
583
+ throw new EvidenceParseError(
584
+ `callsite-coverage: symbolName must not be empty in "${chunk}"`,
585
+ 'Use format: callsite-coverage:<symbolName>:<relativeSourcePath>',
586
+ );
587
+ }
588
+ if (!relativeSourcePath) {
589
+ throw new EvidenceParseError(
590
+ `callsite-coverage: relativeSourcePath must not be empty in "${chunk}"`,
591
+ 'Use format: callsite-coverage:<symbolName>:<relativeSourcePath>',
592
+ );
593
+ }
594
+ atoms.push({ kind: 'callsite-coverage', symbolName, relativeSourcePath });
595
+ break;
596
+ }
597
+ case 'state': {
598
+ // T9838: explicit-form modifier for the preceding pr: atom.
599
+ // Format: pr:<num>;state:MERGED. The resolver always requires
600
+ // state === 'MERGED' so the modifier is intent-documenting — it
601
+ // asserts the caller knows the contract.
602
+ if (payload !== 'MERGED') {
603
+ throw new EvidenceParseError(
604
+ `state atom only accepts "MERGED", got "${payload}" in "${chunk}"`,
605
+ 'Use format: pr:<num>;state:MERGED (state is only meaningful paired with a pr: atom)',
606
+ );
607
+ }
608
+ const lastAtom = atoms[atoms.length - 1];
609
+ if (!lastAtom || lastAtom.kind !== 'pr') {
610
+ throw new EvidenceParseError(
611
+ `state:MERGED must immediately follow a pr:<num> atom in the same evidence string ` +
612
+ `(got "${chunk}" with no preceding pr: atom)`,
613
+ 'Use format: --evidence "pr:357;state:MERGED" (state modifier requires a pr: predecessor)',
614
+ );
615
+ }
616
+ // Modifier consumed — no new atom emitted.
617
+ break;
618
+ }
619
+ default:
620
+ throw new EvidenceParseError(
621
+ `Unknown evidence kind: "${kind}" in atom "${chunk}"`,
622
+ 'Valid kinds: commit, files, test-run, tool, url, note, loc-drop, callsite-coverage, decision, pr, state',
623
+ );
624
+ }
625
+ }
626
+
627
+ return atoms;
628
+ }
package/src/index.ts CHANGED
@@ -415,6 +415,31 @@ export {
415
415
  normalizeError,
416
416
  ThinAgentViolationError,
417
417
  } from './errors.js';
418
+ // === ADR-051 Evidence Atom Grammar (T10337 — Saga T10326) ===
419
+ export type {
420
+ EvidenceAtom as EvidenceAtomInput,
421
+ EvidenceAtomKind,
422
+ EvidenceValidationResult,
423
+ GateEvidenceRequirement,
424
+ } from './evidence-atom-schema.js';
425
+ export {
426
+ callsiteCoverageAtomSchema,
427
+ commitAtomSchema,
428
+ decisionAtomSchema,
429
+ EvidenceAtomSchema,
430
+ EvidenceParseError,
431
+ filesAtomSchema,
432
+ formatGateRequirement,
433
+ GATE_EVIDENCE_REQUIREMENTS,
434
+ locDropAtomSchema,
435
+ noteAtomSchema,
436
+ parseEvidenceString,
437
+ prAtomSchema,
438
+ testRunAtomSchema,
439
+ toolAtomSchema,
440
+ urlAtomSchema,
441
+ validateEvidenceForGate,
442
+ } from './evidence-atom-schema.js';
418
443
  // === Evidence Record Types (IVTR typed proof artifacts) ===
419
444
  export type {
420
445
  CommandOutputRecord,
@@ -529,6 +554,20 @@ export {
529
554
  } from './graph.js';
530
555
  export type { AdapterHookProvider } from './hooks.js';
531
556
  export type { AdapterInstallProvider, InstallOptions, InstallResult } from './install.js';
557
+ // === Invariants Registry (Saga T10326 / Epic T10327 / Task T10335 — SG-SUBSTRATE-RECONCILIATION) ===
558
+ export type {
559
+ InvariantDoctorAudit,
560
+ InvariantLintRule,
561
+ InvariantRuntimeGate,
562
+ InvariantSeverity,
563
+ RegisteredInvariant,
564
+ } from './invariants/index.js';
565
+ export {
566
+ ADR_073_INVARIANTS,
567
+ getInvariant,
568
+ getInvariantsByAdr,
569
+ INVARIANTS_REGISTRY,
570
+ } from './invariants/index.js';
532
571
  // === Background Job Status (T9955 — promoted from core/store/tasks-schema.ts) ===
533
572
  export type { BackgroundJobStatus } from './jobs.js';
534
573
  export type {