@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.
- package/AGENTS.md +611 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +278 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +14 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +59 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +828 -0
- package/dist/src/checks.js +3314 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +57 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +320 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/doctor.d.ts +35 -0
- package/dist/src/commands/doctor.js +605 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +128 -0
- package/dist/src/commands/lock.d.ts +1 -0
- package/dist/src/commands/lock.js +229 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +128 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +146 -0
- package/dist/src/commands/status.d.ts +19 -0
- package/dist/src/commands/status.js +120 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +79 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +360 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/exec/env.d.ts +128 -0
- package/dist/src/exec/env.js +190 -0
- package/dist/src/fleet.d.ts +51 -0
- package/dist/src/fleet.js +80 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1163 -0
- package/dist/src/gates/coverage.d.ts +284 -0
- package/dist/src/gates/coverage.js +701 -0
- package/dist/src/gates/credentials.d.ts +74 -0
- package/dist/src/gates/credentials.js +533 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +390 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +566 -0
- package/dist/src/gates/run.js +1536 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +927 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +32 -0
- package/dist/src/hooks.js +62 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +178 -0
- package/dist/src/lock.js +500 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +2 -0
- package/dist/src/path-identity.js +10 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +130 -0
- package/dist/src/pool.js +721 -0
- package/dist/src/roles.d.ts +430 -0
- package/dist/src/roles.js +734 -0
- package/dist/src/spawn.d.ts +177 -0
- package/dist/src/spawn.js +332 -0
- package/dist/src/status.d.ts +91 -0
- package/dist/src/status.js +119 -0
- package/dist/src/task.d.ts +264 -0
- package/dist/src/task.js +305 -0
- package/dist/src/teardown.d.ts +32 -0
- package/dist/src/teardown.js +314 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +230 -0
- package/dist/src/validate.js +732 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +859 -0
- package/dist/src/witness/run.d.ts +274 -0
- package/dist/src/witness/run.js +1327 -0
- package/dist/src/witness/spec.d.ts +102 -0
- package/dist/src/witness/spec.js +253 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +390 -0
- package/gates.manifest.json +195 -0
- package/package.json +57 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +460 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +81 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +166 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +289 -0
- package/schemas/work-history.schema.json +183 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- 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
|
+
}
|