@cosyte/script-utils 0.1.2 → 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 +24 -12
- package/LICENSE +1 -1
- package/README.md +192 -12
- 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/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/agent-notes.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
// @cosyte/script-utils/agent-notes
|
|
2
|
+
//
|
|
3
|
+
// THE AGENT-NOTES CHECKER (RD4). A repository's CLAUDE.md card stays short, and what does not fit in
|
|
4
|
+
// it lives in ONE file, `documentation/agent-notes.md`, which the card points into with ONE form:
|
|
5
|
+
// `documentation/agent-notes.md#<anchor>`. This checker replaces the eight per-repository checkers
|
|
6
|
+
// (mllp, ccda, ncpdp, astm, terminology, transform, deid and cli), keeping every rule they shared and
|
|
7
|
+
// resolving the ones they disagreed on; the README section "The agent-notes checker" lists both.
|
|
8
|
+
//
|
|
9
|
+
// THE RULES, each one id a finding names:
|
|
10
|
+
//
|
|
11
|
+
// layout the notes are one file: no `documentation/agent-notes/` directory, and the card
|
|
12
|
+
// does not point into one
|
|
13
|
+
// pointer-form in CLAUDE.md, a pointer is `documentation/agent-notes.md#<anchor>`: not a bare
|
|
14
|
+
// `#anchor` in backticks, not `agent-notes.md#` without the directory, not `./...`
|
|
15
|
+
// dangling every pointer, in any tracked file, names an anchor the notes have: a heading's
|
|
16
|
+
// GitHub slug, or an explicit `<a id>` or `<a name>`
|
|
17
|
+
// notes-missing a pointer exists and the notes file does not
|
|
18
|
+
// no-pointer the notes exist and CLAUDE.md never points into them
|
|
19
|
+
// blank the notes file is empty, or has no heading
|
|
20
|
+
// empty-section a heading with nothing under it (a heading followed by a deeper one is a container
|
|
21
|
+
// and is not empty)
|
|
22
|
+
// stale-citation the notes cite the umbrella's deleted ADR 0023 or its doc-budget hook
|
|
23
|
+
//
|
|
24
|
+
// The corpus is every file `git ls-files` lists. A file holding a NUL byte is skipped and counted,
|
|
25
|
+
// and so is anything that is not a regular file (a symlink is never followed). A pointer is found by
|
|
26
|
+
// one pattern in every file, so a source comment that cites the notes is held to the same anchor.
|
|
27
|
+
//
|
|
28
|
+
// EXIT CODES, the contract every gate here keeps: 0 clean, 1 a finding, 2 the check could not run
|
|
29
|
+
// (not a git repository, an unreadable tracked file, an unterminated HTML comment in the notes, or an
|
|
30
|
+
// argument it does not accept). Every route that did not finish exits non-zero.
|
|
31
|
+
//
|
|
32
|
+
// ZERO DEPENDENCIES AND NO BUILD STEP, like the rest of this package.
|
|
33
|
+
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
35
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
36
|
+
import { join, resolve } from "node:path";
|
|
37
|
+
|
|
38
|
+
import { isCliEntrypoint } from "./index.js";
|
|
39
|
+
|
|
40
|
+
/** The one place the notes live. */
|
|
41
|
+
export const NOTES_PATH = "documentation/agent-notes.md";
|
|
42
|
+
|
|
43
|
+
/** The directory layout the one-file rule retires. */
|
|
44
|
+
const NOTES_DIRECTORY = "documentation/agent-notes/";
|
|
45
|
+
|
|
46
|
+
/** The characters an anchor may carry. A trailing `.` is prose, not anchor. */
|
|
47
|
+
const ANCHOR = "[\\p{L}\\p{N}_-]+";
|
|
48
|
+
|
|
49
|
+
/** Every rule id a finding can carry, with what it requires. */
|
|
50
|
+
export const AGENT_NOTES_RULES = Object.freeze({
|
|
51
|
+
layout: `The notes are one file, ${NOTES_PATH}, not a directory.`,
|
|
52
|
+
"pointer-form": `CLAUDE.md points into the notes as ${NOTES_PATH}#<anchor>, and in no other form.`,
|
|
53
|
+
dangling: "Every pointer names an anchor the notes have.",
|
|
54
|
+
"notes-missing": "A pointer exists only when the notes do.",
|
|
55
|
+
"no-pointer": "When the notes exist, CLAUDE.md points into them at least once.",
|
|
56
|
+
blank: "The notes file is not empty and has at least one heading.",
|
|
57
|
+
"empty-section": "No heading is empty, except a container followed by a deeper heading.",
|
|
58
|
+
"stale-citation": "The notes do not cite the umbrella's deleted ADR 0023 or its doc-budget hook.",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** Exit 2: the check could not run. */
|
|
62
|
+
export class AgentNotesRefusal extends Error {}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Link text in place of a link, as GitHub does before it slugs a heading: `[text](url)` becomes
|
|
66
|
+
* `text`. Read with `indexOf` rather than one global pattern, which backtracks polynomially on a run
|
|
67
|
+
* of unclosed `[](` and is run on text this check does not control.
|
|
68
|
+
*/
|
|
69
|
+
function stripInline(text) {
|
|
70
|
+
let out = "";
|
|
71
|
+
let at = 0;
|
|
72
|
+
for (;;) {
|
|
73
|
+
const open = text.indexOf("[", at);
|
|
74
|
+
if (open < 0) break;
|
|
75
|
+
const close = text.indexOf("]", open + 1);
|
|
76
|
+
if (close < 0) break;
|
|
77
|
+
if (text[close + 1] !== "(") {
|
|
78
|
+
out += text.slice(at, close + 1);
|
|
79
|
+
at = close + 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const end = text.indexOf(")", close + 2);
|
|
83
|
+
if (end < 0) break;
|
|
84
|
+
out += text.slice(at, open) + text.slice(open + 1, close);
|
|
85
|
+
at = end + 1;
|
|
86
|
+
}
|
|
87
|
+
return out + text.slice(at);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* GitHub's heading slug: lower case, every character that is not a letter, a number, a space, `-`
|
|
92
|
+
* or `_` dropped, and each space a hyphen. A transcription of github-slugger, which matched GitHub
|
|
93
|
+
* on all twelve notes files the per-repository checkers read.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} text Heading text.
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
export function slugify(text) {
|
|
99
|
+
return stripInline(text)
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/[^\p{L}\p{N} \-_]/gu, "")
|
|
102
|
+
.replace(/ /g, "-");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A slugger that suffixes repeats the way GitHub does: the second `Setup` is `setup-1`.
|
|
107
|
+
*
|
|
108
|
+
* @returns {(text: string) => string}
|
|
109
|
+
*/
|
|
110
|
+
export function makeSlugger() {
|
|
111
|
+
const occurrences = new Map();
|
|
112
|
+
return (text) => {
|
|
113
|
+
const original = slugify(text);
|
|
114
|
+
let result = original;
|
|
115
|
+
while (occurrences.has(result)) {
|
|
116
|
+
occurrences.set(original, (occurrences.get(original) ?? 0) + 1);
|
|
117
|
+
result = `${original}-${String(occurrences.get(original))}`;
|
|
118
|
+
}
|
|
119
|
+
occurrences.set(result, 0);
|
|
120
|
+
return result;
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** An ATX heading's level and text, or `null`, read with index arithmetic (no backtracking). */
|
|
125
|
+
function atxHeading(line) {
|
|
126
|
+
let indent = 0;
|
|
127
|
+
while (indent < 3 && line[indent] === " ") indent += 1;
|
|
128
|
+
let level = 0;
|
|
129
|
+
while (line[indent + level] === "#") level += 1;
|
|
130
|
+
if (level === 0 || level > 6) return null;
|
|
131
|
+
const after = line[indent + level];
|
|
132
|
+
if (after !== undefined && after !== " " && after !== "\t") return null;
|
|
133
|
+
let text = line.slice(indent + level).trim();
|
|
134
|
+
let end = text.length;
|
|
135
|
+
while (end > 0 && text[end - 1] === "#") end -= 1;
|
|
136
|
+
if (end === 0) text = "";
|
|
137
|
+
else if (end < text.length && (text[end - 1] === " " || text[end - 1] === "\t")) {
|
|
138
|
+
text = text.slice(0, end).trimEnd();
|
|
139
|
+
}
|
|
140
|
+
return { level, text };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The headings and explicit anchors of a notes file, with each section's emptiness.
|
|
145
|
+
*
|
|
146
|
+
* Fenced code, a leading YAML front matter block and HTML comments carry no heading. ATX headings
|
|
147
|
+
* (up to three leading spaces, closing hashes stripped) and setext headings both count.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} text The notes file.
|
|
150
|
+
* @returns {{ targets: Set<string>, headings: { level: number, text: string, line: number,
|
|
151
|
+
* slug: string, empty: boolean }[] }}
|
|
152
|
+
* @throws {AgentNotesRefusal} On an HTML comment that never closes.
|
|
153
|
+
*/
|
|
154
|
+
export function readNotes(text) {
|
|
155
|
+
const lines = text.split("\n").map((line) => line.replace(/\r$/, ""));
|
|
156
|
+
const slug = makeSlugger();
|
|
157
|
+
const headings = [];
|
|
158
|
+
const content = new Array(lines.length).fill(false);
|
|
159
|
+
let fence = null;
|
|
160
|
+
let comment = -1;
|
|
161
|
+
let start = 0;
|
|
162
|
+
if (lines[0] === "---") {
|
|
163
|
+
const close = lines.indexOf("---", 1);
|
|
164
|
+
if (close > 0) start = close + 1;
|
|
165
|
+
}
|
|
166
|
+
let paragraph = [];
|
|
167
|
+
for (let i = start; i < lines.length; i += 1) {
|
|
168
|
+
const line = lines[i];
|
|
169
|
+
if (comment >= 0) {
|
|
170
|
+
if (line.includes("-->")) comment = -1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
174
|
+
if (fence !== null) {
|
|
175
|
+
content[i] = true;
|
|
176
|
+
if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length) fence = null;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (marker) {
|
|
180
|
+
fence = marker[1];
|
|
181
|
+
content[i] = true;
|
|
182
|
+
paragraph = [];
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (/^ {0,3}<!--/.test(line) && !line.includes("-->")) {
|
|
186
|
+
comment = i;
|
|
187
|
+
paragraph = [];
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const atx = atxHeading(line);
|
|
191
|
+
if (atx !== null) {
|
|
192
|
+
headings.push({ ...atx, line: i });
|
|
193
|
+
paragraph = [];
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const underline = line.trim();
|
|
197
|
+
if (
|
|
198
|
+
paragraph.length > 0 &&
|
|
199
|
+
/^ {0,3}\S/.test(line) &&
|
|
200
|
+
(/^=+$/.test(underline) || /^-+$/.test(underline))
|
|
201
|
+
) {
|
|
202
|
+
const level = underline[0] === "=" ? 1 : 2;
|
|
203
|
+
const first = paragraph[0];
|
|
204
|
+
for (const index of paragraph) content[index] = false;
|
|
205
|
+
headings.push({ level, text: paragraph.map((j) => lines[j].trim()).join("\n"), line: first });
|
|
206
|
+
paragraph = [];
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (line.trim() === "") {
|
|
210
|
+
paragraph = [];
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// An explicit anchor alone on its line marks a place; it is not content of the section.
|
|
214
|
+
if (/^<a\s[^>]*>(?:<\/a>)?$/.test(line.trim())) continue;
|
|
215
|
+
content[i] = true;
|
|
216
|
+
paragraph.push(i);
|
|
217
|
+
}
|
|
218
|
+
if (comment >= 0) {
|
|
219
|
+
throw new AgentNotesRefusal(
|
|
220
|
+
`${NOTES_PATH}:${comment + 1} opens an HTML comment that never closes, so where the notes ` +
|
|
221
|
+
`end could not be read.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
headings.sort((a, b) => a.line - b.line);
|
|
225
|
+
const targets = new Set();
|
|
226
|
+
for (const [index, heading] of headings.entries()) {
|
|
227
|
+
heading.slug = slug(heading.text);
|
|
228
|
+
targets.add(heading.slug);
|
|
229
|
+
const next = headings[index + 1];
|
|
230
|
+
const until = next === undefined ? lines.length : next.line;
|
|
231
|
+
const hasBody = content.slice(heading.line + 1, until).some(Boolean);
|
|
232
|
+
heading.empty = !hasBody && !(next !== undefined && next.level > heading.level);
|
|
233
|
+
}
|
|
234
|
+
// Explicit anchors, `<a id="x">` or `<a name="x">`, read one tag at a time.
|
|
235
|
+
let at = text.indexOf("<a");
|
|
236
|
+
while (at >= 0) {
|
|
237
|
+
const close = text.indexOf(">", at);
|
|
238
|
+
if (close < 0) break;
|
|
239
|
+
const tag = text.slice(at, close);
|
|
240
|
+
if (/^<a\s/.test(tag)) {
|
|
241
|
+
const attribute = /\b(?:id|name)\s*=\s*"([^"]+)"/.exec(tag);
|
|
242
|
+
if (attribute !== null) targets.add(attribute[1]);
|
|
243
|
+
}
|
|
244
|
+
at = text.indexOf("<a", close);
|
|
245
|
+
}
|
|
246
|
+
return { targets, headings };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Every qualified pointer in a text: `[prefix]agent-notes.md#anchor`, with its line. */
|
|
250
|
+
function qualifiedPointers(text) {
|
|
251
|
+
const found = [];
|
|
252
|
+
const pattern = new RegExp(
|
|
253
|
+
`(?<![\\w/.-])((?:\\.{1,2}/)*(?:documentation/)?)agent-notes\\.md#(${ANCHOR})`,
|
|
254
|
+
"gu",
|
|
255
|
+
);
|
|
256
|
+
for (const match of text.matchAll(pattern)) {
|
|
257
|
+
found.push({
|
|
258
|
+
prefix: match[1],
|
|
259
|
+
anchor: match[2],
|
|
260
|
+
line: text.slice(0, match.index).split("\n").length,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return found;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Check the notes and every pointer into them.
|
|
268
|
+
*
|
|
269
|
+
* @param {{ claude: string | null, notes: string | null, notesDirectory?: boolean,
|
|
270
|
+
* localDecision0023?: boolean, files: { path: string, text: string }[] }} input
|
|
271
|
+
* `claude` and `notes` are the two files' contents, or `null` when absent; `files` is every other
|
|
272
|
+
* tracked text file to scan for pointers.
|
|
273
|
+
* @returns {{ rule: string, file: string, line: number, message: string }[]}
|
|
274
|
+
* @throws {AgentNotesRefusal} When the notes cannot be read to the end.
|
|
275
|
+
*/
|
|
276
|
+
export function checkAgentNotes(input) {
|
|
277
|
+
const findings = [];
|
|
278
|
+
const add = (rule, file, line, message) => findings.push({ rule, file, line, message });
|
|
279
|
+
const { claude, notes } = input;
|
|
280
|
+
|
|
281
|
+
if (input.notesDirectory) {
|
|
282
|
+
add(
|
|
283
|
+
"layout",
|
|
284
|
+
NOTES_DIRECTORY,
|
|
285
|
+
0,
|
|
286
|
+
`the notes are one file, ${NOTES_PATH}; fold the files under ${NOTES_DIRECTORY} into it.`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let read = { targets: new Set(), headings: [] };
|
|
291
|
+
if (notes !== null) {
|
|
292
|
+
if (notes.trim() === "") {
|
|
293
|
+
add("blank", NOTES_PATH, 0, "the notes file is empty.");
|
|
294
|
+
} else {
|
|
295
|
+
read = readNotes(notes);
|
|
296
|
+
if (read.headings.length === 0) add("blank", NOTES_PATH, 0, "the notes file has no heading.");
|
|
297
|
+
for (const heading of read.headings) {
|
|
298
|
+
if (heading.empty) {
|
|
299
|
+
add(
|
|
300
|
+
"empty-section",
|
|
301
|
+
NOTES_PATH,
|
|
302
|
+
heading.line + 1,
|
|
303
|
+
`the section "${heading.text}" is empty.`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
notes.split("\n").forEach((line, index) => {
|
|
308
|
+
const deleted =
|
|
309
|
+
/doc-budget/i.test(line) ||
|
|
310
|
+
/0023-doc-budgets/.test(line) ||
|
|
311
|
+
(!input.localDecision0023 && /\bADR[ -]?0023\b/.test(line));
|
|
312
|
+
if (deleted) {
|
|
313
|
+
add(
|
|
314
|
+
"stale-citation",
|
|
315
|
+
NOTES_PATH,
|
|
316
|
+
index + 1,
|
|
317
|
+
"this cites the umbrella's deleted ADR 0023 or its doc-budget hook, neither of which " +
|
|
318
|
+
"exists; the card spec (documentation/claude-card.md in cosyte/config) sets the budget now.",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const sources = [
|
|
326
|
+
...(claude === null ? [] : [{ path: "CLAUDE.md", text: claude }]),
|
|
327
|
+
...(notes === null ? [] : [{ path: NOTES_PATH, text: notes }]),
|
|
328
|
+
...input.files,
|
|
329
|
+
];
|
|
330
|
+
let fromCard = 0;
|
|
331
|
+
for (const { path, text } of sources) {
|
|
332
|
+
for (const pointer of qualifiedPointers(text)) {
|
|
333
|
+
if (path === "CLAUDE.md") {
|
|
334
|
+
fromCard += 1;
|
|
335
|
+
if (pointer.prefix !== "documentation/") {
|
|
336
|
+
add(
|
|
337
|
+
"pointer-form",
|
|
338
|
+
path,
|
|
339
|
+
pointer.line,
|
|
340
|
+
`write the pointer as ${NOTES_PATH}#${pointer.anchor}, the one form.`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (notes === null) {
|
|
345
|
+
add(
|
|
346
|
+
"notes-missing",
|
|
347
|
+
path,
|
|
348
|
+
pointer.line,
|
|
349
|
+
`this points at ${NOTES_PATH}, which does not exist.`,
|
|
350
|
+
);
|
|
351
|
+
} else if (!read.targets.has(pointer.anchor)) {
|
|
352
|
+
add(
|
|
353
|
+
"dangling",
|
|
354
|
+
path,
|
|
355
|
+
pointer.line,
|
|
356
|
+
`#${pointer.anchor} names no heading or anchor in ${NOTES_PATH}.`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (path === NOTES_PATH) {
|
|
361
|
+
for (const match of text.matchAll(new RegExp(`\\]\\(#(${ANCHOR})\\)`, "gu"))) {
|
|
362
|
+
if (!read.targets.has(match[1])) {
|
|
363
|
+
add(
|
|
364
|
+
"dangling",
|
|
365
|
+
path,
|
|
366
|
+
text.slice(0, match.index).split("\n").length,
|
|
367
|
+
`#${match[1]} names no heading or anchor in this file.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (claude !== null) {
|
|
375
|
+
const lines = claude.split("\n");
|
|
376
|
+
lines.forEach((line, index) => {
|
|
377
|
+
for (const match of line.matchAll(new RegExp("`#(" + ANCHOR + ")`", "gu"))) {
|
|
378
|
+
if (/^\p{Nd}+$/u.test(match[1])) continue;
|
|
379
|
+
add(
|
|
380
|
+
"pointer-form",
|
|
381
|
+
"CLAUDE.md",
|
|
382
|
+
index + 1,
|
|
383
|
+
`a bare \`#${match[1]}\` is not a pointer anyone can follow; write ` +
|
|
384
|
+
`${NOTES_PATH}#${match[1]}.`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
if (line.includes(NOTES_DIRECTORY)) {
|
|
388
|
+
add(
|
|
389
|
+
"layout",
|
|
390
|
+
"CLAUDE.md",
|
|
391
|
+
index + 1,
|
|
392
|
+
`this points into ${NOTES_DIRECTORY}; the notes are one file, ${NOTES_PATH}.`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
if (notes !== null && fromCard === 0) {
|
|
397
|
+
add(
|
|
398
|
+
"no-pointer",
|
|
399
|
+
"CLAUDE.md",
|
|
400
|
+
0,
|
|
401
|
+
`${NOTES_PATH} exists and CLAUDE.md never points into it; point at the sections a ` +
|
|
402
|
+
`session needs as ${NOTES_PATH}#<anchor>.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return findings;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The tracked files of a repository: regular files read, everything else counted. */
|
|
410
|
+
function trackedFiles(dir) {
|
|
411
|
+
let listing;
|
|
412
|
+
try {
|
|
413
|
+
listing = execFileSync("git", ["ls-files", "-z", "-s"], {
|
|
414
|
+
cwd: dir,
|
|
415
|
+
encoding: "utf8",
|
|
416
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
417
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
418
|
+
});
|
|
419
|
+
} catch (err) {
|
|
420
|
+
throw new AgentNotesRefusal(`could not list the tracked files of ${dir}: ${err.message}`);
|
|
421
|
+
}
|
|
422
|
+
const files = [];
|
|
423
|
+
const skipped = [];
|
|
424
|
+
for (const record of listing.split("\0")) {
|
|
425
|
+
if (record === "") continue;
|
|
426
|
+
const tab = record.indexOf("\t");
|
|
427
|
+
const mode = record.slice(0, 6);
|
|
428
|
+
const path = record.slice(tab + 1);
|
|
429
|
+
if (mode !== "100644" && mode !== "100755") {
|
|
430
|
+
skipped.push(path);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
let bytes;
|
|
434
|
+
try {
|
|
435
|
+
bytes = readFileSync(join(dir, path));
|
|
436
|
+
} catch (err) {
|
|
437
|
+
throw new AgentNotesRefusal(`could not read the tracked file ${path}: ${err.message}`);
|
|
438
|
+
}
|
|
439
|
+
if (bytes.includes(0)) {
|
|
440
|
+
skipped.push(path);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
files.push({ path, text: bytes.toString("utf8") });
|
|
444
|
+
}
|
|
445
|
+
return { files, skipped };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Run the checker over one repository and return the exit code. It never exits the process.
|
|
450
|
+
*
|
|
451
|
+
* @param {{ dir?: string, argv?: string[] }} [options] `dir` is the repository root (default: the
|
|
452
|
+
* working directory). `argv` is the CLI arguments, read only for `--dir <path>`.
|
|
453
|
+
* @returns {0 | 1 | 2}
|
|
454
|
+
*/
|
|
455
|
+
export function runAgentNotesCheck(options = {}) {
|
|
456
|
+
let dir = options.dir ?? process.cwd();
|
|
457
|
+
const argv = options.argv ?? [];
|
|
458
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
459
|
+
if (argv[i] === "--dir" && typeof argv[i + 1] === "string") {
|
|
460
|
+
dir = argv[i + 1];
|
|
461
|
+
i += 1;
|
|
462
|
+
} else if (argv[i] !== "--") {
|
|
463
|
+
process.stderr.write(
|
|
464
|
+
`agent notes: ${JSON.stringify(argv[i])} is not an argument this check accepts (only ` +
|
|
465
|
+
`--dir <repository root>). Nothing was checked.\n`,
|
|
466
|
+
);
|
|
467
|
+
return 2;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
dir = resolve(dir);
|
|
471
|
+
try {
|
|
472
|
+
const { files, skipped } = trackedFiles(dir);
|
|
473
|
+
const byPath = new Map(files.map((file) => [file.path, file.text]));
|
|
474
|
+
const claude = byPath.get("CLAUDE.md") ?? null;
|
|
475
|
+
const notes = byPath.get(NOTES_PATH) ?? null;
|
|
476
|
+
const others = files.filter((f) => f.path !== "CLAUDE.md" && f.path !== NOTES_PATH);
|
|
477
|
+
const decisions = join(dir, "documentation", "decisions");
|
|
478
|
+
const findings = checkAgentNotes({
|
|
479
|
+
claude,
|
|
480
|
+
notes,
|
|
481
|
+
files: others,
|
|
482
|
+
notesDirectory: files.some((f) => f.path.startsWith(NOTES_DIRECTORY)),
|
|
483
|
+
localDecision0023:
|
|
484
|
+
existsSync(decisions) && readdirSync(decisions).some((name) => name.startsWith("0023-")),
|
|
485
|
+
});
|
|
486
|
+
if (findings.length === 0) {
|
|
487
|
+
process.stdout.write(
|
|
488
|
+
`agent notes: OK (${files.length} tracked file(s) read, ${skipped.length} skipped as ` +
|
|
489
|
+
`binary or not a regular file)\n`,
|
|
490
|
+
);
|
|
491
|
+
return 0;
|
|
492
|
+
}
|
|
493
|
+
process.stderr.write(
|
|
494
|
+
`agent notes: ${findings.length} finding(s).\n` +
|
|
495
|
+
findings
|
|
496
|
+
.map((f) => ` ${f.file}${f.line > 0 ? `:${f.line}` : ""} [${f.rule}] ${f.message}`)
|
|
497
|
+
.join("\n") +
|
|
498
|
+
"\n",
|
|
499
|
+
);
|
|
500
|
+
return 1;
|
|
501
|
+
} catch (err) {
|
|
502
|
+
if (err instanceof AgentNotesRefusal) {
|
|
503
|
+
process.stderr.write(`agent notes: ${err.message} Nothing was checked.\n`);
|
|
504
|
+
return 2;
|
|
505
|
+
}
|
|
506
|
+
throw err;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (isCliEntrypoint(import.meta.url)) {
|
|
511
|
+
process.exitCode = runAgentNotesCheck({ argv: process.argv.slice(2) });
|
|
512
|
+
}
|
package/attw.d.ts
CHANGED
|
@@ -34,3 +34,21 @@ export interface AttwGateOptions {
|
|
|
34
34
|
* for every refusal the gate raises.
|
|
35
35
|
*/
|
|
36
36
|
export declare function runAttwGate(options: AttwGateOptions): number;
|
|
37
|
+
|
|
38
|
+
/** Where the command-line form reads what a package leaves out: `scripts/attw.exclude`. */
|
|
39
|
+
export declare const ATTW_EXCLUDE_PATH: "scripts/attw.exclude";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read an attw exclusions file (one resolution per line: `node10`, `node16-cjs`; `#` comments)
|
|
43
|
+
* into the one profile it selects: `node10` alone selects `node16`, both select `esm-only`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function readAttwExclusions(text: string): {
|
|
46
|
+
profile: "node16" | "esm-only" | null;
|
|
47
|
+
problems: string[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The command-line form: run the gate for the package in the working directory, reading
|
|
52
|
+
* `scripts/attw.exclude`, with no caller file. `node node_modules/@cosyte/script-utils/attw.js`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function runAttwCli(argv: string[]): number;
|