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