@mlmcps/ml-specs-mcp 1.0.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +347 -0
  3. package/agents/coder.md +76 -0
  4. package/agents/developer.md +78 -0
  5. package/agents/pr-author.md +36 -0
  6. package/agents/reviewer.md +65 -0
  7. package/agents/scanner.md +66 -0
  8. package/agents/spec-author.md +91 -0
  9. package/agents/spec-reviewer.md +59 -0
  10. package/commands/code.md +29 -0
  11. package/commands/fix.md +67 -0
  12. package/commands/nfr.md +114 -0
  13. package/commands/pr.md +32 -0
  14. package/commands/repo-adopt.md +86 -0
  15. package/commands/repo-doctor.md +57 -0
  16. package/commands/repo-estate.md +79 -0
  17. package/commands/repo-impact.md +77 -0
  18. package/commands/repo-init.md +155 -0
  19. package/commands/repo-refresh.md +58 -0
  20. package/commands/repo-rollout.md +84 -0
  21. package/commands/repo-status.md +59 -0
  22. package/commands/spec-advance.md +81 -0
  23. package/commands/spec-build.md +66 -0
  24. package/commands/spec-fanout.md +64 -0
  25. package/commands/spec-review.md +24 -0
  26. package/commands/spec-verify.md +55 -0
  27. package/commands/spec.md +73 -0
  28. package/mcp/README.md +173 -0
  29. package/mcp/ml-specs-server.mjs +708 -0
  30. package/package.json +44 -0
  31. package/scripts/branch-policy.mjs +71 -0
  32. package/scripts/fix-specs.mjs +289 -0
  33. package/scripts/lib/cli.mjs +43 -0
  34. package/scripts/lib/estate.mjs +108 -0
  35. package/scripts/lib/http.mjs +73 -0
  36. package/scripts/lib/knowledge.mjs +91 -0
  37. package/scripts/lib/nfr.mjs +119 -0
  38. package/scripts/lib/policy.mjs +114 -0
  39. package/scripts/lib/scm.mjs +189 -0
  40. package/scripts/lib/specs.mjs +192 -0
  41. package/scripts/lib/trace.mjs +90 -0
  42. package/scripts/lib/tracker.mjs +257 -0
  43. package/scripts/nfr-compile.mjs +120 -0
  44. package/scripts/spec-brief.mjs +127 -0
  45. package/scripts/spec-dashboard.mjs +331 -0
  46. package/scripts/spec-fanout.mjs +120 -0
  47. package/scripts/spec-gate.mjs +329 -0
  48. package/scripts/spec-trace.mjs +91 -0
  49. package/scripts/survey-estate.mjs +230 -0
  50. package/scripts/tracker-sync.mjs +91 -0
  51. package/templates/ci/knowledge-check.mjs +176 -0
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@mlmcps/ml-specs-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Read-only MCP server for spec-driven development: estate contract lookup, knowledge-layer checks, and spec inventory over a local repo, plus the toolkit's spec-driven commands served as MCP prompts.",
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node --test 'scripts/lib/*.test.mjs' 'scripts/*.test.mjs' 'mcp/*.test.mjs'"
8
+ },
9
+ "private": false,
10
+ "license": "MIT",
11
+ "bin": {
12
+ "ml-specs-mcp": "mcp/ml-specs-server.mjs"
13
+ },
14
+ "files": [
15
+ "mcp/",
16
+ "commands/",
17
+ "agents/",
18
+ "scripts/",
19
+ "!scripts/**/*.test.mjs",
20
+ "!scripts/lib/*.test.mjs",
21
+ "!mcp/*.test.mjs",
22
+ "templates/ci/knowledge-check.mjs",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "publishConfig": {
30
+ "registry": "https://registry.npmjs.org",
31
+ "access": "public"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/MLMCPS/ml-claude-plugins.git",
36
+ "directory": "ml-specs"
37
+ },
38
+ "keywords": [
39
+ "mcp",
40
+ "spec-driven-development",
41
+ "claude"
42
+ ],
43
+ "dependencies": {}
44
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // Install and audit the branch policy that makes the spec gate blocking.
3
+ //
4
+ // node branch-policy.mjs audit api-neelias neelias-pos
5
+ // node branch-policy.mjs install api-neelias --dry-run
6
+ // node branch-policy.mjs audit api-neelias --json
7
+ //
8
+ // Credentials from the environment: ADO_ORG/ADO_PROJECT/ADO_PAT, or
9
+ // GITHUB_OWNER/GITHUB_TOKEN with SDD_SCM_TOOL=github.
10
+ // Branch from SDD_PROTECTED_BRANCH (default main); build ref from SDD_BUILD_ID.
11
+ //
12
+ // Exit code 1 if any branch is ungated.
13
+ //
14
+ // Why this exists: ci/azure-pipelines.yml describes the checks, but a pipeline
15
+ // that runs and reports changes nothing. The gate is the POLICY. And the way a
16
+ // gate stops being a gate is silent — someone sets it to advisory on a Friday to
17
+ // unblock a release, nothing breaks, no test fails, and merges quietly stop
18
+ // being gated until an audit finds the holes months later.
19
+
20
+ import { adoPolicy, githubPolicy, auditOk, auditSummary } from './lib/policy.mjs';
21
+ import { args, colours, transportFor, scmConfig, env, printTranscript } from './lib/cli.mjs';
22
+
23
+ const { positional, json, dryRun } = args();
24
+ const [action, ...repos] = positional;
25
+ const C = colours(process.stdout.isTTY && !json);
26
+
27
+ if (!['install', 'audit'].includes(action) || repos.length === 0) {
28
+ console.error('usage: branch-policy.mjs <install|audit> <repo...> [--dry-run] [--json]');
29
+ process.exit(1);
30
+ }
31
+
32
+ const transport = transportFor(dryRun);
33
+ const { tool, config } = scmConfig(transport);
34
+ const mgr = tool === 'github' ? githubPolicy(config) : adoPolicy(config);
35
+ const branch = env('SDD_PROTECTED_BRANCH', 'main');
36
+ const buildRef = env('SDD_BUILD_ID', 'sdd-spec-gate');
37
+
38
+ if (dryRun && !json) console.log(`${C.bold('dry run')} ${C.dim('— nothing is sent')}\n`);
39
+
40
+ const results = [];
41
+ for (const repo of repos) {
42
+ try {
43
+ results.push(action === 'install'
44
+ ? await mgr.install(repo, branch, buildRef)
45
+ : await mgr.audit(repo, branch));
46
+ } catch (e) {
47
+ results.push({ repo, branch, tool, present: false, blocking: false,
48
+ findings: [{ severity: 'blocker', message: e.message }] });
49
+ }
50
+ }
51
+
52
+ const allOk = results.every(auditOk);
53
+
54
+ if (json) {
55
+ console.log(JSON.stringify({ action, branch, tool, ok: allOk, results }, null, 2));
56
+ } else {
57
+ for (const r of results) {
58
+ console.log(` ${auditOk(r) ? C.green('OK ') : C.red('FAIL')} ${r.repo.padEnd(22)} ${C.dim(branch)} ${auditSummary(r)}`);
59
+ for (const f of r.findings) {
60
+ const paint = f.severity === 'blocker' ? C.red : C.yellow;
61
+ console.log(` ${paint(f.severity.padEnd(8))} ${C.dim(f.message)}`);
62
+ }
63
+ }
64
+ console.log();
65
+ console.log(allOk
66
+ ? ` ${C.green('every branch is gated')}`
67
+ : ` ${C.red('an ungated branch means a pull request can merge with no approved spec')}`);
68
+ printTranscript(transport, C);
69
+ }
70
+
71
+ process.exit(allOk ? 0 : 1);
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ // Repair spec hygiene in a repo that has been using specs/ for a while.
3
+ // Pure Node, no dependencies. Runs entirely on your machine — no network calls.
4
+ //
5
+ // node fix-specs.mjs --root /path/to/repo # dry run: prints the plan, writes nothing
6
+ // node fix-specs.mjs --root /path/to/repo --apply # actually does it
7
+ //
8
+ // Two repairs, both opt-outable (--no-numbering / --no-status):
9
+ //
10
+ // NUMBERING Two specs sharing a number is a filename collision — git merges those badly.
11
+ // The later-created file is renumbered to the next free number, across ALL
12
+ // branches, and references to its old filename are rewritten.
13
+ //
14
+ // STATUS A Status cell holding prose ("Implemented — all 16 ACs met, gate green…")
15
+ // is real information in the wrong field: /spec-advance, /repo-status, and the
16
+ // MCP spec_list tool all read Status as one word. The lifecycle word moves into
17
+ // Status; the prose is PRESERVED verbatim as a note under the header table.
18
+ // Nothing is ever deleted.
19
+ //
20
+ // Idempotent: running it twice changes nothing the second time.
21
+
22
+ import { readFileSync, writeFileSync, existsSync, readdirSync, renameSync, statSync } from 'node:fs';
23
+ import { join, basename } from 'node:path';
24
+ import { execFileSync } from 'node:child_process';
25
+ import { resolveStatus } from './lib/specs.mjs';
26
+
27
+ const args = process.argv.slice(2);
28
+ const flag = (n) => { const i = args.indexOf(n); return i === -1 ? null : args[i + 1]; };
29
+ const ROOT = flag('--root') || process.cwd();
30
+ const APPLY = args.includes('--apply');
31
+ const FORCE = args.includes('--force');
32
+ const DO_NUMBERING = !args.includes('--no-numbering');
33
+ const DO_STATUS = !args.includes('--no-status');
34
+
35
+ const LIFECYCLE = ['Draft', 'Approved', 'Implemented', 'Verified', 'Archived'];
36
+ // A spec id is digits plus an OPTIONAL letter: 0165 and 0165b are different specs, not a clash.
37
+ const SPEC_FILE = /^(\d{4}[a-z]?)-(.+)\.md$/;
38
+
39
+ const abs = (p) => join(ROOT, p);
40
+ const git = (...a) => {
41
+ try {
42
+ return execFileSync('git', ['-C', ROOT, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
43
+ } catch {
44
+ return '';
45
+ }
46
+ };
47
+ const isRepo = git('rev-parse', '--is-inside-work-tree') === 'true';
48
+
49
+ // --- collect specs ----------------------------------------------------------
50
+
51
+ function collect() {
52
+ const out = [];
53
+ for (const dir of ['specs', 'specs/archive']) {
54
+ if (!existsSync(abs(dir))) continue;
55
+ for (const f of readdirSync(abs(dir))) {
56
+ const m = f.match(SPEC_FILE);
57
+ if (!m) continue;
58
+ out.push({ dir, file: `${dir}/${f}`, name: f, id: m[1], slug: m[2] });
59
+ }
60
+ }
61
+ return out.sort((a, b) => a.name.localeCompare(b.name));
62
+ }
63
+
64
+ function addedAt(file) {
65
+ if (isRepo) {
66
+ const t = git('log', '--diff-filter=A', '--format=%at', '-1', '--', file);
67
+ if (t) return Number(t);
68
+ }
69
+ try { return Math.floor(statSync(abs(file)).mtimeMs / 1000); } catch { return 0; }
70
+ }
71
+
72
+ // Highest number in use anywhere — working tree plus every branch's history, so a renumber
73
+ // can't land on an id that exists only on a branch nobody has checked out.
74
+ function highestUsed(specs) {
75
+ let max = 0;
76
+ const consider = (name) => {
77
+ const m = basename(name).match(/^(\d{4})/);
78
+ if (m) max = Math.max(max, Number(m[1]));
79
+ };
80
+ specs.forEach((s) => consider(s.name));
81
+ if (isRepo) {
82
+ git('log', '--all', '--pretty=format:', '--name-only', '--diff-filter=A', '--', 'specs/[0-9]*')
83
+ .split('\n').filter(Boolean).forEach(consider);
84
+ }
85
+ return max;
86
+ }
87
+
88
+ // --- repair 1: duplicate numbers -------------------------------------------
89
+
90
+ function planNumbering(specs) {
91
+ const byId = new Map();
92
+ for (const s of specs) {
93
+ if (!byId.has(s.id)) byId.set(s.id, []);
94
+ byId.get(s.id).push(s);
95
+ }
96
+
97
+ let next = highestUsed(specs);
98
+ const renames = [];
99
+ for (const [id, group] of byId) {
100
+ if (group.length < 2) continue;
101
+ // Keep the original; renumber everything created after it.
102
+ const dated = group.map((s) => ({ ...s, at: addedAt(s.file) })).sort((a, b) => a.at - b.at);
103
+ for (const later of dated.slice(1)) {
104
+ next += 1;
105
+ const id2 = String(next).padStart(4, '0');
106
+ renames.push({
107
+ from: later.file,
108
+ to: `${later.dir}/${id2}-${later.slug}.md`,
109
+ oldId: id,
110
+ newId: id2,
111
+ addedAt: later.at ? new Date(later.at * 1000).toISOString().slice(0, 10) : 'unknown',
112
+ keeping: dated[0].file,
113
+ });
114
+ }
115
+ }
116
+ return renames;
117
+ }
118
+
119
+ // Files that might mention a renamed spec by filename. Deliberately narrow: specs, docs,
120
+ // and top-level markdown. An exact-filename rewrite is safe; guessing at prose is not.
121
+ function referenceCandidates() {
122
+ const out = [];
123
+ const walk = (dir, depth) => {
124
+ if (!existsSync(abs(dir)) || depth > 3) return;
125
+ for (const f of readdirSync(abs(dir))) {
126
+ const rel = `${dir}/${f}`;
127
+ let st;
128
+ try { st = statSync(abs(rel)); } catch { continue; }
129
+ if (st.isDirectory()) walk(rel, depth + 1);
130
+ else if (f.endsWith('.md')) out.push(rel);
131
+ }
132
+ };
133
+ walk('specs', 0);
134
+ walk('docs', 0);
135
+ if (existsSync(abs('CLAUDE.md'))) out.push('CLAUDE.md');
136
+ if (existsSync(abs('README.md'))) out.push('README.md');
137
+ return [...new Set(out)];
138
+ }
139
+
140
+ // --- repair 2: status normalization ----------------------------------------
141
+
142
+ const STATUS_ROW = /^\|\s*\*\*Status\*\*\s*\|/;
143
+
144
+ function planStatus(specs) {
145
+ const changes = [];
146
+ const skipped = [];
147
+
148
+ for (const s of specs) {
149
+ const text = readFileSync(abs(s.file), 'utf8');
150
+ const lines = text.split('\n');
151
+ const i = lines.findIndex((l) => STATUS_ROW.test(l));
152
+ if (i === -1) continue;
153
+
154
+ const line = lines[i];
155
+ // Take everything between the cell delimiter and the LAST pipe: the prose itself often
156
+ // contains pipes, and truncating at the first one would silently eat content.
157
+ const start = line.indexOf('|', line.indexOf('**Status**')) + 1;
158
+ const end = line.lastIndexOf('|');
159
+ if (start <= 0 || end <= start) continue;
160
+
161
+ const raw = line.slice(start, end).trim();
162
+ const plain = raw.replace(/\*\*/g, '').replace(/`/g, '').trim();
163
+ if (LIFECYCLE.includes(plain)) continue; // already canonical
164
+ if (/^Draft \\?\| Approved/.test(plain)) continue; // untouched TEMPLATE placeholder
165
+
166
+ const r = resolveStatus(plain);
167
+ if (!r.status) {
168
+ skipped.push({ file: s.file, raw, why: 'no lifecycle word at all' });
169
+ continue;
170
+ }
171
+ // The word must LEAD the cell. "Implemented (2026-07-07) — …" is an author declaring a
172
+ // status; a stage word buried mid-sentence is narration, and promoting it writes a claim
173
+ // they never made. This is not about ambiguity — one buried word is just as much a guess
174
+ // as three, and this script rewrites files.
175
+ if (!r.leading) {
176
+ skipped.push({
177
+ file: s.file,
178
+ raw,
179
+ why: r.candidates.length > 1
180
+ ? `mentions ${r.candidates.join(', ')} mid-text, none leading`
181
+ : `"${r.status}" appears mid-text, not as the status`,
182
+ });
183
+ continue;
184
+ }
185
+ if (text.includes('> **Status note:**')) continue; // already normalized
186
+
187
+ changes.push({ file: s.file, from: raw, to: r.status, lineIndex: i });
188
+ }
189
+ return { changes, skipped };
190
+ }
191
+
192
+ function applyStatus(change) {
193
+ const lines = readFileSync(abs(change.file), 'utf8').split('\n');
194
+ const i = change.lineIndex;
195
+ const line = lines[i];
196
+ const start = line.indexOf('|', line.indexOf('**Status**')) + 1;
197
+ const end = line.lastIndexOf('|');
198
+ lines[i] = `${line.slice(0, start)} ${change.to} ${line.slice(end)}`;
199
+
200
+ // Insert the preserved prose immediately after the header table block.
201
+ let j = i;
202
+ while (j + 1 < lines.length && lines[j + 1].trimStart().startsWith('|')) j++;
203
+ lines.splice(j + 1, 0, '', `> **Status note:** ${change.from}`);
204
+
205
+ writeFileSync(abs(change.file), lines.join('\n'));
206
+ }
207
+
208
+ // --- run --------------------------------------------------------------------
209
+
210
+ const specs = collect();
211
+ if (specs.length === 0) {
212
+ console.log(`No specs found under ${ROOT}/specs — nothing to do.`);
213
+ process.exit(0);
214
+ }
215
+
216
+ if (APPLY && isRepo && !FORCE) {
217
+ const dirty = git('status', '--porcelain', '--', 'specs', 'docs').split('\n').filter(Boolean);
218
+ if (dirty.length) {
219
+ console.error('Refusing to --apply with uncommitted changes under specs/ or docs/.');
220
+ console.error('Commit or stash first so this script\'s changes are reviewable on their own,');
221
+ console.error('or pass --force if you know what you are doing.');
222
+ process.exit(1);
223
+ }
224
+ }
225
+
226
+ const renames = DO_NUMBERING ? planNumbering(specs) : [];
227
+ const { changes, skipped } = DO_STATUS ? planStatus(specs) : { changes: [], skipped: [] };
228
+
229
+ console.log(`repo: ${ROOT}`);
230
+ console.log(`specs: ${specs.length}${isRepo ? '' : ' (not a git repo — using file mtimes, local branches unchecked)'}`);
231
+ console.log(APPLY ? '\nMODE: APPLY — writing changes\n' : '\nMODE: DRY RUN — nothing will be written (pass --apply to do it)\n');
232
+
233
+ // 1. numbering
234
+ if (DO_NUMBERING) {
235
+ console.log(`── duplicate spec numbers: ${renames.length} file(s) to renumber`);
236
+ const candidates = renames.length ? referenceCandidates() : [];
237
+ for (const r of renames) {
238
+ console.log(` ${r.from}`);
239
+ console.log(` → ${r.to} (added ${r.addedAt}; keeping ${basename(r.keeping)} on ${r.oldId})`);
240
+
241
+ const refs = candidates.filter((c) => {
242
+ if (c === r.from) return false;
243
+ try { return readFileSync(abs(c), 'utf8').includes(basename(r.from)); } catch { return false; }
244
+ });
245
+ if (refs.length) console.log(` ${refs.length} file(s) reference the old filename and will be updated`);
246
+
247
+ if (APPLY) {
248
+ const tracked = isRepo && git('ls-files', '--', r.from) !== '';
249
+ if (tracked) git('mv', r.from, r.to);
250
+ else renameSync(abs(r.from), abs(r.to));
251
+ for (const c of refs) {
252
+ const t = readFileSync(abs(c), 'utf8');
253
+ writeFileSync(abs(c), t.split(basename(r.from)).join(basename(r.to)));
254
+ }
255
+ }
256
+ }
257
+ if (renames.length) {
258
+ console.log(' NOTE: only exact filename references are rewritten. Prose like "see spec 0043"');
259
+ console.log(' is left alone — search for the old number by hand if you use that style.');
260
+ }
261
+ }
262
+
263
+ // 2. status
264
+ if (DO_STATUS) {
265
+ console.log(`\n── non-canonical Status values: ${changes.length} file(s)`);
266
+ for (const c of changes.slice(0, 8)) {
267
+ const preview = c.from.length > 88 ? c.from.slice(0, 88) + '…' : c.from;
268
+ console.log(` ${c.file}`);
269
+ console.log(` "${preview}"`);
270
+ console.log(` → Status: ${c.to} (full text preserved as a "Status note" under the table)`);
271
+ }
272
+ if (changes.length > 8) console.log(` … and ${changes.length - 8} more`);
273
+ if (APPLY) changes.forEach(applyStatus);
274
+ }
275
+
276
+ if (skipped.length) {
277
+ console.log(`\n── needs a human: ${skipped.length} file(s) left untouched`);
278
+ for (const s of skipped.slice(0, 10)) {
279
+ console.log(` ${s.file} (${s.why})`);
280
+ console.log(` "${s.raw.length > 70 ? s.raw.slice(0, 70) + '…' : s.raw}"`);
281
+ }
282
+ if (skipped.length > 10) console.log(` … and ${skipped.length - 10} more`);
283
+ }
284
+
285
+ console.log(
286
+ APPLY
287
+ ? `\n✓ applied: ${renames.length} renumbered, ${changes.length} statuses normalized. Review with \`git diff\` / \`git status\` — nothing was committed.`
288
+ : `\nDry run complete: would renumber ${renames.length}, normalize ${changes.length}. Re-run with --apply to write.`,
289
+ );
@@ -0,0 +1,43 @@
1
+ // Argument and environment handling shared by the scripts that talk to a tracker
2
+ // or a source-control host. One implementation, so --dry-run and --root behave
3
+ // identically everywhere.
4
+ import { recorder, request } from './http.mjs';
5
+
6
+ export function args(argv = process.argv.slice(2)) {
7
+ const flag = (n, d = null) => { const i = argv.indexOf(`--${n}`); return i === -1 ? d : (argv[i + 1] ?? d); };
8
+ const has = (n) => argv.includes(`--${n}`);
9
+ const positional = argv.filter((a, i) => !a.startsWith('--') && !String(argv[i - 1] ?? '').startsWith('--'));
10
+ return { argv, flag, has, positional, root: flag('root', process.cwd()), json: has('json'), dryRun: has('dry-run') };
11
+ }
12
+
13
+ export const colours = (enabled) => enabled
14
+ ? { dim: (s) => `\x1b[2m${s}\x1b[0m`, red: (s) => `\x1b[31m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`,
15
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m` }
16
+ : { dim: (s) => s, red: (s) => s, green: (s) => s, yellow: (s) => s, bold: (s) => s };
17
+
18
+ /** A recording transport for --dry-run, the real one otherwise. */
19
+ export const transportFor = (dryRun) => (dryRun ? recorder() : request);
20
+
21
+ export const env = (name, fallback = '') => process.env[name] ?? fallback;
22
+
23
+ /** Credentials are read from the environment, never from a file in the repo. */
24
+ export function trackerConfig(transport) {
25
+ const tool = env('SDD_PM_TOOL', 'ado');
26
+ return tool === 'jira'
27
+ ? { tool, config: { baseUrl: env('JIRA_BASE_URL'), email: env('JIRA_EMAIL'),
28
+ apiToken: env('JIRA_API_TOKEN'), projectKey: env('JIRA_PROJECT_KEY', 'PAY'), transport } }
29
+ : { tool, config: { org: env('ADO_ORG'), project: env('ADO_PROJECT'), pat: env('ADO_PAT'), transport } };
30
+ }
31
+
32
+ export function scmConfig(transport) {
33
+ const tool = env('SDD_SCM_TOOL', 'ado-repos');
34
+ return tool === 'github'
35
+ ? { tool, config: { owner: env('GITHUB_OWNER'), token: env('GITHUB_TOKEN'), transport } }
36
+ : { tool, config: { org: env('ADO_ORG'), project: env('ADO_PROJECT'), pat: env('ADO_PAT'), transport } };
37
+ }
38
+
39
+ export function printTranscript(transport, C) {
40
+ if (!transport.calls) return;
41
+ console.log(`\n${C.bold(`requests that would be sent (${transport.calls.length})`)}\n`);
42
+ console.log(transport.transcript());
43
+ }
@@ -0,0 +1,108 @@
1
+ // Read docs/ESTATE.md — the index /repo-estate generates — as a contract graph,
2
+ // and answer "who else breaks if this changes".
3
+ //
4
+ // Pure Node, no dependencies. Read-only.
5
+ //
6
+ // Parsing is tolerant by design: this file is written for humans and edited by
7
+ // them, and a parser that demanded exact formatting would fail on the first
8
+ // sensible edit someone makes.
9
+
10
+ const ROW = /^\|(.+)\|\s*$/;
11
+ const SEP = /^\|[\s:|-]+\|\s*$/;
12
+ const PLACEHOLDER = /^(<.*>|_TBD_|—|-|n\/?a|none|)$/i;
13
+
14
+ const clean = (v) => String(v ?? '').replace(/[`*]/g, '').replace(/\s*\(.*\)\s*$/, '').trim();
15
+ const names = (v) => String(v ?? '').split(/[,/]| and /).map(clean).filter((n) => n && !PLACEHOLDER.test(n));
16
+
17
+ function tables(raw) {
18
+ const out = [];
19
+ let current = [];
20
+ for (const line of raw.split('\n')) {
21
+ const t = line.trim();
22
+ // The `|---|---|` rule belongs to the table it divides. Treating it as a
23
+ // break splits every table in two and orphans its header.
24
+ if (SEP.test(t)) continue;
25
+ const m = ROW.exec(t);
26
+ if (!m) { if (current.length) out.push(current); current = []; continue; }
27
+ current.push(m[1].split('|').map((c) => c.trim()));
28
+ }
29
+ if (current.length) out.push(current);
30
+ return out;
31
+ }
32
+
33
+ const headerHas = (row, ...required) => {
34
+ const lower = row.map((c) => c.toLowerCase());
35
+ return required.every((r) => lower.some((c) => c.includes(r)));
36
+ };
37
+
38
+ export function parseEstate(raw) {
39
+ const publishes = new Map();
40
+ const consumes = new Map();
41
+ const note = (repo) => {
42
+ if (!publishes.has(repo)) { publishes.set(repo, new Set()); consumes.set(repo, new Set()); }
43
+ return repo;
44
+ };
45
+
46
+ for (const table of tables(raw)) {
47
+ const [header, ...rows] = table;
48
+ if (!header) continue;
49
+
50
+ if (headerHas(header, 'service', 'owns')) {
51
+ for (const row of rows) for (const n of names(row[0])) note(n);
52
+
53
+ } else if (headerHas(header, 'caller', 'callee')) {
54
+ for (const row of rows) {
55
+ const contract = clean(row[3]) || clean(row[2]);
56
+ if (!contract || PLACEHOLDER.test(contract)) continue;
57
+ for (const callee of names(row[1])) publishes.get(note(callee)).add(contract);
58
+ for (const caller of names(row[0])) consumes.get(note(caller)).add(contract);
59
+ }
60
+
61
+ } else if (headerHas(header, 'producer', 'consumer')) {
62
+ for (const row of rows) {
63
+ const event = clean(row[0]);
64
+ if (!event || PLACEHOLDER.test(event)) continue;
65
+ for (const p of names(row[1])) publishes.get(note(p)).add(event);
66
+ for (const c of names(row[2])) consumes.get(note(c)).add(event);
67
+ }
68
+
69
+ } else if (headerHas(header, 'owner', 'used by')) {
70
+ for (const row of rows) {
71
+ const thing = clean(row[0]);
72
+ if (!thing || PLACEHOLDER.test(thing)) continue;
73
+ for (const o of names(row[1])) publishes.get(note(o)).add(thing);
74
+ for (const u of names(row[2])) consumes.get(note(u)).add(thing);
75
+ }
76
+ }
77
+ }
78
+
79
+ const generatedAt = (/(?:generated|updated|refreshed)[^\n]*?(\d{4}-\d{2}-\d{2})/i.exec(raw) ?? [])[1] ?? '';
80
+ return {
81
+ generatedAt,
82
+ repos: [...publishes.keys()].sort().map((name) => ({
83
+ name,
84
+ publishes: [...publishes.get(name)].sort(),
85
+ consumes: [...consumes.get(name)].sort(),
86
+ })),
87
+ };
88
+ }
89
+
90
+ /** Who consumes what this change touches — directly, and one hop further out. */
91
+ export function impactOf(index, changed) {
92
+ const owners = index.repos.filter((r) => r.publishes.some((c) => changed.includes(c))).map((r) => r.name);
93
+
94
+ const directlyAffected = index.repos
95
+ .filter((r) => !owners.includes(r.name))
96
+ .map((r) => ({ repo: r.name, via: r.consumes.filter((c) => changed.includes(c)) }))
97
+ .filter((r) => r.via.length);
98
+
99
+ const directNames = directlyAffected.map((d) => d.repo);
100
+ const secondWave = new Set(index.repos.filter((r) => directNames.includes(r.name)).flatMap((r) => r.publishes));
101
+
102
+ const transitivelyAffected = index.repos
103
+ .filter((r) => !owners.includes(r.name) && !directNames.includes(r.name))
104
+ .map((r) => ({ repo: r.name, via: r.consumes.filter((c) => secondWave.has(c)) }))
105
+ .filter((r) => r.via.length);
106
+
107
+ return { changed, directlyAffected, transitivelyAffected };
108
+ }
@@ -0,0 +1,73 @@
1
+ // The seam between an adapter and the network.
2
+ //
3
+ // `request` is the real transport. Injecting `recorder()` instead is what makes
4
+ // the tracker and SCM clients testable without credentials — the documents they
5
+ // build are the part most likely to be wrong (a JSON-patch path, a field written
6
+ // directly that Jira only moves through a transition), and all of that is
7
+ // assertable offline.
8
+ //
9
+ // It also backs --dry-run, so an operator can read the exact request that would
10
+ // hit their organisation before anyone grants an access token.
11
+ //
12
+ // Pure Node, no dependencies. Node 18+ for global fetch.
13
+
14
+ export const basicAuth = (user, password) =>
15
+ 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
16
+
17
+ /** The real transport. Throws with the response body, which is where the useful
18
+ * part of an ADO or Jira error lives. */
19
+ export async function request(url, { method = 'GET', headers = {}, body, contentType = 'application/json', timeout = 30000 } = {}) {
20
+ const init = { method, headers: { Accept: 'application/json', ...headers } };
21
+ if (body !== undefined) {
22
+ init.headers['Content-Type'] = contentType;
23
+ init.body = JSON.stringify(body);
24
+ }
25
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeout) });
26
+ if (!res.ok) {
27
+ throw new Error(`${method} ${new URL(url).pathname} -> ${res.status} ${(await res.text()).slice(0, 500)}`);
28
+ }
29
+ const text = await res.text();
30
+ return text ? JSON.parse(text) : null;
31
+ }
32
+
33
+ /**
34
+ * A transport that records instead of sending. Returns canned responses in
35
+ * order, then a default shaped so every adapter's response parser completes —
36
+ * which is what lets --dry-run walk a whole flow rather than stopping at the
37
+ * first read.
38
+ */
39
+ export function recorder(responses = []) {
40
+ let cursor = 0;
41
+ const calls = [];
42
+
43
+ const fn = async (url, { method = 'GET', body, contentType = 'application/json' } = {}) => {
44
+ calls.push({ method, url, body, contentType });
45
+ return cursor < responses.length ? responses[cursor++] : DEFAULT_RESPONSE;
46
+ };
47
+
48
+ fn.calls = calls;
49
+ fn.last = () => calls[calls.length - 1];
50
+ fn.transcript = () => calls.map(render).join('\n\n');
51
+ return fn;
52
+ }
53
+
54
+ const render = (c) => {
55
+ const head = `${c.method} ${c.url}`;
56
+ if (c.body === undefined) return head;
57
+ return `${head}\n content-type: ${c.contentType}\n` +
58
+ JSON.stringify(c.body, null, 2).split('\n').map((l) => ' ' + l).join('\n');
59
+ };
60
+
61
+ // Keys are namespaced by their API so they do not collide.
62
+ const SHA = '0f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6';
63
+ const DEFAULT_RESPONSE = {
64
+ // trackers
65
+ id: 1847, key: 'PAY-1847',
66
+ fields: { 'System.Title': '[SPEC-0031] placeholder', 'System.State': 'New',
67
+ summary: '[SPEC-0031] placeholder', status: { name: 'To Do' } },
68
+ workItems: [], issues: [], transitions: [],
69
+ // source control — ADO returns refs under `value`, GitHub a single `object`
70
+ value: [{ objectId: SHA, name: 'refs/heads/main' }],
71
+ object: { sha: SHA },
72
+ pullRequestId: 4321, number: 4321, html_url: 'https://example.invalid/pull/4321',
73
+ };