@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.
@@ -2,9 +2,10 @@
2
2
  // cosyte-phi-scan: the PHI commit-gate over the repository in the working directory.
3
3
  // A bin of @cosyte/script-utils (D56), so a repository with no package.json can run it with
4
4
  // `npx -y -p @cosyte/script-utils cosyte-phi-scan`. It forwards every argument to the gate and exits with
5
- // the gate's own code; `--help` prints what it does and exits 0.
5
+ // the gate's own code; `--help` prints what it does and exits 0, or 2 under CI.
6
6
 
7
7
  import { runPhiScanCli } from "../phi-scan.js";
8
+ import { answerHelp } from "./help.js";
8
9
 
9
10
  const HELP = `cosyte-phi-scan: run the PHI commit-gate over the repository in the working directory.
10
11
 
@@ -20,7 +21,7 @@ values, and scripts/phi-scan.exclude names literal tracked paths to leave out. E
20
21
 
21
22
  const args = process.argv.slice(2);
22
23
  if (args.includes("--help") || args.includes("-h")) {
23
- process.stdout.write(HELP);
24
+ process.exitCode = answerHelp("cosyte-phi-scan", HELP);
24
25
  } else {
25
26
  process.exitCode = runPhiScanCli(args);
26
27
  }
@@ -2,9 +2,10 @@
2
2
  // cosyte-readme: the README gate for the library in the working directory.
3
3
  // A bin of @cosyte/script-utils (D56), so a repository with no package.json can run it with
4
4
  // `npx -y -p @cosyte/script-utils cosyte-readme`. It forwards every argument to the gate and exits with
5
- // the gate's own code; `--help` prints what it does and exits 0.
5
+ // the gate's own code; `--help` prints what it does and exits 0, or 2 under CI.
6
6
 
7
7
  import { runReadmeGate } from "../readme.js";
8
+ import { answerHelp } from "./help.js";
8
9
 
9
10
  const HELP = `cosyte-readme: check a library README against the house skeleton and npm's size limit.
10
11
 
@@ -16,7 +17,7 @@ names its line and rule), 2 the package could not be read.
16
17
 
17
18
  const args = process.argv.slice(2);
18
19
  if (args.includes("--help") || args.includes("-h")) {
19
- process.stdout.write(HELP);
20
+ process.exitCode = answerHelp("cosyte-readme", HELP);
20
21
  } else {
21
22
  process.exitCode = runReadmeGate({ argv: args });
22
23
  }
package/bin/help.js ADDED
@@ -0,0 +1,41 @@
1
+ // Not a bin: the one answer every gate bin gives to `--help` and `-h`, written once beside them.
2
+ //
3
+ // A gate asked for its help checks nothing. At a terminal that is what was asked for, so the help
4
+ // goes to stdout and the exit is 0. Under CI it is a trap: a package script that ends in `--help`
5
+ // passes a required check having checked nothing (`"attw": "cosyte-attw --help"` did exactly that).
6
+ // So when `CI` is set to anything but an empty string, `false` or `0`, which is how GitHub Actions
7
+ // and the other common runners announce themselves, the help goes to stderr, a refusal follows it,
8
+ // and the bin exits 2, the code every gate here uses for "did not run".
9
+
10
+ /**
11
+ * Whether this process runs under CI, read the way the common runners set it.
12
+ *
13
+ * @param {Record<string, string | undefined>} [env] The environment to read.
14
+ * @returns {boolean} True when `CI` is set to anything but an empty string, `false` or `0`.
15
+ */
16
+ export function underCi(env = process.env) {
17
+ const value = env.CI;
18
+ return value !== undefined && value !== "" && value !== "false" && value !== "0";
19
+ }
20
+
21
+ /**
22
+ * Answer `--help`: print the help and return 0, or under CI print it to stderr with a refusal and
23
+ * return 2.
24
+ *
25
+ * @param {string} name The bin's name, for the refusal line.
26
+ * @param {string} help The help text.
27
+ * @param {Record<string, string | undefined>} [env] The environment to read.
28
+ * @returns {number} The exit code.
29
+ */
30
+ export function answerHelp(name, help, env = process.env) {
31
+ if (!underCi(env)) {
32
+ process.stdout.write(help);
33
+ return 0;
34
+ }
35
+ process.stderr.write(help);
36
+ process.stderr.write(
37
+ `${name}: refusing --help under CI (CI=${env.CI}). It prints this text and checks nothing, ` +
38
+ "so a CI step that runs it must not pass. Run the gate without --help.\n",
39
+ );
40
+ return 2;
41
+ }
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // script-utils: one entry for every gate in @cosyte/script-utils (D56). npx runs the bin named
3
3
  // after the package, so `npx -y @cosyte/script-utils@<version> <command> [args]` reaches each gate
