@cosyte/script-utils 0.1.5 → 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/CHANGELOG.md +11 -0
- package/README.md +52 -12
- package/bin/cosyte-agent-notes.js +3 -2
- package/bin/cosyte-attw.js +3 -2
- package/bin/cosyte-catalog.js +8 -6
- package/bin/cosyte-claude-card.js +3 -2
- package/bin/cosyte-internal-refs.js +3 -2
- package/bin/cosyte-phi-scan.js +3 -2
- package/bin/cosyte-readme.js +3 -2
- package/bin/help.js +41 -0
- package/bin/script-utils.js +8 -3
- package/catalog.d.ts +10 -3
- package/catalog.js +44 -15
- package/check-no-emdash.sh +180 -15
- package/internal-refs.d.ts +31 -0
- package/internal-refs.js +461 -14
- package/package.json +1 -1
- package/phi-scan.d.ts +11 -0
- package/phi-scan.js +174 -23
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,
|
|
@@ -343,14 +353,21 @@ export function compileCanonicalRules(resolved) {
|
|
|
343
353
|
{
|
|
344
354
|
id: "internal-identifier",
|
|
345
355
|
name: "internal project identifier",
|
|
346
|
-
patterns: [
|
|
347
|
-
|
|
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"],
|
|
348
364
|
// The WORD-N trap turned into an assertion. Every token here is a segment-field reference or
|
|
349
365
|
// a diagnosis-code range, and a prefix set that starts matching one of them reds HERE rather
|
|
350
366
|
// than deleting it from a parser's documentation on the next sweep.
|
|
351
367
|
negatives: [
|
|
352
368
|
"MSH-2 encoding characters, PID-3 identifier list, OBX-5 value, SCH-11 timing, " +
|
|
353
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",
|
|
354
371
|
],
|
|
355
372
|
},
|
|
356
373
|
{
|
|
@@ -364,6 +381,15 @@ export function compileCanonicalRules(resolved) {
|
|
|
364
381
|
`documentation residual\\b|${ORDINAL} (?:slice|wave)\\b)`,
|
|
365
382
|
"gi",
|
|
366
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
|
+
),
|
|
367
393
|
],
|
|
368
394
|
positives: [
|
|
369
395
|
"Phase 5b closes it (Phase W, Phase-L and the thirteenth slice landed earlier, in wave 2)",
|
|
@@ -373,6 +399,9 @@ export function compileCanonicalRules(resolved) {
|
|
|
373
399
|
// phase named before the words that make a bare `phase` a noun.
|
|
374
400
|
"The connect-phase socket shipped in phase 2",
|
|
375
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",
|
|
376
405
|
],
|
|
377
406
|
negatives: [
|
|
378
407
|
"CSP-1 Study Phase Identifier, CSP-2 Study Phase Start Date/Time, CSP-3 Study Phase End " +
|
|
@@ -385,15 +414,20 @@ export function compileCanonicalRules(resolved) {
|
|
|
385
414
|
"All 5 phases are defined; Connection lifecycle phase where the error occurred; the " +
|
|
386
415
|
"phase was open, the phases were closed, and a phase during which and a phase at which " +
|
|
387
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",
|
|
388
420
|
],
|
|
389
421
|
},
|
|
390
422
|
{
|
|
391
423
|
id: "adr-reference",
|
|
392
424
|
name: "ADR reference",
|
|
393
425
|
// `[ /-]`: ten of the eleven per-repository copies also matched an ADR path segment.
|
|
394
|
-
|
|
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")],
|
|
395
428
|
positives: [
|
|
396
429
|
"Decided in ADR 0015 and restated in ADR-0021",
|
|
430
|
+
"Superseded by ADRs 0015 and ADR #0018, after ADR 23",
|
|
397
431
|
"Recorded in docs/adr/0001-scope.md",
|
|
398
432
|
],
|
|
399
433
|
negatives: ["ADR is not a segment, and 0015 alone is a value"],
|
|
@@ -402,18 +436,50 @@ export function compileCanonicalRules(resolved) {
|
|
|
402
436
|
id: "internal-jargon",
|
|
403
437
|
name: 'internal jargon ("slice")',
|
|
404
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.
|
|
405
447
|
new RegExp(
|
|
406
|
-
"\\b(?:this|that|
|
|
448
|
+
"\\b(?:this|that|another|previous|next|final|current)\\s+" +
|
|
407
449
|
"(?:(?!(?:of|in|on|between|per|for|to|with|at)\\s)[\\w-]+\\s+){0,2}slices?\\b" +
|
|
408
|
-
`(?!\\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",
|
|
409
463
|
"gi",
|
|
410
464
|
),
|
|
411
465
|
],
|
|
412
|
-
positives: [
|
|
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
|
+
],
|
|
413
472
|
negatives: [
|
|
414
473
|
"The slice thickness and the number of slices are DICOM attributes, each slice location " +
|
|
415
474
|
"is too, and the phase of the clinical study, the phase of illness and each phase of " +
|
|
416
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",
|
|
417
483
|
],
|
|
418
484
|
},
|
|
419
485
|
{
|
|
@@ -426,10 +492,24 @@ export function compileCanonicalRules(resolved) {
|
|
|
426
492
|
"|\\bBACKLOG\\.md\\b",
|
|
427
493
|
"g",
|
|
428
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",
|
|
429
509
|
],
|
|
430
|
-
positives: ["Roadmap operations/roadmaps/hl7.md and documentation/decisions/0015-x.md"],
|
|
431
510
|
negatives: [
|
|
432
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",
|
|
433
513
|
],
|
|
434
514
|
},
|
|
435
515
|
{
|
|
@@ -617,6 +697,21 @@ export function resolveConfig(config) {
|
|
|
617
697
|
}
|
|
618
698
|
}
|
|
619
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
|
+
|
|
620
715
|
if (config.extraRules !== undefined && !Array.isArray(config.extraRules)) {
|
|
621
716
|
problems.push("axis `extraRules` is not a list");
|
|
622
717
|
}
|
|
@@ -644,6 +739,11 @@ export function resolveConfig(config) {
|
|
|
644
739
|
paths: source.enabled === true ? [...source.paths] : [],
|
|
645
740
|
extraRules: source.extraRules === undefined ? [] : [...source.extraRules],
|
|
646
741
|
},
|
|
742
|
+
sourceStringLiterals: {
|
|
743
|
+
enabled: literals.enabled === true,
|
|
744
|
+
paths: literals.enabled === true ? [...literals.paths] : [],
|
|
745
|
+
extraRules: literals.extraRules === undefined ? [] : [...literals.extraRules],
|
|
746
|
+
},
|
|
647
747
|
exitCodes: codes,
|
|
648
748
|
extraRules: config.extraRules === undefined ? [] : [...config.extraRules],
|
|
649
749
|
extraSamples: config.extraSamples === undefined ? {} : config.extraSamples,
|
|
@@ -1050,10 +1150,260 @@ export function extractDocComments(text, file) {
|
|
|
1050
1150
|
// ---------------------------------------------------------------------------
|
|
1051
1151
|
|
|
1052
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
|
+
|
|
1053
1172
|
function looksBinary(bytes) {
|
|
1054
1173
|
return bytes.includes(0);
|
|
1055
1174
|
}
|
|
1056
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
|
+
|
|
1057
1407
|
/**
|
|
1058
1408
|
* Run the internal-reference gate and return an exit code drawn from `config.exitCodes`.
|
|
1059
1409
|
*
|
|
@@ -1064,6 +1414,7 @@ function looksBinary(bytes) {
|
|
|
1064
1414
|
* @returns {number} `exitCodes.clean`, `exitCodes.hits` or `exitCodes.refuse`.
|
|
1065
1415
|
*/
|
|
1066
1416
|
export function runInternalRefsScan(config) {
|
|
1417
|
+
sourceNulBytes = 0;
|
|
1067
1418
|
const writeOut = config?.write?.out ?? ((s) => process.stdout.write(s));
|
|
1068
1419
|
const writeErr = config?.write?.err ?? ((s) => process.stderr.write(s));
|
|
1069
1420
|
|
|
@@ -1305,14 +1656,8 @@ export function runInternalRefsScan(config) {
|
|
|
1305
1656
|
"could not open its input.",
|
|
1306
1657
|
);
|
|
1307
1658
|
}
|
|
1308
|
-
if (looksBinary(read.bytes)) {
|
|
1309
|
-
return refuse(
|
|
1310
|
-
`tracked source file ${rel} holds a NUL byte, so a text matcher classifies it as ` +
|
|
1311
|
-
"binary. Refusing to report green over input it cannot read.",
|
|
1312
|
-
);
|
|
1313
|
-
}
|
|
1314
1659
|
sourceFiles += 1;
|
|
1315
|
-
const extracted = extractDocComments(read.bytes
|
|
1660
|
+
const extracted = extractDocComments(sourceText(read.bytes), rel);
|
|
1316
1661
|
docLines.push(...extracted.lines);
|
|
1317
1662
|
docParagraphs.push(...extracted.paragraphs);
|
|
1318
1663
|
}
|
|
@@ -1325,6 +1670,78 @@ export function runInternalRefsScan(config) {
|
|
|
1325
1670
|
scanDocComments(docLines, docParagraphs, sourceRules, hits);
|
|
1326
1671
|
}
|
|
1327
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
|
+
|
|
1328
1745
|
if (hits.length > 0) {
|
|
1329
1746
|
writeErr(report(hits));
|
|
1330
1747
|
return resolved.exitCodes.hits;
|
|
@@ -1336,6 +1753,10 @@ export function runInternalRefsScan(config) {
|
|
|
1336
1753
|
(resolved.sourceDocComments.enabled
|
|
1337
1754
|
? `; ${sourceFiles} source file(s) scanned for doc-comment bookkeeping`
|
|
1338
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` : "") +
|
|
1339
1760
|
`; ${gitlinks} gitlink(s) skipped)\n`,
|
|
1340
1761
|
);
|
|
1341
1762
|
return resolved.exitCodes.clean;
|
|
@@ -1390,6 +1811,31 @@ function scanDocComments(docLines, docParagraphs, rules, hits) {
|
|
|
1390
1811
|
}
|
|
1391
1812
|
}
|
|
1392
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
|
+
|
|
1393
1839
|
/** The hit report: every finding names its file, its line where it has one, and its rule. */
|
|
1394
1840
|
function report(hits) {
|
|
1395
1841
|
const lines = [];
|
|
@@ -1398,6 +1844,7 @@ function report(hits) {
|
|
|
1398
1844
|
const label =
|
|
1399
1845
|
`[${hit.rule}` +
|
|
1400
1846
|
(hit.source ? " / src doc comment" : "") +
|
|
1847
|
+
(hit.literal ? " / src string literal" : "") +
|
|
1401
1848
|
(hit.wrapped ? " / wrapped across lines" : "") +
|
|
1402
1849
|
"]";
|
|
1403
1850
|
lines.push(`${label} ${where}: ${hit.text}`);
|
package/package.json
CHANGED
package/phi-scan.d.ts
CHANGED
|
@@ -296,3 +296,14 @@ export declare function readPhiExclusions(
|
|
|
296
296
|
* `node node_modules/@cosyte/script-utils/phi-scan.js [--staged | <path>...]`.
|
|
297
297
|
*/
|
|
298
298
|
export declare function runPhiScanCli(argv: string[]): number;
|
|
299
|
+
|
|
300
|
+
/** Whether a repo-relative path is a JavaScript or TypeScript source, a container of literals. */
|
|
301
|
+
export declare function isSourceLiteralContainer(relPath: string): boolean;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Decode JavaScript and TypeScript string-literal escapes over a whole source (`\u002D`, `\u{2D}`,
|
|
305
|
+
* `\x2D`, `\n` and the rest), an escaped backslash first, so the document a literal spells can be
|
|
306
|
+
* scanned. A view, not a parser: it also decodes a `String.raw` template and a comment quoting an
|
|
307
|
+
* escape.
|
|
308
|
+
*/
|
|
309
|
+
export declare function decodeSourceLiterals(text: string): string;
|