@cosyte/script-utils 0.1.5 → 0.1.7
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 +18 -0
- package/README.md +86 -54
- package/agent-notes.d.ts +60 -9
- package/agent-notes.js +51 -14
- package/bin/cosyte-agent-notes.js +16 -6
- 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 +9 -4
- package/catalog.d.ts +10 -3
- package/catalog.js +44 -15
- package/check-no-emdash.sh +180 -15
- package/claude-card.js +11 -2
- 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/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
|
-
|
|
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
|
-
|
|
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.
|
|
1480
|
-
|
|
1481
|
-
const
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
// -------------------------------------------------------------------------
|