@tiphys/kernel 0.0.0 → 0.1.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 (181) hide show
  1. package/AGENTS.md +611 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +278 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +14 -0
  11. package/dist/src/brief.d.ts +32 -0
  12. package/dist/src/brief.js +59 -0
  13. package/dist/src/checklists.d.ts +169 -0
  14. package/dist/src/checklists.js +310 -0
  15. package/dist/src/checks.d.ts +828 -0
  16. package/dist/src/checks.js +3314 -0
  17. package/dist/src/cli.d.ts +9 -0
  18. package/dist/src/cli.js +57 -0
  19. package/dist/src/commands/brief.d.ts +92 -0
  20. package/dist/src/commands/brief.js +320 -0
  21. package/dist/src/commands/checklist.d.ts +42 -0
  22. package/dist/src/commands/checklist.js +168 -0
  23. package/dist/src/commands/doctor.d.ts +35 -0
  24. package/dist/src/commands/doctor.js +605 -0
  25. package/dist/src/commands/gates.d.ts +9 -0
  26. package/dist/src/commands/gates.js +360 -0
  27. package/dist/src/commands/init.d.ts +30 -0
  28. package/dist/src/commands/init.js +128 -0
  29. package/dist/src/commands/lock.d.ts +1 -0
  30. package/dist/src/commands/lock.js +229 -0
  31. package/dist/src/commands/mode.d.ts +40 -0
  32. package/dist/src/commands/mode.js +134 -0
  33. package/dist/src/commands/plan.d.ts +20 -0
  34. package/dist/src/commands/plan.js +105 -0
  35. package/dist/src/commands/pool.d.ts +1 -0
  36. package/dist/src/commands/pool.js +128 -0
  37. package/dist/src/commands/spawn.d.ts +1 -0
  38. package/dist/src/commands/spawn.js +146 -0
  39. package/dist/src/commands/status.d.ts +19 -0
  40. package/dist/src/commands/status.js +120 -0
  41. package/dist/src/commands/teardown.d.ts +1 -0
  42. package/dist/src/commands/teardown.js +79 -0
  43. package/dist/src/commands/tuition.d.ts +21 -0
  44. package/dist/src/commands/tuition.js +218 -0
  45. package/dist/src/commands/validate.d.ts +78 -0
  46. package/dist/src/commands/validate.js +360 -0
  47. package/dist/src/commands/watch.d.ts +1 -0
  48. package/dist/src/commands/watch.js +172 -0
  49. package/dist/src/exec/env.d.ts +128 -0
  50. package/dist/src/exec/env.js +190 -0
  51. package/dist/src/fleet.d.ts +51 -0
  52. package/dist/src/fleet.js +80 -0
  53. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  54. package/dist/src/gates/adapters/http-json.js +283 -0
  55. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  56. package/dist/src/gates/adapters/migrations-command.js +373 -0
  57. package/dist/src/gates/citations.d.ts +408 -0
  58. package/dist/src/gates/citations.js +1163 -0
  59. package/dist/src/gates/coverage.d.ts +284 -0
  60. package/dist/src/gates/coverage.js +701 -0
  61. package/dist/src/gates/credentials.d.ts +74 -0
  62. package/dist/src/gates/credentials.js +533 -0
  63. package/dist/src/gates/deploy.d.ts +1 -0
  64. package/dist/src/gates/deploy.js +33 -0
  65. package/dist/src/gates/manifest.d.ts +99 -0
  66. package/dist/src/gates/manifest.js +208 -0
  67. package/dist/src/gates/migrations.d.ts +1 -0
  68. package/dist/src/gates/migrations.js +36 -0
  69. package/dist/src/gates/pin.d.ts +114 -0
  70. package/dist/src/gates/pin.js +154 -0
  71. package/dist/src/gates/red-witness.d.ts +22 -0
  72. package/dist/src/gates/red-witness.js +390 -0
  73. package/dist/src/gates/release.d.ts +283 -0
  74. package/dist/src/gates/release.js +820 -0
  75. package/dist/src/gates/result.d.ts +116 -0
  76. package/dist/src/gates/result.js +91 -0
  77. package/dist/src/gates/run.d.ts +566 -0
  78. package/dist/src/gates/run.js +1536 -0
  79. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  80. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  81. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  82. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  83. package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
  84. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  85. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  86. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  87. package/dist/src/gates/scope.d.ts +131 -0
  88. package/dist/src/gates/scope.js +1018 -0
  89. package/dist/src/gates/suite.d.ts +217 -0
  90. package/dist/src/gates/suite.js +927 -0
  91. package/dist/src/gates/validate.d.ts +121 -0
  92. package/dist/src/gates/validate.js +414 -0
  93. package/dist/src/hooks.d.ts +32 -0
  94. package/dist/src/hooks.js +62 -0
  95. package/dist/src/liveness.d.ts +321 -0
  96. package/dist/src/liveness.js +396 -0
  97. package/dist/src/lock.d.ts +178 -0
  98. package/dist/src/lock.js +500 -0
  99. package/dist/src/modes.d.ts +149 -0
  100. package/dist/src/modes.js +258 -0
  101. package/dist/src/path-identity.d.ts +2 -0
  102. package/dist/src/path-identity.js +10 -0
  103. package/dist/src/plan.d.ts +73 -0
  104. package/dist/src/plan.js +153 -0
  105. package/dist/src/pool.d.ts +130 -0
  106. package/dist/src/pool.js +721 -0
  107. package/dist/src/roles.d.ts +430 -0
  108. package/dist/src/roles.js +734 -0
  109. package/dist/src/spawn.d.ts +177 -0
  110. package/dist/src/spawn.js +332 -0
  111. package/dist/src/status.d.ts +91 -0
  112. package/dist/src/status.js +119 -0
  113. package/dist/src/task.d.ts +264 -0
  114. package/dist/src/task.js +305 -0
  115. package/dist/src/teardown.d.ts +32 -0
  116. package/dist/src/teardown.js +314 -0
  117. package/dist/src/tuition.d.ts +159 -0
  118. package/dist/src/tuition.js +311 -0
  119. package/dist/src/validate.d.ts +230 -0
  120. package/dist/src/validate.js +732 -0
  121. package/dist/src/version.d.ts +3 -0
  122. package/dist/src/version.js +38 -0
  123. package/dist/src/watcher.d.ts +275 -0
  124. package/dist/src/watcher.js +859 -0
  125. package/dist/src/witness/run.d.ts +274 -0
  126. package/dist/src/witness/run.js +1327 -0
  127. package/dist/src/witness/spec.d.ts +102 -0
  128. package/dist/src/witness/spec.js +253 -0
  129. package/dist/tsconfig.src.tsbuildinfo +1 -0
  130. package/gate-registry.yaml +390 -0
  131. package/gates.manifest.json +195 -0
  132. package/package.json +57 -3
  133. package/role-model-config.yaml +88 -0
  134. package/roles/README.md +128 -0
  135. package/roles/_shared-dispatch-contract.md +87 -0
  136. package/roles/adversarial-plan-reviewer.md +80 -0
  137. package/roles/clean-room-reviewer.md +140 -0
  138. package/roles/implementer.md +460 -0
  139. package/roles/investigator.md +138 -0
  140. package/roles/plan-writer.md +95 -0
  141. package/schemas/README.md +81 -0
  142. package/schemas/assurance-modes.schema.json +264 -0
  143. package/schemas/charter.schema.json +166 -0
  144. package/schemas/checklist.schema.json +114 -0
  145. package/schemas/decision-record.schema.json +88 -0
  146. package/schemas/final-report.schema.json +90 -0
  147. package/schemas/finding.schema.json +106 -0
  148. package/schemas/gate-registry.schema.json +260 -0
  149. package/schemas/mechanism-index.schema.json +94 -0
  150. package/schemas/plan.schema.json +300 -0
  151. package/schemas/report.schema.json +579 -0
  152. package/schemas/role-brief.schema.json +105 -0
  153. package/schemas/role-model-config.schema.json +90 -0
  154. package/schemas/status-line.schema.json +40 -0
  155. package/schemas/tuition.schema.json +191 -0
  156. package/schemas/verdict.schema.json +289 -0
  157. package/schemas/work-history.schema.json +183 -0
  158. package/templates/charter.example.yaml +54 -0
  159. package/templates/decision-record.example.yaml +27 -0
  160. package/templates/final-report.example.yaml +80 -0
  161. package/templates/plan.example.yaml +87 -0
  162. package/templates/report.example.yaml +236 -0
  163. package/templates/warnings.md +74 -0
  164. package/templates/work-history.example.yaml +185 -0
  165. package/tuition/README.md +76 -0
  166. package/tuition/T-001.yaml +48 -0
  167. package/tuition/T-002.yaml +51 -0
  168. package/tuition/T-003.yaml +100 -0
  169. package/tuition/T-004.yaml +52 -0
  170. package/tuition/T-005.yaml +72 -0
  171. package/tuition/T-006.yaml +81 -0
  172. package/tuition/T-007.yaml +56 -0
  173. package/tuition/T-008.yaml +111 -0
  174. package/tuition/T-009.yaml +50 -0
  175. package/tuition/T-015.yaml +36 -0
  176. package/tuition/T-016.yaml +36 -0
  177. package/tuition/T-017.yaml +46 -0
  178. package/tuition/T-018.yaml +84 -0
  179. package/tuition/T-021.yaml +40 -0
  180. package/tuition/T-022.yaml +36 -0
  181. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,1163 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { lstatSync, readdirSync, writeFileSync } from "node:fs";