4
- // from a repository with no package.json. `<command> --help` is the gate's own help.
4
+ // from a repository with no package.json. `<command> --help` is the gate's own help; under CI
5
+ // every `--help` exits 2, because it checks nothing.
5
6
 
6
7
  import { spawnSync } from "node:child_process";
7
8
  import { fileURLToPath } from "node:url";
8
9
 
10
+ import { answerHelp } from "./help.js";
11
+
9
12
  const COMMANDS = {
10
13
  "no-emdash": ["cosyte-no-emdash", "the em and en dash gate, over tracked files and their names"],
11
14
  attw: ["cosyte-attw", "the attw publish gate, for the package here"],
@@ -16,7 +19,7 @@ const COMMANDS = {
16
19
  ],
17
20
  readme: ["cosyte-readme", "the README gate, for the library here"],
18
21
  "claude-card": ["cosyte-claude-card", "the CLAUDE.md card checker"],
19
- "agent-notes": ["cosyte-agent-notes", "the agent-notes checker"],
22
+ "agent-notes": ["cosyte-agent-notes", "the agent-notes checker, deprecated by D62"],
20
23
  catalog: ["cosyte-catalog", "the catalog check, for the library here"],
21
24
  };
22
25
 
@@ -31,9 +34,11 @@ ${Object.entries(COMMANDS)
31
34
  `;
32
35
 
33
36
  const [command, ...rest] = process.argv.slice(2);
34
- if (command === undefined || command === "--help" || command === "-h") {
37
+ if (command === undefined) {
35
38
  process.stdout.write(HELP);
36
- process.exitCode = command === undefined ? 2 : 0;
39
+ process.exitCode = 2;
40
+ } else if (command === "--help" || command === "-h") {
41
+ process.exitCode = answerHelp("script-utils", HELP);
37
42
  } else if (!(command in COMMANDS)) {
38
43
  process.stderr.write(`script-utils: ${JSON.stringify(command)} is not a command.\n\n${HELP}`);
39
44
  process.exitCode = 2;
package/catalog.d.ts CHANGED
@@ -99,12 +99,19 @@ export declare function compareWithCatalog(
99
99
  export interface CatalogCheckOptions {
100
100
  /** The package directory holding package.json. Default: the working directory. */
101
101
  dir?: string;
102
- /** CLI arguments; only `--dir <path>` is accepted. */
102
+ /** CLI arguments; `--dir <path>` and `--strict` are accepted. */
103
103
  argv?: string[];
104
+ /**
105
+ * Fail on a disagreement (1) or an unlisted package (2) instead of printing a note and exiting
106
+ * 0. Default: false, because keeping the catalog fresh is cosyte/config's job.
107
+ */
108
+ strict?: boolean;
104
109
  }
105
110
 
106
111
  /**
107
- * Run the check over one package directory and return the exit code: 0 the description is the
108
- * catalog's, 1 it is not, 2 the check could not run. It never exits the process.
112
+ * Run the check over one package directory and return the exit code. Advisory by default: 0 when
113
+ * the description is the catalog's, and 0 with a note when it is not or the package is unlisted.
114
+ * With `strict`, 1 for a disagreement and 2 for an unlisted package. 2 whenever the check could not
115
+ * run. It never exits the process.
109
116
  */
110
117
  export declare function runCatalogCheck(options?: CatalogCheckOptions): 0 | 1 | 2;
package/catalog.js CHANGED
@@ -8,16 +8,20 @@
8
8
  // A site, the org profile, a deck or a knowledgebase page reads that one list instead of keeping
9
9
  // its own roster.
10
10
  //
11
- // IT ALSO CARRIES THE CHECK EVERY LIBRARY RUNS. Each package.json description is that library's one
12
- // canonical line (ST14), so the description in a library's package.json must be the catalog's.
13
- // When a library changes its description, its check fails until the catalog is regenerated and a
14
- // script-utils release carries it: the catalog and the package cannot quietly disagree.
11
+ // IT ALSO CARRIES THE CHECK A LIBRARY CAN RUN, AND THAT CHECK IS ADVISORY. Each package.json
12
+ // description is that library's one canonical line (ST14). Keeping the catalog fresh is cosyte/config's
13
+ // job: `scripts/catalog.mjs check --npm` compares the catalog with every library's latest npm release,
14
+ // nightly and before each config release. So a library that changes its description is not held red
15
+ // until the next script-utils release: this check prints both values as a note and exits 0. `--strict`
16
+ // makes a disagreement exit 1 and an unlisted package exit 2, for a caller that wants the old gate.
15
17
  //
16
18
  // node node_modules/@cosyte/script-utils/catalog.js # the package in this directory
17
19
  // node node_modules/@cosyte/script-utils/catalog.js --dir <path> # another package directory
20
+ // node node_modules/@cosyte/script-utils/catalog.js --strict # a disagreement fails
18
21
  //
19
- // EXIT CODES, the contract every gate here keeps: 0 the description agrees, 1 it does not, 2 the
20
- // check could not run (no package.json, or a package the catalog does not list).
22
+ // EXIT CODES: 0 the description agrees, or (advisory) it does not and a note says so; 1 it does not,
23
+ // with --strict; 2 the check could not run (no package.json, a bad argument, or, with --strict, a
24
+ // package the catalog does not list).
21
25
  //
22
26
  // ZERO DEPENDENCIES AND NO BUILD STEP, like the rest of this package.
23
27
 
@@ -192,21 +196,29 @@ export function compareWithCatalog(manifest, catalog = CATALOG) {
192
196
  /**
193
197
  * Run the check over one package directory and return the exit code. It never exits the process.
194
198
  *
195
- * @param {{ dir?: string, argv?: string[] }} [options] `dir` is the package directory (default:
196
- * the working directory). `argv` is the CLI arguments, read only for `--dir <path>`.
197
- * @returns {0 | 1 | 2} 0 agree, 1 disagree, 2 the check could not run.
199
+ * ADVISORY BY DEFAULT: a disagreement or an unlisted package is printed as a note and exits 0,
200
+ * because keeping the catalog fresh is cosyte/config's job (`scripts/catalog.mjs check --npm`).
201
+ * `strict` (or `--strict`) restores the gate: 1 for a disagreement, 2 for an unlisted package.
202
+ *
203
+ * @param {{ dir?: string, argv?: string[], strict?: boolean }} [options] `dir` is the package
204
+ * directory (default: the working directory). `argv` is the CLI arguments, read for
205
+ * `--dir <path>` and `--strict`.
206
+ * @returns {0 | 1 | 2} 0 agree (or advisory), 1 disagree under strict, 2 the check could not run.
198
207
  */
199
208
  export function runCatalogCheck(options = {}) {
200
209
  let dir = options.dir ?? process.cwd();
210
+ let strict = options.strict === true;
201
211
  const argv = options.argv ?? [];
202
212
  for (let i = 0; i < argv.length; i += 1) {
203
213
  if (argv[i] === "--dir" && typeof argv[i + 1] === "string") {
204
214
  dir = argv[i + 1];
205
215
  i += 1;
216
+ } else if (argv[i] === "--strict") {
217
+ strict = true;
206
218
  } else if (argv[i] !== "--") {
207
219
  process.stderr.write(
208
220
  `catalog check: ${JSON.stringify(argv[i])} is not an argument this check accepts (only ` +
209
- `--dir <package directory>). Nothing was checked.\n`,
221
+ `--dir <package directory> and --strict). Nothing was checked.\n`,
210
222
  );
211
223
  return 2;
212
224
  }
@@ -223,8 +235,15 @@ export function runCatalogCheck(options = {}) {
223
235
  try {
224
236
  differences = compareWithCatalog(manifest);
225
237
  } catch (err) {
226
- process.stderr.write(`catalog check: ${err.message} Nothing was checked.\n`);
227
- return 2;
238
+ if (strict) {
239
+ process.stderr.write(`catalog check: ${err.message} Nothing was checked.\n`);
240
+ return 2;
241
+ }
242
+ process.stderr.write(
243
+ `catalog check: note: ${err.message} The catalog is cosyte/config's to keep fresh; this ` +
244
+ `check is advisory, so it exits 0.\n`,
245
+ );
246
+ return 0;
228
247
  }
