@cosyte/script-utils 0.1.4 → 0.1.6

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/internal-refs.js CHANGED
@@ -103,6 +103,16 @@ export const CONFIG_AXES = Object.freeze([
103
103
  "rules. `/** */` blocks compile into shipped type declarations, so they are public surface " +
104
104
  "in a repository that publishes types.",
105
105
  }),
106
+ Object.freeze({
107
+ name: "sourceStringLiterals",
108
+ required: false,
109
+ refusesEmpty: false,
110
+ default: "{ enabled: false }",
111
+ summary:
112
+ "Whether the source string-literal pass runs, over which tracked paths, and with which extra " +
113
+ "rules. A string literal reaches a consumer as error and warning text, so it is public " +
114
+ "surface; a comment is not, and this pass never reads one.",
115
+ }),
106
116
  Object.freeze({
107
117
  name: "exitCodes",
108
118
  required: false,
@@ -230,10 +240,28 @@ const PHASE_NOT_CLINICAL =
230
240
  "(?<!study )(?<!clinical )(?<!trial )(?<!acute )(?<!chronic )(?<!luteal )(?<!follicular )" +
231
241
  "(?<!liquid )(?<!gas )";
232
242
 
233
- /** Lookahead that drops the clinical-study field-name tails and the trial roman numerals. */
243
+ /**
244
+ * Lookbehind that drops `phase` as the second half of a compound word.
245
+ *
246
+ * `connect-phase socket`, `two-phase state` and `read-phase timeout` describe a protocol, not a
247
+ * roadmap: a letter and a hyphen in front of `phase` make it part of an adjective. Only the
248
+ * characters BEFORE the word are read, so `Phase-L` (the hyphen after it) is still a hit, and so is
249
+ * a phase after a digit and a hyphen.
250
+ */
251
+ const PHASE_NOT_COMPOUND = "(?<![A-Za-z]-)";
252
+
253
+ /**
254
+ * Lookahead that drops the clinical-study field-name tails, the trial roman numerals, and the
255
+ * words that make `phase` a noun in a sentence rather than the name of one.
256
+ *
257
+ * `All 5 phases are defined`, `the lifecycle phase where the error occurred`, `the phase was`, `the
258
+ * phases were`, `the phase during which` and `the phase at which` say what a phase is. A roadmap
259
+ * phase is named by what follows the word, and none of these words names one.
260
+ */
234
261
  const PHASE_NOT_FIELD =
235
262
  "(?!of\\b|with\\b|in\\b|out\\b|the\\b|and\\b|is\\b|for\\b|to\\b|identifier\\b|start\\b|end\\b|" +
236
- "evaluability\\b|number\\b|(?:I{1,3}|IV)\\s+(?:trial|stud|clinical|oncolog))";
263
+ "evaluability\\b|number\\b|where\\b|are\\b|was\\b|were\\b|during\\b|at\\b|" +
264
+ "(?:I{1,3}|IV)\\s+(?:trial|stud|clinical|oncolog))";
237
265
 
238
266
  /**
239
267
  * Imaging vocabulary that follows `slice` in reference material rather than in our jargon.
@@ -325,14 +353,21 @@ export function compileCanonicalRules(resolved) {
325
353
  {
326
354
  id: "internal-identifier",
327
355
  name: "internal project identifier",
328
- patterns: [new RegExp(identifier, "g")],
329
- positives: identifierPositives,
356
+ patterns: [
357
+ new RegExp(identifier, "g"),
358
+ // An umbrella spec or work-item id: `S` and four digits, then a lower-case slug
359
+ // (`S0228-github-profile-ci-drift`). The slug is what keeps a HCPCS code such as `S9083`
360
+ // out; dicom's gate carried the rule.
361
+ new RegExp("\\bS\\d{4}-[a-z][a-z0-9]*(?:-[a-z0-9]+)*\\b", "g"),
362
+ ],
363
+ positives: [...identifierPositives, "Tracked as S0228-github-profile-ci-drift"],
330
364
  // The WORD-N trap turned into an assertion. Every token here is a segment-field reference or
331
365
  // a diagnosis-code range, and a prefix set that starts matching one of them reds HERE rather
332
366
  // than deleting it from a parser's documentation on the next sweep.
333
367
  negatives: [
334
368
  "MSH-2 encoding characters, PID-3 identifier list, OBX-5 value, SCH-11 timing, " +
335
369
  "TQ1-7 start, NM1-03 name, ICD-10-CM P00-P96, 835 remittance",
370
+ "HCPCS S9083 and S0630 are billing codes, and S0228 alone is one too",
336
371
  ],
337
372
  },
338
373
  {
@@ -340,31 +375,59 @@ export function compileCanonicalRules(resolved) {
340
375
  name: "phase or wave language",
341
376
  patterns: [
342
377
  new RegExp(
343
- `\\b(?:roadmap phases?\\b[ ]?[A-Za-z0-9]*|${PHASE_NOT_CLINICAL}phases?[ -]${PHASE_NOT_FIELD}` +
378
+ `\\b(?:roadmap phases?\\b[ ]?[A-Za-z0-9]*|${PHASE_NOT_CLINICAL}${PHASE_NOT_COMPOUND}phases?[ -]` +
379
+ `${PHASE_NOT_FIELD}` +
344
380
  `[A-Za-z0-9]+[a-z]?\\b|wave \\d+\\b|the \\w+ and final phase\\b|` +
345
381
  `documentation residual\\b|${ORDINAL} (?:slice|wave)\\b)`,
346
382
  "gi",
347
383
  ),
384
+ // Taken from dicom's gate: the adjective forms (`in subsequent phases`, `a later wave`), a
385
+ // plural wave count, and a citation of the roadmap. `of` after the noun is the clinical
386
+ // sense (`a later phase of the disease`, `the next wave of infections`), so it is let through.
387
+ new RegExp(
388
+ "\\b(?:subsequent|later|earlier|future|upcoming|next|previous|remaining)\\s+" +
389
+ "(?:phases?|waves?)\\b(?!\\s+of\\b)|\\bwaves\\s+\\d+\\b|" +
390
+ "\\b(?:the|this|its|our)\\s+(?:\\w+\\s+)?roadmaps?\\b",
391
+ "gi",
392
+ ),
348
393
  ],
349
394
  positives: [
350
395
  "Phase 5b closes it (Phase W, Phase-L and the thirteenth slice landed earlier, in wave 2)",
351
396
  // The plural, which ten of the eleven per-repository copies matched (`phases?[ -]`).
352
397
  "Phases 1 to 6 shipped together",
398
+ // What the two guards must still catch: a phase named next to a compound word, and a
399
+ // phase named before the words that make a bare `phase` a noun.
400
+ "The connect-phase socket shipped in phase 2",
401
+ "Phase 3 was where the error occurred",
402
+ "The helpers are populated in subsequent phases",
403
+ "It arrives in a later wave, after waves 2 and 3",
404
+ "Obsolete codes are tracked by the roadmap",
353
405
  ],
354
406
  negatives: [
355
407
  "CSP-1 Study Phase Identifier, CSP-2 Study Phase Start Date/Time, CSP-3 Study Phase End " +
356
408
  "Date/Time, CSP-4 Study Phase Evaluability; a Phase III oncology trial and a Phase II " +
357
409
  "study; the acute phase reactant; the adapter stays in phase with the source system " +
358
410
  "and is out of phase",
411
+ // The compound-word guard: `phase` as the second half of an adjective.
412
+ "The connect-phase socket, the two-phase state and the read-phase timeout",
413
+ // The sentence guard: `phase` as a noun followed by what it is, not by its name.
414
+ "All 5 phases are defined; Connection lifecycle phase where the error occurred; the " +
415
+ "phase was open, the phases were closed, and a phase during which and a phase at which " +
416
+ "the socket failed",
417
+ // The clinical senses of the adjective forms.
418
+ "A later phase of the disease, the next wave of infections, and the previous phase of " +
419
+ "the trial",
359
420
  ],
360
421
  },
361
422
  {
362
423
  id: "adr-reference",
363
424
  name: "ADR reference",
364
425
  // `[ /-]`: ten of the eleven per-repository copies also matched an ADR path segment.
365
- patterns: [new RegExp("\\bADR[ /-]?\\d{3,4}\\b", "gi")],
426
+ // The plural, `#` and two-digit forms are dicom's: `ADRs 0015 and 0016`, `ADR #0018`, `ADR 23`.
427
+ patterns: [new RegExp("\\bADRs?[ /#-]?\\d{2,4}\\b", "gi")],
366
428
  positives: [
367
429
  "Decided in ADR 0015 and restated in ADR-0021",
430
+ "Superseded by ADRs 0015 and ADR #0018, after ADR 23",
368
431
  "Recorded in docs/adr/0001-scope.md",
369
432
  ],
370
433
  negatives: ["ADR is not a segment, and 0015 alone is a value"],
@@ -373,18 +436,50 @@ export function compileCanonicalRules(resolved) {
373
436
  id: "internal-jargon",
374
437
  name: 'internal jargon ("slice")',
375
438
  patterns: [
439
+ // `the` and `each` are NOT determiners here. A fixed-width code file's FIELD SLICE is a
440
+ // byte range and a public API parameter (terminology's `FixedWidthFieldSlice`), and with
441
+ // `the` in the class the rule flagged every `the field slice` and `the slice holding the
442
+ // concept code` in that documentation, which a remediator cannot rewrite. What the
443
+ // narrowing costs: a bare `the slice`, `the last slice` and `each slice` are no longer
444
+ // hits. `this`, `that`, `another`, `previous`, `next`, `final` and `current` still are,
445
+ // so `the final slice` and `the next slice` still red, and the ordinal forms
446
+ // (`the thirteenth slice`) are the phase rule's.
376
447
  new RegExp(
377
- "\\b(?:this|that|the|each|another|previous|next|final|current)\\s+" +
448
+ "\\b(?:this|that|another|previous|next|final|current)\\s+" +
378
449
  "(?:(?!(?:of|in|on|between|per|for|to|with|at)\\s)[\\w-]+\\s+){0,2}slices?\\b" +
379
- `(?!\\s+(?:${IMAGING_NOUNS}))`,
450
+ `(?!\\s+(?:${IMAGING_NOUNS}))` +
451
+ // An image slice in its volume is DICOM's word (dicom's own reference sentences).
452
+ "(?!\\s+in\\s+(?:the|this|a|an|each)\\s+(?:volume|series|stack|study|image|frame)\\b)",
453
+ "gi",
454
+ ),
455
+ // Process commentary, taken from dicom's gate: a pull request, changeset or work item named
456
+ // as ours, and work deferred out of scope. `this spec`, `this session` and `this port` are
457
+ // left out: a parser's specification, a TLS session and a TCP port are reader words here.
458
+ new RegExp(
459
+ "\\bthis\\s+(?:work item|changeset|change set|pull request|PR)\\b|" +
460
+ "\\b(?:a|the|another|one)\\s+(?:later|future|separate|follow-up|subsequent)\\s+" +
461
+ "(?:PR|pull request|work item|changeset)\\b|" +
462
+ "\\bout\\s+of\\s+scope\\s+for\\s+(?:this|the)\\s+(?:slice|item|change|pass)\\b",
380
463
  "gi",
381
464
  ),
382
465
  ],
383
- positives: ["This slice adds the helper and the final slice removes it"],
466
+ positives: [
467
+ "This slice adds the helper and the final slice removes it",
468
+ // What the narrowing must still catch: the determiners it kept, after `the`.
469
+ "The next slice moves the parser, and that slice and another slice follow it",
470
+ "Landed by a later PR, and out of scope for this change",
471
+ ],
384
472
  negatives: [
385
473
  "The slice thickness and the number of slices are DICOM attributes, each slice location " +
386
474
  "is too, and the phase of the clinical study, the phase of illness and each phase of " +
387
475
  "the trial are the reader words this rule must not touch",
476
+ // A fixed-width field slice is reference material: the byte range a reader passes in.
477
+ "The consumer supplies the field slices, the reader takes the slices as a parameter, a " +
478
+ "line too short to contain the code slice is skipped, the field is empty when the slice " +
479
+ "is entirely past the line's end, and each slice is a start and an end. The slice " +
480
+ "holding the concept code. The field slice.",
481
+ "Slice Thickness and Slice Location describe the next slice in the volume, and this " +
482
+ "specification, this session and this port are the reader's",
388
483
  ],
389
484
  },
390
485
  {
@@ -397,10 +492,24 @@ export function compileCanonicalRules(resolved) {
397
492
  "|\\bBACKLOG\\.md\\b",
398
493
  "g",
399
494
  ),
495
+ // Taken from dicom's gate: the planning files a session writes, the umbrella named as a
496
+ // place, and its work and card directories.
497
+ new RegExp(
498
+ "\\b(?:\\d{2}-)?CONTEXT\\.md\\b|\\bREQUIREMENTS\\.md\\b|\\bPITFALLS\\.md\\b|" +
499
+ "\\b[Mm]eta[- ]repo\\b|\\b[Uu]mbrella repo\\b|" +
500
+ "\\bwork/(?:specs|backlog|inbox|archive)/|\\bcards/[a-z-]+\\.md\\b|" +
501
+ "\\bdecisions?/\\d{4}-[a-z0-9-]+",
502
+ "g",
503
+ ),
504
+ ],
505
+ positives: [
506
+ "Roadmap operations/roadmaps/hl7.md and documentation/decisions/0015-x.md",
507
+ "Locked by CONTEXT.md in the meta-repo, per work/specs/ and cards/hl7.md",
508
+ "Recorded in decisions/0015-scope.md",
400
509
  ],
401
- positives: ["Roadmap operations/roadmaps/hl7.md and documentation/decisions/0015-x.md"],
402
510
  negatives: [
403
511
  "Parser operations are documented in the README, and documentation for the API is generated",
512
+ "The context and the requirements are in docs-content, and a repo's work is its own",
404
513
  ],
405
514
  },
406
515
  {
@@ -588,6 +697,21 @@ export function resolveConfig(config) {
588
697
  }
589
698
  }
590
699
 
700
+ const literals = config.sourceStringLiterals ?? { enabled: false };
701
+ if (!isPlainObject(literals)) {
702
+ problems.push("axis `sourceStringLiterals` is not an object");
703
+ } else if (literals.enabled === true) {
704
+ if (!isStringList(literals.paths) || literals.paths.length === 0) {
705
+ problems.push(
706
+ "axis `sourceStringLiterals.paths` is required when the pass is enabled, and must name at " +
707
+ "least one tracked path. An enabled pass with no paths reads nothing.",
708
+ );
709
+ }
710
+ if (literals.extraRules !== undefined && !Array.isArray(literals.extraRules)) {
711
+ problems.push("axis `sourceStringLiterals.extraRules` is not a list");
712
+ }
713
+ }
714
+
591
715
  if (config.extraRules !== undefined && !Array.isArray(config.extraRules)) {
592
716
  problems.push("axis `extraRules` is not a list");
593
717
  }
@@ -615,6 +739,11 @@ export function resolveConfig(config) {
615
739
  paths: source.enabled === true ? [...source.paths] : [],
616
740
  extraRules: source.extraRules === undefined ? [] : [...source.extraRules],
617
741
  },
742
+ sourceStringLiterals: {
743
+ enabled: literals.enabled === true,
744
+ paths: literals.enabled === true ? [...literals.paths] : [],
745
+ extraRules: literals.extraRules === undefined ? [] : [...literals.extraRules],
746
+ },
618
747
  exitCodes: codes,
619
748
  extraRules: config.extraRules === undefined ? [] : [...config.extraRules],
620
749
  extraSamples: config.extraSamples === undefined ? {} : config.extraSamples,
@@ -1021,10 +1150,260 @@ export function extractDocComments(text, file) {
1021
1150
  // ---------------------------------------------------------------------------
1022
1151
 
1023
1152
  /** A NUL byte is how every text matcher decides a file is not text. Refuse rather than skip it. */
1153
+ /** NUL bytes read as spaces in source text, counted so the clean line can say so. */
1154
+ let sourceNulBytes = 0;
1155
+
1156
+ /**
1157
+ * A source file's text for the two source passes. A NUL byte is legitimate in source (a raw
1158
+ * separator inside a string literal, as deid's HMAC keys and dicom's charset table carry), and it
1159
+ * reads as a SPACE rather than refusing the file or vanishing: a space keeps the word boundary on
1160
+ * either side, so `x<NUL>DEID-77` is still an identifier where deleting the byte would glue it to
1161
+ * the `x`. The public-surface pass still refuses a NUL-bearing target.
1162
+ */
1163
+ function sourceText(bytes) {
1164
+ let text = bytes.toString("utf8");
1165
+ if (text.includes("\0")) {
1166
+ sourceNulBytes += text.split("\0").length - 1;
1167
+ text = text.replaceAll("\0", " ");
1168
+ }
1169
+ return text;
1170
+ }
1171
+
1024
1172
  function looksBinary(bytes) {
1025
1173
  return bytes.includes(0);
1026
1174
  }
1027
1175
 
1176
+ /** Keywords after which a `/` starts a regular expression rather than a division. */
1177
+ const REGEX_AFTER_WORDS = new Set([
1178
+ "return",
1179
+ "typeof",
1180
+ "instanceof",
1181
+ "case",
1182
+ "do",
1183
+ "else",
1184
+ "in",
1185
+ "of",
1186
+ "new",
1187
+ "delete",
1188
+ "void",
1189
+ "throw",
1190
+ "yield",
1191
+ "await",
1192
+ ]);
1193
+
1194
+ /** Punctuation after which a `/` starts a regular expression rather than a division. */
1195
+ const REGEX_AFTER_PUNCTUATION = new Set([..."(,=:[!&|?{};+-*%<>~^"]);
1196
+
1197
+ /** The escapes a string literal decodes, so a hit spelled with them is still seen. */
1198
+ function decodeLiteral(raw) {
1199
+ let out = "";
1200
+ for (let i = 0; i < raw.length; i += 1) {
1201
+ const ch = raw[i];
1202
+ if (ch !== "\\" || i + 1 >= raw.length) {
1203
+ out += ch;
1204
+ continue;
1205
+ }
1206
+ const next = raw[i + 1];
1207
+ if (next === "u" && raw[i + 2] === "{") {
1208
+ const end = raw.indexOf("}", i + 3);
1209
+ const body = end < 0 ? "" : raw.slice(i + 3, end);
1210
+ if (/^[0-9a-fA-F]{1,6}$/.test(body) && Number.parseInt(body, 16) <= 0x10ffff) {
1211
+ out += String.fromCodePoint(Number.parseInt(body, 16));
1212
+ i = end;
1213
+ continue;
1214
+ }
1215
+ }
1216
+ if (next === "u" && /^[0-9a-fA-F]{4}$/.test(raw.slice(i + 2, i + 6))) {
1217
+ out += String.fromCharCode(Number.parseInt(raw.slice(i + 2, i + 6), 16));
1218
+ i += 5;
1219
+ continue;
1220
+ }
1221
+ if (next === "x" && /^[0-9a-fA-F]{2}$/.test(raw.slice(i + 2, i + 4))) {
1222
+ out += String.fromCharCode(Number.parseInt(raw.slice(i + 2, i + 4), 16));
1223
+ i += 3;
1224
+ continue;
1225
+ }
1226
+ // `\n`, `\t` and the rest of the single-character escapes read as a space: they separate
1227
+ // words in the message a consumer sees. An escaped quote or backslash is itself.
1228
+ out += /[nrtbfv0]/.test(next) ? " " : next === "\n" ? "" : next;
1229
+ i += 1;
1230
+ }
1231
+ return out;
1232
+ }
1233
+
1234
+ /**
1235
+ * Extract the string literals of JavaScript or TypeScript source: double-quoted, single-quoted and
1236
+ * template literals, each decoded. A string literal reaches a consumer as error and warning text,
1237
+ * so it is public surface. A COMMENT IS NEVER READ HERE: a `//` line comment, a trailing one, and a
1238
+ * `/* *\/` block are skipped whole, because a maintainer's note is where an item identifier is
1239
+ * welcome (doc comments are the doc-comment pass's). A template literal's `${...}` reads as a
1240
+ * space and any literal inside it is extracted on its own; a regular-expression literal is
1241
+ * skipped, so a quote inside one is not mistaken for a string.
1242
+ *
1243
+ * IT IS A SCANNER, NOT A PARSER. A `/` after a value is division and after an operator, an opening
1244
+ * bracket or a keyword such as `return` starts a regular expression, which is the rule every
1245
+ * JavaScript tokenizer uses; a source that defeats it is not one this estate writes.
1246
+ *
1247
+ * @param {string} text The source.
1248
+ * @param {string} file Its repo-relative path, for the report.
1249
+ * @returns {{ literals: { text: string, where: string }[], lines: { text: string, where: string }[] }}
1250
+ * One entry per non-empty literal, and one per source line holding two or more literals, joined
1251
+ * with a space, so a phrase split across adjacent literals is still seen.
1252
+ */
1253
+ export function extractStringLiterals(text, file) {
1254
+ const literals = [];
1255
+ const byLine = new Map();
1256
+ let line = 1;
1257
+ let i = 0;
1258
+
1259
+ const record = (raw, startLine) => {
1260
+ const value = decodeLiteral(raw).replace(/\s+/g, " ").trim();
1261
+ if (value === "") return;
1262
+ literals.push({ text: value, where: `${file}:${startLine}` });
1263
+ const onLine = byLine.get(startLine) ?? [];
1264
+ onLine.push(value);
1265
+ byLine.set(startLine, onLine);
1266
+ };
1267
+
1268
+ /** Lex from `i` until the end, or until the `}` closing a template expression when `inBraces`. */
1269
+ const lex = (inBraces) => {
1270
+ let depth = 0;
1271
+ let prev = "";
1272
+ let prevWord = "";
1273
+ while (i < text.length) {
1274
+ const ch = text[i];
1275
+ if (ch === "\n") {
1276
+ line += 1;
1277
+ i += 1;
1278
+ continue;
1279
+ }
1280
+ if (ch === "/" && text[i + 1] === "/") {
1281
+ while (i < text.length && text[i] !== "\n") i += 1;
1282
+ continue;
1283
+ }
1284
+ if (ch === "/" && text[i + 1] === "*") {
1285
+ i += 2;
1286
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) {
1287
+ if (text[i] === "\n") line += 1;
1288
+ i += 1;
1289
+ }
1290
+ i += 2;
1291
+ continue;
1292
+ }
1293
+ if (ch === '"' || ch === "'") {
1294
+ const start = line;
1295
+ let raw = "";
1296
+ i += 1;
1297
+ while (i < text.length && text[i] !== ch && text[i] !== "\n") {
1298
+ if (text[i] === "\\" && i + 1 < text.length) {
1299
+ if (text[i + 1] === "\n") line += 1;
1300
+ raw += text[i] + text[i + 1];
1301
+ i += 2;
1302
+ continue;
1303
+ }
1304
+ raw += text[i];
1305
+ i += 1;
1306
+ }
1307
+ i += 1;
1308
+ record(raw, start);
1309
+ prev = "v";
1310
+ prevWord = "";
1311
+ continue;
1312
+ }
1313
+ if (ch === "`") {
1314
+ const start = line;
1315
+ let raw = "";
1316
+ i += 1;
1317
+ while (i < text.length && text[i] !== "`") {
1318
+ if (text[i] === "\\" && i + 1 < text.length) {
1319
+ if (text[i + 1] === "\n") line += 1;
1320
+ raw += text[i] + text[i + 1];
1321
+ i += 2;
1322
+ continue;
1323
+ }
1324
+ if (text[i] === "$" && text[i + 1] === "{") {
1325
+ i += 2;
1326
+ lex(true);
1327
+ raw += " ";
1328
+ continue;
1329
+ }
1330
+ if (text[i] === "\n") line += 1;
1331
+ raw += text[i];
1332
+ i += 1;
1333
+ }
1334
+ i += 1;
1335
+ record(raw, start);
1336
+ prev = "v";
1337
+ prevWord = "";
1338
+ continue;
1339
+ }
1340
+ if (
1341
+ ch === "/" &&
1342
+ (prev === "" || REGEX_AFTER_PUNCTUATION.has(prev) || REGEX_AFTER_WORDS.has(prevWord))
1343
+ ) {
1344
+ // A regular-expression literal: up to the unescaped `/` outside a character class.
1345
+ let inClass = false;
1346
+ i += 1;
1347
+ while (i < text.length && text[i] !== "\n") {
1348
+ if (text[i] === "\\") {
1349
+ i += 2;
1350
+ continue;
1351
+ }
1352
+ if (text[i] === "[") inClass = true;
1353
+ else if (text[i] === "]") inClass = false;
1354
+ else if (text[i] === "/" && !inClass) break;
1355
+ i += 1;
1356
+ }
1357
+ i += 1;
1358
+ while (i < text.length && /[A-Za-z]/.test(text[i])) i += 1;
1359
+ prev = "v";
1360
+ prevWord = "";
1361
+ continue;
1362
+ }
1363
+ if (/\s/.test(ch)) {
1364
+ i += 1;
1365
+ continue;
1366
+ }
1367
+ if (/[A-Za-z_$]/.test(ch)) {
1368
+ let word = "";
1369
+ while (i < text.length && /[\w$]/.test(text[i])) {
1370
+ word += text[i];
1371
+ i += 1;
1372
+ }
1373
+ prev = "v";
1374
+ prevWord = word;
1375
+ continue;
1376
+ }
1377
+ if (/[0-9]/.test(ch)) {
1378
+ while (i < text.length && /[\w.]/.test(text[i])) i += 1;
1379
+ prev = "v";
1380
+ prevWord = "";
1381
+ continue;
1382
+ }
1383
+ if (inBraces) {
1384
+ if (ch === "{") depth += 1;
1385
+ if (ch === "}") {
1386
+ if (depth === 0) {
1387
+ i += 1;
1388
+ return;
1389
+ }
1390
+ depth -= 1;
1391
+ }
1392
+ }
1393
+ prev = ch === ")" || ch === "]" || ch === "}" ? "v" : ch;
1394
+ prevWord = "";
1395
+ i += 1;
1396
+ }
1397
+ };
1398
+
1399
+ lex(false);
1400
+ const lines = [];
1401
+ for (const [startLine, values] of byLine) {
1402
+ if (values.length > 1) lines.push({ text: values.join(" "), where: `${file}:${startLine}` });
1403
+ }
1404
+ return { literals, lines };
1405
+ }
1406
+
1028
1407
  /**
1029
1408
  * Run the internal-reference gate and return an exit code drawn from `config.exitCodes`.
1030
1409
  *
@@ -1035,6 +1414,7 @@ function looksBinary(bytes) {
1035
1414
  * @returns {number} `exitCodes.clean`, `exitCodes.hits` or `exitCodes.refuse`.
1036
1415
  */
1037
1416
  export function runInternalRefsScan(config) {
1417
+ sourceNulBytes = 0;
1038
1418
  const writeOut = config?.write?.out ?? ((s) => process.stdout.write(s));
1039
1419
  const writeErr = config?.write?.err ?? ((s) => process.stderr.write(s));
1040
1420
 
@@ -1276,14 +1656,8 @@ export function runInternalRefsScan(config) {
1276
1656
  "could not open its input.",
1277
1657
  );
1278
1658
  }
1279
- if (looksBinary(read.bytes)) {
1280
- return refuse(
1281
- `tracked source file ${rel} holds a NUL byte, so a text matcher classifies it as ` +
1282
- "binary. Refusing to report green over input it cannot read.",
1283
- );
1284
- }
1285
1659
  sourceFiles += 1;
1286
- const extracted = extractDocComments(read.bytes.toString("utf8"), rel);
1660
+ const extracted = extractDocComments(sourceText(read.bytes), rel);
1287
1661
  docLines.push(...extracted.lines);
1288
1662
  docParagraphs.push(...extracted.paragraphs);
1289
1663
  }
