@arjunkhera/atlas 0.3.46 → 0.3.49

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,778 @@
1
+ // The owner-only reader. It answers one question for the gate and the review
2
+ // job: which files of a diff must come to the owner, and why.
3
+ //
4
+ // loadClasses() the package file owner-only.yaml, checked
5
+ // listPaths({ root, ref }) the floor, the named paths and the repo's list at ref
6
+ // classify({ root, base, head }) { owner, rows }: one row { path, class, cause } for each hit
7
+ // closure({ root }) the files that the entry points of a repo load
8
+ //
9
+ // Rules that hold everywhere here (design: docs/design-docs/owner-only-list/):
10
+ //
11
+ // 1. The floor, the named paths and the repo's own list come from the base
12
+ // commit, never from the head. A pull request cannot take itself off.
13
+ // 2. The reader reads text only. It never runs code from either commit.
14
+ // 3. It fails closed. A changed path that no rule clears, and that is outside
15
+ // `product_paths:`, is owner-only.
16
+ // 4. It reads the real bytes: no rename detection, no textconv, no external
17
+ // diff, and a .gitattributes `-diff` cannot hide a line.
18
+ // 5. A path matches on whole folder names, with no case, after `./` and `..`
19
+ // are cleaned.
20
+ import { execFileSync } from 'node:child_process';
21
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
22
+ import { dirname, join, posix } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
25
+
26
+ const HERE = dirname(fileURLToPath(import.meta.url));
27
+ export const LIST_FILE = join(HERE, '..', '..', 'owner-only.yaml');
28
+
29
+ export const SIGN_TYPES = Object.freeze(['path', 'added_line', 'secret', 'json_keys', 'surface', 'schema', 'any_change']);
30
+ const NETS = ['code', 'review'];
31
+
32
+ export class OwnerOnlyError extends Error {
33
+ constructor(message) { super(message); this.name = 'OwnerOnlyError'; }
34
+ }
35
+
36
+ // Sources are written with a leading (?i), which JavaScript spells as a flag.
37
+ export function compile(source, extra = '') {
38
+ const insensitive = source.startsWith('(?i)');
39
+ return new RegExp(insensitive ? source.slice(4) : source, `${insensitive ? 'i' : ''}${extra}`);
40
+ }
41
+
42
+ // ------------------------------------------------------------------ the file
43
+
44
+ export function loadClasses(text = readFileSync(LIST_FILE, 'utf8')) {
45
+ const data = parseYaml(text, { strict: true });
46
+ if (!data || !Array.isArray(data.classes)) throw new OwnerOnlyError('owner-only.yaml: there is no list of classes');
47
+ const ids = data.classes.map((entry) => entry?.id);
48
+ const known = ['money', 'credentials', 'outside-visibility', 'lost-data', 'security', 'new-service', 'rule-book', 'direction'];
49
+ for (const id of ids) if (!known.includes(id)) throw new OwnerOnlyError(`owner-only.yaml: unknown class "${id}". The classes are ${known.join(', ')}.`);
50
+ for (const id of known) if (!ids.includes(id)) throw new OwnerOnlyError(`owner-only.yaml: the class "${id}" is missing`);
51
+ if (new Set(ids).size !== ids.length) throw new OwnerOnlyError('owner-only.yaml: a class appears twice');
52
+ const signIds = new Set();
53
+ for (const entry of data.classes) {
54
+ if (!Array.isArray(entry.examples) || entry.examples.length !== 2) throw new OwnerOnlyError(`owner-only.yaml: the class "${entry.id}" needs two examples`);
55
+ if (!Array.isArray(entry.net) || !entry.net.length || entry.net.some((net) => !NETS.includes(net))) throw new OwnerOnlyError(`owner-only.yaml: the class "${entry.id}" has no net; a net is code, review or both`);
56
+ const signs = entry.signs ?? [];
57
+ if (entry.net.includes('code') && !signs.length && !['repo', 'floor'].includes(entry.signs_from)) throw new OwnerOnlyError(`owner-only.yaml: the code class "${entry.id}" has no sign`);
58
+ for (const sign of signs) {
59
+ if (!SIGN_TYPES.includes(sign.type)) throw new OwnerOnlyError(`owner-only.yaml: the sign "${sign.id}" has the unknown type "${sign.type}"`);
60
+ if (signIds.has(sign.id)) throw new OwnerOnlyError(`owner-only.yaml: the sign "${sign.id}" appears twice`);
61
+ signIds.add(sign.id);
62
+ if (sign.type !== 'secret' && !(Array.isArray(sign.paths) && sign.paths.length)) throw new OwnerOnlyError(`owner-only.yaml: the sign "${sign.id}" has no paths`);
63
+ for (const key of ['pattern', 'line', 'part']) if (sign[key] !== undefined) compile(sign[key]);
64
+ if (sign.type === 'added_line' && !sign.pattern) throw new OwnerOnlyError(`owner-only.yaml: the sign "${sign.id}" has no pattern`);
65
+ if (sign.type === 'surface' && !sign.line) throw new OwnerOnlyError(`owner-only.yaml: the sign "${sign.id}" has no line pattern`);
66
+ if (sign.version_key !== undefined && (sign.type !== 'any_change' || typeof sign.version_key !== 'string' || !/^[A-Za-z_]+$/.test(sign.version_key))) throw new OwnerOnlyError(`owner-only.yaml: the sign "${sign.id}" has a bad version_key`);
67
+ if (sign.type === 'json_keys' && !(Array.isArray(sign.keys) && sign.keys.length)) throw new OwnerOnlyError(`owner-only.yaml: the sign "${sign.id}" has no keys`);
68
+ }
69
+ }
70
+ if (!Array.isArray(data.floor) || !data.floor.length) throw new OwnerOnlyError('owner-only.yaml: there is no floor');
71
+ for (const entry of data.floor) if (typeof entry?.path !== 'string' || typeof entry?.rule !== 'string') throw new OwnerOnlyError('owner-only.yaml: each floor entry needs a path and a rule');
72
+ return data;
73
+ }
74
+
75
+ // ------------------------------------------------------------------ paths
76
+
77
+ // `./a/../b` becomes `b`. A path that climbs out of the repo is kept as it is,
78
+ // and nothing matches it but the fail-closed rule.
79
+ export function clean(path) {
80
+ const out = posix.normalize(String(path).replaceAll('\\', '/')).replace(/^\.\/+/, '').replace(/\/+$/, '');
81
+ return out === '.' ? '' : out;
82
+ }
83
+
84
+ const escape = (text) => text.replace(/[.+^${}()|[\]\\]/g, '\\$&');
85
+
86
+ // A pattern of owner-only.yaml or atlas.yaml. `**/` is any depth, `*` is one
87
+ // name, `?` one character, and a trailing `/` is a folder. It matches with no
88
+ // case and on whole names: `door/` never matches `door-x/`.
89
+ export function matcher(pattern) {
90
+ const folder = pattern.endsWith('/');
91
+ const body = clean(pattern);
92
+ let out = '';
93
+ for (let index = 0; index < body.length; index += 1) {
94
+ const character = body[index];
95
+ if (body.startsWith('**/', index)) { out += '(?:.*/)?'; index += 2; continue; }
96
+ if (body.startsWith('**', index)) { out += '.*'; index += 1; continue; }
97
+ if (character === '*') { out += '[^/]*'; continue; }
98
+ if (character === '?') { out += '[^/]'; continue; }
99
+ out += escape(character);
100
+ }
101
+ const regex = new RegExp(`^${out}${folder ? '/.+' : '(?:/.+)?'}$`, 'i');
102
+ return (path) => regex.test(clean(path));
103
+ }
104
+
105
+ const matchesAny = (patterns, path) => (patterns ?? []).some((pattern) => matcher(pattern)(path));
106
+
107
+ // ------------------------------------------------------------------ git
108
+
109
+ function git(root, args, { allowFail = false } = {}) {
110
+ try {
111
+ return execFileSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], { encoding: 'buffer', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
112
+ } catch (error) {
113
+ if (allowFail) return null;
114
+ throw new OwnerOnlyError(`git ${args[0]} failed: ${String(error.stderr ?? error.message).trim().split('\n')[0]}`);
115
+ }
116
+ }
117
+
118
+ // The text of one file at a commit, or null when it is not there. `ref` null
119
+ // reads the working tree.
120
+ export function readAt(root, ref, path) {
121
+ if (ref === null) {
122
+ const full = join(root, path);
123
+ try { return statSync(full).isFile() ? readFileSync(full, 'utf8') : null; } catch { return null; }
124
+ }
125
+ const out = git(root, ['cat-file', 'blob', `${ref}:${path}`], { allowFail: true });
126
+ return out === null ? null : out.toString('utf8');
127
+ }
128
+
129
+ // Every entry of the tree at a commit: { path, mode }.
130
+ export function treeAt(root, ref) {
131
+ if (ref === null) return walk(root).map((path) => ({ path, mode: '100644' }));
132
+ const out = git(root, ['ls-tree', '-r', '-z', '--full-tree', ref]).toString('utf8');
133
+ return out.split('\0').filter(Boolean).map((line) => {
134
+ const tab = line.indexOf('\t');
135
+ const [mode] = line.slice(0, tab).split(' ');
136
+ return { path: line.slice(tab + 1), mode };
137
+ });
138
+ }
139
+
140
+ function walk(root, prefix = '') {
141
+ const out = [];
142
+ for (const name of readdirSync(join(root, prefix)).sort()) {
143
+ if (name === '.git' || name === 'node_modules') continue;
144
+ const rel = prefix ? `${prefix}/${name}` : name;
145
+ let stat;
146
+ try { stat = statSync(join(root, rel)); } catch { continue; }
147
+ if (stat.isDirectory()) out.push(...walk(root, rel));
148
+ else out.push(rel);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ // Each changed path, from the raw diff: { status, path, oldMode, newMode }.
154
+ // Rename detection is off, so a move is a delete of the old path and an add of
155
+ // the new one, and both reach the rules.
156
+ export function changedPaths(root, base, head) {
157
+ const out = git(root, ['diff', '--raw', '-z', '--no-renames', '--no-ext-diff', '--no-textconv', base, head]).toString('utf8');
158
+ const fields = out.split('\0');
159
+ const rows = [];
160
+ for (let index = 0; index < fields.length - 1; index += 2) {
161
+ const meta = fields[index];
162
+ if (!meta.startsWith(':')) { index -= 1; continue; }
163
+ const [oldMode, newMode, , , status] = meta.slice(1).split(' ');
164
+ rows.push({ status, path: fields[index + 1], oldMode, newMode });
165
+ }
166
+ return rows;
167
+ }
168
+
169
+ // The added and removed lines of one file, from the real bytes.
170
+ export function lineChanges(root, base, head, path) {
171
+ const out = git(root, ['diff', '--no-renames', '--text', '--no-textconv', '--no-ext-diff', '--no-color', '-U0', base, head, '--', path]).toString('utf8');
172
+ const added = [], removed = [];
173
+ // The header lines (`+++ b/path`, `--- a/path`) come before the first hunk.
174
+ // Inside a hunk, an added line that starts with `++` is still an added line.
175
+ let inHunk = false;
176
+ for (const line of out.split('\n')) {
177
+ if (line.startsWith('diff --git ')) { inHunk = false; continue; }
178
+ if (line.startsWith('@@')) { inHunk = true; continue; }
179
+ if (!inHunk) continue;
180
+ if (line.startsWith('+')) added.push(line.slice(1));
181
+ else if (line.startsWith('-')) removed.push(line.slice(1));
182
+ }
183
+ return { added, removed };
184
+ }
185
+
186
+ // ------------------------------------------------------------------ atlas.yaml
187
+
188
+ function readYaml(text) {
189
+ if (text === null) return { data: null, error: null };
190
+ try { return { data: parseYaml(text, { strict: true }), error: null }; } catch (error) { return { data: null, error: error.message }; }
191
+ }
192
+
193
+ export function repoFacts(root, ref) {
194
+ const { data } = readYaml(readAt(root, ref, 'atlas.yaml'));
195
+ const facts = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
196
+ const list = Array.isArray(facts.owner_only) ? facts.owner_only.filter((entry) => entry && typeof entry.path === 'string') : [];
197
+ const product = Array.isArray(facts.product_paths) ? facts.product_paths.filter((entry) => typeof entry === 'string') : null;
198
+ return { facts, list, product };
199
+ }
200
+
201
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
202
+
203
+ // The key rule for atlas.yaml. It returns the reason the change is owner-only,
204
+ // or null when the change only adds entries under an add-only key.
205
+ export function atlasYamlCause(baseText, headText, rules) {
206
+ if (baseText === headText) return null;
207
+ const base = readYaml(baseText), head = readYaml(headText);
208
+ if (base.error || head.error) return 'atlas.yaml cannot be read in strict mode';
209
+ if (baseText === null || headText === null) return 'atlas.yaml added or removed';
210
+ const a = base.data ?? {}, b = head.data ?? {};
211
+ const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])];
212
+ const changed = keys.filter((key) => !same(a[key], b[key]));
213
+ if (!changed.length) return 'atlas.yaml text changed outside its keys';
214
+ for (const key of changed) {
215
+ if (rules.fixed.includes(key)) return `atlas.yaml key ${key} changed`;
216
+ if (!rules.add_only.includes(key)) return `atlas.yaml key ${key} changed, and R62 does not list it yet`;
217
+ if (!onlyAdds(a[key], b[key])) return `atlas.yaml key ${key}: an entry was removed, moved or changed`;
218
+ }
219
+ return null;
220
+ }
221
+
222
+ function onlyAdds(before, after) {
223
+ if (before === undefined || before === null) return true;
224
+ if (Array.isArray(before)) return Array.isArray(after) && after.length >= before.length && before.every((entry, index) => same(entry, after[index]));
225
+ if (typeof before === 'object') return after && typeof after === 'object' && !Array.isArray(after) && Object.keys(before).every((key) => same(before[key], after[key])) && same(Object.keys(before), Object.keys(after).slice(0, Object.keys(before).length));
226
+ return false;
227
+ }
228
+
229
+ // ------------------------------------------------------------------ named paths
230
+
231
+ const NAME_FORMS = [
232
+ /(?:^|\s)@([A-Za-z0-9_.~\-/]+[A-Za-z0-9_\-])/g, // @ import
233
+ /\]\(\s*<?([^)\s>#]+)/g, // Markdown link
234
+ ];
235
+
236
+ // The repo paths that one text names, by the three forms. A name is read from
237
+ // the folder of the file that names it, and from the repo root. `imports`
238
+ // keeps only the @ import form.
239
+ export function namesIn(text, fromFile, exists, { imports = false } = {}) {
240
+ const found = new Set();
241
+ const folder = posix.dirname(fromFile);
242
+ const words = [];
243
+ for (const form of imports ? NAME_FORMS.slice(0, 1) : NAME_FORMS) for (const match of text.matchAll(form)) words.push(match[1]);
244
+ // A command in backticks, such as `bash scripts/cloud/session.sh`, names
245
+ // each of its words.
246
+ if (!imports) for (const match of text.matchAll(/`([^`\n]+)`/g)) words.push(...match[1].split(/\s+/));
247
+ for (const word of words) {
248
+ const raw = word.replace(/[.,;:]+$/, '');
249
+ if (/^[a-z]+:\/\//i.test(raw) || raw.startsWith('#') || raw.startsWith('$') || raw.includes('*')) continue;
250
+ const candidates = [clean(posix.join(folder, raw)), clean(raw.replace(/^\/+/, ''))];
251
+ // Both readings count when both files are there, so the reader fails
252
+ // closed.
253
+ for (const candidate of candidates) {
254
+ if (candidate && !candidate.startsWith('..') && exists(candidate)) found.add(candidate);
255
+ }
256
+ }
257
+ return [...found];
258
+ }
259
+
260
+ // A name joins the floor when it is an instruction, a setting or a script:
261
+ // a doc, a data file, a shell script, a file with no extension, or a file in
262
+ // a scripts/ or bin/ folder. A source file that a rule only points at, such
263
+ // as `src/db.ts`, is product code and stays off the floor.
264
+ const isInstruction = (path) => /\.(md|markdown|mdx|txt|ya?ml|json|toml|sh|bash)$/i.test(path) || !/\.[A-Za-z0-9]+$/.test(posix.basename(path)) || /(^|\/)(scripts|bin)\//i.test(path);
265
+
266
+ const isText = (path) => /\.(md|markdown|txt|ya?ml|json)$/i.test(path) || !/\.[A-Za-z0-9]+$/.test(posix.basename(path));
267
+
268
+ // The plugin roots that a marketplace file or a plugin.json names.
269
+ function pluginRoots(tree, read) {
270
+ const roots = new Set();
271
+ for (const { path } of tree) {
272
+ const lower = path.toLowerCase();
273
+ if (lower.endsWith('.claude-plugin/plugin.json')) roots.add(clean(posix.dirname(posix.dirname(path))));
274
+ if (lower.endsWith('.claude-plugin/marketplace.json')) {
275
+ let data = null;
276
+ try { data = JSON.parse(read(path) ?? 'null'); } catch { data = null; }
277
+ const base = clean(posix.dirname(posix.dirname(path)));
278
+ // `metadata.pluginRoot` is a folder put in front of each plain source.
279
+ // Both readings count, so a root is never missed.
280
+ const prefix = typeof data?.metadata?.pluginRoot === 'string' ? data.metadata.pluginRoot : null;
281
+ for (const plugin of data?.plugins ?? []) {
282
+ const source = typeof plugin?.source === 'string' ? plugin.source : null;
283
+ if (!source || /^[a-z]+:\/\//i.test(source)) continue;
284
+ roots.add(clean(posix.join(base, source)));
285
+ if (prefix) roots.add(clean(posix.join(base, prefix, source)));
286
+ }
287
+ }
288
+ }
289
+ return [...roots];
290
+ }
291
+
292
+ // ------------------------------------------------------------------ the list
293
+
294
+ // Every rule that holds at a commit: the floor, the plugin roots, the named
295
+ // paths and the repo's own list. Each rule: { pattern, class, cause, except,
296
+ // version_only_exempt, atlas_yaml }. `named` holds exact paths.
297
+ export function rulesAt({ root, ref, classes = loadClasses() }) {
298
+ const tree = treeAt(root, ref);
299
+ const files = new Set(tree.map((entry) => entry.path));
300
+ const folders = new Set();
301
+ for (const path of files) { let at = posix.dirname(path); while (at !== '.' && !folders.has(at)) { folders.add(at); at = posix.dirname(at); } }
302
+ const exists = (path) => files.has(path) || folders.has(path);
303
+ const read = (path) => readAt(root, ref, path);
304
+ const rules = [];
305
+ for (const entry of classes.floor) {
306
+ rules.push({ pattern: entry.path, class: 'rule-book', cause: `floor: ${entry.rule}`, version_only_exempt: entry.version_only_exempt === true, atlas_yaml: entry.path === 'atlas.yaml', follow: entry.follow === true });
307
+ }
308
+ for (const pluginRoot of pluginRoots(tree, read)) {
309
+ for (const part of ['skills/', 'agents/', 'hooks/', 'commands/', '.mcp.json', '.claude-plugin/']) {
310
+ const pattern = pluginRoot ? `${pluginRoot}/${part}` : part;
311
+ rules.push({ pattern, class: 'rule-book', cause: `floor: plugin root ${pluginRoot || '.'}`, follow: part !== '.claude-plugin/' });
312
+ }
313
+ }
314
+ // Each script that a session start runs: the hook commands of the root
315
+ // settings and of each plugin root.
316
+ for (const { path, from } of hookScripts([''].concat(pluginRoots(tree, read)), read, (path) => files.has(path)).scripts) {
317
+ rules.push({ pattern: path, class: 'rule-book', cause: `floor: a session start runs it (${from})` });
318
+ }
319
+ // Names, followed from file to file, out of every floor file. The entry map
320
+ // is exempt: it is not on the floor for what only it names, and the reader
321
+ // does not follow names out of it.
322
+ const entryMap = clean(classes.entry_map ?? 'docs/index.md');
323
+ const floorMatch = (path) => rules.some((rule) => matcher(rule.pattern)(path));
324
+ const followed = rules.filter((rule) => rule.follow);
325
+ const seeds = [...files].filter((path) => followed.some((rule) => matcher(rule.pattern)(path)) && isText(path));
326
+ const named = new Map();
327
+ // Names are followed two steps: a file that a floor file names, and a file
328
+ // that such a file names. The other floor areas (archive/, docs/intake/, the
329
+ // locked design, atlas/) are history and facts, so no name is read out of
330
+ // them.
331
+ const stops = rules.filter((rule) => !rule.follow);
332
+ // A name is read from the folder of the file that names it first, then from
333
+ // the repo root. A nested entry file, such as src/CLAUDE.md, that says
334
+ // `bash scripts/check.sh` can mean a root script, so both count.
335
+ const queue = seeds.map((path) => ({ path, depth: 0 }));
336
+ const seen = new Set(seeds);
337
+ while (queue.length) {
338
+ const { path: from, depth } = queue.shift();
339
+ const text = read(from);
340
+ if (text === null) continue;
341
+ for (const name of namesIn(text, from, exists)) {
342
+ // A named folder is not followed: "the package lives in packages/atlas/"
343
+ // names a place, not a file of instructions.
344
+ for (const target of files.has(name) ? [name] : []) {
345
+ // The entry map is exempt by whichever file names it. "List the doc in
346
+ // docs/index.md" asks for an edit of the index; it does not make the
347
+ // index a file of instructions. Each new doc edits it, and a new doc
348
+ // that does not ship may merge alone.
349
+ if (target.toLowerCase() === entryMap.toLowerCase()) continue;
350
+ if (!isInstruction(target)) continue;
351
+ if (!named.has(target) && !floorMatch(target)) named.set(target, from);
352
+ const stop = stops.some((rule) => matcher(rule.pattern)(target));
353
+ if (!seen.has(target) && isText(target) && !stop && depth < 1) { seen.add(target); queue.push({ path: target, depth: depth + 1 }); }
354
+ }
355
+ }
356
+ }
357
+ // An @ import loads into every session, and Claude Code follows imports up
358
+ // to five steps deep, through any file, the entry map too. So each import
359
+ // chain from an entry file is on the floor, to that depth.
360
+ const importQueue = seeds.map((path) => ({ path, depth: 0 }));
361
+ const imported = new Set(seeds);
362
+ while (importQueue.length) {
363
+ const { path: from, depth } = importQueue.shift();
364
+ if (depth >= 5) continue;
365
+ const text = read(from);
366
+ if (text === null) continue;
367
+ for (const target of namesIn(text, from, exists, { imports: true })) {
368
+ if (!files.has(target) || imported.has(target)) continue;
369
+ imported.add(target);
370
+ if (!named.has(target) && !floorMatch(target)) named.set(target, `${from} (an @ import)`);
371
+ if (isText(target)) importQueue.push({ path: target, depth: depth + 1 });
372
+ }
373
+ }
374
+ for (const [path, from] of [...named].sort()) rules.push({ pattern: path, class: 'rule-book', cause: `floor: named in ${from}` });
375
+ const { list, product } = repoFacts(root, ref);
376
+ for (const entry of list) rules.push({ pattern: entry.path, class: entry.class, cause: `list: ${entry.path}`, except: Array.isArray(entry.except) ? entry.except : [] });
377
+ return { rules, product, tree };
378
+ }
379
+
380
+ const ruleHits = (rule, path) => matcher(rule.pattern)(path) && !matchesAny(rule.except, path);
381
+
382
+ export function listPaths({ root, ref = 'HEAD', classes = loadClasses() }) {
383
+ const { rules, product } = rulesAt({ root, ref, classes });
384
+ return { rows: rules.map((rule) => ({ path: rule.pattern, class: rule.class, cause: rule.cause, except: rule.except ?? [] })), product_paths: product };
385
+ }
386
+
387
+ // Does a path sit under the floor or the repo's list at that commit?
388
+ export function onList(rules, path) {
389
+ return rules.find((rule) => ruleHits(rule, path)) ?? null;
390
+ }
391
+
392
+ // ------------------------------------------------------------------ signs
393
+
394
+ // True when two JSON texts differ only in the value of one top-level key,
395
+ // line for line. A release moves `version` in package.json and plugin.json,
396
+ // and `atlas` in the kit manifest.
397
+ function versionOnly(baseText, headText, key = 'version') {
398
+ let a, b;
399
+ try { a = strictJson(baseText); b = strictJson(headText); } catch { return false; }
400
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) || Array.isArray(b)) return false;
401
+ const { [key]: va, ...ra } = a, { [key]: vb, ...rb } = b;
402
+ if (typeof va !== 'string' || typeof vb !== 'string' || va === vb || !same(sorted(ra), sorted(rb))) return false;
403
+ const lines = (text) => text.split('\n');
404
+ const x = lines(baseText), y = lines(headText);
405
+ if (x.length !== y.length) return false;
406
+ const keyLine = new RegExp(`^\\s*"${key}"\\s*:`);
407
+ return x.every((line, index) => line === y[index] || (keyLine.test(line) && keyLine.test(y[index])));
408
+ }
409
+
410
+ function sorted(value) {
411
+ if (Array.isArray(value)) return value.map(sorted);
412
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]));
413
+ return value;
414
+ }
415
+
416
+ // JSON.parse keeps the last of two equal keys. Refuse them, so that two
417
+ // readers never see two different files.
418
+ function strictJson(text) {
419
+ if (text === null) return null;
420
+ const keys = /"((?:[^"\\]|\\.)*)"\s*:/g;
421
+ const stack = [];
422
+ let depth = 0;
423
+ for (let index = 0; index < text.length; index += 1) {
424
+ const character = text[index];
425
+ if (character === '"') {
426
+ keys.lastIndex = index;
427
+ const match = keys.exec(text);
428
+ if (match && match.index === index) {
429
+ const set = stack[depth - 1];
430
+ if (set) { if (set.has(match[1])) throw new Error(`the key "${match[1]}" appears twice`); set.add(match[1]); }
431
+ index = match.index + match[0].length - 1;
432
+ continue;
433
+ }
434
+ const end = /"((?:[^"\\]|\\.)*)"/y; end.lastIndex = index; const string = end.exec(text);
435
+ index = string ? index + string[0].length - 1 : index;
436
+ continue;
437
+ }
438
+ if (character === '{') { stack[depth] = new Set(); depth += 1; }
439
+ else if (character === '[') { stack[depth] = null; depth += 1; }
440
+ else if (character === '}' || character === ']') depth -= 1;
441
+ }
442
+ return JSON.parse(text);
443
+ }
444
+
445
+ function surfaceLost(sign, baseText, headText) {
446
+ if (baseText === null) return null;
447
+ const line = compile(sign.line);
448
+ const part = sign.part ? compile(sign.part, 'g') : null;
449
+ const read = (text) => {
450
+ const out = new Map();
451
+ for (const row of (text ?? '').split('\n')) {
452
+ const match = line.exec(row);
453
+ if (!match) continue;
454
+ const parts = part ? [...row.slice(match.index + match[0].indexOf(match[1]) + match[1].length).matchAll(part)].map((hit) => hit[1]) : [];
455
+ out.set(match[1], new Set([...(out.get(match[1]) ?? []), ...parts]));
456
+ }
457
+ return out;
458
+ };
459
+ const before = read(baseText), after = read(headText);
460
+ for (const [key, parts] of before) {
461
+ if (!after.has(key)) return `${key} removed`;
462
+ for (const name of parts) if (!after.get(key).has(name)) return `${key} lost ${name}`;
463
+ }
464
+ return null;
465
+ }
466
+
467
+ function flatten(value, prefix = '', out = { keys: new Set(), enums: new Set(), required: new Set() }) {
468
+ if (Array.isArray(value)) {
469
+ value.forEach((entry, index) => flatten(entry, `${prefix}[${entry && typeof entry === 'object' && typeof entry.name === 'string' ? entry.name : index}]`, out));
470
+ return out;
471
+ }
472
+ if (value && typeof value === 'object') {
473
+ for (const [key, entry] of Object.entries(value)) {
474
+ const at = `${prefix}.${key}`;
475
+ out.keys.add(at);
476
+ if ((key === 'enum') && Array.isArray(entry)) for (const option of entry) out.enums.add(`${at}=${JSON.stringify(option)}`);
477
+ if ((key === 'required' || key === '$required') && Array.isArray(entry)) { for (const name of entry) out.required.add(`${at}=${name}`); continue; }
478
+ flatten(entry, at, out);
479
+ }
480
+ }
481
+ return out;
482
+ }
483
+
484
+ function schemaNarrowed(baseText, headText) {
485
+ let a, b;
486
+ try { a = flatten(strictJson(baseText)); b = flatten(strictJson(headText)); } catch { return 'the schema cannot be read'; }
487
+ for (const key of a.keys) if (!b.keys.has(key)) return `field ${key} removed`;
488
+ for (const option of a.enums) if (!b.enums.has(option)) return `enum value ${option} removed`;
489
+ for (const name of b.required) if (!a.required.has(name)) return `required field ${name} added`;
490
+ return null;
491
+ }
492
+
493
+ const versionOf = (path) => (/(\d+)\.(\d+)\.(\d+)/.exec(posix.basename(path)) ?? []).slice(1).map(Number);
494
+ const newer = (a, b) => { const x = versionOf(a), y = versionOf(b); for (let i = 0; i < 3; i += 1) if ((x[i] ?? -1) !== (y[i] ?? -1)) return (x[i] ?? -1) > (y[i] ?? -1); return a > b; };
495
+ const newest = (paths) => paths.reduce((best, path) => (best === null || newer(path, best) ? path : best), null);
496
+
497
+ // ------------------------------------------------------------------ classify
498
+
499
+ export function classify({ root, base, head, classes = loadClasses() }) {
500
+ const { rules, product, tree: baseTree } = rulesAt({ root, ref: base, classes });
501
+ const headTree = treeAt(root, head);
502
+ const changes = changedPaths(root, base, head);
503
+ const rows = [];
504
+ const add = (path, klass, cause) => { if (!rows.some((row) => row.path === path && row.cause === cause)) rows.push({ path, class: klass, cause }); };
505
+ const signs = classes.classes.flatMap((entry) => (entry.signs ?? []).map((sign) => ({ ...sign, class: entry.id })));
506
+ const secrets = SECRET_PATTERNS.map((pattern) => ({ id: pattern.id, regex: compile(pattern.source) }));
507
+
508
+ for (const change of changes) {
509
+ const { path } = change;
510
+ const hits = [];
511
+ // A symlink or a submodule can point anywhere, so it always comes to the owner.
512
+ if ([change.oldMode, change.newMode].includes('120000')) hits.push(['rule-book', 'a symlink changed']);
513
+ if ([change.oldMode, change.newMode].includes('160000')) hits.push(['rule-book', 'a submodule changed']);
514
+
515
+ const baseText = change.status === 'A' ? null : readAt(root, base, path);
516
+ const headText = change.status === 'D' ? null : readAt(root, head, path);
517
+ // A manifest whose version alone changed is a release, and the owner took
518
+ // releases off the list. The exemption holds for every rule on that path.
519
+ const release = change.status === 'M' && rules.some((rule) => rule.version_only_exempt && ruleHits(rule, path)) && versionOnly(baseText, headText);
520
+ // The kit manifest keeps its version under "atlas". A move of that key
521
+ // alone skips the kit-manifest sign and the repo's own list, never the
522
+ // floor.
523
+ const kitRelease = change.status === 'M' && signs.some((sign) => sign.version_key && matchesAny(sign.paths, path) && versionOnly(baseText, headText, sign.version_key));
524
+ for (const rule of release ? [] : rules) {
525
+ if (!ruleHits(rule, path)) continue;
526
+ if (kitRelease && rule.cause.startsWith('list: ')) continue;
527
+ if (rule.atlas_yaml) {
528
+ const cause = atlasYamlCause(baseText, headText, classes.atlas_yaml);
529
+ if (cause) hits.push([rule.class, `floor: ${cause}`]);
530
+ else hits.push([null, 'cleared']);
531
+ continue;
532
+ }
533
+ hits.push([rule.class, rule.cause]);
534
+ }
535
+
536
+ let lines = null;
537
+ const changesOf = () => (lines ??= lineChanges(root, base, head, path));
538
+ for (const sign of signs) {
539
+ if (sign.type !== 'secret' && !matchesAny(sign.paths, path)) continue;
540
+ const cause = `sign: ${sign.id}`;
541
+ if (kitRelease && sign.version_key) continue;
542
+ if (sign.type === 'path' || sign.type === 'any_change') hits.push([sign.class, cause]);
543
+ else if (sign.type === 'secret') {
544
+ const found = secrets.find((pattern) => changesOf().added.some((line) => pattern.regex.test(line)));
545
+ if (found) hits.push([sign.class, `${cause} (${found.id})`]);
546
+ } else if (sign.type === 'added_line') {
547
+ const regex = compile(sign.pattern);
548
+ if (changesOf().added.some((line) => regex.test(line))) hits.push([sign.class, cause]);
549
+ } else if (sign.type === 'json_keys') {
550
+ let a = null, b = null;
551
+ try { a = strictJson(baseText) ?? {}; b = strictJson(headText) ?? {}; } catch { hits.push([sign.class, `${cause} (the file cannot be read)`]); continue; }
552
+ const key = sign.keys.find((name) => !same(sorted(a[name]), sorted(b[name])));
553
+ if (key) hits.push([sign.class, `${cause} (${key})`]);
554
+ } else if (sign.type === 'surface') {
555
+ const lost = surfaceLost(sign, baseText, headText);
556
+ if (lost) hits.push([sign.class, `${cause} (${lost})`]);
557
+ } else if (sign.type === 'schema') {
558
+ const pick = (tree) => newest(tree.map((entry) => entry.path).filter((name) => matchesAny(sign.paths, name)));
559
+ const before = pick(baseTree), after = pick(headTree);
560
+ if (path !== before && path !== after) continue;
561
+ if (before === null) continue;
562
+ const narrowed = after === null ? 'the schema file is gone' : schemaNarrowed(readAt(root, base, before), readAt(root, head, after));
563
+ if (narrowed) hits.push([sign.class, `${cause} (${narrowed})`]);
564
+ }
565
+ }
566
+
567
+ const owner = hits.filter(([klass]) => klass !== null);
568
+ for (const [klass, cause] of owner) add(path, klass, cause);
569
+ const cleared = hits.some(([klass]) => klass === null);
570
+ if (!owner.length && !cleared && !(product && matchesAny(product, path))) {
571
+ add(path, null, product ? 'outside product_paths' : 'the repo names no product_paths');
572
+ }
573
+ }
574
+ return { owner: rows.length > 0, rows };
575
+ }
576
+
577
+ // ------------------------------------------------------------------ closure
578
+
579
+ // The entry points of a repo: each script that a workflow step, a session hook
580
+ // or an MCP server runs. Then every file that they load, by a static import or
581
+ // a fixed relative path that the code opens. A step that names a file that is
582
+ // not there is an error, so the closure cannot miss a script quietly.
583
+ const RUNS = /\b(?:node|bash|sh)\s+((?:--?[A-Za-z][\w-]*(?:=\S+)?\s+)*)([^\s'"|;&)]+)/g;
584
+
585
+ // The shell text of a workflow: each `run:` value, inline or as a block.
586
+ // Comments and every other key are left out.
587
+ export function runText(yaml) {
588
+ const out = [];
589
+ const lines = yaml.split('\n');
590
+ for (let index = 0; index < lines.length; index += 1) {
591
+ const match = /^(\s*)(?:-\s+)?run:\s*(.*)$/.exec(lines[index]);
592
+ if (!match) continue;
593
+ const rest = match[2].trim();
594
+ if (rest && !/^[|>][-+]?$/.test(rest)) { out.push(rest); continue; }
595
+ const indent = match[1].length;
596
+ while (index + 1 < lines.length && (lines[index + 1].trim() === '' || lines[index + 1].length - lines[index + 1].trimStart().length > indent)) {
597
+ index += 1;
598
+ const line = lines[index].trim();
599
+ if (line && !line.startsWith('#')) out.push(line);
600
+ }
601
+ }
602
+ return out.join('\n');
603
+ }
604
+
605
+ // The scripts that the hook commands of each root run. `read` reads a file,
606
+ // `exists` says whether a path is there. A command that names a script that
607
+ // is not there is an error.
608
+ export function hookScripts(roots, read, exists) {
609
+ const scripts = [], errors = [];
610
+ const under = (pluginRoot, name) => (pluginRoot ? `${pluginRoot}/${name}` : name);
611
+ const parse = (file) => {
612
+ const text = read(file);
613
+ if (text === null) return undefined;
614
+ try { return JSON.parse(text); } catch { errors.push(`${file} cannot be read`); return undefined; }
615
+ };
616
+ // One command line, split into words. "$CLAUDE_PROJECT_DIR"/hooks/x.sh and
617
+ // ${CLAUDE_PLUGIN_ROOT}/x.mjs both name a repo file. Quotes may sit inside
618
+ // the word.
619
+ const take = (command, pluginRoot, from) => {
620
+ for (const word of String(command).split(/\s+/)) {
621
+ const bare = word.replace(/["']/g, '');
622
+ const path = clean(bare.replace(/\$\{?CLAUDE_PLUGIN_ROOT\}?/g, pluginRoot || '.').replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, '.'));
623
+ const script = /\.(m?js|cjs|sh|bash|py)$/.test(path);
624
+ // A script with no extension counts when the command names it by a
625
+ // path and the file is there.
626
+ if (!script && !(path.includes('/') && !path.startsWith('$') && exists(path))) continue;
627
+ if (exists(path)) scripts.push({ path, from });
628
+ else errors.push(`${from} runs ${word}, which is not in the repo`);
629
+ }
630
+ };
631
+ const hooksIn = (data, pluginRoot, from) => { for (const command of commandsIn(data)) take(command, pluginRoot, from); };
632
+ // Each server runs its command with its args.
633
+ const serversIn = (data, pluginRoot, from) => {
634
+ for (const server of Object.values(data ?? {})) {
635
+ if (!server || typeof server !== 'object') continue;
636
+ for (const word of [server.command, ...(Array.isArray(server.args) ? server.args : [])]) if (typeof word === 'string') take(word, pluginRoot, from);
637
+ }
638
+ };
639
+ // A plugin.json names hooks and servers inline, or by a path, or by a list
640
+ // of paths.
641
+ const byPath = (value, pluginRoot, from, each) => {
642
+ if (value && typeof value === 'object' && !Array.isArray(value)) { each(value, pluginRoot, from); return; }
643
+ for (const name of [value].flat()) {
644
+ if (typeof name !== 'string') continue;
645
+ const file = clean(posix.join(pluginRoot || '.', name.replace(/\$\{?CLAUDE_PLUGIN_ROOT\}?/g, '.')));
646
+ const data = parse(file);
647
+ if (data === undefined) { if (!exists(file)) errors.push(`${from} names ${name}, which is not in the repo`); continue; }
648
+ each(data, pluginRoot, file);
649
+ }
650
+ };
651
+ for (const pluginRoot of [...new Set(roots)]) {
652
+ const base = clean(pluginRoot || '.');
653
+ for (const name of ['hooks/hooks.json', '.claude/settings.json', '.claude/settings.local.json']) {
654
+ const data = parse(under(pluginRoot, name));
655
+ if (data !== undefined) hooksIn(data, base, under(pluginRoot, name));
656
+ }
657
+ const mcp = parse(under(pluginRoot, '.mcp.json'));
658
+ if (mcp !== undefined) serversIn(mcp?.mcpServers ?? mcp, base, under(pluginRoot, '.mcp.json'));
659
+ const manifestFile = under(pluginRoot, '.claude-plugin/plugin.json');
660
+ const manifest = parse(manifestFile);
661
+ if (manifest && typeof manifest === 'object') {
662
+ if (manifest.hooks !== undefined) byPath(manifest.hooks, base, manifestFile, hooksIn);
663
+ if (manifest.mcpServers !== undefined) byPath(manifest.mcpServers, base, manifestFile, (data, root, from) => serversIn(data?.mcpServers ?? data, root, from));
664
+ }
665
+ }
666
+ return { scripts, errors };
667
+ }
668
+
669
+ function commandsIn(value, out = []) {
670
+ if (Array.isArray(value)) for (const entry of value) commandsIn(entry, out);
671
+ else if (value && typeof value === 'object') for (const [key, entry] of Object.entries(value)) {
672
+ if (key === 'command' && typeof entry === 'string') out.push(entry);
673
+ else commandsIn(entry, out);
674
+ }
675
+ return out;
676
+ }
677
+
678
+ export function entryPoints(root) {
679
+ const entries = [], errors = [];
680
+ const workflows = join(root, '.github', 'workflows');
681
+ const tests = [];
682
+ if (existsSync(workflows)) {
683
+ for (const name of readdirSync(workflows).sort()) {
684
+ if (!/\.ya?ml$/.test(name)) continue;
685
+ const raw = readFileSync(join(workflows, name), 'utf8');
686
+ // `npm test` and `npm run <name>` run a script of the root package.json.
687
+ let scripts = {};
688
+ try { scripts = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts ?? {}; } catch { scripts = {}; }
689
+ const text = runText(raw).replace(/\bnpm\s+(?:run\s+)?([\w:-]+)/g, (whole, script) => (typeof scripts[script] === 'string' ? `${whole}\n${scripts[script]}\n` : whole));
690
+ for (const match of text.matchAll(RUNS)) {
691
+ const flags = match[1] ?? '';
692
+ const target = match[2];
693
+ if (target.includes('$') || target.startsWith('-')) continue;
694
+ if (/--test\b/.test(flags)) {
695
+ // node --test <globs>: the test files are entries, and only what
696
+ // they load counts. Collect each glob after --test on that line.
697
+ const lineEnd = text.indexOf('\n', match.index);
698
+ const rest = text.slice(match.index, lineEnd === -1 ? undefined : lineEnd).split(/\s+/).slice(1).filter((word) => !word.startsWith('-') && !word.includes('$'));
699
+ for (const glob of rest) tests.push(glob);
700
+ continue;
701
+ }
702
+ const path = clean(target);
703
+ if (!existsSync(join(root, path))) { errors.push(`.github/workflows/${name} runs ${target}, which is not in the repo`); continue; }
704
+ entries.push({ path, from: `.github/workflows/${name}`, counts: true });
705
+ }
706
+ }
707
+ }
708
+ const tree = walk(root).map((path) => ({ path }));
709
+ const roots = ['', ...pluginRoots(tree, (path) => readAt(root, null, path))];
710
+ const under = (pluginRoot, name) => (pluginRoot ? `${pluginRoot}/${name}` : name);
711
+ const present = (path) => existsSync(join(root, path));
712
+ const hooks = hookScripts(roots, (path) => readAt(root, null, path), present);
713
+ for (const { path, from } of hooks.scripts) entries.push({ path, from, counts: true });
714
+ errors.push(...hooks.errors);
715
+ const all = tree.map((entry) => entry.path);
716
+ for (const glob of tests) {
717
+ const match = matcher(glob);
718
+ for (const path of all.filter((file) => match(file))) entries.push({ path, from: 'node --test', counts: false });
719
+ }
720
+ return { entries, errors };
721
+ }
722
+
723
+ const IMPORTS = [
724
+ /\bimport\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/g,
725
+ /\bexport\s+[^'"]*?\s+from\s+['"]([^'"]+)['"]/g,
726
+ /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
727
+ /\bnew URL\(\s*['"]([^'"]+)['"]\s*,\s*import\.meta\.url\s*\)/g,
728
+ ];
729
+
730
+ // `join(HERE, 'a', 'b.json')`, where HERE is the folder of the module: the
731
+ // way this repo opens a file beside its code.
732
+ const JOINS = /\b(?:join|resolve)\(\s*(?:HERE|__dirname)\s*((?:,\s*['"][^'"]+['"]\s*)+)\)/g;
733
+
734
+ export function closure({ root }) {
735
+ const { entries, errors } = entryPoints(root);
736
+ const files = new Map();
737
+ const queue = entries.map((entry) => ({ ...entry }));
738
+ const visited = new Set();
739
+ while (queue.length) {
740
+ const { path, from, counts } = queue.shift();
741
+ const key = `${path}|${counts}`;
742
+ if (visited.has(key)) continue;
743
+ visited.add(key);
744
+ if (counts && !files.has(path)) files.set(path, from);
745
+ if (!/\.(m?js)$/.test(path) && !isNodeScript(root, path)) continue;
746
+ const text = readAt(root, null, path);
747
+ if (text === null) continue;
748
+ const specs = [];
749
+ for (const form of IMPORTS) for (const match of text.matchAll(form)) specs.push(match[1]);
750
+ for (const match of text.matchAll(JOINS)) specs.push(`./${[...match[1].matchAll(/['"]([^'"]+)['"]/g)].map((part) => part[1]).join('/')}`);
751
+ for (const spec of specs) {
752
+ if (!spec.startsWith('.')) continue;
753
+ const target = clean(posix.join(posix.dirname(path), spec));
754
+ if (!existsSync(join(root, target))) continue;
755
+ if (statSync(join(root, target)).isDirectory()) continue;
756
+ // What a unit test loads from its own test folders is part of the
757
+ // suite, not of what defines green. It is still followed.
758
+ const suite = !counts && /(^|\/)(test|tests|fixtures)\//.test(target) && !/^packages\//.test(target);
759
+ queue.push({ path: target, from: path, counts: !suite });
760
+ }
761
+ }
762
+ return { files: [...files].map(([path, from]) => ({ path, from })).sort((a, b) => a.path.localeCompare(b.path)), errors };
763
+ }
764
+
765
+ function isNodeScript(root, path) {
766
+ const text = readAt(root, null, path);
767
+ return text !== null && /^#!.*\bnode\b/.test(text);
768
+ }
769
+
770
+ // The files of a closure that no rule covers. A test file that an `except`
771
+ // takes off a list still counts as uncovered here: code that the checks load
772
+ // is never exempt as a test.
773
+ export function uncovered({ root, ref = 'HEAD', classes = loadClasses() }) {
774
+ const { rules } = rulesAt({ root, ref, classes });
775
+ const { files, errors } = closure({ root });
776
+ const missing = files.filter(({ path }) => !rules.some((rule) => ruleHits(rule, path)));
777
+ return { missing, errors };
778
+ }