@cosyte/script-utils 0.1.1 → 0.1.3
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 +36 -11
- package/LICENSE +1 -1
- package/README.md +198 -17
- package/agent-notes.d.ts +92 -0
- package/agent-notes.js +512 -0
- package/attw.d.ts +18 -0
- package/attw.js +188 -8
- package/catalog.d.ts +110 -0
- package/catalog.js +251 -0
- package/catalog.json +210 -0
- package/check-no-emdash.sh +46 -23
- package/claude-card.d.ts +64 -0
- package/claude-card.js +296 -0
- package/internal-refs.d.ts +12 -2
- package/internal-refs.js +131 -6
- package/package.json +27 -1
- package/phi-scan.d.ts +22 -0
- package/phi-scan.js +152 -4
- package/readme.d.ts +81 -0
- package/readme.js +489 -0
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
|
+
}
|
package/internal-refs.d.ts
CHANGED
|
@@ -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 {
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
355
|
-
|
|
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(
|
|
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,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosyte/script-utils",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Zero-dependency helpers for the repo-local gate scripts in @cosyte/* repos.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
7
7
|
"entrypoint",
|
|
8
8
|
"import-meta",
|
|
9
9
|
"phi",
|
|
10
|
+
"readme",
|
|
10
11
|
"scripts",
|
|
11
12
|
"cosyte"
|
|
12
13
|
],
|
|
@@ -37,6 +38,22 @@
|
|
|
37
38
|
"./attw": {
|
|
38
39
|
"types": "./attw.d.ts",
|
|
39
40
|
"default": "./attw.js"
|
|
41
|
+
},
|
|
42
|
+
"./readme": {
|
|
43
|
+
"types": "./readme.d.ts",
|
|
44
|
+
"default": "./readme.js"
|
|
45
|
+
},
|
|
46
|
+
"./claude-card": {
|
|
47
|
+
"types": "./claude-card.d.ts",
|
|
48
|
+
"default": "./claude-card.js"
|
|
49
|
+
},
|
|
50
|
+
"./catalog": {
|
|
51
|
+
"types": "./catalog.d.ts",
|
|
52
|
+
"default": "./catalog.js"
|
|
53
|
+
},
|
|
54
|
+
"./agent-notes": {
|
|
55
|
+
"types": "./agent-notes.d.ts",
|
|
56
|
+
"default": "./agent-notes.js"
|
|
40
57
|
}
|
|
41
58
|
},
|
|
42
59
|
"files": [
|
|
@@ -48,6 +65,15 @@
|
|
|
48
65
|
"internal-refs.d.ts",
|
|
49
66
|
"attw.js",
|
|
50
67
|
"attw.d.ts",
|
|
68
|
+
"readme.js",
|
|
69
|
+
"readme.d.ts",
|
|
70
|
+
"claude-card.js",
|
|
71
|
+
"claude-card.d.ts",
|
|
72
|
+
"catalog.js",
|
|
73
|
+
"catalog.d.ts",
|
|
74
|
+
"catalog.json",
|
|
75
|
+
"agent-notes.js",
|
|
76
|
+
"agent-notes.d.ts",
|
|
51
77
|
"check-no-emdash.sh",
|
|
52
78
|
"README.md",
|
|
53
79
|
"CHANGELOG.md"
|
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;
|