@@ -1296,6 +1670,78 @@ export function runInternalRefsScan(config) {
1296
1670
  scanDocComments(docLines, docParagraphs, sourceRules, hits);
1297
1671
  }
1298
1672
 
1673
+ // The source string-literal pass, when the caller has enabled it.
1674
+ let literalFiles = 0;
1675
+ if (resolved.sourceStringLiterals.enabled) {
1676
+ const literalRules = [...rules];
1677
+ let index = 0;
1678
+ for (const rule of resolved.sourceStringLiterals.extraRules) {
1679
+ const compiled = compileExtraRule(rule, index);
1680
+ index += 1;
1681
+ if (compiled.rule === undefined) {
1682
+ return refuse(
1683
+ "a source string-literal rule was refused:\n" +
1684
+ compiled.problems
1685
+ .map((p) => ` ${p}\n`)
1686
+ .join("")
1687
+ .trimEnd(),
1688
+ );
1689
+ }
1690
+ literalRules.push(compiled.rule);
1691
+ }
1692
+ const literalSelfTest = runSelfTests(literalRules);
1693
+ if (literalSelfTest.length > 0) {
1694
+ return refuse(
1695
+ "SELF-TEST FAILED for the source string-literal pass:\n" +
1696
+ literalSelfTest
1697
+ .map((f) => ` ${f}\n`)
1698
+ .join("")
1699
+ .trimEnd(),
1700
+ );
1701
+ }
1702
+
1703
+ const literalTracked = trackedEntriesUnder(root, resolved.sourceStringLiterals.paths);
1704
+ if (literalTracked === null || literalTracked.length === 0) {
1705
+ return refuse(
1706
+ `no tracked source files under ${resolved.sourceStringLiterals.paths.join(", ")} to scan ` +
1707
+ "for string literals. Either the source moved (update `sourceStringLiterals.paths`, " +
1708
+ "deliberately) or the scan is about to cover less than it claims.",
1709
+ );
1710
+ }
1711
+ const literalEntries = [];
1712
+ const joinedEntries = [];
1713
+ for (const entry of literalTracked) {
1714
+ const rel = entry.path;
1715
+ const verdict = classifyTrackedMode(entry.mode);
1716
+ if (verdict === "skip") continue;
1717
+ if (verdict === "refuse") {
1718
+ return refuse(
1719
+ `tracked source entry ${rel} has mode ${entry.mode}, which names nothing this scan can ` +
1720
+ "read. Refusing to report green from a scan that skipped one of its inputs.",
1721
+ );
1722
+ }
1723
+ const read = readTarget(join(root, rel));
1724
+ if (read.error !== undefined) {
1725
+ return refuse(
1726
+ `tracked source file ${rel} ${read.error}. Refusing to report green from a scan that ` +
1727
+ "could not open its input.",
1728
+ );
1729
+ }
1730
+ literalFiles += 1;
1731
+ const extracted = extractStringLiterals(sourceText(read.bytes), rel);
1732
+ literalEntries.push(...extracted.literals);
1733
+ joinedEntries.push(...extracted.lines);
1734
+ }
1735
+ if (literalEntries.length === 0) {
1736
+ return refuse(
1737
+ `extracted no string literals from ${literalFiles} tracked source file(s). A source tree ` +
1738
+ "with no string literal at all is not one this estate writes, so an empty extraction " +
1739
+ "means the extractor is broken, not that the source is clean.",
1740
+ );
1741
+ }
1742
+ scanStringLiterals(literalEntries, joinedEntries, literalRules, hits);
1743
+ }
1744
+
1299
1745
  if (hits.length > 0) {
1300
1746
  writeErr(report(hits));
1301
1747
  return resolved.exitCodes.hits;
@@ -1307,6 +1753,10 @@ export function runInternalRefsScan(config) {
1307
1753
  (resolved.sourceDocComments.enabled
1308
1754
  ? `; ${sourceFiles} source file(s) scanned for doc-comment bookkeeping`
1309
1755
  : "") +
1756
+ (resolved.sourceStringLiterals.enabled
1757
+ ? `; ${literalFiles} source file(s) scanned for string-literal bookkeeping`
1758
+ : "") +
1759
+ (sourceNulBytes > 0 ? `; ${sourceNulBytes} NUL byte(s) in source read as spaces` : "") +
1310
1760
  `; ${gitlinks} gitlink(s) skipped)\n`,
1311
1761
  );
1312
1762
  return resolved.exitCodes.clean;
@@ -1361,6 +1811,31 @@ function scanDocComments(docLines, docParagraphs, rules, hits) {
1361
1811
  }
1362
1812
  }