229
248
  if (differences.length === 0) {
230
249
  process.stdout.write(`catalog check: ${manifest.name}'s description is the catalog's.\n`);
@@ -237,11 +256,21 @@ export function runCatalogCheck(options = {}) {
237
256
  ` catalog: ${JSON.stringify(d.catalog)}`,
238
257
  )
239
258
  .join("\n");
259
+ if (!strict) {
260
+ process.stderr.write(
261
+ `catalog check: note: ${manifest.name}'s package.json and the catalog this release of ` +
262
+ `@cosyte/script-utils carries disagree.\n${report}\n` +
263
+ `This is advisory and exits 0. Once this package is released with the new description, ` +
264
+ `cosyte/config's nightly check against npm (scripts/catalog.mjs check --npm) flags the ` +
265
+ `catalog, and the next script-utils release carries it.\n`,
266
+ );
267
+ return 0;
268
+ }
240
269
  process.stderr.write(
241
270
  `catalog check: ${manifest.name}'s package.json and the catalog disagree.\n${report}\n` +
242
- `The catalog is generated from package.json in cosyte/config (scripts/catalog.mjs) and ` +
243
- `ships in @cosyte/script-utils. Change both together: regenerate the catalog, release ` +
244
- `script-utils, then move this package to that release.\n`,
271
+ `The catalog is generated in cosyte/config (scripts/catalog.mjs) and ships in ` +
272
+ `@cosyte/script-utils. Regenerate it there, release script-utils, then move this package to ` +
273
+ `that release.\n`,
245
274
  );
246
275
  return 1;
247
276
  }
