@stonepandastudio/cairn 0.2.0

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,517 @@
1
+ 'use strict';
2
+
3
+ // cairn doctor — reports drift across the shared scaffolding in several repos.
4
+ // Read-only: it never writes to the repos it inspects.
5
+ //
6
+ // Two independent axes are reported, and keeping them separate matters:
7
+ //
8
+ // drift cross-repo. Do these five copies of a file still agree?
9
+ // managed per-repo. Does what cairn wrote still match what is on disk?
10
+ //
11
+ // A file can be perfectly in sync across repos and still be hand-modified away
12
+ // from its template, or vice versa. Collapsing them into one status column hides
13
+ // both signals.
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const crypto = require('crypto');
18
+
19
+ const { matchRule, readFile } = require('./scan');
20
+ const { buildNormalizer } = require('./normalize');
21
+ const diff = require('./diff');
22
+ const { makePaint, stripAnsi, table } = require('../paint');
23
+ const { loadRepoConfig } = require('../config');
24
+ const { readManifest, statusFor } = require('../manifest');
25
+
26
+ function hash(s) {
27
+ return crypto.createHash('sha1').update(s).digest('hex').slice(0, 12);
28
+ }
29
+
30
+ const STATUS_COLOR = {
31
+ IDENTICAL: 'green',
32
+ COSMETIC: 'cyan',
33
+ DRIFT: 'red',
34
+ MISSING: 'yellow',
35
+ STRANDED: 'magenta',
36
+ };
37
+
38
+ const MANAGED_COLOR = {
39
+ MANAGED: 'green',
40
+ OUTDATED: 'yellow',
41
+ MODIFIED: 'red',
42
+ DELETED: 'magenta',
43
+ };
44
+
45
+ // ------------------------------------------------------------------ workspace
46
+
47
+ // Resolve each workspace entry into a repo record. Vars come from the repo's own
48
+ // cairn.config.json when it has one, and fall back to the inline `vars` block in
49
+ // repos.json when it does not — so the doctor keeps working on repos that have
50
+ // not been migrated yet, and migration can happen one repo at a time.
51
+ function resolveRepos(config, configDir) {
52
+ return config.repos.map((entry) => {
53
+ const repoPath = path.resolve(configDir, entry.path);
54
+ const repoConfig = loadRepoConfig(repoPath, { fallbackVars: entry.vars || null });
55
+ return {
56
+ name: entry.name,
57
+ path: repoPath.replace(/\\/g, '/'),
58
+ stack: entry.stack || repoConfig.stack,
59
+ vars: { ...(repoConfig.vars || {}), ...(entry.vars || {}) },
60
+ tracker: repoConfig.tracker,
61
+ managed: !repoConfig.unmanaged,
62
+ // local -> canonical. Lets a repo that named a file differently still be
63
+ // compared against the cohort instead of showing up as MISSING+STRANDED.
64
+ pathAliases: entry.pathAliases || {},
65
+ };
66
+ });
67
+ }
68
+
69
+ // ------------------------------------------------------------------- analysis
70
+
71
+ function cohortGroups(rule, repos) {
72
+ if (rule.cohort === 'stack') {
73
+ const byStack = new Map();
74
+ for (const repo of repos) {
75
+ const key = repo.stack || 'unknown';
76
+ if (!byStack.has(key)) byStack.set(key, []);
77
+ byStack.get(key).push(repo);
78
+ }
79
+ return [...byStack.entries()].map(([name, members]) => ({ name, repos: members }));
80
+ }
81
+ return [{ name: 'all', repos }];
82
+ }
83
+
84
+ // A rule can declare prerequisites. `requires: { tracker: true }` skips repos
85
+ // with no issue tracker, so glossr-cli lacking a Jira client reads as "not
86
+ // applicable" rather than as a gap someone should go fill.
87
+ //
88
+ // A repo with no cairn.config.json has an *unknown* tracker, not an absent one,
89
+ // and unknown must not be treated as none — doing so silently emptied every
90
+ // tracker-gated rule while the repos were still being migrated.
91
+ function repoMeetsRequirements(repo, rule) {
92
+ const req = rule.requires;
93
+ if (!req) return true;
94
+ if (req.tracker === true && repo.managed && (!repo.tracker || repo.tracker.provider === 'none')) {
95
+ return false;
96
+ }
97
+ if (req.stack && repo.stack !== req.stack) return false;
98
+ return true;
99
+ }
100
+
101
+ function analyzeFile(relPath, groupRepos, contents, normalize) {
102
+ const present = [];
103
+ const absent = [];
104
+ for (const repo of groupRepos) {
105
+ if (contents.has(repo.name)) present.push(repo);
106
+ else absent.push(repo);
107
+ }
108
+
109
+ // Cluster by normalized content: each cluster is one distinct real variant.
110
+ const clusters = new Map();
111
+ for (const repo of present) {
112
+ const raw = contents.get(repo.name);
113
+ const norm = normalize(raw, repo);
114
+ const key = hash(norm);
115
+ if (!clusters.has(key)) {
116
+ clusters.set(key, { key, text: norm, lines: diff.splitLines(norm), repos: [] });
117
+ }
118
+ clusters.get(key).repos.push(repo.name);
119
+ }
120
+ const clusterList = [...clusters.values()].sort(
121
+ (a, b) => b.repos.length - a.repos.length || b.lines.length - a.lines.length,
122
+ );
123
+ clusterList.forEach((c, i) => {
124
+ c.label = String.fromCharCode(65 + i);
125
+ });
126
+
127
+ const rawKeys = new Set(present.map((r) => hash(contents.get(r.name).replace(/\r\n?/g, '\n'))));
128
+
129
+ // Everything is measured against the most widely shared variant.
130
+ const reference = clusterList[0] || null;
131
+ let worstChanged = 0;
132
+ let worstSimilarity = 1;
133
+ for (const cluster of clusterList.slice(1)) {
134
+ const stats = diff.diffStats(reference.lines, cluster.lines);
135
+ cluster.changed = stats.added + stats.removed;
136
+ cluster.similarity = diff.similarity(stats, reference.lines.length, cluster.lines.length);
137
+ cluster.approx = stats.approx;
138
+ worstChanged = Math.max(worstChanged, cluster.changed);
139
+ worstSimilarity = Math.min(worstSimilarity, cluster.similarity);
140
+ }
141
+
142
+ let status;
143
+ if (present.length === 1 && groupRepos.length > 1) status = 'STRANDED';
144
+ else if (clusterList.length > 1) status = 'DRIFT';
145
+ else if (absent.length > 0) status = 'MISSING';
146
+ else if (rawKeys.size > 1) status = 'COSMETIC';
147
+ else status = 'IDENTICAL';
148
+
149
+ return {
150
+ path: relPath,
151
+ status,
152
+ present: present.map((r) => r.name),
153
+ missing: absent.map((r) => r.name),
154
+ aliased: present
155
+ .filter((r) => Object.values(r.pathAliases || {}).includes(relPath))
156
+ .map((r) => r.name),
157
+ clusters: clusterList.map((c) => ({
158
+ label: c.label,
159
+ repos: c.repos,
160
+ lines: c.lines.length,
161
+ changed: c.changed || 0,
162
+ similarity: c.similarity === undefined ? 1 : c.similarity,
163
+ approx: !!c.approx,
164
+ })),
165
+ changedLines: worstChanged,
166
+ similarity: worstSimilarity,
167
+ cosmeticOnly: clusterList.length === 1 && rawKeys.size > 1,
168
+ };
169
+ }
170
+
171
+ // Collect a repo's files for a rule, folding any aliased path onto its canonical
172
+ // name so renamed-but-equivalent files land in the same comparison bucket.
173
+ function collectRepoFiles(repo, rule, byPath) {
174
+ const aliases = repo.pathAliases || {};
175
+ for (const rel of matchRule(repo.path, rule)) {
176
+ const text = readFile(repo.path, rel);
177
+ if (text === null) continue;
178
+ const key = aliases[rel] || rel;
179
+ if (!byPath.has(key)) byPath.set(key, new Map());
180
+ byPath.get(key).set(repo.name, text);
181
+ }
182
+ }
183
+
184
+ function analyze(repos, config, opts) {
185
+ const normalize = buildNormalizer(repos, {
186
+ ...(config.normalize || {}),
187
+ issueTypes: opts.issueTypes || (config.normalize || {}).issueTypes,
188
+ });
189
+
190
+ const sections = [];
191
+ for (const rule of config.shared) {
192
+ const eligible = repos.filter((r) => repoMeetsRequirements(r, rule));
193
+ for (const group of cohortGroups(rule, eligible)) {
194
+ if (group.repos.length < 2) continue;
195
+
196
+ // Union of relative paths any repo in the group has for this rule.
197
+ const byPath = new Map();
198
+ for (const repo of group.repos) collectRepoFiles(repo, rule, byPath);
199
+
200
+ const files = [...byPath.keys()]
201
+ .sort()
202
+ .map((rel) => analyzeFile(rel, group.repos, byPath.get(rel), normalize));
203
+
204
+ if (files.length) {
205
+ sections.push({
206
+ rule: rule.glob,
207
+ note: rule.note || null,
208
+ cohort: rule.cohort === 'stack' ? `stack:${group.name}` : 'all',
209
+ repos: group.repos.map((r) => r.name),
210
+ skipped: repos.filter((r) => !eligible.includes(r)).map((r) => r.name),
211
+ files,
212
+ });
213
+ }
214
+ }
215
+ }
216
+ return sections;
217
+ }
218
+
219
+ // Per-repo manifest state. Repos with no manifest are simply not managed yet.
220
+ function analyzeManaged(repos) {
221
+ const out = [];
222
+ for (const repo of repos) {
223
+ let manifest;
224
+ try {
225
+ manifest = readManifest(repo.path);
226
+ } catch (err) {
227
+ out.push({ repo: repo.name, error: err.message, files: [] });
228
+ continue;
229
+ }
230
+ if (!manifest) continue;
231
+ out.push({ repo: repo.name, files: statusFor(repo.path, manifest), error: null });
232
+ }
233
+ return out;
234
+ }
235
+
236
+ // --------------------------------------------------------------------- report
237
+
238
+ function render(sections, managed, repos, paint) {
239
+ const out = [];
240
+ out.push(
241
+ paint.bold('cairn doctor') +
242
+ paint.dim(` — ${repos.length} repos: ${repos.map((r) => r.name).join(', ')}`),
243
+ );
244
+
245
+ const unmanaged = repos.filter((r) => !r.managed);
246
+ if (unmanaged.length) {
247
+ out.push(
248
+ paint.dim(
249
+ ` no cairn.config.json yet (falling back to repos.json vars): ${unmanaged
250
+ .map((r) => r.name)
251
+ .join(', ')}`,
252
+ ),
253
+ );
254
+ }
255
+
256
+ for (const section of sections) {
257
+ out.push('');
258
+ out.push(
259
+ paint.bold(section.rule) +
260
+ paint.dim(` [cohort ${section.cohort}: ${section.repos.join(', ')}]`),
261
+ );
262
+ if (section.note) out.push(paint.dim(` ${section.note}`));
263
+ if (section.skipped && section.skipped.length) {
264
+ out.push(paint.dim(` n/a: ${section.skipped.join(', ')}`));
265
+ }
266
+ out.push('');
267
+
268
+ const rows = section.files.map((f) => {
269
+ const color = paint[STATUS_COLOR[f.status]];
270
+ const drift =
271
+ f.status === 'DRIFT'
272
+ ? `${f.changedLines} ln ${(f.similarity * 100).toFixed(0)}% same`
273
+ : f.cosmeticOnly
274
+ ? paint.dim('vars only')
275
+ : paint.dim('—');
276
+ const variants =
277
+ f.clusters.length > 1
278
+ ? f.clusters.map((c) => `${c.label}:${c.repos.join('+')}`).join(' ')
279
+ : paint.dim('—');
280
+ const notes = [];
281
+ if (f.missing.length) notes.push(paint.yellow(`missing: ${f.missing.join(', ')}`));
282
+ if (f.aliased.length) notes.push(paint.dim(`aliased in ${f.aliased.join(', ')}`));
283
+ return [
284
+ ' ' + f.path,
285
+ color(f.status),
286
+ `${f.present.length}/${section.repos.length}`,
287
+ drift,
288
+ variants,
289
+ notes.join(' '),
290
+ ];
291
+ });
292
+ out.push(table(rows, [' FILE', 'STATUS', 'HAVE', 'DRIFT', 'VARIANTS', 'GAP'], paint));
293
+ }
294
+
295
+ // ---- generated files, from each repo's manifest
296
+ if (managed.length) {
297
+ out.push('');
298
+ out.push(paint.bold('Generated files') + paint.dim(' [from _cairn/manifest.json]'));
299
+ out.push('');
300
+ const rows = [];
301
+ for (const repo of managed) {
302
+ if (repo.error) {
303
+ rows.push([' ' + repo.repo, paint.red('ERROR'), repo.error, '']);
304
+ continue;
305
+ }
306
+ for (const f of repo.files) {
307
+ rows.push([
308
+ ' ' + repo.repo,
309
+ paint[MANAGED_COLOR[f.state]](f.state),
310
+ f.path,
311
+ paint.dim(f.source),
312
+ ]);
313
+ }
314
+ }
315
+ out.push(table(rows, [' REPO', 'STATE', 'FILE', 'SOURCE'], paint));
316
+ }
317
+
318
+ // ---- roll-up
319
+ const all = sections.flatMap((s) => s.files.map((f) => ({ ...f, rule: s.rule })));
320
+ const counts = {};
321
+ for (const f of all) counts[f.status] = (counts[f.status] || 0) + 1;
322
+
323
+ out.push('');
324
+ out.push(paint.bold('Summary'));
325
+ out.push(
326
+ ' ' +
327
+ Object.keys(STATUS_COLOR)
328
+ .filter((k) => counts[k])
329
+ .map((k) => paint[STATUS_COLOR[k]](`${k} ${counts[k]}`))
330
+ .join(' '),
331
+ );
332
+
333
+ const drifted = all
334
+ .filter((f) => f.status === 'DRIFT')
335
+ .sort((a, b) => b.changedLines - a.changedLines)
336
+ .slice(0, 8);
337
+ if (drifted.length) {
338
+ out.push('');
339
+ out.push(paint.bold('Worst drift — extract these last, they need real merging'));
340
+ for (const f of drifted) {
341
+ out.push(` ${paint.red(String(f.changedLines).padStart(4))} ln ${f.path}`);
342
+ }
343
+ }
344
+
345
+ const stranded = all.filter((f) => f.status === 'STRANDED' || f.status === 'MISSING');
346
+ if (stranded.length) {
347
+ out.push('');
348
+ out.push(paint.bold('Promotion candidates — one repo has it, others do not'));
349
+ for (const f of stranded) {
350
+ out.push(` ${f.path} ${paint.dim(`in ${f.present.join(', ')}`)}`);
351
+ }
352
+ }
353
+
354
+ const easy = all.filter((f) => f.status === 'IDENTICAL' || f.status === 'COSMETIC');
355
+ if (easy.length) {
356
+ out.push('');
357
+ out.push(
358
+ paint.green(`${easy.length} files are already identical modulo project vars`) +
359
+ paint.dim(' — extract these first, zero merge cost.'),
360
+ );
361
+ }
362
+
363
+ return out.join('\n');
364
+ }
365
+
366
+ function renderDiff(repos, config, target, opts, paint) {
367
+ const normalize = buildNormalizer(repos, {
368
+ ...(config.normalize || {}),
369
+ issueTypes: opts.issueTypes || (config.normalize || {}).issueTypes,
370
+ });
371
+
372
+ const variants = new Map();
373
+ for (const repo of repos) {
374
+ // Honour aliases here too, so --diff on a canonical path finds a renamed copy.
375
+ const aliasEntry = Object.entries(repo.pathAliases || {}).find(
376
+ ([, canonical]) => canonical === target,
377
+ );
378
+ const localName = aliasEntry ? aliasEntry[0] : target;
379
+ const text = readFile(repo.path, localName);
380
+ if (text === null) continue;
381
+ const norm = normalize(text, repo);
382
+ const key = hash(norm);
383
+ if (!variants.has(key)) variants.set(key, { lines: diff.splitLines(norm), repos: [] });
384
+ variants.get(key).repos.push(repo.name);
385
+ }
386
+
387
+ if (variants.size === 0) return paint.yellow(`No repo has ${target}`);
388
+
389
+ const list = [...variants.values()].sort((a, b) => b.repos.length - a.repos.length);
390
+ const out = [paint.bold(target) + paint.dim(` ${list.length} variant(s), normalized`)];
391
+ const [reference, ...rest] = list;
392
+ out.push(paint.green(` A (reference): ${reference.repos.join(', ')}`));
393
+
394
+ if (rest.length === 0) {
395
+ out.push(paint.dim(' No normalized differences.'));
396
+ return out.join('\n');
397
+ }
398
+
399
+ rest.forEach((cluster, i) => {
400
+ const label = String.fromCharCode(66 + i);
401
+ out.push('');
402
+ out.push(paint.bold(` ${label}: ${cluster.repos.join(', ')}`) + paint.dim(` (A → ${label})`));
403
+ const ops = diff.diffOps(reference.lines, cluster.lines);
404
+ if (!ops) {
405
+ out.push(paint.yellow(' file too large for line-level diff'));
406
+ return;
407
+ }
408
+ for (const hunk of diff.hunks(ops, opts.context)) {
409
+ out.push(paint.dim(' ┄┄┄'));
410
+ for (const [kind, line] of hunk) {
411
+ if (kind === '=') out.push(paint.dim(` ${line}`));
412
+ else if (kind === '-') out.push(paint.red(` - ${line}`));
413
+ else out.push(paint.green(` + ${line}`));
414
+ }
415
+ }
416
+ });
417
+ return out.join('\n');
418
+ }
419
+
420
+ // ----------------------------------------------------------------------- main
421
+
422
+ const HELP = `cairn doctor — shared-file drift report
423
+
424
+ Usage: cairn doctor [options]
425
+
426
+ --config <file> workspace config (default: ./repos.json)
427
+ --diff <relPath> show hunks for one shared file across repos
428
+ --json emit JSON instead of the table
429
+ --strict exit 1 on drift, gaps, or hand-modified generated files
430
+ --normalize-issue-type also fold Story/Task issue-type wording together
431
+ --context <n> context lines around hunks (default 2)
432
+ --no-color disable ANSI colour
433
+ `;
434
+
435
+ function parseArgs(argv) {
436
+ const args = {
437
+ // Resolved against cwd in main(). The workspace config is the user's, not the
438
+ // package's — an installed cairn has no repos.json of its own to fall back to.
439
+ config: 'repos.json',
440
+ json: false,
441
+ diff: null,
442
+ strict: false,
443
+ color: process.stdout.isTTY,
444
+ issueTypes: false,
445
+ context: 2,
446
+ };
447
+ for (let i = 0; i < argv.length; i++) {
448
+ const a = argv[i];
449
+ if (a === '--config') args.config = argv[++i];
450
+ else if (a === '--json') args.json = true;
451
+ else if (a === '--diff') args.diff = argv[++i];
452
+ else if (a === '--strict') args.strict = true;
453
+ else if (a === '--no-color') args.color = false;
454
+ else if (a === '--color') args.color = true;
455
+ else if (a === '--normalize-issue-type') args.issueTypes = true;
456
+ else if (a === '--context') args.context = Number(argv[++i]);
457
+ else if (a === '-h' || a === '--help') args.help = true;
458
+ else {
459
+ console.error(`Unknown argument: ${a}`);
460
+ return { error: 2 };
461
+ }
462
+ }
463
+ return args;
464
+ }
465
+
466
+ function main(argv = process.argv.slice(2)) {
467
+ const opts = parseArgs(argv);
468
+ if (opts.error) return opts.error;
469
+ if (opts.help) {
470
+ process.stdout.write(HELP);
471
+ return 0;
472
+ }
473
+
474
+ const configFile = path.resolve(opts.config);
475
+ if (!fs.existsSync(configFile)) {
476
+ console.error(`Workspace config not found: ${configFile}`);
477
+ console.error(`Run cairn doctor from the directory holding repos.json, or pass --config <file>.`);
478
+ return 2;
479
+ }
480
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
481
+ const repos = resolveRepos(config, path.dirname(configFile));
482
+
483
+ const missingRepos = repos.filter((r) => !fs.existsSync(r.path));
484
+ if (missingRepos.length) {
485
+ console.error(`Repo path not found: ${missingRepos.map((r) => r.path).join(', ')}`);
486
+ return 2;
487
+ }
488
+
489
+ const paint = makePaint(opts.color);
490
+
491
+ if (opts.diff) {
492
+ console.log(renderDiff(repos, config, opts.diff.replace(/\\/g, '/'), opts, paint));
493
+ return 0;
494
+ }
495
+
496
+ const sections = analyze(repos, config, opts);
497
+ const managed = analyzeManaged(repos);
498
+
499
+ if (opts.json) {
500
+ console.log(JSON.stringify({ repos: repos.map((r) => r.name), sections, managed }, null, 2));
501
+ } else {
502
+ console.log(render(sections, managed, repos, paint));
503
+ }
504
+
505
+ if (opts.strict) {
506
+ const drifted = sections
507
+ .flatMap((s) => s.files)
508
+ .some((f) => f.status === 'DRIFT' || f.status === 'MISSING' || f.status === 'STRANDED');
509
+ const broken = managed.some(
510
+ (m) => m.error || m.files.some((f) => f.state === 'MODIFIED' || f.state === 'DELETED'),
511
+ );
512
+ if (drifted || broken) return 1;
513
+ }
514
+ return 0;
515
+ }
516
+
517
+ module.exports = { main, analyze, analyzeManaged, resolveRepos, render, HELP, stripAnsi };
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ const { escapeRe } = require('./scan');
4
+
5
+ // Raw file comparison across these repos is close to useless: every copy differs
6
+ // on the project key (GLO vs PROOF), on absolute paths, and on line endings.
7
+ // Normalizing those axes away is what separates "cosmetic, safe to extract" from
8
+ // "someone genuinely edited this in one repo only".
9
+
10
+ function buildNormalizer(repos, opts = {}) {
11
+ const keys = [...new Set(repos.map((r) => r.vars && r.vars.jiraKey).filter(Boolean))];
12
+ const keyAlt = keys.map(escapeRe).join('|');
13
+
14
+ const issueRe = keyAlt ? new RegExp(`\\b(?:${keyAlt})-\\d+\\b`, 'g') : null;
15
+ const bareKeyRe = keyAlt ? new RegExp(`\\b(?:${keyAlt})\\b`, 'g') : null;
16
+
17
+ // Longest paths first so D:/projects/snap-proof/backend wins over D:/projects.
18
+ const pathEntries = repos
19
+ .map((r) => r.path.replace(/\\/g, '/'))
20
+ .sort((a, b) => b.length - a.length);
21
+ const nameEntries = repos.map((r) => r.name).sort((a, b) => b.length - a.length);
22
+
23
+ // Var names to fold into placeholders, read from each repo's `vars` block.
24
+ // jiraKey is handled separately below because it also appears as `KEY-123`.
25
+ const varNames = (opts.vars || []).filter((v) => v !== 'jiraKey');
26
+
27
+ return function normalize(text, repo) {
28
+ let s = text.replace(/^/, '').replace(/\r\n?/g, '\n');
29
+
30
+ if (opts.trailingWhitespace !== false) {
31
+ s = s.replace(/[ \t]+$/gm, '');
32
+ s = s.replace(/\n+$/, '\n');
33
+ }
34
+
35
+ if (opts.repoPaths !== false) {
36
+ for (const p of pathEntries) {
37
+ // Match either separator flavour; markdown in these repos mixes them.
38
+ const flexible = escapeRe(p).replace(/\//g, '[\\\\/]');
39
+ s = s.replace(new RegExp(flexible, 'gi'), '{{REPO_PATH}}');
40
+ }
41
+ for (const n of nameEntries) {
42
+ s = s.replace(new RegExp(`\\b${escapeRe(n)}\\b`, 'g'), '{{REPO_NAME}}');
43
+ }
44
+ }
45
+
46
+ if (opts.jiraKeys !== false && issueRe) {
47
+ s = s.replace(issueRe, '{{ISSUE}}');
48
+ s = s.replace(bareKeyRe, '{{KEY}}');
49
+ }
50
+
51
+ // Fold declared per-repo vars (agent role names, agent doc paths, …). These
52
+ // are exactly the values a template would parameterize, so collapsing them
53
+ // shows what is left over — the drift that is nobody's variable.
54
+ if (repo && repo.vars && varNames.length) {
55
+ const pairs = varNames
56
+ .filter((name) => typeof repo.vars[name] === 'string' && repo.vars[name])
57
+ .map((name) => [name, repo.vars[name]])
58
+ .sort((a, b) => b[1].length - a[1].length);
59
+ for (const [name, value] of pairs) {
60
+ const bare = /^[\w-]+$/.test(value);
61
+ const re = new RegExp(bare ? `\\b${escapeRe(value)}\\b` : escapeRe(value), 'g');
62
+ s = s.replace(re, `{{${name}}}`);
63
+ }
64
+ }
65
+
66
+ // Off by default: "Task" and "Story" appear constantly as ordinary prose, so
67
+ // this normalizer trades false-identical risk for signal. Opt in when you
68
+ // specifically want to see past the Story/Task issue-type split.
69
+ if (opts.issueTypes && repo && repo.vars) {
70
+ const { storyType, subtaskType } = repo.vars;
71
+ if (subtaskType) {
72
+ s = s.replace(new RegExp(`\\b${escapeRe(subtaskType)}\\b`, 'g'), '{{SUBTASK_TYPE}}');
73
+ }
74
+ if (storyType) {
75
+ s = s.replace(new RegExp(`\\b${escapeRe(storyType)}\\b`, 'g'), '{{STORY_TYPE}}');
76
+ }
77
+ }
78
+
79
+ return s;
80
+ };
81
+ }
82
+
83
+ module.exports = { buildNormalizer };
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function escapeRe(s) {
7
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+ }
9
+
10
+ // Minimal glob: `*` matches within one segment, `**` matches across segments.
11
+ // Enough for the path shapes in repos.json; deliberately not a full glob engine.
12
+ function globToRegex(glob) {
13
+ const pattern = glob
14
+ .split('/')
15
+ .map((seg) => {
16
+ if (seg === '**') return '(?:.+)';
17
+ return seg
18
+ .split('*')
19
+ .map(escapeRe)
20
+ .join('[^/]*');
21
+ })
22
+ .join('/');
23
+ return new RegExp(`^${pattern}$`);
24
+ }
25
+
26
+ // The fixed directory prefix of a glob — everything before the first wildcard.
27
+ // Lets us walk only what a rule can possibly match instead of the whole repo,
28
+ // which matters because ai/tasks/ holds thousands of files no rule targets.
29
+ function globBase(glob) {
30
+ const segs = glob.split('/');
31
+ const out = [];
32
+ for (const seg of segs) {
33
+ if (seg.includes('*')) break;
34
+ out.push(seg);
35
+ }
36
+ // Drop the filename when the glob has no wildcard at all (exact-path rule).
37
+ if (out.length === segs.length) out.pop();
38
+ return out.join('/');
39
+ }
40
+
41
+ function walk(root, relBase, recursive, acc) {
42
+ const abs = relBase ? path.join(root, relBase) : root;
43
+ let entries;
44
+ try {
45
+ entries = fs.readdirSync(abs, { withFileTypes: true });
46
+ } catch {
47
+ return acc; // Missing directory is a finding, not an error — handled by caller.
48
+ }
49
+ for (const entry of entries) {
50
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
51
+ if (entry.isDirectory()) {
52
+ if (recursive && entry.name !== 'node_modules' && !entry.name.startsWith('.git')) {
53
+ walk(root, rel, recursive, acc);
54
+ }
55
+ } else if (entry.isFile()) {
56
+ acc.push(rel);
57
+ }
58
+ }
59
+ return acc;
60
+ }
61
+
62
+ // Relative paths in `repo` matching `rule`, sorted.
63
+ function matchRule(repoPath, rule) {
64
+ const re = globToRegex(rule.glob);
65
+ const excludes = (rule.exclude || []).map(globToRegex);
66
+ const base = globBase(rule.glob);
67
+ const recursive = rule.glob.includes('**');
68
+
69
+ const found = walk(repoPath, base, recursive, []);
70
+ return found
71
+ .filter((rel) => re.test(rel))
72
+ .filter((rel) => !excludes.some((ex) => ex.test(rel)))
73
+ .sort();
74
+ }
75
+
76
+ function readFile(repoPath, rel) {
77
+ try {
78
+ return fs.readFileSync(path.join(repoPath, rel), 'utf8');
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ module.exports = { matchRule, readFile, globToRegex, escapeRe };
package/lib/index.js ADDED
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ // Programmatic surface. The CLI is a thin wrapper over this, and the workflow
4
+ // runner that lands in v2 will consume it directly rather than shelling out.
5
+
6
+ const config = require('./config');
7
+ const manifest = require('./manifest');
8
+ const tracker = require('./tracker');
9
+ const doctor = require('./doctor');
10
+ const init = require('./init');
11
+
12
+ module.exports = {
13
+ ...config,
14
+ manifest,
15
+ tracker,
16
+ doctor,
17
+ init,
18
+ createTracker: tracker.createTracker,
19
+ trackerForRepo: tracker.trackerForRepo,
20
+ };