@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/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;
package/phi-scan.js CHANGED
@@ -339,6 +339,91 @@ export function runPhiScan(config) {
339
339
  return new PhiScan(normalizeConfig(config)).run();
340
340
  }
341
341
 
342
+ /** What a reason says when the hit came from the escape-decoded view of a source literal. */
343
+ const ESCAPED = ", escape-encoded in a source literal";
344
+
345
+ /** A JavaScript or TypeScript source: a container of string literals, whatever its extension. */
346
+ export function isSourceLiteralContainer(relPath) {
347
+ return /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/i.test(relPath);
348
+ }
349
+
350
+ /**
351
+ * Decode JavaScript and TypeScript string-literal escapes over a whole source, so the DOCUMENT a
352
+ * literal spells is scanned and not only the bytes that spell it.
353
+ *
354
+ * IT IS A VIEW, NOT A PARSER, AND THAT IS DELIBERATE: it needs no TypeScript dependency in a
355
+ * zero-dependency engine, and no literal's quoting can defeat it. So it also decodes text that
356
+ * JavaScript would not: a `String.raw` template, and a comment quoting an escape. Both are real
357
+ * false-positive classes, and the remedy is the allow-list's `ID <value>`, a whole-value match that
358
+ * cannot widen. It misses two spellings that evaluate to a dashed identifier, concatenation
359
+ * (`"123-45-" + "6789"`) and a line continuation, and that is recorded rather than guarded.
360
+ *
361
+ * AN ESCAPED BACKSLASH IS CONSUMED FIRST, ON PURPOSE. A doubled backslash is ONE literal backslash,
362
+ * so a doubled backslash followed by `u002D` decodes to a backslash and that text, not to a dash.
363
+ * The loop scans left to right, emits the single backslash, and never rescans its own output.
364
+ *
365
+ * Ported from terminology's scanner, which carried it before this engine did.
366
+ *
367
+ * @param {string} text
368
+ * @returns {string}
369
+ */
370
+ export function decodeSourceLiterals(text) {
371
+ let out = "";
372
+ let i = 0;
373
+ while (i < text.length) {
374
+ const ch = text[i];
375
+ if (ch !== "\\") {
376
+ out += ch;
377
+ i += 1;
378
+ continue;
379
+ }
380
+ const next = text[i + 1];
381
+ if (next === undefined) {
382
+ out += ch;
383
+ i += 1;
384
+ continue;
385
+ }
386
+ if (next === "u" && text[i + 2] === "{") {
387
+ const end = text.indexOf("}", i + 3);
388
+ const body = end < 0 ? "" : text.slice(i + 3, end);
389
+ if (end > 0 && /^[0-9a-fA-F]{1,6}$/.test(body)) {
390
+ const cp = Number.parseInt(body, 16);
391
+ if (cp <= 0x10ffff) {
392
+ out += String.fromCodePoint(cp);
393
+ i = end + 1;
394
+ continue;
395
+ }
396
+ }
397
+ }
398
+ if (next === "u" && /^[0-9a-fA-F]{4}$/.test(text.slice(i + 2, i + 6))) {
399
+ out += String.fromCharCode(Number.parseInt(text.slice(i + 2, i + 6), 16));
400
+ i += 6;
401
+ continue;
402
+ }
403
+ if (next === "x" && /^[0-9a-fA-F]{2}$/.test(text.slice(i + 2, i + 4))) {
404
+ out += String.fromCharCode(Number.parseInt(text.slice(i + 2, i + 4), 16));
405
+ i += 4;
406
+ continue;
407
+ }
408
+ // Single-character escapes. A doubled backslash lands here and emits ONE backslash, which is
409
+ // never rescanned, so the sequence it introduces stays literal text.
410
+ out += SIMPLE_ESCAPES[next] ?? next;
411
+ i += 2;
412
+ }
413
+ return out;
414
+ }
415
+
416
+ /** The single-character escapes, decoded to what they spell. */
417
+ const SIMPLE_ESCAPES = Object.freeze({
418
+ n: "\n",
419
+ r: "\r",
420
+ t: "\t",
421
+ b: "\b",
422
+ f: "\f",
423
+ v: "\v",
424
+ 0: "\0",
425
+ });
426
+
342
427
  class PhiScan {
343
428
  /** @param {ReturnType<typeof normalizeConfig>} cfg */
344
429
  constructor(cfg) {
@@ -1417,18 +1502,36 @@ class PhiScan {
1417
1502
  * @param {import("./phi-scan.js").Hit[]} hits
1418
1503
  */
1419
1504
  scanCommonShapes(path, content, allow, hits) {
1505
+ for (const m of this.commonShapeMatches(content, allow)) {
1506
+ hits.push({ path, segment: m.segment, reason: m.reason });
1507
+ }
1508
+ }
1509
+
1510
+ /**
1511
+ * The floor's matches WITH the matched token, for one comparison and nothing else: the
1512
+ * escape-decoded view below adds only what the raw pass did not itself find, and "did not find"
1513
+ * is a question about tokens. The token never leaves `scanTarget`; every hit pushed carries a
1514
+ * position and a rule, exactly as `scanCommonShapes` does.
1515
+ *
1516
+ * @param {string} content
1517
+ * @param {import("./phi-scan.js").AllowList} allow
1518
+ * @returns {{ segment: string, reason: string, value: string }[]}
1519
+ */
1520
+ commonShapeMatches(content, allow) {
1521
+ const found = [];
1420
1522
  for (const m of content.matchAll(/\b\d{3}-\d{2}-\d{4}\b/g)) {
1421
1523
  const value = m[0];
1422
1524
  if (allow.ids.has(value.toUpperCase())) continue;
1423
1525
  if (allow.ids.has(value.replace(/\D/g, ""))) continue;
1424
- hits.push({ path, segment: "(ssn)", reason: "dashed SSN pattern" });
1526
+ found.push({ segment: "(ssn)", reason: "dashed SSN pattern", value });
1425
1527
  }
1426
1528
  for (const m of content.matchAll(/\b[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g)) {
1427
1529
  const domain = (m[1] ?? "").toLowerCase();
1428
1530
  if (!allow.emailDomains.has(domain)) {
1429
- hits.push({ path, segment: "(email)", reason: "email with non-test domain" });
1531
+ found.push({ segment: "(email)", reason: "email with non-test domain", value: m[0] });
1430
1532
  }
1431
1533
  }
1534
+ return found;
1432
1535
  }
1433
1536
 
1434
1537
  /**
@@ -1476,28 +1579,34 @@ class PhiScan {
1476
1579
  // carries, and reading that back off the text of a path is a parse of the engine's own prose.
1477
1580
  if (target.origin !== undefined) this.indexLoci.add(locus);
1478
1581
 
1479
- this.scanCommonShapes(locus, text, allow, hits);
1480
-
1481
- const detect = this.cfg.detect;
1482
- if (detect !== undefined) {
1483
- try {
1484
- detect({
1485
- path: locus,
1486
- text,
1487
- bytes: buf,
1582
+ const rawMatches = this.commonShapeMatches(text, allow);
1583
+ for (const m of rawMatches) hits.push({ path: locus, segment: m.segment, reason: m.reason });
1584
+ const rawDetected = this.runDetector(locus, text, buf, allow, hits, "");
1585
+
1586
+ // THE SAME FLOOR, AND THE CALLER'S DETECTOR, OVER THE ESCAPE-DECODED DOCUMENT, IN ADDITION AND
1587
+ // NEVER INSTEAD. A JavaScript or TypeScript source is a container of literals, so its bytes are
1588
+ // the SPELLING of a fixture and not the fixture: a literal can spell a dashed identifier's
1589
+ // separators as `\u002D`, and the raw floor reads past it. Only what the raw pass did not itself
1590
+ // report is added, compared hit to hit (token to token for the floor, rule to rule for a
1591
+ // detector), never by asking whether the bytes appear somewhere, because a `\b`-anchored match
1592
+ // can be present as a substring the raw pass rightly declined.
1593
+ if (isSourceLiteralContainer(target.path)) {
1594
+ const decoded = decodeSourceLiterals(text);
1595
+ if (decoded !== text) {
1596
+ const reported = new Set(rawMatches.map((m) => `${m.segment}\0${m.value}`));
1597
+ for (const m of this.commonShapeMatches(decoded, allow)) {
1598
+ if (reported.has(`${m.segment}\0${m.value}`)) continue;
1599
+ reported.add(`${m.segment}\0${m.value}`);
1600
+ hits.push({ path: locus, segment: m.segment, reason: `${m.reason}${ESCAPED}` });
1601
+ }
1602
+ this.runDetector(
1603
+ locus,
1604
+ decoded,
1605
+ Buffer.from(decoded, "utf8"),
1488
1606
  allow,
1489
- hit: (h) => {
1490
- // THE LOCUS AND THE RULE ARE TAKEN; ANY `value` A DETECTOR PASSES IS DROPPED HERE. A
1491
- // per-standard detector holds the record it parsed, so it is the caller most likely to
1492
- // hand over a token that is real PHI, and the engine is the side that can refuse to
1493
- // carry it. Dropping it at the boundary is what makes "no matched value reaches a
1494
- // report" a property of this engine rather than of every detector's discipline.
1495
- hits.push({ path: locus, segment: h.segment, reason: h.reason });
1496
- },
1497
- });
1498
- } catch (err) {
1499
- throw new InvocationError(
1500
- `the field detector threw on ${locus}: ${err instanceof Error ? err.message : String(err)}`,
1607
+ hits,
1608
+ ESCAPED,
1609
+ rawDetected,
1501
1610
  );
1502
1611
  }
1503
1612
  }
@@ -1505,6 +1614,48 @@ class PhiScan {
1505
1614
  return buf;
1506
1615
  }
1507
1616
 
1617
+ /**
1618
+ * Run the caller's detector over one view of a target, and return the rules it raised.
1619
+ *
1620
+ * @param {string} locus
1621
+ * @param {string} text
1622
+ * @param {Buffer} bytes
1623
+ * @param {import("./phi-scan.js").AllowList} allow
1624
+ * @param {import("./phi-scan.js").Hit[]} hits
1625
+ * @param {string} suffix Appended to each reason: empty for the raw bytes, a note for the decoded view.
1626
+ * @param {Set<string>} [already] Rules the raw view raised; the decoded view adds only others.
1627
+ * @returns {Set<string>}
1628
+ */
1629
+ runDetector(locus, text, bytes, allow, hits, suffix, already = new Set()) {
1630
+ const raised = new Set();
1631
+ const detect = this.cfg.detect;
1632
+ if (detect === undefined) return raised;
1633
+ try {
1634
+ detect({
1635
+ path: locus,
1636
+ text,
1637
+ bytes,
1638
+ allow,
1639
+ hit: (h) => {
1640
+ // THE LOCUS AND THE RULE ARE TAKEN; ANY `value` A DETECTOR PASSES IS DROPPED HERE. A
1641
+ // per-standard detector holds the record it parsed, so it is the caller most likely to
1642
+ // hand over a token that is real PHI, and the engine is the side that can refuse to
1643
+ // carry it. Dropping it at the boundary is what makes "no matched value reaches a
1644
+ // report" a property of this engine rather than of every detector's discipline.
1645
+ const rule = `${h.segment}\0${h.reason}`;
1646
+ raised.add(rule);
1647
+ if (already.has(rule)) return;
1648
+ hits.push({ path: locus, segment: h.segment, reason: `${h.reason}${suffix}` });
1649
+ },
1650
+ });
1651
+ } catch (err) {
1652
+ throw new InvocationError(
1653
+ `the field detector threw on ${locus}${suffix === "" ? "" : " (its escape-decoded source literals)"}: ${err instanceof Error ? err.message : String(err)}`,
1654
+ );
1655
+ }
1656
+ return raised;
1657
+ }
1658
+
1508
1659
  // -------------------------------------------------------------------------
1509
1660
  // Reporting
1510
1661
  // -------------------------------------------------------------------------