@cosyte/script-utils 0.1.2 → 0.1.4

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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Types for `@cosyte/script-utils/claude-card`: the checker for the CLAUDE.md card every Cosyte
3
+ * repository keeps. The spec is `documentation/claude-card.md` in cosyte/config.
4
+ */
5
+
6
+ /** The most lines a card may have, counted as `wc -l` counts them. */
7
+ export declare const CARD_MAX_LINES: 60;
8
+
9
+ /** The most characters (code points) a line may have, code blocks included. */
10
+ export declare const CARD_MAX_LINE_LENGTH: 100;
11
+
12
+ /** The most bytes of UTF-8 a card may have. */
13
+ export declare const CARD_MAX_BYTES: 5120;
14
+
15
+ /** The three sections, in order. */
16
+ export declare const CARD_SECTIONS: readonly ["Gate", "Hazard", "Rules"];
17
+
18
+ /** The line that imports AGENTS.md into the card. */
19
+ export declare const AGENTS_IMPORT: "@AGENTS.md";
20
+
21
+ /** A rule id a finding can carry. */
22
+ export type CardRule =
23
+ | "title"
24
+ | "intro"
25
+ | "section-missing"
26
+ | "section-order"
27
+ | "section-extra"
28
+ | "section-empty"
29
+ | "lines"
30
+ | "width"
31
+ | "bytes"
32
+ | "agents-import";
33
+
34
+ /** Every rule id, with what it requires. */
35
+ export declare const CARD_RULES: Readonly<Record<CardRule, string>>;
36
+
37
+ /** One place a card departs from the spec. */
38
+ export interface CardFinding {
39
+ rule: CardRule;
40
+ /** 1-based line, or 0 when the finding is about the whole file. */
41
+ line: number;
42
+ message: string;
43
+ }
44
+
45
+ export interface CardContext {
46
+ /** Whether an AGENTS.md sits beside the card. Left out, the import rule is not checked. */
47
+ agentsMd?: boolean;
48
+ }
49
+
50
+ /** Check a CLAUDE.md card against the spec. Returns no findings when it follows it. */
51
+ export declare function checkClaudeCard(text: string, context?: CardContext): CardFinding[];
52
+
53
+ export interface ClaudeCardGateOptions {
54
+ /** The repository root holding CLAUDE.md. Default: the working directory. */
55
+ dir?: string;
56
+ /** CLI arguments; only `--dir <path>` is accepted. */
57
+ argv?: string[];
58
+ }
59
+
60
+ /**
61
+ * Run the checker over one repository and return the exit code: 0 the card follows the spec, 1 at
62
+ * least one finding, 2 CLAUDE.md could not be read. It never exits the process.
63
+ */
64
+ export declare function runClaudeCardGate(options?: ClaudeCardGateOptions): 0 | 1 | 2;
package/claude-card.js ADDED
@@ -0,0 +1,296 @@
1
+ // @cosyte/script-utils/claude-card
2
+ //
3
+ // THE CLAUDE.md CARD CHECKER. Every Cosyte repository keeps a short CLAUDE.md that every Claude Code
4
+ // session in it reads first: what must pass before a push (`## Gate`), what a change can break that
5
+ // a green gate would not show (`## Hazard`), and the rules that bind there (`## Rules`). The spec is
6
+ // `documentation/claude-card.md` in cosyte/config; this file is what enforces it.
7
+ //
8
+ // THE RULES, each one id a finding names:
9
+ //
10
+ // title line 1 is `# <name>` (after a YAML front matter block, when there is one)
11
+ // intro a paragraph sits between the title and the first section
12
+ // section-missing `## Gate`, `## Hazard` and `## Rules` are all there (a heading may go on
13
+ // after its word: `## Hazard: ...`, `## Rules that bind here`)
14
+ // section-order in that order
15
+ // section-extra no other level-2 heading
16
+ // section-empty none of the three is empty
17
+ // lines at most `CARD_MAX_LINES` lines, counted as `wc -l` counts them
18
+ // width every line at most `CARD_MAX_LINE_LENGTH` characters, code included
19
+ // bytes at most `CARD_MAX_BYTES` bytes of UTF-8
20
+ // agents-import when an AGENTS.md sits beside the card, the card imports it on a line of its own
21
+ // (`@AGENTS.md`); an import of an AGENTS.md that is not there is a finding too
22
+ //
23
+ // EXIT CODES, the contract every gate here keeps: 0 the card follows the spec, 1 a finding, 2 the
24
+ // gate could not read its input.
25
+ //
26
+ // ZERO DEPENDENCIES AND NO BUILD STEP, like the rest of this package.
27
+
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import { join, resolve } from "node:path";
30
+
31
+ import { isCliEntrypoint } from "./index.js";
32
+
33
+ /** The most lines a card may have. */
34
+ export const CARD_MAX_LINES = 60;
35
+
36
+ /** The most characters (code points) a line may have, code blocks included. */
37
+ export const CARD_MAX_LINE_LENGTH = 100;
38
+
39
+ /**
40
+ * The most bytes of UTF-8 a card may have: 5 KiB, about 1,300 tokens, paid by every session in the
41
+ * repository before any work starts. 60 lines of 100 characters is up to 6,060 bytes, so this is
42
+ * the cap that binds; the spec gives the measurement behind the number.
43
+ */
44
+ export const CARD_MAX_BYTES = 5_120;
45
+
46
+ /** The three sections, in order. A heading matches when it is the word, or the word then more. */
47
+ export const CARD_SECTIONS = Object.freeze(["Gate", "Hazard", "Rules"]);
48
+
49
+ /** The line that imports AGENTS.md into the card. */
50
+ export const AGENTS_IMPORT = "@AGENTS.md";
51
+
52
+ /** Every rule id a finding can carry, with what it requires. */
53
+ export const CARD_RULES = Object.freeze({
54
+ title: "The card opens with `# <name>`, after a YAML front matter block if it has one.",
55
+ intro: "A paragraph sits between the title and the first section.",
56
+ "section-missing": "`## Gate`, `## Hazard` and `## Rules` are all there.",
57
+ "section-order": "The three sections appear in that order.",
58
+ "section-extra": "There is no other level-2 heading.",
59
+ "section-empty": "None of the three sections is empty.",
60
+ lines: `At most ${CARD_MAX_LINES} lines.`,
61
+ width: `Every line is at most ${CARD_MAX_LINE_LENGTH} characters, code blocks included.`,
62
+ bytes: `At most ${CARD_MAX_BYTES.toLocaleString("en-US")} bytes.`,
63
+ "agents-import": `The card imports a sibling AGENTS.md on a line of its own (${AGENTS_IMPORT}).`,
64
+ });
65
+
66
+ /** Which of the three sections a level-2 heading's text is, or `null` for none of them. */
67
+ function sectionOf(text) {
68
+ for (const word of CARD_SECTIONS) {
69
+ if (text.startsWith(word) && !/[A-Za-z0-9]/.test(text.charAt(word.length))) return word;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * An ATX heading's level and text, or `null` when the line is not one.
76
+ *
77
+ * Read with index arithmetic rather than one regular expression: a pattern of the shape
78
+ * `[ \t]+(.*?)[ \t]*#*[ \t]*$` backtracks polynomially on a long run of tabs or spaces.
79
+ */
80
+ function parseHeading(line) {
81
+ let level = 0;
82
+ while (level < line.length && line[level] === "#") level += 1;
83
+ if (level === 0 || level > 6) return null;
84
+ if (line[level] !== " " && line[level] !== "\t") return null;
85
+ let text = line.slice(level).trim();
86
+ // An optional closing run of `#`, which counts only when a space or tab comes before it.
87
+ let end = text.length;
88
+ while (end > 0 && text[end - 1] === "#") end -= 1;
89
+ if (end === 0) text = "";
90
+ else if (end < text.length && (text[end - 1] === " " || text[end - 1] === "\t")) {
91
+ text = text.slice(0, end).trimEnd();
92
+ }
93
+ return { level, text };
94
+ }
95
+
96
+ /** The headings outside fenced code blocks, with 0-based line numbers. */
97
+ function headingsOf(lines) {
98
+ const headings = [];
99
+ let fence = null;
100
+ lines.forEach((line, index) => {
101
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line);
102
+ if (fence !== null) {
103
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length) fence = null;
104
+ return;
105
+ }
106
+ if (marker) {
107
+ fence = marker[1];
108
+ return;
109
+ }
110
+ const heading = parseHeading(line);
111
+ if (heading !== null) headings.push({ ...heading, line: index });
112
+ });
113
+ return headings;
114
+ }
115
+
116
+ /**
117
+ * Check a CLAUDE.md card against the spec.
118
+ *
119
+ * @param {string} text The card's contents.
120
+ * @param {{ agentsMd?: boolean }} [context] `agentsMd`: whether an AGENTS.md sits beside the card.
121
+ * When it is left out, the import rule is not checked.
122
+ * @returns {{ rule: string, line: number, message: string }[]} The findings; empty when the card
123
+ * follows the spec. `line` is 1-based, 0 for the whole file.
124
+ */
125
+ export function checkClaudeCard(text, context = {}) {
126
+ if (typeof text !== "string") throw new TypeError("checkClaudeCard(text) expects a string.");
127
+ const findings = [];
128
+ const add = (rule, line, message) => findings.push({ rule, line, message });
129
+ const lines = text.split("\n");
130
+ if (lines[lines.length - 1] === "") lines.pop();
131
+
132
+ // ---- Size ---------------------------------------------------------------------------------
133
+ const lineCount = (text.match(/\n/g) ?? []).length;
134
+ if (lineCount > CARD_MAX_LINES) {
135
+ add("lines", 0, `the card is ${lineCount} lines; the limit is ${CARD_MAX_LINES}.`);
136
+ }
137
+ const bytes = Buffer.byteLength(text, "utf8");
138
+ if (bytes > CARD_MAX_BYTES) {
139
+ add(
140
+ "bytes",
141
+ 0,
142
+ `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.`,
144
+ );
145
+ }
146
+ lines.forEach((line, index) => {
147
+ const length = [...line.replace(/\r$/, "")].length;
148
+ if (length > CARD_MAX_LINE_LENGTH) {
149
+ add(
150
+ "width",
151
+ index + 1,
152
+ `line ${index + 1} is ${length} characters; the limit is ${CARD_MAX_LINE_LENGTH}.`,
153
+ );
154
+ }
155
+ });
156
+
157
+ // ---- Title and intro ------------------------------------------------------------------------
158
+ // A YAML front matter block may come first (a repository whose documents carry one); the title
159
+ // is then the first line after it.
160
+ let titleLine = 0;
161
+ if (lines[0] === "---") {
162
+ const close = lines.indexOf("---", 1);
163
+ if (close > 0) titleLine = close + 1;
164
+ while (titleLine < lines.length && lines[titleLine].trim() === "") titleLine += 1;
165
+ }
166
+ if (!/^# \S/.test(lines[titleLine] ?? "")) {
167
+ add("title", titleLine + 1, "the card must open with its title, `# <name>`.");
168
+ }
169
+ const headings = headingsOf(lines);
170
+ const firstSection = headings.find((h) => h.level === 2);
171
+ const introEnd = firstSection === undefined ? lines.length : firstSection.line;
172
+ const intro = lines
173
+ .slice(titleLine + 1, introEnd)
174
+ .filter((line) => line.trim() !== "" && line.trim() !== AGENTS_IMPORT && !/^#/.test(line));
175
+ if (intro.length === 0) {
176
+ add("intro", 2, "say what this repository is in a paragraph under the title.");
177
+ }
178
+
179
+ // ---- Sections -------------------------------------------------------------------------------
180
+ const found = new Map();
181
+ for (const heading of headings.filter((h) => h.level === 2)) {
182
+ const word = sectionOf(heading.text);
183
+ if (word === null) {
184
+ add(
185
+ "section-extra",
186
+ heading.line + 1,
187
+ `"## ${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.`,
189
+ );
190
+ } else if (!found.has(word)) {
191
+ found.set(word, heading);
192
+ } else {
193
+ add("section-extra", heading.line + 1, `"## ${heading.text}" repeats the ${word} section.`);
194
+ }
195
+ }
196
+ for (const word of CARD_SECTIONS) {
197
+ if (!found.has(word)) add("section-missing", 0, `the card has no "## ${word}" section.`);
198
+ }
199
+ const present = CARD_SECTIONS.filter((word) => found.has(word));
200
+ for (let i = 1; i < present.length; i += 1) {
201
+ if (found.get(present[i]).line < found.get(present[i - 1]).line) {
202
+ add(
203
+ "section-order",
204
+ found.get(present[i]).line + 1,
205
+ `"## ${present[i]}" must come after "## ${present[i - 1]}" (the order is ` +
206
+ `${CARD_SECTIONS.join(", ")}).`,
207
+ );
208
+ break;
209
+ }
210
+ }
211
+ for (const [word, heading] of found) {
212
+ const next = headings.find((h) => h.line > heading.line && h.level <= 2);
213
+ const body = lines.slice(heading.line + 1, next === undefined ? lines.length : next.line);
214
+ if (body.every((line) => line.trim() === "")) {
215
+ add("section-empty", heading.line + 1, `the ${word} section is empty.`);
216
+ }
217
+ }
218
+
219
+ // ---- AGENTS.md ------------------------------------------------------------------------------
220
+ if (typeof context.agentsMd === "boolean") {
221
+ const imports = lines.some((line) => line.trim() === AGENTS_IMPORT);
222
+ if (context.agentsMd && !imports) {
223
+ add(
224
+ "agents-import",
225
+ 0,
226
+ `an AGENTS.md sits beside the card, so the card imports it on a line of its own: ` +
227
+ `${AGENTS_IMPORT}.`,
228
+ );
229
+ }
230
+ if (!context.agentsMd && imports) {
231
+ add(
232
+ "agents-import",
233
+ 0,
234
+ `the card imports ${AGENTS_IMPORT}, and there is no AGENTS.md beside it.`,
235
+ );
236
+ }
237
+ }
238
+
239
+ return findings;
240
+ }
241
+
242
+ /**
243
+ * Run the card checker over one repository: read its CLAUDE.md, note whether an AGENTS.md sits
244
+ * beside it, report, and return the exit code. It never exits the process.
245
+ *
246
+ * @param {{ dir?: string, argv?: string[] }} [options] `dir` is the repository root (default: the
247
+ * working directory). `argv` is the CLI arguments, read only for `--dir <path>`.
248
+ * @returns {0 | 1 | 2} 0 the card follows the spec, 1 a finding, 2 the card could not be read.
249
+ */
250
+ export function runClaudeCardGate(options = {}) {
251
+ let dir = options.dir ?? process.cwd();
252
+ const argv = options.argv ?? [];
253
+ for (let i = 0; i < argv.length; i += 1) {
254
+ if (argv[i] === "--dir" && typeof argv[i + 1] === "string") {
255
+ dir = argv[i + 1];
256
+ i += 1;
257
+ } else if (argv[i] !== "--") {
258
+ process.stderr.write(
259
+ `CLAUDE.md card: ${JSON.stringify(argv[i])} is not an argument this check accepts (only ` +
260
+ `--dir <repository root>). Nothing was checked.\n`,
261
+ );
262
+ return 2;
263
+ }
264
+ }
265
+ dir = resolve(dir);
266
+
267
+ let text;
268
+ try {
269
+ text = readFileSync(join(dir, "CLAUDE.md"), "utf8");
270
+ } catch (err) {
271
+ process.stderr.write(`CLAUDE.md card: could not read CLAUDE.md in ${dir}: ${err.message}\n`);
272
+ return 2;
273
+ }
274
+ const findings = checkClaudeCard(text, { agentsMd: existsSync(join(dir, "AGENTS.md")) });
275
+ if (findings.length === 0) {
276
+ const lineCount = (text.match(/\n/g) ?? []).length;
277
+ process.stdout.write(
278
+ `CLAUDE.md card: follows the spec (${lineCount} of ${CARD_MAX_LINES} lines, ` +
279
+ `${Buffer.byteLength(text, "utf8").toLocaleString("en-US")} of ` +
280
+ `${CARD_MAX_BYTES.toLocaleString("en-US")} bytes).\n`,
281
+ );
282
+ return 0;
283
+ }
284
+ const report = findings
285
+ .map((f) => ` CLAUDE.md${f.line > 0 ? `:${f.line}` : ""} [${f.rule}] ${f.message}`)
286
+ .join("\n");
287
+ process.stderr.write(
288
+ `CLAUDE.md card: CLAUDE.md does not follow the card spec.\n${report}\n` +
289
+ `${findings.length} finding(s). The spec is documentation/claude-card.md in cosyte/config.\n`,
290
+ );
291
+ return 1;
292
+ }
293
+
294
+ if (isCliEntrypoint(import.meta.url)) {
295
+ process.exitCode = runClaudeCardGate({ argv: process.argv.slice(2) });
296
+ }
@@ -46,9 +46,9 @@ export interface ExtraRule {
46
46
  /** What a hit report calls it. */
47
47
  name: string;
48
48
  /** One pattern, as a source string or a `RegExp`. */
49
- pattern?: string | RegExp;
49
+ pattern?: string | RegExp | { source: string; flags?: string };
50
50
  /** Several patterns, when one rule needs two case policies. */
51
- patterns?: (string | RegExp)[];
51
+ patterns?: (string | RegExp | { source: string; flags?: string })[];
52
52
  /** Text this rule MUST match. At least one is required. */
53
53
  positives: string[];
54
54
  /** Text this rule must NOT match. At least one is required: it is what stops a widening. */
@@ -221,3 +221,13 @@ export declare const DEFAULT_EXIT_CODES: InternalRefsExitCodes;
221
221
 
222
222
  /** Option names refused by name, with the reason each one is not something a caller may supply. */
223
223
  export declare const REFUSED_OPTIONS: ReadonlyMap<string, string>;
224
+
225
+ /** Where the command-line form reads the repository's axes: `scripts/internal-refs.json`. */
226
+ export declare const INTERNAL_REFS_CONFIG_PATH: "scripts/internal-refs.json";
227
+
228
+ /**
229
+ * The command-line form: run the gate over the repository in the working directory, with the
230
+ * axes read from `scripts/internal-refs.json` (or `--config <path>`). Exit 0 clean, 1 hits, 2 the
231
+ * gate could not run. `node node_modules/@cosyte/script-utils/internal-refs.js`.
232
+ */
233
+ export declare function runInternalRefsCli(argv: string[]): number;
package/internal-refs.js CHANGED
@@ -24,8 +24,16 @@
24
24
  // change what this gate looks for or what it concludes.
25
25
 
26
26
  import { execFileSync } from "node:child_process";
27
- import { closeSync, constants as fsConstants, fstatSync, openSync, readFileSync } from "node:fs";
28
- import { join } from "node:path";
27
+ import {
28
+ closeSync,
29
+ constants as fsConstants,
30
+ fstatSync,
31
+ openSync,
32
+ readFileSync,
33
+ realpathSync,
34
+ } from "node:fs";
35
+ import { basename, join } from "node:path";
36
+ import { fileURLToPath } from "node:url";
29
37
 
30
38
  /**
31
39
  * The exit codes this gate assigns, as a caller may redeclare them.
@@ -332,7 +340,7 @@ export function compileCanonicalRules(resolved) {
332
340
  name: "phase or wave language",
333
341
  patterns: [
334
342
  new RegExp(
335
- `\\b(?:roadmap phase\\b[ ]?[A-Za-z0-9]*|${PHASE_NOT_CLINICAL}phase[ -]${PHASE_NOT_FIELD}` +
343
+ `\\b(?:roadmap phases?\\b[ ]?[A-Za-z0-9]*|${PHASE_NOT_CLINICAL}phases?[ -]${PHASE_NOT_FIELD}` +
336
344
  `[A-Za-z0-9]+[a-z]?\\b|wave \\d+\\b|the \\w+ and final phase\\b|` +
337
345
  `documentation residual\\b|${ORDINAL} (?:slice|wave)\\b)`,
338
346
  "gi",
@@ -340,6 +348,8 @@ export function compileCanonicalRules(resolved) {
340
348
  ],
341
349
  positives: [
342
350
  "Phase 5b closes it (Phase W, Phase-L and the thirteenth slice landed earlier, in wave 2)",
351
+ // The plural, which ten of the eleven per-repository copies matched (`phases?[ -]`).
352
+ "Phases 1 to 6 shipped together",
343
353
  ],
344
354
  negatives: [
345
355
  "CSP-1 Study Phase Identifier, CSP-2 Study Phase Start Date/Time, CSP-3 Study Phase End " +
@@ -351,8 +361,12 @@ export function compileCanonicalRules(resolved) {
351
361
  {
352
362
  id: "adr-reference",
353
363
  name: "ADR reference",
354
- patterns: [new RegExp("\\bADR[ -]?\\d{3,4}\\b", "gi")],
355
- positives: ["Decided in ADR 0015 and restated in ADR-0021"],
364
+ // `[ /-]`: ten of the eleven per-repository copies also matched an ADR path segment.
365
+ patterns: [new RegExp("\\bADR[ /-]?\\d{3,4}\\b", "gi")],
366
+ positives: [
367
+ "Decided in ADR 0015 and restated in ADR-0021",
368
+ "Recorded in docs/adr/0001-scope.md",
369
+ ],
356
370
  negatives: ["ADR is not a segment, and 0015 alone is a value"],
357
371
  },
358
372
  {
@@ -646,13 +660,26 @@ function compileExtraRule(rule, index) {
646
660
  }
647
661
  continue;
648
662
  }
663
+ // `{ source, flags }`: the form a JSON configuration can carry, so a rule read from
664
+ // `scripts/internal-refs.json` can be case-insensitive (`"flags": "i"`). `g` is always added.
665
+ if (isPlainObject(entry) && typeof entry.source === "string") {
666
+ const flags = typeof entry.flags === "string" ? entry.flags : "";
667
+ try {
668
+ patterns.push(new RegExp(entry.source, flags.includes("g") ? flags : `${flags}g`));
669
+ } catch (cause) {
670
+ problems.push(`${where} carries a pattern that does not compile: ${String(cause)}`);
671
+ }
672
+ continue;
673
+ }
649
674
  if (entry instanceof RegExp) {
650
675
  patterns.push(
651
676
  new RegExp(entry.source, entry.flags.includes("g") ? entry.flags : `${entry.flags}g`),
652
677
  );
653
678
  continue;
654
679
  }
655
- problems.push(`${where} carries a pattern that is neither a string nor a RegExp`);
680
+ problems.push(
681
+ `${where} carries a pattern that is neither a string, a RegExp nor a { source, flags } object`,
682
+ );
656
683
  }
657
684
  if (patterns.length === 0) problems.push(`${where} declares no pattern`);
658
685
  if (!isStringList(rule.positives) || rule.positives.length === 0) {
@@ -1361,3 +1388,101 @@ function report(hits) {
1361
1388
 
1362
1389
  /** Re-exported for a caller that wants to state, in its own docs, what it cannot turn off. */
1363
1390
  export { DEFAULT_EXIT_CODES, REFUSED_OPTIONS };
1391
+
1392
+ // ---- The command-line form (ST34) --------------------------------------------
1393
+ //
1394
+ // `node node_modules/@cosyte/script-utils/internal-refs.js` runs this gate over the repository in
1395
+ // the working directory with no caller script: the axes a caller would pass are read from
1396
+ // `scripts/internal-refs.json`, the one file a repository keeps for this gate. Keys starting with
1397
+ // `$` are notes and are ignored. The exit codes are the house contract, 0 clean, 1 hits, 2 the gate
1398
+ // could not run, and the file may not set them, the repository root or the output sinks: the
1399
+ // command-line form owns those, and a file that names one is refused rather than half-honoured.
1400
+
1401
+ /** Where the command-line form reads the repository's axes. */
1402
+ export const INTERNAL_REFS_CONFIG_PATH = "scripts/internal-refs.json";
1403
+
1404
+ /** The exit codes of the command-line form. */
1405
+ const CLI_EXIT_CODES = Object.freeze({ clean: 0, hits: 1, refuse: 2 });
1406
+
1407
+ /** Keys a configuration file may not set, because the command-line form sets them. */
1408
+ const CLI_OWNED_KEYS = ["exitCodes", "repoRoot", "write"];
1409
+
1410
+ /**
1411
+ * Run the gate from the command line over the repository in the working directory.
1412
+ *
1413
+ * @param {string[]} argv Only `--config <path>` is accepted (default `scripts/internal-refs.json`).
1414
+ * @returns {number} 0 clean, 1 hits, 2 the gate could not run.
1415
+ */
1416
+ export function runInternalRefsCli(argv) {
1417
+ let path = INTERNAL_REFS_CONFIG_PATH;
1418
+ const args = argv.filter((arg) => arg !== "--");
1419
+ for (let i = 0; i < args.length; i += 1) {
1420
+ if (args[i] === "--config" && typeof args[i + 1] === "string") {
1421
+ path = args[i + 1];
1422
+ i += 1;
1423
+ } else {
1424
+ process.stderr.write(
1425
+ `internal-refs: ${JSON.stringify(args[i])} is not an argument this gate accepts (only ` +
1426
+ `--config <path>). Nothing was checked.\n`,
1427
+ );
1428
+ return CLI_EXIT_CODES.refuse;
1429
+ }
1430
+ }
1431
+ let parsed;
1432
+ try {
1433
+ parsed = JSON.parse(readFileSync(join(process.cwd(), path), "utf8"));
1434
+ } catch (err) {
1435
+ process.stderr.write(
1436
+ `internal-refs: could not read ${path}: ${err.message} Nothing was checked.\n`,
1437
+ );
1438
+ return CLI_EXIT_CODES.refuse;
1439
+ }
1440
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1441
+ process.stderr.write(`internal-refs: ${path} is not a JSON object. Nothing was checked.\n`);
1442
+ return CLI_EXIT_CODES.refuse;
1443
+ }
1444
+ const owned = CLI_OWNED_KEYS.filter((key) => key in parsed);
1445
+ if (owned.length > 0) {
1446
+ process.stderr.write(
1447
+ `internal-refs: ${path} sets ${owned.join(", ")}, which the command-line form sets ` +
1448
+ `itself. Remove ${owned.length === 1 ? "it" : "them"}. Nothing was checked.\n`,
1449
+ );
1450
+ return CLI_EXIT_CODES.refuse;
1451
+ }
1452
+ const config = Object.fromEntries(Object.entries(parsed).filter(([key]) => !key.startsWith("$")));
1453
+ return runInternalRefsScan({ ...config, exitCodes: CLI_EXIT_CODES, repoRoot: process.cwd() });
1454
+ }
1455
+
1456
+ /**
1457
+ * Whether node was pointed at this file, which is the command-line form.
1458
+ *
1459
+ * SELF-CONTAINED ON PURPOSE: this module is also vendored and copied as a single file, so it may not
1460
+ * import a sibling at load. A file whose name differs from the one node was pointed at cannot be the
1461
+ * entry point, and that is settled without loading anything; only a matching name asks `index.js`'s
1462
+ * `isCliEntrypoint`, and a copy with no `index.js` beside it compares real paths instead. Unsure is
1463
+ * yes, so a gate that cannot tell runs loudly rather than skipping.
1464
+ *
1465
+ * @param {string} moduleUrl This module's `import.meta.url`.
1466
+ * @returns {Promise<boolean>}
1467
+ */
1468
+ async function invokedAsCommand(moduleUrl) {
1469
+ const entry = process.argv[1];
1470
+ if (typeof entry !== "string" || entry === "") return false;
1471
+ const own = fileURLToPath(moduleUrl);
1472
+ const stem = (path) => basename(path).replace(/\.[cm]?[jt]s$/, "");
1473
+ if (stem(entry) !== stem(own)) return false;
1474
+ try {
1475
+ const { isCliEntrypoint } = await import("./index.js");
1476
+ return isCliEntrypoint(moduleUrl);
1477
+ } catch {
1478
+ try {
1479
+ return realpathSync(entry) === realpathSync(own);
1480
+ } catch {
1481
+ return true;
1482
+ }
1483
+ }
1484
+ }
1485
+
1486
+ void invokedAsCommand(import.meta.url).then((command) => {
1487
+ if (command) process.exitCode = runInternalRefsCli(process.argv.slice(2));
1488
+ });
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@cosyte/script-utils",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Zero-dependency helpers for the repo-local gate scripts in @cosyte/* repos.",
5
5
  "keywords": [
6
6
  "cli",
7
+ "bin",
7
8
  "entrypoint",
8
9
  "import-meta",
9
10
  "phi",
11
+ "readme",
10
12
  "scripts",
11
13
  "cosyte"
12
14
  ],
@@ -20,6 +22,17 @@
20
22
  "author": "Cosyte <hello@cosyte.com> (https://cosyte.com)",
21
23
  "type": "module",
22
24
  "main": "index.js",
25
+ "bin": {
26
+ "script-utils": "bin/script-utils.js",
27
+ "cosyte-no-emdash": "bin/cosyte-no-emdash.js",
28
+ "cosyte-attw": "bin/cosyte-attw.js",
29
+ "cosyte-phi-scan": "bin/cosyte-phi-scan.js",
30
+ "cosyte-internal-refs": "bin/cosyte-internal-refs.js",
31
+ "cosyte-readme": "bin/cosyte-readme.js",
32
+ "cosyte-claude-card": "bin/cosyte-claude-card.js",
33
+ "cosyte-agent-notes": "bin/cosyte-agent-notes.js",
34
+ "cosyte-catalog": "bin/cosyte-catalog.js"
35
+ },
23
36
  "types": "index.d.ts",
24
37
  "exports": {
25
38
  ".": {
@@ -37,6 +50,22 @@
37
50
  "./attw": {
38
51
  "types": "./attw.d.ts",
39
52
  "default": "./attw.js"
53
+ },
54
+ "./readme": {
55
+ "types": "./readme.d.ts",
56
+ "default": "./readme.js"
57
+ },
58
+ "./claude-card": {
59
+ "types": "./claude-card.d.ts",
60
+ "default": "./claude-card.js"
61
+ },
62
+ "./catalog": {
63
+ "types": "./catalog.d.ts",
64
+ "default": "./catalog.js"
65
+ },
66
+ "./agent-notes": {
67
+ "types": "./agent-notes.d.ts",
68
+ "default": "./agent-notes.js"
40
69
  }
41
70
  },
42
71
  "files": [
@@ -48,7 +77,17 @@
48
77
  "internal-refs.d.ts",
49
78
  "attw.js",
50
79
  "attw.d.ts",
80
+ "readme.js",
81
+ "readme.d.ts",
82
+ "claude-card.js",
83
+ "claude-card.d.ts",
84
+ "catalog.js",
85
+ "catalog.d.ts",
86
+ "catalog.json",
87
+ "agent-notes.js",
88
+ "agent-notes.d.ts",
51
89
  "check-no-emdash.sh",
90
+ "bin",
52
91
  "README.md",
53
92
  "CHANGELOG.md"
54
93
  ],
package/phi-scan.d.ts CHANGED
@@ -274,3 +274,25 @@ export declare function runPhiScan(config: PhiScanConfig): number;
274
274
  * is read by NEITHER sweeping route, while a `.md` named explicitly on argv IS scanned.
275
275
  */
276
276
  export declare function exemptsMarkdown(relPath: string): boolean;
277
+
278
+ /** Where the command-line form reads what a repository leaves out: `scripts/phi-scan.exclude`. */
279
+ export declare const PHI_EXCLUDE_PATH: "scripts/phi-scan.exclude";
280
+
281
+ /**
282
+ * Read an exclusions file (one literal tracked path per line, `#` lines are comments) against the
283
+ * repository's tracked paths. A directory, a pattern, a repeat or a path git does not track is a
284
+ * problem, never a silent skip.
285
+ */
286
+ export declare function readPhiExclusions(
287
+ text: string,
288
+ tracked: ReadonlySet<string>,
289
+ ): { paths: Set<string>; problems: string[] };
290
+
291
+ /**
292
+ * The command-line form: the engine over the repository in the working directory, with exit codes
293
+ * 0 clean, 1 hits, 2 refuse, the whole repository as the scan root, the `--staged` route reading
294
+ * everything but Markdown, and `scripts/phi-scan.exclude`. The cross-cutting floor only: a
295
+ * repository that needs field-level detectors keeps a caller passing `detect`.
296
+ * `node node_modules/@cosyte/script-utils/phi-scan.js [--staged | <path>...]`.
297
+ */
298
+ export declare function runPhiScanCli(argv: string[]): number;