@@ -118,6 +118,22 @@
118
118
  # * A repo with no such file declares no exclusions.
119
119
  # * The declaration file is itself tracked and is SCANNED LIKE ANY OTHER TRACKED
120
120
  # FILE. It is not self-excluding.
121
+ # * A BOUNDARY is the one entry that names part of a file rather than a whole one:
122
+ #
123
+ # CHANGELOG.md em-only below ## Released before this file was generated
124
+ # CHANGELOG.md unread below ## Released before this file was generated
125
+ #
126
+ # It is for a changelog whose dated archive predates the generated part and is kept
127
+ # verbatim. The path is ONE EXACT TRACKED PATH (no `/` prefix entry, no spaces), and
128
+ # the text after `below ` is a WHOLE LINE of that file, matched exactly. From the
129
+ # first line equal to it to the end of the file is the archive: `em-only` reads the
130
+ # archive for the em dash alone, in every form, so an en dash in an old range passes
131
+ # and an em dash still reds; `unread` does not read the archive at all. Everything
132
+ # above the boundary line is read for both dashes like any other file, and the file's
133
+ # NAME is scanned as always. A boundary line the file does not contain is a stale
134
+ # entry and the run exits 1, so a lost heading fails closed instead of widening what
135
+ # is left out. A path may carry one boundary, and a path the declaration also leaves
136
+ # out whole is refused, since it cannot be both.
121
137
  #
122
138
  # AN ENTRY IS THE LINE VERBATIM: nothing is trimmed off either end, because a path is
123
139
  # bytes and trimming would invent a second spelling for one entry. A line carrying
@@ -255,6 +271,14 @@ PATTERN='(?i)\x{2014}|\x{2013}|&[mn]dash(?![a-z])|&#0*821[12](?![0-9])|&#x0*201[
255
271
  PATTERN+='|%E2%80%9[34]|\\u(?:0000)?201[34]|\\u\{0*201[34]\}|\\x\{0*201[34]\}'
256
272
  PATTERN+='|\\0{0,2}201[34](?![0-9a-f])|\\N\{E[MN] DASH\}'
257
273
 
274
+ # The em dash's half of PATTERN, form for form, for the archive a declared `em-only`
275
+ # boundary reads. The self-test below holds it to matching the em dash and NOT the en
276
+ # dash, and test/check-no-emdash.test.ts holds every em form to it, so it cannot drift
277
+ # from PATTERN without a red.
278
+ EM_PATTERN='(?i)\x{2014}|&mdash(?![a-z])|&#0*8212(?![0-9])|&#x0*2014(?![0-9a-f])'
279
+ EM_PATTERN+='|%E2%80%94|\\u(?:0000)?2014|\\u\{0*2014\}|\\x\{0*2014\}'
280
+ EM_PATTERN+='|\\0{0,2}2014(?![0-9a-f])|\\N\{EM DASH\}'
281
+
258
282
  # The declaration's path, relative to the top level of the repo under scan. Deliberately
259
283
  # NOT relative to this file: in a consuming repo this file lives under `node_modules/`
260
284
  # and the declaration is the consumer's own tracked file.
@@ -314,6 +338,25 @@ if [ "$#" -gt 0 ] && [ "$1" = '--' ]; then
314
338
  shift
315
339
  fi
