@cosyte/script-utils 0.0.2 → 0.1.1

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.
@@ -0,0 +1,1363 @@
1
+ // packages/script-utils/internal-refs.js
2
+ //
3
+ // THE SHARED INTERNAL-REFERENCE GATE. One implementation of the rule that keeps our own
4
+ // bookkeeping (item identifiers, phase and wave language, ADR numbers, internal repo paths,
5
+ // traceability markers) off every surface a consumer reads.
6
+ //
7
+ // WHY A PACKAGE AND NOT A TEMPLATE. Twelve repositories carry a hand-written copy of this gate.
8
+ // Every escape found in one was fixed in one, and the other eleven kept shipping it. A scaffold
9
+ // reproduces that; a dependency does not. Here a fix is one pull request and a version bump.
10
+ //
11
+ // WHAT A CALLER OWNS AND WHAT IT DOES NOT. Detection is configurable: a caller supplies the
12
+ // prefix set, the standards designations that must never be flagged, the surface it publishes,
13
+ // its tarball accounting, and any extra rule it wants. A caller may WIDEN the scanned surface and
14
+ // may ADD detection. A caller may NOT subtract a rule from the canonical set and may NOT disable
15
+ // the self-test or any completeness refusal: a configuration that tries is REFUSED, not honoured,
16
+ // and an unknown option is refused for the same reason. Exit 0 means the run completed, enumerated
17
+ // its configured surface, read every target it enumerated, passed its own self-tests, and found
18
+ // nothing. It means nothing else.
19
+ //
20
+ // IMPORTING THIS FILE DOES NOT LOAD THE PHI SCANNER. The two gates are separately adoptable and
21
+ // share no module, so a repo can take this one without taking a position on PHI scanning.
22
+ //
23
+ // NO ENVIRONMENT IS READ. Nothing here consults `process.env`, so no variable set in a CI job can
24
+ // change what this gate looks for or what it concludes.
25
+
26
+ import { execFileSync } from "node:child_process";
27
+ import { closeSync, constants as fsConstants, fstatSync, openSync, readFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+
30
+ /**
31
+ * The exit codes this gate assigns, as a caller may redeclare them.
32
+ *
33
+ * `clean` MUST be 0 and the other two MUST NOT be, which is checked rather than documented: a
34
+ * caller that mapped `hits` onto 0 would turn every finding into a green run, and that is the one
35
+ * defect this whole file exists to make impossible.
36
+ */
37
+ const DEFAULT_EXIT_CODES = { clean: 0, hits: 1, refuse: 1 };
38
+
39
+ /** The refusal code used when the configuration is so broken that `exitCodes` cannot be trusted. */
40
+ const HARD_REFUSE = 1;
41
+
42
+ /**
43
+ * THE DOCUMENTED CONFIGURATION SURFACE, in one machine-readable table.
44
+ *
45
+ * This is not decoration and it is not a duplicate of the README. It is the enumeration a grader
46
+ * walks to assert the universal in the contract: over EVERY entry here, no value a caller can
47
+ * supply makes a run that found something exit 0. A table a test can iterate is the only form of
48
+ * that claim which can be checked; prose quantifying over "any flag or setting" cannot be failed.
49
+ *
50
+ * `required: true` means there is deliberately no default, because it is the axis a port gets
51
+ * wrong. `refusesEmpty: true` means an empty value is refused as well as an absent one, because an
52
+ * empty prefix set and an empty surface list each produce a scan that reads nothing and says OK.
53
+ */
54
+ export const CONFIG_AXES = Object.freeze([
55
+ Object.freeze({
56
+ name: "projectPrefixes",
57
+ required: true,
58
+ refusesEmpty: true,
59
+ summary:
60
+ "The project and programme prefixes an identifier is keyed on. Keying on known prefixes " +
61
+ "rather than on a WORD-N shape is what keeps MSH-2, PID-3 and OBX-5 out of the hit list.",
62
+ }),
63
+ Object.freeze({
64
+ name: "standardsDesignations",
65
+ required: true,
66
+ refusesEmpty: false,
67
+ summary:
68
+ "Regular-expression sources for the standards designations that must NEVER be flagged, " +
69
+ "even when they collide with a prefix. An explicitly empty list declares no collisions.",
70
+ }),
71
+ Object.freeze({
72
+ name: "surfacePaths",
73
+ required: true,
74
+ refusesEmpty: true,
75
+ summary:
76
+ "The public surface, as repo-relative paths. Every one must be tracked; a widened list is " +
77
+ "always allowed, and a path the repository does not track is refused rather than skipped.",
78
+ }),
79
+ Object.freeze({
80
+ name: "accountedTarballFiles",
81
+ required: true,
82
+ refusesEmpty: false,
83
+ summary:
84
+ "The `package.json` `files` entries this repository has already accounted for, either by " +
85
+ "scanning them or by excluding them deliberately. Anything in `files` that is in neither " +
86
+ "this list nor `surfacePaths` trips the tarball drift tripwire.",
87
+ }),
88
+ Object.freeze({
89
+ name: "sourceDocComments",
90
+ required: false,
91
+ refusesEmpty: false,
92
+ default: "{ enabled: false }",
93
+ summary:
94
+ "Whether the source doc-comment pass runs, over which tracked paths, and with which extra " +
95
+ "rules. `/** */` blocks compile into shipped type declarations, so they are public surface " +
96
+ "in a repository that publishes types.",
97
+ }),
98
+ Object.freeze({
99
+ name: "exitCodes",
100
+ required: false,
101
+ refusesEmpty: false,
102
+ default: "{ clean: 0, hits: 1, refuse: 1 }",
103
+ summary:
104
+ "The three codes this gate returns. `clean` must be 0 and the other two must not be; a " +
105
+ "mapping that breaks that is refused.",
106
+ }),
107
+ Object.freeze({
108
+ name: "extraRules",
109
+ required: false,
110
+ refusesEmpty: false,
111
+ default: "[]",
112
+ summary:
113
+ "Detection this repository adds on top of the canonical set. Each rule carries its own " +
114
+ "positive and negative samples and joins the self-test floor on the same terms.",
115
+ }),
116
+ Object.freeze({
117
+ name: "extraSamples",
118
+ required: false,
119
+ refusesEmpty: false,
120
+ default: "{}",
121
+ summary:
122
+ "Extra positive and negative self-test samples, keyed by rule id. ADDITIVE ONLY: a sample " +
123
+ "here joins the floor, and nothing here can remove a canonical sample from it.",
124
+ }),
125
+ Object.freeze({
126
+ name: "repoRoot",
127
+ required: false,
128
+ refusesEmpty: false,
129
+ default: "process.cwd(), resolved to its git top level",
130
+ summary:
131
+ "Where the repository under test is. The scan always anchors at the git top level, so an " +
132
+ "invocation from a subdirectory covers the whole surface rather than a subtree.",
133
+ }),
134
+ Object.freeze({
135
+ name: "write",
136
+ required: false,
137
+ refusesEmpty: false,
138
+ default: "process.stdout.write / process.stderr.write",
139
+ summary:
140
+ "Where the report goes. Supplied so a test can drive the gate in process; it changes where " +
141
+ "text lands and nothing about what the run concludes.",
142
+ }),
143
+ ]);
144
+
145
+ /** Every option name this gate accepts. Anything else is refused, which is how it fails closed. */
146
+ const KNOWN_OPTIONS = new Set(CONFIG_AXES.map((axis) => axis.name));
147
+
148
+ /**
149
+ * Option names that are REFUSED BY NAME, with the reason, because each one is an attempt to
150
+ * subtract something the contract makes non-subtractable.
151
+ *
152
+ * An unknown option is already refused, so this map does not decide anything. What it decides is
153
+ * the MESSAGE: "no such option" sends a reader looking for the right spelling, and the right
154
+ * answer is that there is no spelling, because the thing they are reaching for does not exist.
155
+ */
156
+ const REFUSED_OPTIONS = new Map([
157
+ ["rules", "the canonical rule set is not a caller input; ADD detection with `extraRules`"],
158
+ ["ruleSet", "the canonical rule set is not a caller input; ADD detection with `extraRules`"],
159
+ ["excludeRules", "a rule cannot be subtracted from the canonical set"],
160
+ ["omitRules", "a rule cannot be subtracted from the canonical set"],
161
+ ["ignoreRules", "a rule cannot be subtracted from the canonical set"],
162
+ ["disableRules", "a rule cannot be subtracted from the canonical set"],
163
+ ["disableRule", "a rule cannot be subtracted from the canonical set"],
164
+ ["skipRules", "a rule cannot be subtracted from the canonical set"],
165
+ ["only", "a rule cannot be subtracted from the canonical set by selecting a subset"],
166
+ ["selfTest", "the self-test floor is not configurable"],
167
+ ["skipSelfTest", "the self-test floor is not configurable"],
168
+ ["disableSelfTest", "the self-test floor is not configurable"],
169
+ ["noSelfTest", "the self-test floor is not configurable"],
170
+ ["skipCompleteness", "the completeness refusal is not configurable"],
171
+ ["allowUnreadTargets", "the completeness refusal is not configurable"],
172
+ ["allowUntrackedSurfacePaths", "the tracked-surface refusal is not configurable"],
173
+ ["skipTarballCheck", "the tarball drift tripwire is not configurable"],
174
+ ["allowUnaccountedFiles", "the tarball drift tripwire is not configurable"],
175
+ ["disableRefusals", "a refusal is not configurable"],
176
+ ["force", "there is no override that makes a refusal into a pass"],
177
+ ["warnOnly", "there is no mode in which a finding is reported without a non-zero exit"],
178
+ ]);
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // THE CANONICAL RULE SET
182
+ // ---------------------------------------------------------------------------
183
+ //
184
+ // THE FOUR TRAPS THAT BREAK A NAIVE DETECTOR, carried here from the variant this set was measured
185
+ // on rather than rediscovered. Each one shipped a public defect somewhere before it was caught.
186
+ //
187
+ // (1) KEY ON KNOWN PROJECT PREFIXES, NEVER ON THE `WORD-N` SHAPE. `HL7-N` and `MLLP-10` are
188
+ // ours; `MSH-2`, `PID-3`, `OBX-5`, `SCH-11`, `TQ1-7` and `NM1-03` are segment-field
189
+ // references and are exactly the reference material a consumer of a parser needs. A shape
190
+ // rule destroys the documentation it was added to protect. The cost is that a new programme
191
+ // means adding its prefix, and nothing catches it until someone does. That is the cheaper of
192
+ // the two mistakes, and it is why `projectPrefixes` is required rather than defaulted.
193
+ // (2) DECAPITATION is a rule for the person REMEDIATING a hit, not for the scanner. Stripping an
194
+ // identifier off the FRONT leaves a fragment behind, which reads worse than the text it
195
+ // replaced. The hit report says so.
196
+ // (3) CASE SENSITIVITY. The identifier rule is case-SENSITIVE and the segment after the hyphen
197
+ // must start uppercase, which is what lets `FHIR-bridge` and `docs-content/` through.
198
+ // (4) PHASE PATTERNS NEED A LETTER SUFFIX (`Phase 5b`) AND A LETTER-ONLY FORM (`Phase W`).
199
+ //
200
+ // WHAT IS CANONICAL AND WHAT IS CONFIGURED. The clinical guards below (the CSP field names, the
201
+ // trial roman numerals, the imaging nouns) are canonical because the collision they avoid is with
202
+ // healthcare reference material rather than with one repository's vocabulary. The standards
203
+ // designations are NOT canonical: which designations a repository's docs legitimately carry is a
204
+ // property of what it parses, so they arrive as configuration and their concrete instances arrive
205
+ // as that repository's own self-test samples.
206
+
207
+ /** Written ordinals plus the numeric forms, for `thirteenth slice` and `2nd wave`. */
208
+ const ORDINAL =
209
+ "(?:first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|eleventh|twelfth|" +
210
+ "thirteenth|fourteenth|fifteenth|sixteenth|seventeenth|eighteenth|nineteenth|twentieth|" +
211
+ "twenty-first|twenty-second|twenty-third|twenty-fourth|\\d+(?:st|nd|rd|th))";
212
+
213
+ /**
214
+ * Lookbehinds that drop the clinical senses of `phase` before the rule ever sees it.
215
+ *
216
+ * `phase` is real clinical vocabulary: the Clinical Study Phase segment's field names are literally
217
+ * `Study Phase Identifier` and `Study Phase Start Date/Time`, and "the acute phase reactant" is
218
+ * ordinary reference material. A rule that flags those tells a remediator to rewrite a field name,
219
+ * which is trap (1) arriving through the phase rule instead of the identifier rule.
220
+ */
221
+ const PHASE_NOT_CLINICAL =
222
+ "(?<!study )(?<!clinical )(?<!trial )(?<!acute )(?<!chronic )(?<!luteal )(?<!follicular )" +
223
+ "(?<!liquid )(?<!gas )";
224
+
225
+ /** Lookahead that drops the clinical-study field-name tails and the trial roman numerals. */
226
+ const PHASE_NOT_FIELD =
227
+ "(?!of\\b|with\\b|in\\b|out\\b|the\\b|and\\b|is\\b|for\\b|to\\b|identifier\\b|start\\b|end\\b|" +
228
+ "evaluability\\b|number\\b|(?:I{1,3}|IV)\\s+(?:trial|stud|clinical|oncolog))";
229
+
230
+ /**
231
+ * Imaging vocabulary that follows `slice` in reference material rather than in our jargon.
232
+ *
233
+ * Grounded in a generated DICOM tag dictionary: SliceThickness, SliceLocation, SpacingBetweenSlices,
234
+ * SliceVector, NumberOfSlices, TimeSliceVector, SliceProgressionDirection, SliceSensitivityFactor.
235
+ */
236
+ const IMAGING_NOUNS =
237
+ "thickness|location|spacing|position|interval|order|number|index|gap|count|data|pixel|" +
238
+ "orientation|plane|direction|width|vector|sensitivity|progression|factor";
239
+
240
+ /** A rule the caller cannot subtract: an id, a reader-facing name, and how it is built. */
241
+ const CANONICAL_RULES = Object.freeze([
242
+ Object.freeze({
243
+ id: "internal-identifier",
244
+ name: "internal project identifier",
245
+ /** Built from `projectPrefixes` and `standardsDesignations`, so this one rule is parametric. */
246
+ parametric: true,
247
+ }),
248
+ Object.freeze({ id: "phase-or-wave", name: "phase or wave language", parametric: false }),
249
+ Object.freeze({ id: "adr-reference", name: "ADR reference", parametric: false }),
250
+ Object.freeze({ id: "internal-jargon", name: 'internal jargon ("slice")', parametric: false }),
251
+ Object.freeze({ id: "internal-repo-path", name: "internal repo path", parametric: false }),
252
+ Object.freeze({
253
+ id: "traceability-marker",
254
+ name: "internal traceability marker",
255
+ parametric: false,
256
+ }),
257
+ ]);
258
+
259
+ /**
260
+ * The canonical rule set's identity: a stable id and the name a hit report prints, in run order.
261
+ *
262
+ * Exported so a conformance corpus can key a transcribed rule onto the canonical one it maps to,
263
+ * which is what makes "the shared implementation carries this variant's whole rule set" a claim
264
+ * that can be made per rule rather than in aggregate.
265
+ */
266
+ export const CANONICAL_RULE_INDEX = Object.freeze(
267
+ CANONICAL_RULES.map((rule) => Object.freeze({ id: rule.id, name: rule.name })),
268
+ );
269
+
270
+ /**
271
+ * The reader-facing names of the canonical rules, in the order they run.
272
+ *
273
+ * Exported so a caller can state in its own documentation what it has adopted, and so a conformance
274
+ * suite can assert the set has not shrunk without a version bump.
275
+ *
276
+ * @returns {string[]} The canonical rule names.
277
+ */
278
+ export function canonicalRuleNames() {
279
+ return CANONICAL_RULES.map((rule) => rule.name);
280
+ }
281
+
282
+ /** A prefix must be a plain uppercase token, so joining them into an alternation is safe. */
283
+ const PREFIX_SHAPE = /^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*$/;
284
+
285
+ /**
286
+ * Compile the canonical rule set against one caller's configuration.
287
+ *
288
+ * Exported because a caller's own documentation and a conformance corpus both need to see exactly
289
+ * what will run, and because the self-test floor below is graded against the compiled form rather
290
+ * than against the sources it was built from.
291
+ *
292
+ * @param {object} resolved A configuration that has already passed validation.
293
+ * @returns {{ id: string, name: string, patterns: RegExp[], positives: string[], negatives: string[] }[]}
294
+ * The compiled rules, in run order.
295
+ */
296
+ export function compileCanonicalRules(resolved) {
297
+ const prefixAlternation = resolved.projectPrefixes.join("|");
298
+ const designations = resolved.standardsDesignations;
299
+ const exclusion = designations.length === 0 ? "" : `(?!(?:${designations.join("|")})\\b)`;
300
+
301
+ const identifier =
302
+ `\\b${exclusion}(?:${prefixAlternation})(?:-[A-Z0-9][A-Z0-9.]*)+\\b` +
303
+ `|\\bP\\d+ (?:safety|documentation)\\b`;
304
+
305
+ /**
306
+ * ONE POSITIVE SAMPLE PER CONFIGURED PREFIX, not one per rule.
307
+ *
308
+ * A prefix that is shadowed by a standards designation, or mistyped, contributes nothing and the
309
+ * gate would keep reporting green over the identifiers it was added to catch. The floor therefore
310
+ * proves every prefix is still reachable, which is the same property as "the scanner can still
311
+ * see" applied to the axis a caller is most likely to edit.
312
+ */
313
+ const identifierPositives = resolved.projectPrefixes.map((prefix) => `Item ${prefix}-N7 is done`);
314
+ identifierPositives.push("The P3 safety follow-up is queued");
315
+
316
+ const rules = [
317
+ {
318
+ id: "internal-identifier",
319
+ name: "internal project identifier",
320
+ patterns: [new RegExp(identifier, "g")],
321
+ positives: identifierPositives,
322
+ // The WORD-N trap turned into an assertion. Every token here is a segment-field reference or
323
+ // a diagnosis-code range, and a prefix set that starts matching one of them reds HERE rather
324
+ // than deleting it from a parser's documentation on the next sweep.
325
+ negatives: [
326
+ "MSH-2 encoding characters, PID-3 identifier list, OBX-5 value, SCH-11 timing, " +
327
+ "TQ1-7 start, NM1-03 name, ICD-10-CM P00-P96, 835 remittance",
328
+ ],
329
+ },
330
+ {
331
+ id: "phase-or-wave",
332
+ name: "phase or wave language",
333
+ patterns: [
334
+ new RegExp(
335
+ `\\b(?:roadmap phase\\b[ ]?[A-Za-z0-9]*|${PHASE_NOT_CLINICAL}phase[ -]${PHASE_NOT_FIELD}` +
336
+ `[A-Za-z0-9]+[a-z]?\\b|wave \\d+\\b|the \\w+ and final phase\\b|` +
337
+ `documentation residual\\b|${ORDINAL} (?:slice|wave)\\b)`,
338
+ "gi",
339
+ ),
340
+ ],
341
+ positives: [
342
+ "Phase 5b closes it (Phase W, Phase-L and the thirteenth slice landed earlier, in wave 2)",
343
+ ],
344
+ negatives: [
345
+ "CSP-1 Study Phase Identifier, CSP-2 Study Phase Start Date/Time, CSP-3 Study Phase End " +
346
+ "Date/Time, CSP-4 Study Phase Evaluability; a Phase III oncology trial and a Phase II " +
347
+ "study; the acute phase reactant; the adapter stays in phase with the source system " +
348
+ "and is out of phase",
349
+ ],
350
+ },
351
+ {
352
+ id: "adr-reference",
353
+ name: "ADR reference",
354
+ patterns: [new RegExp("\\bADR[ -]?\\d{3,4}\\b", "gi")],
355
+ positives: ["Decided in ADR 0015 and restated in ADR-0021"],
356
+ negatives: ["ADR is not a segment, and 0015 alone is a value"],
357
+ },
358
+ {
359
+ id: "internal-jargon",
360
+ name: 'internal jargon ("slice")',
361
+ patterns: [
362
+ new RegExp(
363
+ "\\b(?:this|that|the|each|another|previous|next|final|current)\\s+" +
364
+ "(?:(?!(?:of|in|on|between|per|for|to|with|at)\\s)[\\w-]+\\s+){0,2}slices?\\b" +
365
+ `(?!\\s+(?:${IMAGING_NOUNS}))`,
366
+ "gi",
367
+ ),
368
+ ],
369
+ positives: ["This slice adds the helper and the final slice removes it"],
370
+ negatives: [
371
+ "The slice thickness and the number of slices are DICOM attributes, each slice location " +
372
+ "is too, and the phase of the clinical study, the phase of illness and each phase of " +
373
+ "the trial are the reader words this rule must not touch",
374
+ ],
375
+ },
376
+ {
377
+ id: "internal-repo-path",
378
+ name: "internal repo path",
379
+ patterns: [
380
+ new RegExp(
381
+ "\\boperations/(?:BACKLOG\\.md|roadmaps/|plans/)" +
382
+ "|\\bdocumentation/(?:decisions/|ecosystem-map\\.md|conventions\\.md)" +
383
+ "|\\bBACKLOG\\.md\\b",
384
+ "g",
385
+ ),
386
+ ],
387
+ positives: ["Roadmap operations/roadmaps/hl7.md and documentation/decisions/0015-x.md"],
388
+ negatives: [
389
+ "Parser operations are documented in the README, and documentation for the API is generated",
390
+ ],
391
+ },
392
+ {
393
+ id: "traceability-marker",
394
+ name: "internal traceability marker",
395
+ // TWO PATTERNS RATHER THAN ONE WITH AN INLINE MODIFIER. The bracketed tag is case-SENSITIVE
396
+ // (a lower-case `[s-...]` is not one of ours) and the open-question phrasing is not. Splitting
397
+ // them is what lets each half carry the case policy it needs.
398
+ patterns: [
399
+ new RegExp("\\[S-[A-Z][A-Z0-9]+(?:-[A-Z0-9]+)*\\]", "g"),
400
+ new RegExp("\\bopen[- ]question #?\\d+\\b", "gi"),
401
+ ],
402
+ positives: ["Repeating [S-NTE], and Open-question #12 resolves the direction"],
403
+ negatives: [
404
+ "A character range like [S-Z], a value set written [SNOMED], and open questions about the feed",
405
+ ],
406
+ },
407
+ ];
408
+
409
+ for (const rule of rules) {
410
+ const extra = resolved.extraSamples[rule.id];
411
+ if (extra === undefined) continue;
412
+ rule.positives = [...rule.positives, ...(extra.positives ?? [])];
413
+ rule.negatives = [...rule.negatives, ...(extra.negatives ?? [])];
414
+ }
415
+
416
+ return rules;
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Configuration validation. Every failure here is a REFUSAL naming the axis.
421
+ // ---------------------------------------------------------------------------
422
+
423
+ /** Is this a plain object, as opposed to null, an array, or a primitive? */
424
+ function isPlainObject(value) {
425
+ return value !== null && typeof value === "object" && !Array.isArray(value);
426
+ }
427
+
428
+ /** Every entry of an array is a non-empty string. */
429
+ function isStringList(value) {
430
+ return Array.isArray(value) && value.every((v) => typeof v === "string" && v.trim() !== "");
431
+ }
432
+
433
+ /**
434
+ * Resolve and check the exit-code mapping.
435
+ *
436
+ * @param {unknown} supplied The caller's `exitCodes`, if any.
437
+ * @returns {{ codes?: { clean: number, hits: number, refuse: number }, problem?: string }} Either.
438
+ */
439
+ function resolveExitCodes(supplied) {
440
+ if (supplied === undefined) return { codes: { ...DEFAULT_EXIT_CODES } };
441
+ if (!isPlainObject(supplied)) {
442
+ return { problem: "axis `exitCodes` is not an object" };
443
+ }
444
+ const codes = { ...DEFAULT_EXIT_CODES, ...supplied };
445
+ for (const key of ["clean", "hits", "refuse"]) {
446
+ if (!Number.isInteger(codes[key])) {
447
+ return { problem: `axis \`exitCodes.${key}\` is not an integer` };
448
+ }
449
+ }
450
+ if (codes.clean !== 0) {
451
+ return {
452
+ problem: "axis `exitCodes.clean` must be 0: a clean run is the only run that exits 0",
453
+ };
454
+ }
455
+ for (const key of ["hits", "refuse"]) {
456
+ if (codes[key] === 0) {
457
+ return {
458
+ problem:
459
+ `axis \`exitCodes.${key}\` is 0, which would report a finding as a clean run. ` +
460
+ "A non-clean outcome exits non-zero; that is not configurable.",
461
+ };
462
+ }
463
+ }
464
+ return { codes };
465
+ }
466
+
467
+ /**
468
+ * Check a caller's configuration and fill in the defaults.
469
+ *
470
+ * @param {unknown} config The caller's configuration.
471
+ * @returns {{ resolved?: object, problems?: string[], codes: { clean: number, hits: number, refuse: number } }}
472
+ * The resolved configuration, or the problems that stopped it.
473
+ */
474
+ export function resolveConfig(config) {
475
+ const problems = [];
476
+ if (!isPlainObject(config)) {
477
+ return {
478
+ problems: ["the configuration is not an object, so no axis could be read from it"],
479
+ codes: { ...DEFAULT_EXIT_CODES },
480
+ };
481
+ }
482
+
483
+ // FAIL CLOSED ON AN OPTION THIS GATE DOES NOT KNOW. An ignored option reads, from the caller's
484
+ // side, exactly like an honoured one: they wrote `skipSelfTest: true`, the run went green, and
485
+ // nothing said the setting did nothing. Refusing names it instead.
486
+ for (const key of Object.keys(config)) {
487
+ if (KNOWN_OPTIONS.has(key)) continue;
488
+ const reason = REFUSED_OPTIONS.get(key);
489
+ problems.push(
490
+ reason === undefined
491
+ ? `option \`${key}\` is not one this gate accepts. The accepted axes are: ` +
492
+ `${[...KNOWN_OPTIONS].join(", ")}.`
493
+ : `option \`${key}\` is refused: ${reason}.`,
494
+ );
495
+ }
496
+
497
+ const exit = resolveExitCodes(config.exitCodes);
498
+ if (exit.problem !== undefined) problems.push(exit.problem);
499
+ const codes = exit.codes ?? { ...DEFAULT_EXIT_CODES };
500
+
501
+ if (config.projectPrefixes === undefined) {
502
+ problems.push("axis `projectPrefixes` is required and was not supplied");
503
+ } else if (!isStringList(config.projectPrefixes)) {
504
+ problems.push("axis `projectPrefixes` is not a list of non-empty strings");
505
+ } else if (config.projectPrefixes.length === 0) {
506
+ problems.push(
507
+ "axis `projectPrefixes` is empty. An empty prefix set matches nothing, so the identifier " +
508
+ "rule would report every surface clean. Refusing rather than substituting a default.",
509
+ );
510
+ } else {
511
+ for (const prefix of config.projectPrefixes) {
512
+ if (!PREFIX_SHAPE.test(prefix)) {
513
+ problems.push(
514
+ `axis \`projectPrefixes\` carries ${JSON.stringify(prefix)}, which is not an uppercase ` +
515
+ "token. A prefix is joined into an alternation, so anything else is refused rather " +
516
+ "than escaped into something that no longer means what was written.",
517
+ );
518
+ }
519
+ }
520
+ }
521
+
522
+ if (config.standardsDesignations === undefined) {
523
+ problems.push("axis `standardsDesignations` is required and was not supplied");
524
+ } else if (!Array.isArray(config.standardsDesignations)) {
525
+ problems.push("axis `standardsDesignations` is not a list");
526
+ } else {
527
+ for (const source of config.standardsDesignations) {
528
+ if (typeof source !== "string" || source.trim() === "") {
529
+ problems.push("axis `standardsDesignations` carries an entry that is not a pattern");
530
+ continue;
531
+ }
532
+ try {
533
+ new RegExp(source);
534
+ } catch (cause) {
535
+ problems.push(
536
+ `axis \`standardsDesignations\` carries ${JSON.stringify(source)}, which does not ` +
537
+ `compile: ${String(cause)}`,
538
+ );
539
+ }
540
+ }
541
+ }
542
+
543
+ if (config.surfacePaths === undefined) {
544
+ problems.push("axis `surfacePaths` is required and was not supplied");
545
+ } else if (!isStringList(config.surfacePaths)) {
546
+ problems.push("axis `surfacePaths` is not a list of non-empty strings");
547
+ } else if (config.surfacePaths.length === 0) {
548
+ problems.push(
549
+ "axis `surfacePaths` is empty. A scan with no surface reads nothing and would report OK " +
550
+ "over every page a consumer sees. Refusing rather than substituting a default.",
551
+ );
552
+ }
553
+
554
+ if (config.accountedTarballFiles === undefined) {
555
+ problems.push("axis `accountedTarballFiles` is required and was not supplied");
556
+ } else if (!Array.isArray(config.accountedTarballFiles)) {
557
+ problems.push("axis `accountedTarballFiles` is not a list");
558
+ } else if (!config.accountedTarballFiles.every((f) => typeof f === "string")) {
559
+ problems.push("axis `accountedTarballFiles` carries an entry that is not a string");
560
+ }
561
+
562
+ const source = config.sourceDocComments ?? { enabled: false };
563
+ if (!isPlainObject(source)) {
564
+ problems.push("axis `sourceDocComments` is not an object");
565
+ } else if (source.enabled === true) {
566
+ if (!isStringList(source.paths) || source.paths.length === 0) {
567
+ problems.push(
568
+ "axis `sourceDocComments.paths` is required when the pass is enabled, and must name at " +
569
+ "least one tracked path. An enabled pass with no paths reads nothing.",
570
+ );
571
+ }
572
+ if (source.extraRules !== undefined && !Array.isArray(source.extraRules)) {
573
+ problems.push("axis `sourceDocComments.extraRules` is not a list");
574
+ }
575
+ }
576
+
577
+ if (config.extraRules !== undefined && !Array.isArray(config.extraRules)) {
578
+ problems.push("axis `extraRules` is not a list");
579
+ }
580
+ if (config.extraSamples !== undefined && !isPlainObject(config.extraSamples)) {
581
+ problems.push("axis `extraSamples` is not an object");
582
+ }
583
+ if (config.repoRoot !== undefined && typeof config.repoRoot !== "string") {
584
+ problems.push("axis `repoRoot` is not a string");
585
+ }
586
+ if (config.write !== undefined && !isPlainObject(config.write)) {
587
+ problems.push("axis `write` is not an object");
588
+ }
589
+
590
+ if (problems.length > 0) return { problems, codes };
591
+
592
+ return {
593
+ codes,
594
+ resolved: {
595
+ projectPrefixes: [...config.projectPrefixes],
596
+ standardsDesignations: [...config.standardsDesignations],
597
+ surfacePaths: [...config.surfacePaths],
598
+ accountedTarballFiles: [...config.accountedTarballFiles],
599
+ sourceDocComments: {
600
+ enabled: source.enabled === true,
601
+ paths: source.enabled === true ? [...source.paths] : [],
602
+ extraRules: source.extraRules === undefined ? [] : [...source.extraRules],
603
+ },
604
+ exitCodes: codes,
605
+ extraRules: config.extraRules === undefined ? [] : [...config.extraRules],
606
+ extraSamples: config.extraSamples === undefined ? {} : config.extraSamples,
607
+ repoRoot: config.repoRoot ?? process.cwd(),
608
+ write: config.write ?? {},
609
+ },
610
+ };
611
+ }
612
+
613
+ // ---------------------------------------------------------------------------
614
+ // Rules supplied by the caller
615
+ // ---------------------------------------------------------------------------
616
+
617
+ /**
618
+ * Compile one caller-supplied rule, which joins the canonical set on the same terms.
619
+ *
620
+ * A caller's rule must carry its own samples: an added rule with no negative sample is the WORD-N
621
+ * trap with nothing holding it, and an added rule with no positive sample is one that can stop
622
+ * matching without anything noticing.
623
+ *
624
+ * @param {unknown} rule The caller's rule.
625
+ * @param {number} index Its position, for a diagnostic that can be acted on.
626
+ * @returns {{ rule?: object, problems: string[] }} The compiled rule or what stopped it.
627
+ */
628
+ function compileExtraRule(rule, index) {
629
+ const problems = [];
630
+ const where = `extra rule ${index}`;
631
+ if (!isPlainObject(rule)) return { problems: [`${where} is not an object`] };
632
+ if (typeof rule.name !== "string" || rule.name.trim() === "") {
633
+ problems.push(`${where} declares no \`name\``);
634
+ }
635
+ if (typeof rule.id !== "string" || rule.id.trim() === "") {
636
+ problems.push(`${where} declares no \`id\``);
637
+ }
638
+ const patterns = [];
639
+ const sources = Array.isArray(rule.patterns) ? rule.patterns : [rule.pattern];
640
+ for (const entry of sources) {
641
+ if (typeof entry === "string") {
642
+ try {
643
+ patterns.push(new RegExp(entry, "g"));
644
+ } catch (cause) {
645
+ problems.push(`${where} carries a pattern that does not compile: ${String(cause)}`);
646
+ }
647
+ continue;
648
+ }
649
+ if (entry instanceof RegExp) {
650
+ patterns.push(
651
+ new RegExp(entry.source, entry.flags.includes("g") ? entry.flags : `${entry.flags}g`),
652
+ );
653
+ continue;
654
+ }
655
+ problems.push(`${where} carries a pattern that is neither a string nor a RegExp`);
656
+ }
657
+ if (patterns.length === 0) problems.push(`${where} declares no pattern`);
658
+ if (!isStringList(rule.positives) || rule.positives.length === 0) {
659
+ problems.push(`${where} carries no positive sample, so nothing proves it still matches`);
660
+ }
661
+ if (!isStringList(rule.negatives) || rule.negatives.length === 0) {
662
+ problems.push(
663
+ `${where} carries no negative sample, so nothing stops it being widened into the WORD-N shape`,
664
+ );
665
+ }
666
+ if (problems.length > 0) return { problems };
667
+ return {
668
+ problems: [],
669
+ rule: {
670
+ id: rule.id,
671
+ name: rule.name,
672
+ patterns,
673
+ positives: [...rule.positives],
674
+ negatives: [...rule.negatives],
675
+ },
676
+ };
677
+ }
678
+
679
+ /**
680
+ * Every rule a run will execute over the public surface, canonical first.
681
+ *
682
+ * @param {object} resolved The resolved configuration.
683
+ * @returns {{ rules: object[], problems: string[] }} The compiled rule set.
684
+ */
685
+ function buildRules(resolved) {
686
+ const problems = [];
687
+ const rules = compileCanonicalRules(resolved);
688
+ const canonicalIds = new Set(rules.map((rule) => rule.id));
689
+ resolved.extraRules.forEach((rule, index) => {
690
+ const compiled = compileExtraRule(rule, index);
691
+ problems.push(...compiled.problems);
692
+ if (compiled.rule === undefined) return;
693
+ if (canonicalIds.has(compiled.rule.id)) {
694
+ problems.push(
695
+ `extra rule ${index} reuses the canonical id \`${compiled.rule.id}\`. A caller may add ` +
696
+ "detection and may not replace a canonical rule; give it an id of its own.",
697
+ );
698
+ return;
699
+ }
700
+ rules.push(compiled.rule);
701
+ });
702
+ return { rules, problems };
703
+ }
704
+
705
+ // ---------------------------------------------------------------------------
706
+ // The self-test floor
707
+ // ---------------------------------------------------------------------------
708
+
709
+ /** Does any of a rule's patterns match this text? */
710
+ function matches(rule, text) {
711
+ return rule.patterns.some((pattern) => {
712
+ pattern.lastIndex = 0;
713
+ return pattern.test(text);
714
+ });
715
+ }
716
+
717
+ /** The first thing one of a rule's patterns matched in this text, or `null`. */
718
+ function firstMatch(rule, text) {
719
+ for (const pattern of rule.patterns) {
720
+ pattern.lastIndex = 0;
721
+ const found = pattern.exec(text);
722
+ if (found !== null) return found[0];
723
+ }
724
+ return null;
725
+ }
726
+
727
+ /**
728
+ * Prove, in process and before anything is reported, that every rule can still see.
729
+ *
730
+ * TWO HALVES, and the second is the one that is unusual. Positive samples prove each rule still
731
+ * matches what it bans, which is the refusal to report a clean tree from a scanner that cannot see.
732
+ * Negative samples prove each rule still lets through the reference material it was most likely to
733
+ * destroy: if someone widens the identifier rule into a `WORD-N` shape, this reds here instead of
734
+ * silently deleting `MSH-2` from a parser's documentation on the next sweep.
735
+ *
736
+ * @param {object[]} rules The compiled rules.
737
+ * @returns {string[]} One line per failure. Empty means the floor holds.
738
+ */
739
+ export function runSelfTests(rules) {
740
+ const failures = [];
741
+ for (const rule of rules) {
742
+ for (const positive of rule.positives) {
743
+ if (!matches(rule, positive)) {
744
+ failures.push(
745
+ `rule '${rule.name}' no longer matches its own positive sample: ` +
746
+ `${JSON.stringify(positive)}`,
747
+ );
748
+ }
749
+ }
750
+ for (const negative of rule.negatives) {
751
+ const hit = firstMatch(rule, negative);
752
+ if (hit !== null) {
753
+ failures.push(
754
+ `rule '${rule.name}' now matches legitimate reference material (matched: ` +
755
+ `${JSON.stringify(hit)}). This is the WORD-N trap: it destroys the segment-field ` +
756
+ "references a parser's documentation exists to provide.",
757
+ );
758
+ }
759
+ }
760
+ }
761
+ return failures;
762
+ }
763
+
764
+ // ---------------------------------------------------------------------------
765
+ // Git, and the surface it names
766
+ // ---------------------------------------------------------------------------
767
+
768
+ /** Run git and return stdout, or `null` when it failed. */
769
+ function gitOrNull(root, args, encoding = "utf8") {
770
+ try {
771
+ return execFileSync("git", ["-C", root, ...args], {
772
+ encoding,
773
+ stdio: ["ignore", "pipe", "pipe"],
774
+ });
775
+ } catch {
776
+ return null;
777
+ }
778
+ }
779
+
780
+ /** The tracked paths under one or more pathspecs, NUL-separated so a quoted name cannot be lost. */
781
+ function trackedUnder(root, paths) {
782
+ const out = gitOrNull(root, ["ls-files", "-z", "--", ...paths], "utf8");
783
+ if (out === null) return null;
784
+ return out.split("\0").filter((entry) => entry !== "");
785
+ }
786
+
787
+ /** Git's modes for an entry that has bytes at its path. A gitlink and anything else do not. */
788
+ const GITLINK_MODE = "160000";
789
+ const READABLE_MODES = new Set(["100644", "100755", "120000"]);
790
+
791
+ /**
792
+ * What the scan may do with one tracked entry, decided from the mode git recorded for it.
793
+ *
794
+ * Three answers and no fourth. `skip` is a gitlink, which names a commit rather than bytes and is
795
+ * the ONLY entry the scan is allowed to pass over. `read` is a regular blob or a symbolic link,
796
+ * whose content is then decided by opening it rather than by predicting it. `refuse` is everything
797
+ * else, including a mode this version has never seen: an entry the scan cannot account for is a
798
+ * refusal naming it, never an entry quietly dropped from the enumeration.
799
+ *
800
+ * Exported because it is the one branch of the read path that the real enumerator cannot produce.
801
+ * Git emits four modes, so a case driving the scan can never reach the third answer, and a branch
802
+ * no case can reach is a branch no evidence covers. Grading it directly is the honest alternative
803
+ * to a defensive line nobody can fail.
804
+ *
805
+ * @param {string} mode - The six-character mode from `git ls-files -s`.
806
+ * @returns {"skip" | "read" | "refuse"} What to do with the entry.
807
+ */
808
+ export function classifyTrackedMode(mode) {
809
+ if (mode === GITLINK_MODE) return "skip";
810
+ if (READABLE_MODES.has(mode)) return "read";
811
+ return "refuse";
812
+ }
813
+
814
+ /**
815
+ * The tracked entries under one or more pathspecs, each with the MODE git recorded for it.
816
+ *
817
+ * THE KIND OF AN ENTRY COMES FROM THE INDEX, NOT FROM A STAT ON THE WORKING TREE, and that is a
818
+ * correctness decision rather than a performance one. Asking the filesystem what an enumerated path
819
+ * is and then reading that path is two questions about a tree that can change between them, and the
820
+ * branch taken on the first answer is one the second has no way to check. The concrete loss is
821
+ * silent: a tracked file replaced by a directory after enumeration looks exactly like a gitlink to a
822
+ * stat, gitlinks are skipped, the skip is accounted for, and the run reports a clean tree having
823
+ * never opened the file. Git's index already says which entries are gitlinks, which are symbolic
824
+ * links and which are regular blobs, and it says so AS PART OF the enumeration.
825
+ *
826
+ * @param {string} root - Repository top level.
827
+ * @param {string[]} paths - Pathspecs.
828
+ * @returns {{ mode: string, path: string }[] | null} The entries, or `null` when git failed.
829
+ */
830
+ function trackedEntriesUnder(root, paths) {
831
+ const out = gitOrNull(root, ["ls-files", "-s", "-z", "--", ...paths], "utf8");
832
+ if (out === null) return null;
833
+ const entries = [];
834
+ for (const record of out.split("\0")) {
835
+ if (record === "") continue;
836
+ const tab = record.indexOf("\t");
837
+ // A record git wrote in a shape this cannot parse is a refusal, never an entry dropped from the
838
+ // list: a dropped entry is a target the scan never reads and never mentions.
839
+ if (tab === -1 || record.length < 7) return null;
840
+ entries.push({ mode: record.slice(0, 6), path: record.slice(tab + 1) });
841
+ }
842
+ return entries;
843
+ }
844
+
845
+ /**
846
+ * Read one target with ONE open, deriving every verdict from that open and its own descriptor.
847
+ *
848
+ * NO CHECK-THEN-READ, ANYWHERE ON THIS PATH. A guard that stats a path and then reads the same path
849
+ * decides on state that the read is not holding: between the two, the target can be removed,
850
+ * replaced by a directory, or swapped for a link somewhere else, and the guard has already chosen
851
+ * the branch. So the file is opened first and every question is asked of the DESCRIPTOR, which
852
+ * names one file for as long as it is held: `fstat` on the fd cannot describe a different object
853
+ * from the one the subsequent read drains. What the path was at some earlier instant never enters
854
+ * into it.
855
+ *
856
+ * A failure is therefore classified from the error the operation actually raised rather than from a
857
+ * prediction made before it, which is also the only classification the contract can be held to: an
858
+ * enumerated target that could not be read is a refusal naming that target, whatever became of it.
859
+ *
860
+ * @param {string} absolute - The target's absolute path.
861
+ * @returns {{ bytes?: Buffer, error?: string }} The bytes, or why there are none.
862
+ */
863
+ function readTarget(absolute) {
864
+ let fd;
865
+ try {
866
+ // O_NONBLOCK, so that opening cannot BLOCK on something that is not a regular file. A tracked
867
+ // symbolic link may point at a named pipe, and a plain read-only open of one waits for a writer
868
+ // that may never come: a gate that hangs is a gate whose verdict never arrives, which is worse
869
+ // than either answer. With this flag the open returns, `fstat` sees what it really is, and the
870
+ // refusal below names it. The flag has no effect on a regular file.
871
+ fd = openSync(absolute, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0));
872
+ } catch (cause) {
873
+ const code = cause?.code;
874
+ if (code === "ENOENT" || code === "ENOTDIR") {
875
+ return { error: `disappeared between enumeration and reading (${String(cause)})` };
876
+ }
877
+ return { error: `could not be opened (${String(cause)})` };
878
+ }
879
+ try {
880
+ const stat = fstatSync(fd);
881
+ if (!stat.isFile()) {
882
+ return {
883
+ error: stat.isDirectory()
884
+ ? "is a tracked entry that is not a regular file: it resolves to a directory, which has " +
885
+ "no bytes to scan"
886
+ : "is a tracked entry that is not a regular file",
887
+ };
888
+ }
889
+ return { bytes: readFileSync(fd) };
890
+ } catch (cause) {
891
+ return { error: `could not be read (${String(cause)})` };
892
+ } finally {
893
+ // `readFileSync` does not close a descriptor it was handed, so this owns the whole lifetime.
894
+ closeSync(fd);
895
+ }
896
+ }
897
+
898
+ // ---------------------------------------------------------------------------
899
+ // Text passes
900
+ // ---------------------------------------------------------------------------
901
+
902
+ /**
903
+ * Join a document the way markdown renders it: a paragraph becomes one line, blank lines stay.
904
+ *
905
+ * WHY THIS PASS EXISTS, and it is the route that made hand-written copies of this gate print OK
906
+ * over a live violation. Every rule except the bare identifier is MULTI-TOKEN, a line matcher
907
+ * matches within a line, and these repositories hard-wrap their markdown, so a violation that
908
+ * happens to straddle a wrap is invisible to the line scan.
909
+ *
910
+ * WHITESPACE IS SQUEEZED, and that is the difference between this pass working and this pass
911
+ * looking as though it works: an indented continuation otherwise produces `phase may`, and every
912
+ * rule here is written with single spaces. Squeezing is also what markdown itself does.
913
+ *
914
+ * @param {string} text The document.
915
+ * @returns {string[]} One entry per paragraph, in document order.
916
+ */
917
+ export function reflowParagraphs(text) {
918
+ const paragraphs = [];
919
+ let current = [];
920
+ for (const raw of text.split(/\r?\n/)) {
921
+ if (raw.trim() === "") {
922
+ if (current.length > 0) paragraphs.push(current.join(" "));
923
+ current = [];
924
+ continue;
925
+ }
926
+ current.push(raw.replace(/\s+/g, " ").trim());
927
+ }
928
+ if (current.length > 0) paragraphs.push(current.join(" "));
929
+ return paragraphs;
930
+ }
931
+
932
+ /**
933
+ * Extract the `/** *​/` doc comments from one source file, with each line's location beside it.
934
+ *
935
+ * THE TERMINATOR IS TESTED BEFORE THE LEADER IS STRIPPED, and that ordering is the whole
936
+ * correctness of this extractor. Stripping first turns a closing `*​/` into `/`, the block never
937
+ * closes, and every line comment and line of CODE after it is scanned as doc text.
938
+ *
939
+ * Only `/** *​/` blocks are extracted. Line comments and plain block comments are NOT, and that
940
+ * boundary is the point rather than a convenience: a doc comment is what a declaration build copies
941
+ * into the shipped types, so it is what a consumer receives; what only a maintainer reads is not.
942
+ *
943
+ * @param {string} text The source file.
944
+ * @param {string} file Its repo-relative path, for the location map.
945
+ * @returns {{ lines: { text: string, where: string }[], paragraphs: { text: string, where: string }[] }}
946
+ * The doc text, line by line and paragraph by paragraph.
947
+ */
948
+ export function extractDocComments(text, file) {
949
+ const lines = [];
950
+ const paragraphs = [];
951
+ let inDoc = false;
952
+ let blockStart = 0;
953
+ let joined = [];
954
+
955
+ const flushParagraph = () => {
956
+ if (joined.length === 0) return;
957
+ const merged = joined.join(" ").replace(/\s+/g, " ").trim();
958
+ if (merged !== "") paragraphs.push({ text: merged, where: `${file}:${blockStart}` });
959
+ joined = [];
960
+ };
961
+
962
+ const source = text.split(/\r?\n/);
963
+ for (let i = 0; i < source.length; i += 1) {
964
+ let line = source[i];
965
+ if (!inDoc) {
966
+ if (!/^\s*\/\*\*/.test(line)) continue;
967
+ inDoc = true;
968
+ blockStart = i + 1;
969
+ joined = [];
970
+ line = line.replace(/^\s*\/\*\*/, "");
971
+ }
972
+ let closed = false;
973
+ if (line.includes("*/")) {
974
+ closed = true;
975
+ line = line.replace(/\*\/[\s\S]*$/, "");
976
+ }
977
+ // Exactly ONE leading asterisk, never a greedy run: a greedy leader would swallow the opening
978
+ // `**` of markdown bold and alter the text that is scanned.
979
+ line = line.replace(/^\s*\*\s?/, "").replace(/^\s+/, "");
980
+ lines.push({ text: line, where: `${file}:${i + 1}` });
981
+ if (line.trim() === "") flushParagraph();
982
+ else joined.push(line);
983
+ if (closed) {
984
+ flushParagraph();
985
+ inDoc = false;
986
+ }
987
+ }
988
+ if (inDoc) flushParagraph();
989
+ return { lines, paragraphs };
990
+ }
991
+
992
+ // ---------------------------------------------------------------------------
993
+ // The run
994
+ // ---------------------------------------------------------------------------
995
+
996
+ /** A NUL byte is how every text matcher decides a file is not text. Refuse rather than skip it. */
997
+ function looksBinary(bytes) {
998
+ return bytes.includes(0);
999
+ }
1000
+
1001
+ /**
1002
+ * Run the internal-reference gate and return an exit code drawn from `config.exitCodes`.
1003
+ *
1004
+ * Nothing here calls `process.exit`, so a test can drive the gate in process and a caller decides
1005
+ * what to do with the answer.
1006
+ *
1007
+ * @param {object} config The caller's configuration. See {@link CONFIG_AXES}.
1008
+ * @returns {number} `exitCodes.clean`, `exitCodes.hits` or `exitCodes.refuse`.
1009
+ */
1010
+ export function runInternalRefsScan(config) {
1011
+ const writeOut = config?.write?.out ?? ((s) => process.stdout.write(s));
1012
+ const writeErr = config?.write?.err ?? ((s) => process.stderr.write(s));
1013
+
1014
+ const { resolved, problems, codes } = resolveConfig(config);
1015
+ if (resolved === undefined) {
1016
+ writeErr(
1017
+ `ERROR: internal-refs - the configuration was refused, so no scan ran:\n` +
1018
+ problems.map((p) => ` ${p}\n`).join("") +
1019
+ " Refusing to report on a surface this configuration does not describe.\n",
1020
+ );
1021
+ // A configuration that could not be read cannot be trusted to have declared a sane refusal
1022
+ // code either, so this one path uses the hard-coded non-zero rather than a caller's number.
1023
+ return codes.refuse === 0 ? HARD_REFUSE : codes.refuse;
1024
+ }
1025
+
1026
+ const refuse = (message) => {
1027
+ writeErr(`ERROR: internal-refs - ${message}\n`);
1028
+ return resolved.exitCodes.refuse;
1029
+ };
1030
+
1031
+ // The scan always anchors at the git top level. `git ls-files` is relative to the working
1032
+ // directory, so from a subdirectory the scan would cover a subtree and still print OK.
1033
+ const topLevel = gitOrNull(resolved.repoRoot, ["rev-parse", "--show-toplevel"]);
1034
+ if (topLevel === null) {
1035
+ return refuse(
1036
+ `${resolved.repoRoot} is not inside a git repository, so the tracked surface cannot be ` +
1037
+ "enumerated. Refusing to report on a surface that cannot be derived.",
1038
+ );
1039
+ }
1040
+ const root = topLevel.trim();
1041
+
1042
+ const built = buildRules(resolved);
1043
+ if (built.problems.length > 0) {
1044
+ return refuse(
1045
+ "the rule set was refused:\n" +
1046
+ built.problems
1047
+ .map((p) => ` ${p}\n`)
1048
+ .join("")
1049
+ .trimEnd(),
1050
+ );
1051
+ }
1052
+ const rules = built.rules;
1053
+
1054
+ const selfTestFailures = runSelfTests(rules);
1055
+ if (selfTestFailures.length > 0) {
1056
+ return refuse(
1057
+ "SELF-TEST FAILED:\n" +
1058
+ selfTestFailures.map((f) => ` ${f}\n`).join("") +
1059
+ " The scanner is not behaving as specified, so no result from it can be believed.",
1060
+ );
1061
+ }
1062
+
1063
+ // Every named surface path must still be tracked. Without this, renaming or removing a page
1064
+ // makes the gate scan less and still print OK.
1065
+ for (const path of resolved.surfacePaths) {
1066
+ const tracked = trackedUnder(root, [path]);
1067
+ if (tracked === null) {
1068
+ return refuse(`git could not enumerate the surface path '${path}'.`);
1069
+ }
1070
+ if (tracked.length === 0) {
1071
+ return refuse(
1072
+ `the public surface path '${path}' is not tracked. Either it was renamed or removed ` +
1073
+ "(update `surfacePaths`, deliberately), or the scan is about to cover less than it " +
1074
+ "claims. Refusing to report green from a shrunken surface.",
1075
+ );
1076
+ }
1077
+ }
1078
+
1079
+ // DRIFT TRIPWIRE ON THE NPM TARBALL. `files` decides what a consumer actually receives, so
1080
+ // anything added there is new public surface this gate would not know about. EVERY entry is
1081
+ // checked, not just the prose-looking ones: a filter that discarded the build directory first
1082
+ // would structurally miss the tarball's largest prose payload.
1083
+ let manifest;
1084
+ try {
1085
+ manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
1086
+ } catch (cause) {
1087
+ return refuse(
1088
+ `package.json could not be read at ${root}: ${String(cause)}. The npm metadata is part of ` +
1089
+ "the public surface, so this is a refusal rather than a pass over the rest.",
1090
+ );
1091
+ }
1092
+ const accounted = new Set([...resolved.surfacePaths, ...resolved.accountedTarballFiles]);
1093
+ const unaccounted = (Array.isArray(manifest.files) ? manifest.files : []).filter(
1094
+ (entry) => !accounted.has(entry),
1095
+ );
1096
+ if (unaccounted.length > 0) {
1097
+ return refuse(
1098
+ `package.json 'files' ships something this configuration does not cover: ` +
1099
+ `${unaccounted.join(" ")}\n` +
1100
+ " That is public surface a consumer receives in the tarball. Add it to " +
1101
+ "`surfacePaths`, or record it in `accountedTarballFiles` as a deliberate exclusion.",
1102
+ );
1103
+ }
1104
+
1105
+ const enumerated = trackedEntriesUnder(root, resolved.surfacePaths);
1106
+ if (enumerated === null) return refuse("git could not enumerate the public surface.");
1107
+ if (enumerated.length === 0) {
1108
+ return refuse(
1109
+ "no tracked public-surface files to scan. Refusing to report green from a scan that read " +
1110
+ "nothing.",
1111
+ );
1112
+ }
1113
+
1114
+ // ENUMERATE, THEN READ, THEN PROVE THE TWO AGREE. A target that was listed and never read is
1115
+ // the silent-green shape this gate exists to close: a file that vanished between the two steps,
1116
+ // an unreadable one, and one a matcher classifies as binary all land here rather than in the
1117
+ // hit list, and each of them is a refusal naming the target.
1118
+ //
1119
+ // THE ONLY ENTRY THIS LOOP MAY PASS OVER IS A GITLINK, AND THAT IS DECIDED FROM THE INDEX. Git
1120
+ // records a submodule as mode 160000, which names a commit rather than bytes at that path. Every
1121
+ // other tracked mode is opened, and whether it can be read is answered by the open rather than
1122
+ // predicted before it. Deciding the skip from the working tree instead is how a file replaced by
1123
+ // a directory after enumeration gets counted as a submodule and never read.
1124
+ const documents = [];
1125
+ const unread = [];
1126
+ let gitlinks = 0;
1127
+ for (const entry of enumerated) {
1128
+ const rel = entry.path;
1129
+ const verdict = classifyTrackedMode(entry.mode);
1130
+ if (verdict === "skip") {
1131
+ gitlinks += 1;
1132
+ continue;
1133
+ }
1134
+ if (verdict === "refuse") {
1135
+ unread.push({
1136
+ path: rel,
1137
+ why: `is tracked with mode ${entry.mode}, which names nothing this scan can read`,
1138
+ });
1139
+ continue;
1140
+ }
1141
+ const read = readTarget(join(root, rel));
1142
+ if (read.error !== undefined) {
1143
+ unread.push({ path: rel, why: read.error });
1144
+ continue;
1145
+ }
1146
+ if (looksBinary(read.bytes)) {
1147
+ unread.push({
1148
+ path: rel,
1149
+ why:
1150
+ "holds a NUL byte, so a text matcher classifies it as binary and any match in it would " +
1151
+ "carry no line number. Treat it as a real violation and repair the encoding",
1152
+ });
1153
+ continue;
1154
+ }
1155
+ documents.push({ path: rel, text: read.bytes.toString("utf8") });
1156
+ }
1157
+
1158
+ if (unread.length > 0) {
1159
+ return refuse(
1160
+ "the scan enumerated targets it could not read, so it did not read all of its input:\n" +
1161
+ unread.map((u) => ` ${u.path} ${u.why}\n`).join("") +
1162
+ " Refusing to report green from an incomplete scan.",
1163
+ );
1164
+ }
1165
+ if (documents.length + gitlinks !== enumerated.length) {
1166
+ return refuse(
1167
+ `the scan enumerated ${enumerated.length} target(s) and accounted for ` +
1168
+ `${documents.length + gitlinks}. Refusing to report green from an incomplete scan.`,
1169
+ );
1170
+ }
1171
+ if (documents.length === 0) {
1172
+ return refuse(
1173
+ "no public-surface files survived list building. Refusing to report green from a scan that " +
1174
+ "read nothing.",
1175
+ );
1176
+ }
1177
+
1178
+ // The npm metadata is public surface that is not a file of its own. It is ALWAYS scanned and no
1179
+ // axis removes it, which is what makes the universal above checkable: there is at least one
1180
+ // target every configuration reads.
1181
+ documents.push({
1182
+ path: "package.json (npm metadata)",
1183
+ text:
1184
+ `description: ${manifest.description ?? ""}\n` +
1185
+ `keywords: ${(manifest.keywords ?? []).join(", ")}\n`,
1186
+ });
1187
+
1188
+ const hits = [];
1189
+ for (const document of documents) {
1190
+ scanDocument(document, rules, hits);
1191
+ }
1192
+
1193
+ // The source doc-comment pass, when the caller has enabled it.
1194
+ let sourceFiles = 0;
1195
+ if (resolved.sourceDocComments.enabled) {
1196
+ const sourceRules = [...rules];
1197
+ let index = 0;
1198
+ for (const rule of resolved.sourceDocComments.extraRules) {
1199
+ const compiled = compileExtraRule(rule, index);
1200
+ index += 1;
1201
+ if (compiled.rule === undefined) {
1202
+ return refuse(
1203
+ "a source doc-comment rule was refused:\n" +
1204
+ compiled.problems
1205
+ .map((p) => ` ${p}\n`)
1206
+ .join("")
1207
+ .trimEnd(),
1208
+ );
1209
+ }
1210
+ sourceRules.push(compiled.rule);
1211
+ }
1212
+ const sourceSelfTest = runSelfTests(sourceRules);
1213
+ if (sourceSelfTest.length > 0) {
1214
+ return refuse(
1215
+ "SELF-TEST FAILED for the source doc-comment pass:\n" +
1216
+ sourceSelfTest
1217
+ .map((f) => ` ${f}\n`)
1218
+ .join("")
1219
+ .trimEnd(),
1220
+ );
1221
+ }
1222
+
1223
+ const sourceTracked = trackedEntriesUnder(root, resolved.sourceDocComments.paths);
1224
+ if (sourceTracked === null || sourceTracked.length === 0) {
1225
+ return refuse(
1226
+ `no tracked source files under ${resolved.sourceDocComments.paths.join(", ")} to scan for ` +
1227
+ "doc comments. Either the source moved (update `sourceDocComments.paths`, " +
1228
+ "deliberately) or the scan is about to cover less than it claims.",
1229
+ );
1230
+ }
1231
+ const docLines = [];
1232
+ const docParagraphs = [];
1233
+ // Same discipline as the public surface: the kind comes from the index, the bytes come from one
1234
+ // open, and nothing here predicts a read before performing it.
1235
+ for (const entry of sourceTracked) {
1236
+ const rel = entry.path;
1237
+ const verdict = classifyTrackedMode(entry.mode);
1238
+ if (verdict === "skip") continue;
1239
+ if (verdict === "refuse") {
1240
+ return refuse(
1241
+ `tracked source entry ${rel} has mode ${entry.mode}, which names nothing this scan can ` +
1242
+ "read. Refusing to report green from a scan that skipped one of its inputs.",
1243
+ );
1244
+ }
1245
+ const read = readTarget(join(root, rel));
1246
+ if (read.error !== undefined) {
1247
+ return refuse(
1248
+ `tracked source file ${rel} ${read.error}. Refusing to report green from a scan that ` +
1249
+ "could not open its input.",
1250
+ );
1251
+ }
1252
+ if (looksBinary(read.bytes)) {
1253
+ return refuse(
1254
+ `tracked source file ${rel} holds a NUL byte, so a text matcher classifies it as ` +
1255
+ "binary. Refusing to report green over input it cannot read.",
1256
+ );
1257
+ }
1258
+ sourceFiles += 1;
1259
+ const extracted = extractDocComments(read.bytes.toString("utf8"), rel);
1260
+ docLines.push(...extracted.lines);
1261
+ docParagraphs.push(...extracted.paragraphs);
1262
+ }
1263
+ if (docLines.length === 0) {
1264
+ return refuse(
1265
+ `extracted no doc-comment text from ${sourceFiles} tracked source file(s). An empty ` +
1266
+ "extraction means the extractor is broken, not that the source is clean.",
1267
+ );
1268
+ }
1269
+ scanDocComments(docLines, docParagraphs, sourceRules, hits);
1270
+ }
1271
+
1272
+ if (hits.length > 0) {
1273
+ writeErr(report(hits));
1274
+ return resolved.exitCodes.hits;
1275
+ }
1276
+
1277
+ writeOut(
1278
+ `internal-refs: OK (${documents.length} target(s) scanned against ${rules.length} rule(s), ` +
1279
+ `line by line and paragraph-joined` +
1280
+ (resolved.sourceDocComments.enabled
1281
+ ? `; ${sourceFiles} source file(s) scanned for doc-comment bookkeeping`
1282
+ : "") +
1283
+ `; ${gitlinks} gitlink(s) skipped)\n`,
1284
+ );
1285
+ return resolved.exitCodes.clean;
1286
+ }
1287
+
1288
+ /** Scan one document line by line, then paragraph-joined for what a wrap hid. */
1289
+ function scanDocument(document, rules, hits) {
1290
+ const lines = document.text.split(/\r?\n/);
1291
+ for (const rule of rules) {
1292
+ const seen = new Set();
1293
+ for (let i = 0; i < lines.length; i += 1) {
1294
+ const found = firstMatch(rule, lines[i]);
1295
+ if (found === null) continue;
1296
+ seen.add(found);
1297
+ hits.push({ rule: rule.name, file: document.path, line: i + 1, text: lines[i].trim() });
1298
+ }
1299
+ for (const paragraph of reflowParagraphs(document.text)) {
1300
+ const found = firstMatch(rule, paragraph);
1301
+ if (found === null || seen.has(found)) continue;
1302
+ seen.add(found);
1303
+ // A wrapped hit has no line number by construction: the match exists only in the joined
1304
+ // paragraph. It reports the file and the matched text, which is what a remediator searches
1305
+ // for, and it is reported only when the line pass could not already see it.
1306
+ hits.push({ rule: rule.name, file: document.path, line: null, text: found, wrapped: true });
1307
+ }
1308
+ }
1309
+ }
1310
+
1311
+ /** Scan extracted doc-comment text, line by line and then paragraph-reflowed. */
1312
+ function scanDocComments(docLines, docParagraphs, rules, hits) {
1313
+ for (const rule of rules) {
1314
+ const located = new Set();
1315
+ for (const entry of docLines) {
1316
+ const found = firstMatch(rule, entry.text);
1317
+ if (found === null) continue;
1318
+ located.add(entry.where);
1319
+ hits.push({ rule: rule.name, file: entry.where, line: null, text: entry.text, source: true });
1320
+ }
1321
+ for (const entry of docParagraphs) {
1322
+ const found = firstMatch(rule, entry.text);
1323
+ if (found === null || located.has(entry.where)) continue;
1324
+ located.add(entry.where);
1325
+ hits.push({
1326
+ rule: rule.name,
1327
+ file: entry.where,
1328
+ line: null,
1329
+ text: found,
1330
+ source: true,
1331
+ wrapped: true,
1332
+ });
1333
+ }
1334
+ }
1335
+ }
1336
+
1337
+ /** The hit report: every finding names its file, its line where it has one, and its rule. */
1338
+ function report(hits) {
1339
+ const lines = [];
1340
+ for (const hit of hits) {
1341
+ const where = hit.line === null ? hit.file : `${hit.file}:${hit.line}`;
1342
+ const label =
1343
+ `[${hit.rule}` +
1344
+ (hit.source ? " / src doc comment" : "") +
1345
+ (hit.wrapped ? " / wrapped across lines" : "") +
1346
+ "]";
1347
+ lines.push(`${label} ${where}: ${hit.text}`);
1348
+ }
1349
+ lines.push("");
1350
+ lines.push("ERROR: internal-refs - internal project bookkeeping found on a public surface.");
1351
+ lines.push(" A consumer reads this surface. Item identifiers, phase and wave language,");
1352
+ lines.push(" ADR numbers and internal repo paths belong in the changeset, the changelog,");
1353
+ lines.push(" the commit, the pull request and the roadmap. Translate at the boundary: say");
1354
+ lines.push(" what the software does and what changed.");
1355
+ lines.push(" When you strip an identifier off the FRONT of a line, repair the head too:");
1356
+ lines.push(
1357
+ " drop a leading orphan parenthetical, strip leading punctuation, recapitalise.",
1358
+ );
1359
+ return `${lines.join("\n")}\n`;
1360
+ }
1361
+
1362
+ /** Re-exported for a caller that wants to state, in its own docs, what it cannot turn off. */
1363
+ export { DEFAULT_EXIT_CODES, REFUSED_OPTIONS };