4
+ import { join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { classifyEntry, readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
7
+ import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
8
+ import { formatDiagnostics, loadSchema, validate } from "./validate.js";
9
+ /**
10
+ * THE ONE COMMITTED CONFIGURATION (step 2; CR-1022 fix round).
11
+ *
12
+ * `roots[0].match` no longer includes `bin/**`. It used to, and `bin/fm-
13
+ * lock.sh` matched BOTH that pattern and the external `firstmate` root's
14
+ * `bin/fm-*.sh`; the two-root match was resolved correctly only because the
15
+ * code checked external roots before local ones, an implicit, undeclared
16
+ * precedence the hazard review named as fragile (CR-1022). The local
17
+ * pattern is narrowed to `bin/*.ts`, the actual files this checkout ships
18
+ * in `bin/` (today, `bin/tiphys.ts`), which shares no path with any
19
+ * `externalRoots` glob. The two root lists are therefore DISJOINT by
20
+ * construction, and `classifyPathAgainstRoots` needs no precedence rule at
21
+ * all: it checks every root and refuses ambiguity outright (CR-1022).
22
+ *
23
+ * Orchestrator decision (2026-08-07, under DR-0015; the owner delegated this
24
+ * scope call and is not an approval step in execution; reversible, owner may
25
+ * override): the gate governs FORWARD-CLAIMING delivery docs, NOT the
26
+ * historical delivery RECORD. Rationale recorded in
27
+ * delivery/work-history/m2-citations-scope.md. The `delivery/review/` and
28
+ * `delivery/work-history/` trees are records of what was examined at the
29
+ * time they were written; their `path:line` refs were valid when authored
30
+ * and drift as the code moves. Once the exit harness runs the full gate set
31
+ * on every doc PR, requiring a record's citations to still RESOLVE at head
32
+ * is the wrong policy: it re-litigates settled history against current code.
33
+ * So both trees are removed from `documents`, and review from
34
+ * `citationRequired`. The docs whose claims MUST hold against current code
35
+ * stay gated: the `delivery/plan/`, `delivery/verification/`,
36
+ * `delivery/decisions/`, `delivery/requirements/` trees and `delivery/STATE.md`
37
+ * are documents; the `delivery/plan/` and `delivery/verification/` trees are
38
+ * citationRequired. Anti-fabrication is preserved on every forward-claiming
39
+ * doc; the drift of the historical record is no longer a gate failure.
40
+ */
41
+ export const DEFAULT_CITATION_CONFIG = {
42
+ version: 1,
43
+ roots: [
44
+ {
45
+ name: "kernel",
46
+ description: "This checkout: the Tiphys kernel repository (src/, bin/*.ts, " +
47
+ "test/, scripts/, delivery/, schemas/, roles/, tuition/, and " +
48
+ "root-level *.md / *.json).",
49
+ match: [
50
+ "src/**",
51
+ "bin/*.ts",
52
+ "test/**",
53
+ "scripts/**",
54
+ "delivery/**",
55
+ "schemas/**",
56
+ "roles/**",
57
+ "tuition/**",
58
+ "*.md",
59
+ "*.json",
60
+ ],
61
+ },
62
+ ],
63
+ externalRoots: [
64
+ {
65
+ name: "firstmate",
66
+ description: "The scouted firstmate clone (delivery/requirements/firstmate-scout-report.md). " +
67
+ "Not present in this checkout: a citation matching this root is recorded " +
68
+ "unverifiable-external with provenance rather than resolved against this " +
69
+ "repository's own bin/ (kernel plan M2, M2-P5 grounding, M2R-014).",
70
+ match: ["bin/fm-*.sh", "bin/fm-session-lock-lib.sh"],
71
+ },
72
+ ],
73
+ documents: [
74
+ "delivery/plan/**/*.md",
75
+ "delivery/verification/**/*.md",
76
+ "delivery/decisions/**/*.md",
77
+ "delivery/tuition/**/*.md",
78
+ "delivery/requirements/**/*.md",
79
+ "delivery/STATE.md",
80
+ ],
81
+ citationRequired: [
82
+ "delivery/plan/**/*.md",
83
+ "delivery/verification/**/*.md",
84
+ ],
85
+ };
86
+ /* -------------------------------------------------------------------- */
87
+ /* The config schema, loaded and validated through the delivered reader */
88
+ /* -------------------------------------------------------------------- */
89
+ const schemaUrl = new URL("./schemas/citation-config.schema.json", import.meta.url);
90
+ let cachedSchema;
91
+ export function citationConfigSchema() {
92
+ if (cachedSchema === undefined) {
93
+ const path = fileURLToPath(schemaUrl);
94
+ const read = readRegularFileIfPresent(path);
95
+ if (read.kind !== "read") {
96
+ throw new Error(read.kind === "absent"
97
+ ? `schema document ${path} is missing from this installation`
98
+ : read.reason);
99
+ }
100
+ const parsed = JSON.parse(read.body);
101
+ const loaded = loadSchema(parsed, "citation-config.schema.json");
102
+ if (!loaded.ok) {
103
+ throw new Error(loaded.reason);
104
+ }
105
+ cachedSchema = loaded.schema;
106
+ }
107
+ return cachedSchema;
108
+ }
109
+ export function citationConfigSchemaPath() {
110
+ return fileURLToPath(schemaUrl);
111
+ }
112
+ export function validateCitationConfigDocument(document) {
113
+ return formatDiagnostics(validate(citationConfigSchema(), document));
114
+ }
115
+ /**
116
+ * Config-load-time check: the identical glob STRING declared under more
117
+ * than one root. This is separate from, and does not replace,
118
+ * `classifyPathAgainstRoots`'s per-citation check (CR-1022): this one
119
+ * answers "did the config author write the same glob twice", which is
120
+ * decidable from the config alone; that one answers "does THIS concrete
121
+ * path match glob patterns from more than one root", which needs the
122
+ * concrete string and cannot be answered from the config in isolation.
123
+ */
124
+ export function findAmbiguousGlobs(config) {
125
+ const owners = new Map();
126
+ for (const root of [...config.externalRoots, ...config.roots]) {
127
+ for (const glob of root.match) {
128
+ const set = owners.get(glob) ?? new Set();
129
+ set.add(root.name);
130
+ owners.set(glob, set);
131
+ }
132
+ }
133
+ const ambiguous = [];
134
+ for (const [glob, roots] of owners) {
135
+ if (roots.size > 1) {
136
+ ambiguous.push({ glob, roots: [...roots].sort() });
137
+ }
138
+ }
139
+ ambiguous.sort((a, b) => (a.glob < b.glob ? -1 : a.glob > b.glob ? 1 : 0));
140
+ return ambiguous;
141
+ }
142
+ export function findOrphanCitationRequired(config) {
143
+ return config.citationRequired.filter((glob) => !config.documents.includes(glob));
144
+ }
145
+ /* -------------------------------------------------------------------- */
146
+ /* Minimal glob matching: "/"-separated segments, "*" and "**" only */
147
+ /* -------------------------------------------------------------------- */
148
+ function splitPathSegments(path) {
149
+ return path.split("/").filter((segment) => segment.length > 0);
150
+ }
151
+ function escapeRegExpChar(ch) {
152
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
153
+ }
154
+ function segmentMatches(pattern, segment) {
155
+ let source = "";
156
+ for (const ch of pattern) {
157
+ if (ch === "*") {
158
+ source += "[^/]*";
159
+ }
160
+ else if (ch === "?") {
161
+ source += "[^/]";
162
+ }
163
+ else {
164
+ source += escapeRegExpChar(ch);
165
+ }
166
+ }
167
+ return new RegExp(`^${source}$`).test(segment);
168
+ }
169
+ function matchSegments(pattern, path) {
170
+ if (pattern.length === 0) {
171
+ return path.length === 0;
172
+ }
173
+ const head = pattern[0];
174
+ const restPattern = pattern.slice(1);
175
+ if (head === "**") {
176
+ for (let consumed = 0; consumed <= path.length; consumed += 1) {
177
+ if (matchSegments(restPattern, path.slice(consumed))) {
178
+ return true;
179
+ }
180
+ }
181
+ return false;
182
+ }
183
+ if (path.length === 0) {
184
+ return false;
185
+ }
186
+ if (!segmentMatches(head, path[0])) {
187
+ return false;
188
+ }
189
+ return matchSegments(restPattern, path.slice(1));
190
+ }
191
+ export function matchesGlob(pattern, path) {
192
+ return matchSegments(splitPathSegments(pattern), splitPathSegments(path));
193
+ }
194
+ export function matchesAny(globs, path) {
195
+ return globs.some((glob) => matchesGlob(glob, path));
196
+ }
197
+ /**
198
+ * Checks EVERY declared root (both lists, in full) rather than returning on
199
+ * the first match (CR-1022's named defect). A path matching exactly one
200
+ * root, of either kind, resolves to that root. A path matching two or more
201
+ * roots is `ambiguous`, named with every matching root, and is never
202
+ * guessed by declaration order. The shipped `DEFAULT_CITATION_CONFIG` has
203
+ * no overlap between its local and external match lists (see that
204
+ * constant's own doc comment), so this can only fire on a genuine
205
+ * configuration mistake, not on ordinary use.
206
+ */
207
+ export function classifyPathAgainstRoots(config, path) {
208
+ const matches = [];
209
+ for (const root of config.externalRoots) {
210
+ if (matchesAny(root.match, path)) {
211
+ matches.push({ name: root.name, kind: "external" });
212
+ }
213
+ }
214
+ for (const root of config.roots) {
215
+ if (matchesAny(root.match, path)) {
216
+ matches.push({ name: root.name, kind: "local" });
217
+ }
218
+ }
219
+ if (matches.length === 0) {
220
+ return { kind: "unmatched" };
221
+ }
222
+ if (matches.length === 1) {
223
+ const only = matches[0];
224
+ return only.kind === "external"
225
+ ? { kind: "external", root: only.name }
226
+ : { kind: "local", root: only.name };
227
+ }
228
+ return { kind: "ambiguous", roots: matches.map((m) => m.name).sort() };
229
+ }
230
+ const CITATION_EXTENSIONS = "ts|tsx|js|mjs|cjs|sh|md|json|ya?ml";
231
+ /**
232
+ * The token boundary is Unicode-aware (CR-1023): a match cannot begin
233
+ * immediately after a letter of ANY script (`\p{L}`), a digit (`\p{N}`), or
234
+ * `_`, `.`, `/`, `:`, `\`, `-`. The three CR-1023 members (a colon, a
235
+ * backslash, and a Cyrillic letter immediately before the fabricated
236
+ * token's first character) are all excluded by this one class; none of the
237
+ * three chose which exclusion to add, all three ARE the same rule stated
238
+ * once. Requires the `u` flag (Unicode property escapes).
239
+ */
240
+ const CITATION_SOURCE = String.raw `(?<![\p{L}\p{N}_./:\\-])([A-Za-z0-9_][A-Za-z0-9_./-]*\.(?:${CITATION_EXTENSIONS})):(\d+)(?:-(\d+))?(?:@sha256:([0-9a-zA-Z]+))?`;
241
+ const HASH_SHAPE = /^[0-9a-f]{64}$/;
242
+ /**
243
+ * The character(s), if any, that make a recognized citation malformed
244
+ * because something unsafe follows it with no separating whitespace
245
+ * (CR-1024): a bare trailing `-` (`path:3-`), a decimal-looking range
246
+ * (`path:3.5`), or any other non-ASCII or word-forming character glued on
247
+ * (a Unicode dash lookalike glued onto the digits, U+2011 for example). A `.` is safe UNLESS the next
248
+ * character is a digit, so an ordinary end-of-sentence period after a
249
+ * citation is not flagged. Returns `undefined` when nothing unsafe follows
250
+ * (end of text, whitespace, or an ordinary closing/punctuation character).
251
+ */
252
+ function trailingJunk(text, endIndex) {
253
+ if (endIndex >= text.length) {
254
+ return undefined;
255
+ }
256
+ const ch = text[endIndex];
257
+ if (/\s/.test(ch)) {
258
+ return undefined;
259
+ }
260
+ if ("])}>,;:'\"`".includes(ch)) {
261
+ return undefined;
262
+ }
263
+ if (ch === ".") {
264
+ const next = text[endIndex + 1];
265
+ if (next === undefined || !/[0-9]/.test(next)) {
266
+ return undefined;
267
+ }
268
+ }
269
+ let end = endIndex;
270
+ while (end < text.length && !/\s/.test(text[end]) && end - endIndex < 20) {
271
+ end += 1;
272
+ }
273
+ return text.slice(endIndex, end);
274
+ }
275
+ /**
276
+ * THE ONE GRAMMAR SCAN. Quoted-range exclusion (M2-D-22) and hunk-scoping
277
+ * (M2-D-21) are separate, composable passes over this function's output,
278
+ * not a second implementation of citation recognition.
279
+ */
280
+ export function extractCitations(text) {
281
+ const pattern = new RegExp(CITATION_SOURCE, "gu");
282
+ const tokens = [];
283
+ for (const match of text.matchAll(pattern)) {
284
+ const path = match[1];
285
+ const startLine = Number(match[2]);
286
+ const endLine = match[3] === undefined ? startLine : Number(match[3]);
287
+ const hashCaptured = match[4];
288
+ const hashValid = hashCaptured !== undefined && HASH_SHAPE.test(hashCaptured);
289
+ const offset = match.index ?? 0;
290
+ const matchEnd = offset + match[0].length;
291
+ const junk = trailingJunk(text, matchEnd);
292
+ tokens.push({
293
+ raw: match[0] + (junk ?? ""),
294
+ path,
295
+ startLine,
296
+ endLine,
297
+ hash: hashValid ? hashCaptured : undefined,
298
+ hashMalformed: hashCaptured !== undefined && !hashValid,
299
+ trailingMalformed: junk,
300
+ offset,
301
+ });
302
+ }
303
+ return tokens;
304
+ }
305
+ /**
306
+ * Lines of a file body, 1-indexed by position in the returned array. A
307
+ * single trailing newline is not counted as an extra blank line; a
308
+ * completely empty body has zero lines.
309
+ */
310
+ export function splitLines(body) {
311
+ if (body === "") {
312
+ return [];
313
+ }
314
+ const withoutTrailingNewline = body.endsWith("\n") ? body.slice(0, -1) : body;
315
+ return withoutTrailingNewline === "" ? [""] : withoutTrailingNewline.split("\n");
316
+ }
317
+ /**
318
+ * CR-1026: strip a trailing "\r" per line before hashing, so a CRLF
319
+ * checkout of the same logical content hashes identically to an LF one.
320
+ * Line COUNT was already CRLF-insensitive (it counts "\n" occurrences);
321
+ * only the byte content fed to the digest needed this.
322
+ */
323
+ function stripCr(line) {
324
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
325
+ }
326
+ function sha256Hex(text) {
327
+ return createHash("sha256").update(text, "utf8").digest("hex");
328
+ }
329
+ /* -------------------------------------------------------------------- */
330
+ /* Quoted-range detection (M2-D-22): inline code spans and fenced blocks */
331
+ /* -------------------------------------------------------------------- */
332
+ /**
333
+ * Character ranges (half-open `[start, end)`, sorted) that are QUOTED
334
+ * rather than MADE: fenced code blocks (opened and closed by a line whose
335
+ * first non-space run is 3+ of the same fence character, `` ` `` or `~`,
336
+ * matched by CommonMark's own rule of "same character, at least as long
337
+ * to close") and inline code spans (a run of one or more backticks,
338
+ * matched by the NEXT run of the SAME length on the same line). This is a
339
+ * deliberately LINE-ORIENTED simplification: it does not handle an inline
340
+ * span whose content itself contains a shorter backtick run (needing a
341
+ * longer delimiter per CommonMark), which does not occur anywhere in this
342
+ * repository's corpus (verified by the one-shot inventory finding no
343
+ * citation whose surrounding text uses that construct).
344
+ */
345
+ export function computeQuotedRanges(body) {
346
+ const ranges = [];
347
+ const lines = body.split("\n");
348
+ let offset = 0;
349
+ let inFence = false;
350
+ let fenceChar = "";
351
+ let fenceLen = 0;
352
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
353
+ const SPAN_RE = /(`+)([^`]*?)\1/g;
354
+ for (const line of lines) {
355
+ const lineStart = offset;
356
+ const lineEnd = offset + line.length;
357
+ const fenceMatch = FENCE_RE.exec(line);
358
+ if (fenceMatch !== null) {
359
+ const marker = fenceMatch[1];
360
+ if (!inFence) {
361
+ inFence = true;
362
+ fenceChar = marker[0];
363
+ fenceLen = marker.length;
364
+ }
365
+ else if (marker[0] === fenceChar && marker.length >= fenceLen) {
366
+ inFence = false;
367
+ }
368
+ ranges.push([lineStart, lineEnd]);
369
+ }
370
+ else if (inFence) {
371
+ ranges.push([lineStart, lineEnd]);
372
+ }
373
+ else {
374
+ SPAN_RE.lastIndex = 0;
375
+ let spanMatch;
376
+ while ((spanMatch = SPAN_RE.exec(line)) !== null) {
377
+ ranges.push([lineStart + spanMatch.index, lineStart + spanMatch.index + spanMatch[0].length]);
378
+ }
379
+ }
380
+ offset = lineEnd + 1;
381
+ }
382
+ ranges.sort((a, b) => a[0] - b[0]);
383
+ return ranges;
384
+ }
385
+ function isWithinRanges(offset, ranges) {
386
+ for (const [start, end] of ranges) {
387
+ if (start > offset) {
388
+ break;
389
+ }
390
+ if (offset >= start && offset < end) {
391
+ return true;
392
+ }
393
+ }
394
+ return false;
395
+ }
396
+ /* -------------------------------------------------------------------- */
397
+ /* Line-number index, for hunk-scoping (M2-D-21) */
398
+ /* -------------------------------------------------------------------- */
399
+ function buildLineIndex(body) {
400
+ const starts = [0];
401
+ for (let i = 0; i < body.length; i += 1) {
402
+ if (body[i] === "\n") {
403
+ starts.push(i + 1);
404
+ }
405
+ }
406
+ return starts;
407
+ }
408
+ function lineNumberFor(lineStarts, offset) {
409
+ let lo = 0;
410
+ let hi = lineStarts.length - 1;
411
+ let answer = 0;
412
+ while (lo <= hi) {
413
+ const mid = (lo + hi) >> 1;
414
+ if (lineStarts[mid] <= offset) {
415
+ answer = mid;
416
+ lo = mid + 1;
417
+ }
418
+ else {
419
+ hi = mid - 1;
420
+ }
421
+ }
422
+ return answer + 1;
423
+ }
424
+ function isTouched(line, touched) {
425
+ if (touched.kind === "all") {
426
+ return true;
427
+ }
428
+ return touched.ranges.some(([start, end]) => line >= start && line <= end);
429
+ }
430
+ /* -------------------------------------------------------------------- */
431
+ /* Git-object reads (CR-1017, CR-1018, CR-1021) */
432
+ /* -------------------------------------------------------------------- */
433
+ const GIT_TIMEOUT_MS = 30_000;
434
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
435
+ /**
436
+ * THE ONE ANSWER to "may this git path be treated as file content"
437
+ * (M2-C-6, adapted to the git-object substrate CR-1017 moves reads onto).
438
+ * `git cat-file -t <rev>:<path>` establishes the object's TYPE before its
439
+ * content is ever read, the same probe-then-open shape `classifyEntry`
440
+ * uses for the filesystem: `blob` is the only type this function reads;
441
+ * `tree` (a directory) and `commit` (a submodule gitlink) are `irregular`,
442
+ * named with the observed type, never opened as if they were text; a path
443
+ * git reports as not existing at this revision is `missing`, which is not
444
+ * an error (the normal shape of "the diff added a new document," or of a
445
+ * dangling citation).
446
+ *
447
+ * VERIFIED, not assumed (T-006): `git cat-file -t HEAD:src/../../etc/passwd`
448
+ * and `git cat-file -t HEAD:src/../delivery/STATE.md` (a traversal that
449
+ * WOULD land on a real in-repo file if resolved as a filesystem path) both
450
+ * report "does not exist", because git's tree lookup treats the path
451
+ * segment-by-segment against actual tree entries and no tree can contain a
452
+ * `..` entry; there is no filesystem-style traversal to close, because the
453
+ * data structure this function reads from has no parent pointer to walk
454
+ * (CR-1018, closed structurally rather than by a denylist).
455
+ */
456
+ export function gitObjectType(cwd, rev, path) {
457
+ const result = spawnSync("git", ["cat-file", "-t", `${rev}:${path}`], {
458
+ cwd,
459
+ encoding: "utf8",
460
+ timeout: GIT_TIMEOUT_MS,
461
+ });
462
+ if (result.error !== undefined) {
463
+ return {
464
+ kind: "error",
465
+ reason: `git cat-file -t ${rev}:${path} could not be run: ${singleLine(String(result.error))}`,
466
+ };
467
+ }
468
+ if (result.status === 0) {
469
+ return { kind: "type", type: (result.stdout ?? "").trim() };
470
+ }
471
+ if (result.signal !== null) {
472
+ return {
473
+ kind: "error",
474
+ reason: `git cat-file -t ${rev}:${path} was terminated by ${result.signal}`,
475
+ };
476
+ }
477
+ return { kind: "missing" };
478
+ }
479
+ export function readGitBlob(cwd, rev, path) {
480
+ const typed = gitObjectType(cwd, rev, path);
481
+ if (typed.kind === "error") {
482
+ return { kind: "error", reason: typed.reason };
483
+ }
484
+ if (typed.kind === "missing") {
485
+ return { kind: "missing" };
486
+ }
487
+ if (typed.type !== "blob") {
488
+ return {
489
+ kind: "irregular",
490
+ reason: `${rev}:${path} is a git ${typed.type} object, not a blob (regular file), so it was not read`,
491
+ };
492
+ }
493
+ const result = spawnSync("git", ["cat-file", "-p", `${rev}:${path}`], {
494
+ cwd,
495
+ encoding: "utf8",
496
+ timeout: GIT_TIMEOUT_MS,
497
+ maxBuffer: GIT_MAX_BUFFER,
498
+ });
499
+ if (result.error !== undefined) {
500
+ return {
501
+ kind: "error",
502
+ reason: `git cat-file -p ${rev}:${path} could not be run: ${singleLine(String(result.error))}`,
503
+ };
504
+ }
505
+ if (result.status !== 0) {
506
+ return {
507
+ kind: "error",
508
+ reason: `git cat-file -p ${rev}:${path} exited ${String(result.status)}: ${singleLine(result.stderr ?? "")}`,
509
+ };
510
+ }
511
+ return { kind: "blob", body: result.stdout ?? "" };
512
+ }
513
+ function resolveRev(cwd, ref) {
514
+ const result = spawnSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
515
+ cwd,
516
+ encoding: "utf8",
517
+ timeout: GIT_TIMEOUT_MS,
518
+ });
519
+ if (result.error !== undefined) {
520
+ return { ok: false, reason: `git rev-parse could not be run: ${singleLine(String(result.error))}` };
521
+ }
522
+ if (result.status !== 0) {
523
+ return {
524
+ ok: false,
525
+ reason: `ref ${ref} does not resolve to a commit: ${singleLine(result.stderr ?? "")}`,
526
+ };
527
+ }
528
+ return { ok: true, sha: (result.stdout ?? "").trim() };
529
+ }
530
+ export function gitTargetReader(cwd, rev) {
531
+ return (path) => readGitBlob(cwd, rev, path);
532
+ }
533
+ export function filesystemTargetReader(repoRoot) {
534
+ return (path) => {
535
+ const read = readRegularFileIfPresent(join(repoRoot, path));
536
+ if (read.kind === "read") {
537
+ return { kind: "blob", body: read.body };
538
+ }
539
+ if (read.kind === "absent") {
540
+ return { kind: "missing" };
541
+ }
542
+ return { kind: "irregular", reason: read.reason };
543
+ };
544
+ }
545
+ export function resolveCitation(config, token, readTarget) {
546
+ if (token.hashMalformed) {
547
+ return {
548
+ kind: "unresolved",
549
+ detail: `${token.raw} has a malformed content-hash suffix (expected @sha256: followed by 64 lowercase hex characters)`,
550
+ };
551
+ }
552
+ if (token.trailingMalformed !== undefined) {
553
+ return {
554
+ kind: "unresolved",
555
+ detail: `${token.raw} is not a recognized citation: malformed trailing text follows with no separator`,
556
+ };
557
+ }
558
+ const classification = classifyPathAgainstRoots(config, token.path);
559
+ if (classification.kind === "ambiguous") {
560
+ return {
561
+ kind: "unresolved",
562
+ detail: `${token.raw} matches more than one declared root (${classification.roots.join(", ")}), never guessed`,
563
+ };
564
+ }
565
+ if (classification.kind === "external") {
566
+ return {
567
+ kind: "unverifiable-external",
568
+ root: classification.root,
569
+ detail: `${token.raw} matches external root ${classification.root}, not present in this checkout`,
570
+ };
571
+ }
572
+ if (classification.kind === "unmatched") {
573
+ return {
574
+ kind: "unresolved",
575
+ detail: `${token.raw} matches no declared root (local or external)`,
576
+ };
577
+ }
578
+ if (token.endLine < token.startLine) {
579
+ return {
580
+ kind: "unresolved",
581
+ detail: `${token.raw} has an end line before its start line`,
582
+ };
583
+ }
584
+ const read = readTarget(token.path);
585
+ if (read.kind === "irregular" || read.kind === "error") {
586
+ return { kind: "read-error", path: token.path, reason: read.reason ?? "" };
587
+ }
588
+ if (read.kind === "missing") {
589
+ return {
590
+ kind: "unresolved",
591
+ detail: `${token.raw} cites a file that does not exist (${token.path})`,
592
+ };
593
+ }
594
+ const lines = splitLines(read.body);
595
+ if (token.startLine < 1 || token.endLine > lines.length) {
596
+ return {
597
+ kind: "unresolved",
598
+ detail: `${token.raw} is out of range: ${token.path} has ${String(lines.length)} line(s)`,
599
+ };
600
+ }
601
+ if (token.hash !== undefined) {
602
+ const cited = lines
603
+ .slice(token.startLine - 1, token.endLine)
604
+ .map(stripCr)
605
+ .join("\n");
606
+ const digest = sha256Hex(cited);
607
+ if (digest !== token.hash) {
608
+ return {
609
+ kind: "unresolved",
610
+ detail: `${token.raw} content hash mismatch: recorded ${token.hash}, computed ${digest}`,
611
+ };
612
+ }
613
+ }
614
+ return {
615
+ kind: "resolved",
616
+ detail: `${token.raw} resolved against ${token.path} (${String(lines.length)} line(s))`,
617
+ };
618
+ }
619
+ /**
620
+ * THE ONE COUNTING PASS. `touched` selects which tokens are actually
621
+ * RESOLVED (M2-D-21: hunk ranges for the registered gate, `{kind:"all"}`
622
+ * for the one-shot inventory, which has no diff to scope by). The vacuous
623
+ * `substantiveCount` is NEVER hunk-scoped, per the arbitration: a document
624
+ * makes at least one citation, or it does not, independent of what this
625
+ * particular diff touched.
626
+ */
627
+ export function analyzeDocument(config, body, relativePath, touched, readTarget) {
628
+ const quotedRanges = computeQuotedRanges(body);
629
+ const lineStarts = buildLineIndex(body);
630
+ const tokens = extractCitations(body);
631
+ const nonQuoted = tokens.filter((token) => !isWithinRanges(token.offset, quotedRanges));
632
+ const substantiveCount = nonQuoted.filter((token) => token.path !== relativePath).length;
633
+ const inScope = nonQuoted.filter((token) => isTouched(lineNumberFor(lineStarts, token.offset), touched));
634
+ let resolved = 0;
635
+ let selfResolved = 0;
636
+ let unverifiableExternal = 0;
637
+ const unresolvedDetails = [];
638
+ for (const token of inScope) {
639
+ const isSelf = token.path === relativePath;
640
+ const outcome = resolveCitation(config, token, readTarget);
641
+ if (outcome.kind === "read-error") {
642
+ return outcome;
643
+ }
644
+ if (outcome.kind === "resolved") {
645
+ if (isSelf) {
646
+ selfResolved += 1;
647
+ }
648
+ else {
649
+ resolved += 1;
650
+ }
651
+ }
652
+ else if (outcome.kind === "unverifiable-external") {
653
+ unverifiableExternal += 1;
654
+ }
655
+ else {
656
+ unresolvedDetails.push(outcome.detail);
657
+ }
658
+ }
659
+ return {
660
+ kind: "analyzed",
661
+ analysis: {
662
+ substantiveCount,
663
+ totalNonQuoted: nonQuoted.length,
664
+ resolved,
665
+ selfResolved,
666
+ unverifiableExternal,
667
+ unresolvedDetails,
668
+ },
669
+ };
670
+ }
671
+ /* -------------------------------------------------------------------- */
672
+ /* The one-shot inventory (step 5): reused counting logic, filesystem */
673
+ /* read strategy, unaffected by CR-1017 (there is no --head to pin to). */
674
+ /* -------------------------------------------------------------------- */
675
+ function isRealDirectory(path) {
676
+ try {
677
+ return lstatSync(path).isDirectory();
678
+ }
679
+ catch {
680
+ return false;
681
+ }
682
+ }
683
+ function walkDocuments(repoRoot, dir, config, into) {
684
+ const names = readdirSync(dir).sort();
685
+ for (const name of names) {
686
+ const path = join(dir, name);
687
+ if (isRealDirectory(path)) {
688
+ walkDocuments(repoRoot, path, config, into);
689
+ continue;
690
+ }
691
+ const entry = classifyEntry(path);
692
+ if (entry.kind === "absent" || entry.kind === "dangling") {
693
+ continue;
694
+ }
695
+ if (entry.kind !== "regular") {
696
+ throw new Error(`${entry.reason}; refusing to walk ${repoRoot}`);
697
+ }
698
+ const relativePath = relative(repoRoot, path).split(sep).join("/");
699
+ if (matchesAny(config.documents, relativePath)) {
700
+ into.push(relativePath);
701
+ }
702
+ }
703
+ }
704
+ export function listConfiguredDocuments(repoRoot, config = DEFAULT_CITATION_CONFIG, startDir = "delivery") {
705
+ const into = [];
706
+ const root = join(repoRoot, startDir);
707
+ if (!isRealDirectory(root)) {
708
+ return into;
709
+ }
710
+ walkDocuments(repoRoot, root, config, into);
711
+ return into.sort();
712
+ }
713
+ export function inventoryDeliveryTree(repoRoot, config = DEFAULT_CITATION_CONFIG, startDir = "delivery") {
714
+ const rows = [];
715
+ const readTarget = filesystemTargetReader(repoRoot);
716
+ for (const path of listConfiguredDocuments(repoRoot, config, startDir)) {
717
+ const read = readRegularFileIfPresent(join(repoRoot, path));
718
+ if (read.kind !== "read") {
719
+ throw new Error(read.kind === "absent"
720
+ ? `${path} vanished during the inventory walk`
721
+ : read.reason);
722
+ }
723
+ const outcome = analyzeDocument(config, read.body, path, { kind: "all" }, readTarget);
724
+ if (outcome.kind === "read-error") {
725
+ throw new Error(`${outcome.path}: ${outcome.reason}`);
726
+ }
727
+ const a = outcome.analysis;
728
+ rows.push({
729
+ path,
730
+ total: a.resolved + a.selfResolved + a.unresolvedDetails.length + a.unverifiableExternal,
731
+ resolved: a.resolved + a.selfResolved,
732
+ unresolved: a.unresolvedDetails.length,
733
+ unverifiableExternal: a.unverifiableExternal,
734
+ });
735
+ }
736
+ return rows;
737
+ }
738
+ /* -------------------------------------------------------------------- */
739
+ /* The diff, and the kernel gate entry (M2-D-10, M2-D-21, CR-1017) */
740
+ /* -------------------------------------------------------------------- */
741
+ function gitChangedDocuments(cwd, base, head) {
742
+ const result = spawnSync("git", ["diff", "--name-only", "--diff-filter=d", `${base}...${head}`], { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER });
743
+ if (result.error !== undefined) {
744
+ return {
745
+ ok: false,
746
+ reason: `git diff could not be run: ${singleLine(String(result.error))}`,
747
+ };
748
+ }
749
+ if (result.status !== 0) {
750
+ return {
751
+ ok: false,
752
+ reason: `git diff exited ${String(result.status)}: ${singleLine(result.stderr ?? "")}`,
753
+ };
754
+ }
755
+ const paths = (result.stdout ?? "")
756
+ .split("\n")
757
+ .map((line) => line.trim())
758
+ .filter((line) => line !== "");
759
+ return { ok: true, paths };
760
+ }
761
+ const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
762
+ /**
763
+ * M2-D-21: the NEW-FILE line ranges a hunk introduces or changes, between
764
+ * `base` and `head`, for exactly one path. `--unified=0` removes context
765
+ * lines, so every hunk's `+start,count` is precisely the touched region; a
766
+ * pure-deletion hunk (`count` 0) contributes nothing (nothing was ADDED to
767
+ * the new file at that point). A brand-new file shows one hunk covering the
768
+ * whole file, which is correct: a newly added document is entirely "this
769
+ * PR's content".
770
+ */
771
+ function computeTouchedNewLines(cwd, base, head, path) {
772
+ const result = spawnSync("git", ["diff", "--unified=0", "--no-color", `${base}...${head}`, "--", path], { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER });
773
+ if (result.error !== undefined) {
774
+ return {
775
+ ok: false,
776
+ reason: `git diff --unified=0 could not be run: ${singleLine(String(result.error))}`,
777
+ };
778
+ }
779
+ if (result.status !== 0) {
780
+ return {
781
+ ok: false,
782
+ reason: `git diff --unified=0 exited ${String(result.status)}: ${singleLine(result.stderr ?? "")}`,
783
+ };
784
+ }
785
+ const ranges = [];
786
+ for (const line of (result.stdout ?? "").split("\n")) {
787
+ if (!line.startsWith("@@ ")) {
788
+ continue;
789
+ }
790
+ const match = HUNK_HEADER.exec(line);
791
+ if (match === null) {
792
+ continue;
793
+ }
794
+ const newStart = Number(match[1]);
795
+ const newCount = match[2] === undefined ? 1 : Number(match[2]);
796
+ if (newCount > 0) {
797
+ ranges.push([newStart, newStart + newCount - 1]);
798
+ }
799
+ }
800
+ return { ok: true, touched: { kind: "ranges", ranges } };
801
+ }
802
+ function now() {
803
+ return new Date().toISOString();
804
+ }
805
+ /**
806
+ * The kernel gate entry. Lints only citations ADDED or MODIFIED in the diff
807
+ * `base...head` (M2-D-21), reading every byte of content -- the document
808
+ * under lint AND every cited local target -- from the git object at `head`
809
+ * (CR-1017), never from `cwd`'s working tree.
810
+ *
811
+ * `--base` absent is `error`, never `not-applicable` (M2-C-3), checked
812
+ * independently of the runner's own `diff-touches` precondition because
813
+ * R-025's second call site invokes this function directly.
814
+ *
815
+ * THE VACUOUS GUARD (M2R-014, M2-D-21, CR-1020): a `citationRequired`
816
+ * document with zero SUBSTANTIVE citation tokens anywhere in the WHOLE
817
+ * document (not quoted, not a citation to its own path) is `red`. This is
818
+ * intentionally NOT hunk-scoped: the arbitration is explicit that "the
819
+ * document as a whole makes at least one citation" is the question, so a
820
+ * PR that edits one unrelated line of an otherwise-empty required document
821
+ * still reds on it, and a PR that adds the document's first real citation
822
+ * still turns it green.
823
+ *
824
+ * NOT-APPLICABLE WHEN NOTHING SUBSTANTIVE WAS VERIFIED: M2-C-2 forbids a
825
+ * green record with zero units, so a changed set whose only in-scope
826
+ * citations are quoted, self-referential, or external resolves
827
+ * `not-applicable` rather than a fabricated green. Because `citations` is
828
+ * registered `required`, this still fails the aggregate; that is the
829
+ * escalated, M2-wide policy question the arbitration assigns to M2-P9, not
830
+ * this gate.
831
+ */
832
+ export function runCitationsGate(options) {
833
+ const startedAt = now();
834
+ const config = options.config ?? DEFAULT_CITATION_CONFIG;
835
+ const base = {
836
+ gate: "citations",
837
+ unitLabel: "citations resolved",
838
+ startedAt,
839
+ evidence: [],
840
+ };
841
+ const schemaDiagnostics = validateCitationConfigDocument(config);
842
+ if (schemaDiagnostics.length > 0) {
843
+ return {
844
+ ...base,
845
+ status: "error",
846
+ units: 0,
847
+ endedAt: now(),
848
+ detail: `citation config is invalid: ${schemaDiagnostics.join("; ")}`,
849
+ };
850
+ }
851
+ const ambiguous = findAmbiguousGlobs(config);
852
+ if (ambiguous.length > 0) {
853
+ return {
854
+ ...base,
855
+ status: "error",
856
+ units: 0,
857
+ endedAt: now(),
858
+ detail: "citation config declares glob(s) under more than one root, never guessed: " +
859
+ ambiguous.map((a) => `${JSON.stringify(a.glob)} in ${a.roots.join(", ")}`).join("; "),
860
+ };
861
+ }
862
+ const orphanRequired = findOrphanCitationRequired(config);
863
+ if (orphanRequired.length > 0) {
864
+ return {
865
+ ...base,
866
+ status: "error",
867
+ units: 0,
868
+ endedAt: now(),
869
+ detail: `citationRequired glob(s) not present in documents: ${orphanRequired.join(", ")}`,
870
+ };
871
+ }
872
+ if (options.base === undefined) {
873
+ return {
874
+ ...base,
875
+ status: "error",
876
+ units: 0,
877
+ endedAt: now(),
878
+ detail: "gate citations requires --base (M2-C-3: a gate whose required " +
879
+ "invocation parameter is absent is error, never not-applicable)",
880
+ };
881
+ }
882
+ const headRef = options.head ?? "HEAD";
883
+ const headResolved = resolveRev(options.cwd, headRef);
884
+ if (!headResolved.ok) {
885
+ return { ...base, status: "error", units: 0, endedAt: now(), detail: `--head: ${headResolved.reason}` };
886
+ }
887
+ const baseResolved = resolveRev(options.cwd, options.base);
888
+ if (!baseResolved.ok) {
889
+ return { ...base, status: "error", units: 0, endedAt: now(), detail: `--base: ${baseResolved.reason}` };
890
+ }
891
+ const headSha = headResolved.sha;
892
+ const baseSha = baseResolved.sha;
893
+ const diff = gitChangedDocuments(options.cwd, baseSha, headSha);
894
+ if (!diff.ok) {
895
+ return { ...base, status: "error", units: 0, endedAt: now(), detail: diff.reason };
896
+ }
897
+ const changedDocuments = diff.paths.filter((path) => matchesAny(config.documents, path)).sort();
898
+ if (changedDocuments.length === 0) {
899
+ // This arm carries its own PreconditionRecord for the same reason the
900
+ // zero-substantive-citations arm below does, and it is REACHABLE with the
901
+ // manifest precondition met, which a previous round of this fix (PR #32)
902
+ // asserted it was not. That assertion was wrong and the correction is
903
+ // recorded here rather than silently applied.
904
+ //
905
+ // The two halves do not describe the same set. The manifest precondition
906
+ // `citations-diff-touches-documents` is a PATH PREFIX test over
907
+ // `delivery/plan/`, `delivery/verification/`, `delivery/decisions/`,
908
+ // `delivery/tuition/`, `delivery/requirements/` and `delivery/STATE.md`.
909
+ // The `documents` config is a GLOB set, and every glob but STATE.md
910
+ // requires `*.md`. So any NON-markdown file under one of those trees meets
911
+ // the precondition and matches no document glob: the runner runs the gate,
912
+ // the gate finds nothing configured to lint, and without the record below
913
+ // it reports a not-applicable that DR-0018 rejects as indistinguishable
914
+ // from a skipped or errored gate.
915
+ //
916
+ // Measured 2026-08-08: a change adding
917
+ // `delivery/plan/phase-declarations/m3-p*.json`, the ten M3 phase
918
+ // declarations, is exactly that shape and reddened CI on it.
919
+ //
920
+ // #32's derivation said "citations.ts:1262 is unreachable through the
921
+ // runner because the manifest's diff-touches precondition covers the same
922
+ // condition". It covers a SUPERSET of it. That is the fix-round contract's
923
+ // item 3 failure in its own right: a scope stated too narrowly returns an
924
+ // empty result indistinguishable from an absence of defects.
925
+ const reason = `no changed path under the configured documents globs ` +
926
+ `(${String(diff.paths.length)} changed path(s) total). The diff-touches ` +
927
+ `precondition is a path prefix and the documents config is a glob set, so ` +
928
+ `a changed path under a configured tree that is not a configured document ` +
929
+ `reaches here with the precondition met`;
930
+ return {
931
+ ...base,
932
+ status: "not-applicable",
933
+ units: 0,
934
+ endedAt: now(),
935
+ detail: reason,
936
+ precondition: {
937
+ id: "citations-diff-touches-a-configured-document",
938
+ met: false,
939
+ reason,
940
+ evidence: diff.paths,
941
+ },
942
+ evidence: diff.paths,
943
+ };
944
+ }
945
+ const readTarget = gitTargetReader(options.cwd, headSha);
946
+ let resolved = 0;
947
+ let selfResolved = 0;
948
+ let unverifiableExternal = 0;
949
+ const redDetails = [];
950
+ const linted = [];
951
+ for (const relativePath of changedDocuments) {
952
+ const docRead = readGitBlob(options.cwd, headSha, relativePath);
953
+ if (docRead.kind === "irregular") {
954
+ // M2-C-6, criterion 9's mechanism restated for the git-object
955
+ // substrate: return immediately, never continue.
956
+ return {
957
+ ...base,
958
+ status: "error",
959
+ units: resolved,
960
+ endedAt: now(),
961
+ detail: docRead.reason,
962
+ evidence: [relativePath],
963
+ };
964
+ }
965
+ if (docRead.kind === "error") {
966
+ return { ...base, status: "error", units: resolved, endedAt: now(), detail: docRead.reason, evidence: [relativePath] };
967
+ }
968
+ if (docRead.kind === "missing") {
969
+ // CR-1021: the diff just named this path as present at `headSha`.
970
+ // Both queries read the SAME fixed git objects, so disagreement here
971
+ // is an internal inconsistency, never a benign race to skip past.
972
+ return {
973
+ ...base,
974
+ status: "error",
975
+ units: resolved,
976
+ endedAt: now(),
977
+ detail: `${relativePath} is listed as changed at ${headSha} but git cat-file reports it missing there`,
978
+ evidence: [relativePath],
979
+ };
980
+ }
981
+ const touchedResult = computeTouchedNewLines(options.cwd, baseSha, headSha, relativePath);
982
+ if (!touchedResult.ok) {
983
+ return { ...base, status: "error", units: resolved, endedAt: now(), detail: touchedResult.reason, evidence: [relativePath] };
984
+ }
985
+ const outcome = analyzeDocument(config, docRead.body, relativePath, touchedResult.touched, readTarget);
986
+ if (outcome.kind === "read-error") {
987
+ return {
988
+ ...base,
989
+ status: "error",
990
+ units: resolved,
991
+ endedAt: now(),
992
+ detail: outcome.reason,
993
+ evidence: [outcome.path],
994
+ };
995
+ }
996
+ linted.push(relativePath);
997
+ const a = outcome.analysis;
998
+ resolved += a.resolved;
999
+ selfResolved += a.selfResolved;
1000
+ unverifiableExternal += a.unverifiableExternal;
1001
+ if (a.substantiveCount === 0 && matchesAny(config.citationRequired, relativePath)) {
1002
+ redDetails.push(`${relativePath} is citationRequired and carries zero substantive citations`);
1003
+ }
1004
+ for (const detail of a.unresolvedDetails) {
1005
+ redDetails.push(`${relativePath}: ${detail}`);
1006
+ }
1007
+ }
1008
+ if (redDetails.length > 0) {
1009
+ return {
1010
+ ...base,
1011
+ status: "red",
1012
+ units: resolved,
1013
+ endedAt: now(),
1014
+ detail: redDetails.join("; "),
1015
+ evidence: linted,
1016
+ };
1017
+ }
1018
+ if (resolved === 0) {
1019
+ // This arm's trigger is CONTENT-DEPENDENT, and that is why it carries its
1020
+ // own PreconditionRecord instead of leaning on the manifest's.
1021
+ //
1022
+ // The gate has two not-applicable arms. The FIRST (no changed path under
1023
+ // the `documents` globs, above) is also expressible as a manifest
1024
+ // precondition, and `gates.manifest.json` declares exactly that:
1025
+ // `diff-touches(citations-diff-touches-documents)` over the same path set.
1026
+ // The runner evaluates it first, so that arm is reached through
1027
+ // src/gates/run.ts and arrives with `precondition.met: false` recorded.
1028
+ //
1029
+ // THIS arm cannot be reached that way. Whether the changed documents make
1030
+ // a substantive local citation is only knowable after reading their
1031
+ // CONTENTS at the head sha, and no manifest precondition kind
1032
+ // (file-exists, file-absent, branch-matches, diff-touches) can ask that
1033
+ // question of a diff. So the runner's precondition is MET, the gate runs,
1034
+ // and without the record below the result is a not-applicable carrying no
1035
+ // evaluated precondition at all, which DR-0018 rejects: it is
1036
+ // indistinguishable from a gate that was skipped or that errored.
1037
+ //
1038
+ // Measured 2026-08-07: that is not hypothetical. It reddened CI on both
1039
+ // paperwork PRs that touched only non-required configured documents (#29
1040
+ // and #31), each time with
1041
+ // "[citations] is a diff-scoped gate reporting not-applicable WITHOUT an
1042
+ // evaluated, unmet precondition". The state was legitimate every time; the
1043
+ // gate simply had no way to SAY so. See delivery/tuition/T-009.
1044
+ //
1045
+ // The record is honest rather than cosmetic. `linted` is the list of
1046
+ // documents actually read, so the evidence shows work was done; the reason
1047
+ // carries the three counts that establish the arm was reached by finding
1048
+ // nothing substantive, not by declining to look. A citationRequired
1049
+ // document with zero citations never reaches here: it is red above.
1050
+ const reason = `${String(linted.length)} changed document(s) linted at ${headSha}: ` +
1051
+ `${String(selfResolved)} self-citation(s), ${String(unverifiableExternal)} unverifiable-external, ` +
1052
+ "zero substantive local citations resolved: nothing in the configured document set needed checking";
1053
+ return {
1054
+ ...base,
1055
+ status: "not-applicable",
1056
+ units: 0,
1057
+ endedAt: now(),
1058
+ detail: reason,
1059
+ precondition: {
1060
+ id: "citations-changed-documents-make-a-substantive-citation",
1061
+ met: false,
1062
+ reason,
1063
+ evidence: linted,
1064
+ },
1065
+ evidence: linted,
1066
+ };
1067
+ }
1068
+ return {
1069
+ ...base,
1070
+ status: "green",
1071
+ units: resolved,
1072
+ endedAt: now(),
1073
+ detail: `linted ${String(linted.length)} changed document(s) at ${headSha}: ` +
1074
+ `${String(resolved)} citation(s) resolved, ${String(selfResolved)} self-citation(s), ` +
1075
+ `${String(unverifiableExternal)} unverifiable-external`,
1076
+ evidence: linted,
1077
+ };
1078
+ }
1079
+ /* -------------------------------------------------------------------- */
1080
+ /* CLI entry: the gate subprocess contract (src/gates/run.ts:40-49) */
1081
+ /* -------------------------------------------------------------------- */
1082
+ const EX_USAGE = 64;
1083
+ const USAGE = "usage: node src/gates/citations.ts --result <file> --evidence <dir> " +
1084
+ "--base <ref> [--head <ref>]";
1085
+ const VALUE_FLAGS = ["--result", "--evidence", "--base", "--head"];
1086
+ function parseFlags(args) {
1087
+ const flags = {};
1088
+ for (let i = 0; i < args.length; i += 1) {
1089
+ const flag = args[i];
1090
+ const value = args[i + 1];
1091
+ if (flag === undefined || !VALUE_FLAGS.includes(flag)) {
1092
+ return undefined;
1093
+ }
1094
+ if (value === undefined || value.startsWith("--")) {
1095
+ return undefined;
1096
+ }
1097
+ if (flag === "--result") {
1098
+ flags.result = value;
1099
+ }
1100
+ else if (flag === "--evidence") {
1101
+ flags.evidence = value;
1102
+ }
1103
+ else if (flag === "--base") {
1104
+ flags.base = value;
1105
+ }
1106
+ else {
1107
+ flags.head = value;
1108
+ }
1109
+ i += 1;
1110
+ }
1111
+ return flags;
1112
+ }
1113
+ function usageError(message) {
1114
+ if (message !== undefined) {
1115
+ process.stderr.write(`tiphys gates citations: ${message}\n`);
1116
+ }
1117
+ process.stderr.write(`${USAGE}\n`);
1118
+ return EX_USAGE;
1119
+ }
1120
+ function writeResult(path, fields) {
1121
+ const result = makeGateResult(fields);
1122
+ const refusal = refuseOpenForWrite(path);
1123
+ if (refusal !== undefined) {
1124
+ process.stderr.write(`tiphys gates citations: ${refusal}\n`);
1125
+ return EXIT_GATE_ERROR;
1126
+ }
1127
+ const written = runStep(`writing ${path}`, () => writeFileSync(path, renderGateResult(result)));
1128
+ if (!written.ok) {
1129
+ process.stderr.write(`tiphys gates citations: ${written.reason}\n`);
1130
+ return EXIT_GATE_ERROR;
1131
+ }
1132
+ process.stdout.write(`${result.gate}: ${result.status} (${String(result.units)} ${result.unitLabel})\n`);
1133
+ if (result.detail !== "") {
1134
+ process.stdout.write(`${result.detail}\n`);
1135
+ }
1136
+ return exitCodeForStatus(result.status);
1137
+ }
1138
+ export function main(argv) {
1139
+ try {
1140
+ const flags = parseFlags(argv);
1141
+ if (flags === undefined) {
1142
+ return usageError();
1143
+ }
1144
+ if (flags.result === undefined || flags.evidence === undefined) {
1145
+ return usageError("citations requires --result and --evidence");
1146
+ }
1147
+ const fields = runCitationsGate({
1148
+ cwd: process.cwd(),
1149
+ base: flags.base,
1150
+ head: flags.head,
1151
+ });
1152
+ return writeResult(flags.result, fields);
1153
+ }
1154
+ catch (error) {
1155
+ process.stderr.write(`tiphys gates citations: ${singleLine(error.message ?? String(error))}\n`);
1156
+ return EXIT_GATE_ERROR;
1157
+ }
1158
+ }
1159
+ const invokedDirectly = process.argv[1] !== undefined &&
1160
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url);
1161
+ if (invokedDirectly) {
1162
+ process.exitCode = main(process.argv.slice(2));
1163
+ }