316
340
 
341
+ usage() {
342
+ cat << 'USAGE'
343
+ check-no-emdash: refuse the em dash (U+2014) and the en dash (U+2013), as the characters and
344
+ in every encoded form, in the tracked files of the repository it runs in and in their names.
345
+
346
+ check-no-emdash.sh scan every tracked file and every tracked file name
347
+ check-no-emdash.sh --stdin LABEL scan the text on stdin (a PR title, body, commit message)
348
+ check-no-emdash.sh --list-scanned write the NUL-separated paths the default scan reads
349
+ check-no-emdash.sh --self-id write the sha256 of the implementation that is executing
350
+
351
+ A repository leaves paths out in scripts/check-no-emdash.exclude: one per line, an entry ending
352
+ in / covers everything beneath it, # starts a comment. A changelog archive is named by a boundary,
353
+ `PATH em-only below LINE` or `PATH unread below LINE`: from the first line of PATH equal to LINE to
354
+ the end, em-only reads for the em dash alone and unread reads nothing; above it, both dashes as
355
+ usual. Exit 0 means the mode completed and found nothing banned; exit 1 means anything else. Under
356
+ CI, --help exits 1: it checks nothing.
357
+ USAGE
358
+ }
359
+
317
360
  case "${1:-}" in
318
361
  '')
319
362
  MODE=files
@@ -339,19 +382,17 @@ case "${1:-}" in
339
382
  fi
340
383
  ;;
341
384
  --help | -h)
342
- cat << 'USAGE'
343
- check-no-emdash: refuse the em dash (U+2014) and the en dash (U+2013), as the characters and
344
- in every encoded form, in the tracked files of the repository it runs in and in their names.
345
-
346
- check-no-emdash.sh scan every tracked file and every tracked file name
347
- check-no-emdash.sh --stdin LABEL scan the text on stdin (a PR title, body, commit message)
348
- check-no-emdash.sh --list-scanned write the NUL-separated paths the default scan reads
349
- check-no-emdash.sh --self-id write the sha256 of the implementation that is executing
350
-
351
- A repository leaves paths out in scripts/check-no-emdash.exclude: one per line, an entry ending
352
- in / covers everything beneath it, # starts a comment. Exit 0 means the mode completed and found
353
- nothing banned; exit 1 means anything else.
354
- USAGE
385
+ # Under CI the help is a refusal: it prints this text and checks nothing, so a CI step that
386
+ # runs it must not pass. `CI` set to anything but empty, `false` or `0` is how GitHub Actions
387
+ # and the other common runners announce themselves.
388
+ if [ -n "${CI:-}" ] && [ "${CI}" != false ] && [ "${CI}" != 0 ]; then
389
+ usage >&2
390
+ echo "ERROR: check-no-emdash - refusing --help under CI (CI=${CI}). It prints this text and" >&2
391
+ echo " checks nothing, so a CI step that runs it must not pass. Run the gate without" >&2
392
+ echo " --help." >&2
393
+ exit 1
394
+ fi
395
+ usage
355
396
  exit 0
356
397
  ;;
357
398
  *)