1363
1813
 
1814
+ /** Scan extracted string literals, one by one and then joined per source line. */
1815
+ function scanStringLiterals(literals, joined, rules, hits) {
1816
+ for (const rule of rules) {
1817
+ const located = new Set();
1818
+ for (const entry of literals) {
1819
+ const found = firstMatch(rule, entry.text);
1820
+ if (found === null) continue;
1821
+ located.add(entry.where);
1822
+ hits.push({
1823
+ rule: rule.name,
1824
+ file: entry.where,
1825
+ line: null,
1826
+ text: entry.text,
1827
+ literal: true,
1828
+ });
1829
+ }
1830
+ for (const entry of joined) {
1831
+ const found = firstMatch(rule, entry.text);
1832
+ if (found === null || located.has(entry.where)) continue;
1833
+ located.add(entry.where);
1834
+ hits.push({ rule: rule.name, file: entry.where, line: null, text: found, literal: true });
1835
+ }
1836
+ }
1837
+ }
1838
+
1364
1839
  /** The hit report: every finding names its file, its line where it has one, and its rule. */
1365
1840
  function report(hits) {
1366
1841
  const lines = [];
@@ -1369,6 +1844,7 @@ function report(hits) {
1369
1844
  const label =
1370
1845
  `[${hit.rule}` +
1371
1846
  (hit.source ? " / src doc comment" : "") +
1847
+ (hit.literal ? " / src string literal" : "") +
1372
1848
  (hit.wrapped ? " / wrapped across lines" : "") +
1373
1849
  "]";
1374
1850
  lines.push(`${label} ${where}: ${hit.text}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosyte/script-utils",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Zero-dependency helpers for the repo-local gate scripts in @cosyte/* repos.",
5
5
  "keywords": [
6
6
  "cli",
@@ -98,7 +98,7 @@
98
98
  "access": "public"
99
99
  },
100
100
  "devDependencies": {
101
- "vitest": "4.1.4"
101
+ "vitest": "4.1.12"
102
102
  },
103
103
  "scripts": {
104
104
  "build": "node ../../scripts/pack-emdash-gate.mjs",