@@ -557,6 +598,13 @@ entry_matches_a_tracked_path() {
557
598
 
558
599
  # ---- the exclusion declaration ---------------------------------------------------
559
600
  EXCLUDES=()
601
+ # A declared boundary, one index per entry: the path, `em-only` or `unread`, the boundary
602
+ # line as written, and the number of the first line of the file equal to it.
603
+ BOUND_PATH=()
604
+ BOUND_MODE=()
605
+ BOUND_TEXT=()
606
+ BOUND_LINE=()
607
+ BOUNDARY_SHAPE='^([^ ]+) (em-only|unread) below (.+)$'
560
608
  refuse_declaration() {
561
609
  local line="$1" entry="$2" what="$3"
562
610
  echo "ERROR: check-no-emdash - unusable entry in ${DECLARATION}, line ${line}: ${entry}" >&2
@@ -589,6 +637,45 @@ if is_tracked "$DECLARATION"; then
589
637
  # The entry is the line VERBATIM. See the header: nothing is trimmed, because a path
590
638
  # is bytes. The anchor rules are the whole of what makes an entry usable on its face.
591
639
  entry=$line
640
+
641
+ # A BOUNDARY names part of one file. It is recognised by its shape and validated on its
642
+ # own terms, so nothing below it can mistake it for a path with spaces in it.
643
+ if [[ $entry =~ $BOUNDARY_SHAPE ]]; then
644
+ b_path=${BASH_REMATCH[1]}
645
+ b_mode=${BASH_REMATCH[2]}
646
+ b_text=${BASH_REMATCH[3]}
647
+ case $b_path in
648
+ /* | ./* | *..* | */)
649
+ refuse_declaration "$lineno" "$entry" \
650
+ "A boundary names ONE tracked file, anchored at the repository root: not absolute, not ./, no .., and not a directory."
651
+ ;;
652
+ esac
653
+ if ! is_tracked "$b_path"; then
654
+ refuse_declaration "$lineno" "$entry" \
655
+ "No tracked file is spelled ${b_path}. A boundary that has outlived its file is a hole nobody is looking at."
656
+ fi
657
+ for i in "${!BOUND_PATH[@]}"; do
658
+ if [ "${BOUND_PATH[$i]}" = "$b_path" ]; then
659
+ refuse_declaration "$lineno" "$entry" \
660
+ "${b_path} already carries a boundary. A file has one archive, so it has one boundary."
661
+ fi
662
+ done
663
+ if [ ! -f "$b_path" ] || [ ! -r "$b_path" ]; then
664
+ refuse_declaration "$lineno" "$entry" \
665
+ "${b_path} is not a readable regular file, so its boundary line cannot be looked for."
666
+ fi
667
+ b_line=$(grep -n -m1 -xF -e "$b_text" -- "$b_path" 2> /dev/null || true)
668
+ b_line=${b_line%%:*}
669
+ if [ -z "$b_line" ]; then
670
+ refuse_declaration "$lineno" "$entry" \
671
+ "${b_path} has no line that is exactly: ${b_text}. A lost boundary fails closed rather than leaving out more than was declared: restore the line, or correct or delete the entry."
672
+ fi
673
+ BOUND_PATH+=("$b_path")
674
+ BOUND_MODE+=("$b_mode")
675
+ BOUND_TEXT+=("$b_text")
676
+ BOUND_LINE+=("$b_line")
677
+ continue
678
+ fi
592
679
  case $entry in
593
680
  /*)
594
681
  refuse_declaration "$lineno" "$entry" \
@@ -648,6 +735,30 @@ is_excluded() {
648
735
  return 1
649
736
  }
650
737
 
738
+ # A path is either left out whole or read up to its boundary. Both at once would leave the
739
+ # reader unable to say which one the gate honoured, so it is refused, whichever line came
740
+ # first.
741
+ for i in "${!BOUND_PATH[@]}"; do
742
+ if is_excluded "${BOUND_PATH[$i]}"; then
743
+ echo "ERROR: check-no-emdash - ${DECLARATION} both leaves out ${BOUND_PATH[$i]} and" >&2
744
+ echo " declares a boundary in it. A file is either left out whole or read up to its" >&2
745
+ echo " boundary; delete one of the two entries." >&2
746
+ exit 1
747
+ fi
748
+ done
749
+
750
+ # The index of the boundary declared for a path, or nothing.
751
+ boundary_index() {
752
+ local p="$1" i
753
+ for i in "${!BOUND_PATH[@]}"; do
754
+ if [ "${BOUND_PATH[$i]}" = "$p" ]; then
755
+ printf '%s' "$i"
756
+ return 0
757
+ fi
758
+ done
759
+ return 1
760
+ }
761
+
651
762
  # ---- --self-id: which bytes am I ------------------------------------------------
652
763
  #
653
764
  # Answered before the scanner assertions, because this mode reads no scanned content and
@@ -736,6 +847,20 @@ if [ "$MODE" = files ] || [ "$MODE" = stdin ]; then
736
847
  echo " Refusing to report findings from a scanner that flags what the rule allows." >&2
737
848
  exit 1
738
849
  fi
850
+ # The archive reading a declared `em-only` boundary uses: it must still see the em dash,
851
+ # and it must NOT see the en dash, or `em-only` would mean something other than it says.
852
+ if [ "${#BOUND_PATH[@]}" -gt 0 ]; then
853
+ if ! printf 'a\xe2\x80\x94b\n' | grep -qP "$EM_PATTERN"; then
854
+ echo "ERROR: check-no-emdash - the archive scanner cannot match a known em dash." >&2
855
+ echo " Refusing to read a declared boundary's archive with a scanner that cannot see." >&2
856
+ exit 1
857
+ fi
858
+ if printf 'a\xe2\x80\x93b\n' | grep -qP "$EM_PATTERN"; then
859
+ echo "ERROR: check-no-emdash - the archive scanner matched an en dash, so it is not reading" >&2
860
+ echo " for the em dash alone. Refusing to report what an em-only boundary allows." >&2
861
+ exit 1
862
+ fi
863
+ fi
739
864
 
740
865
  # SCANNER VISIBILITY PROBE: prove the scanner can still READ a file before believing a
741
866
  # word it says about one. The self-test above asserts what the tool MATCHES; this
@@ -901,12 +1026,20 @@ for i in "${!TRACKED[@]}"; do
901
1026
  exit 1
902
1027
  fi
903
1028
 
1029
+ # A file with a declared boundary is read on its own below, in two halves. It is still
1030
+ # READ, so it is listed for --list-scanned and counted as scanned.
1031
+ if boundary_index "$f" > /dev/null; then
1032
+ printf '%s\0' "$f" >> "$PLAINLIST"
1033
+ scanned=$((scanned + 1))
1034
+ continue
1035
+ fi
1036
+
904
1037
  printf './%s\0' "$f" >> "$SCANLIST"
905
1038
  printf '%s\0' "$f" >> "$PLAINLIST"
906
1039
  scanned=$((scanned + 1))
907
1040
  done
908
1041
 
909
- if [ ! -s "$SCANLIST" ]; then
1042
+ if [ ! -s "$SCANLIST" ] && [ "${#BOUND_PATH[@]}" -eq 0 ]; then
910
1043
  echo "ERROR: check-no-emdash - no tracked files left to scan. Refusing to report" >&2
911
1044
  echo " green from a scan that read nothing." >&2
912
1045
  exit 1
@@ -932,6 +1065,38 @@ HITS=$(xargs -0 -r grep -H -nP -e "$PATTERN" -- < "$SCANLIST" 2>> "$ERRLOG" || t
932
1065
 
933
1066
  refuse_if_incomplete
934
1067
 
1068
+ # The files with a declared boundary. Every hit is found with the full pattern first, so
1069
+ # the line numbers are the file's own; a hit above the boundary line stands, and a hit at
1070
+ # or below it stands only for `em-only` and only when the line holds an em dash.
1071
+ bounded_note=''
1072
+ for i in "${!BOUND_PATH[@]}"; do
1073
+ b_path=${BOUND_PATH[$i]}
1074
+ b_line=${BOUND_LINE[$i]}
1075
+ b_mode=${BOUND_MODE[$i]}
1076
+ b_hits=$(grep -H -nP -e "$PATTERN" -- "./${b_path}" 2>> "$ERRLOG" || true)
1077
+ refuse_if_incomplete
1078
+ while IFS= read -r hit; do
1079
+ [ -n "$hit" ] || continue
1080
+ n=${hit#"./${b_path}:"}
1081
+ n=${n%%:*}
1082
+ keep=0
1083
+ if [ "$n" -lt "$b_line" ]; then
1084
+ keep=1
1085
+ elif [ "$b_mode" = em-only ] && printf '%s\n' "${hit#"./${b_path}:${n}:"}" | grep -qP -e "$EM_PATTERN"; then
1086
+ keep=1
1087
+ fi
1088
+ if [ "$keep" -eq 1 ]; then
1089
+ HITS="${HITS:+${HITS}
1090
+ }${hit}"
1091
+ fi
1092
+ done <<< "$b_hits"
1093
+ if [ "$b_mode" = em-only ]; then
1094
+ bounded_note="${bounded_note}, ${b_path} from line ${b_line} read for the em dash only"
1095
+ else
1096
+ bounded_note="${bounded_note}, ${b_path} from line ${b_line} not read"
1097
+ fi
1098
+ done
1099
+
935
1100
  if [ -n "$NAME_HITS" ] && [ -n "$HITS" ]; then
936
1101
  fail_with_hits "the tracked file names and the tracked files listed above" \
937
1102
  "$NAME_HITS"$'\n'"$HITS"
@@ -941,4 +1106,4 @@ fi
941
1106
 
942
1107
  echo "check-no-emdash: OK (no em or en dashes in ${scanned} of ${#TRACKED[@]} tracked file(s)" \
943
1108
  "or their names; ${gitlinks} gitlink(s) skipped, ${self_excluded} excluded as the" \
944
- "implementation under scan, ${declared_excluded} excluded by ${DECLARATION})"
1109
+ "implementation under scan, ${declared_excluded} excluded by ${DECLARATION}${bounded_note})"
package/claude-card.js CHANGED
@@ -49,6 +49,14 @@ export const CARD_SECTIONS = Object.freeze(["Gate", "Hazard", "Rules"]);
49
49
  /** The line that imports AGENTS.md into the card. */
50
50
  export const AGENTS_IMPORT = "@AGENTS.md";
51
51
 
52
+ /**
53
+ * Where what does not fit on the card goes, from the spec's "Where the detail goes". The card stands
54
+ * on its own: there is no notes file beside it to send detail to (D62).
55
+ */
56
+ const DETAIL_GOES =
57
+ "where it is used: a test, a comment at the code it guards, an ADR, the README or docs, or a " +
58
+ "runbook";
59
+
52
60
  /** Every rule id a finding can carry, with what it requires. */
53
61
  export const CARD_RULES = Object.freeze({
54
62
  title: "The card opens with `# <name>`, after a YAML front matter block if it has one.",
@@ -140,7 +148,8 @@ export function checkClaudeCard(text, context = {}) {
140
148
  "bytes",
141
149
  0,
142
150
  `the card is ${bytes.toLocaleString("en-US")} bytes; the limit is ` +
143
- `${CARD_MAX_BYTES.toLocaleString("en-US")}. Move detail to documentation/agent-notes.md.`,
151
+ `${CARD_MAX_BYTES.toLocaleString("en-US")}. Move detail to ${DETAIL_GOES}; git and the ` +
152
+ `PRs keep the history.`,
144
153
  );
145
154
  }
146
155
  lines.forEach((line, index) => {
@@ -185,7 +194,7 @@ export function checkClaudeCard(text, context = {}) {
185
194
  "section-extra",
186
195
  heading.line + 1,
187
196
  `"## ${heading.text}" is not one of the card's sections (${CARD_SECTIONS.join(", ")}); ` +
188
- `make it a ### inside one of them, or move it to the agent notes.`,
197
+ `make it a ### inside one of them, or move it to ${DETAIL_GOES}.`,
189
198
  );
190
199
  } else if (!found.has(word)) {
191
200
  found.set(word, heading);
@@ -68,6 +68,16 @@ export interface SourceDocCommentsAxis {
68
68
  extraRules?: ExtraRule[];
69
69
  }
70
70
 
71
+ /** Whether the source string-literal pass runs, over what, and with which extra rules. */
72
+ export interface SourceStringLiteralsAxis {
73
+ /** @default false */
74
+ enabled: boolean;
75
+ /** Tracked pathspecs holding the source whose string literals reach a consumer as messages. */
76
+ paths?: string[];
77
+ /** Detection that applies to string literals only. */
78
+ extraRules?: ExtraRule[];
79
+ }
80
+
71
81
  export interface InternalRefsConfig {
72
82
  /**
73
83
  * AXIS 1, REQUIRED. The project and programme prefixes an identifier is keyed on, and an empty
@@ -113,6 +123,15 @@ export interface InternalRefsConfig {
113
123
  */
114
124
  sourceDocComments?: SourceDocCommentsAxis;
115
125
 
126
+ /**
127
+ * DEFAULTED OFF. The source string-literal pass: double-quoted, single-quoted and template
128
+ * literals, decoded, with every comment skipped, because a string literal reaches a consumer as
129
+ * error and warning text and a comment does not. A NUL byte in source reads as a space.
130
+ *
131
+ * @default { enabled: false }
132
+ */
133
+ sourceStringLiterals?: SourceStringLiteralsAxis;
134
+
116
135
  /**
117
136
  * AXIS 6, DEFAULTED. `clean` must be 0 and the other two must not be, which is checked: a mapping
118
137
  * that put `hits` on 0 would turn every finding into a green run.
@@ -207,6 +226,18 @@ export declare function runSelfTests(rules: CompiledRule[]): string[];
207
226
  /** Join a document the way markdown renders it, so a violation that straddles a wrap is visible. */
208
227
  export declare function reflowParagraphs(text: string): string[];
209
228
 
229
+ /**
230
+ * Extract the string literals of one JavaScript or TypeScript source file, decoded, with every
231
+ * comment skipped: one entry per literal, and one per source line holding two or more of them.
232
+ */
233
+ export declare function extractStringLiterals(
234
+ text: string,
235
+ file: string,
236
+ ): {
237
+ literals: { text: string; where: string }[];
238
+ lines: { text: string; where: string }[];
239
+ };
240
+
210
241
  /** Extract doc-comment text from one source file, with each line's location beside it. */
211
242
  export declare function extractDocComments(
212
243
  text: string,