@mlmcps/ml-specs 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 (86) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +106 -0
  4. package/ml-specs/.claude-plugin/plugin.json +11 -0
  5. package/ml-specs/.mcp.json +17 -0
  6. package/ml-specs/CHANGELOG.md +860 -0
  7. package/ml-specs/LICENSE +21 -0
  8. package/ml-specs/README.md +347 -0
  9. package/ml-specs/agents/coder.md +76 -0
  10. package/ml-specs/agents/developer.md +78 -0
  11. package/ml-specs/agents/pr-author.md +36 -0
  12. package/ml-specs/agents/reviewer.md +65 -0
  13. package/ml-specs/agents/scanner.md +66 -0
  14. package/ml-specs/agents/spec-author.md +91 -0
  15. package/ml-specs/agents/spec-reviewer.md +59 -0
  16. package/ml-specs/commands/code.md +29 -0
  17. package/ml-specs/commands/fix.md +67 -0
  18. package/ml-specs/commands/nfr.md +114 -0
  19. package/ml-specs/commands/pr.md +32 -0
  20. package/ml-specs/commands/repo-adopt.md +86 -0
  21. package/ml-specs/commands/repo-doctor.md +57 -0
  22. package/ml-specs/commands/repo-estate.md +79 -0
  23. package/ml-specs/commands/repo-impact.md +77 -0
  24. package/ml-specs/commands/repo-init.md +155 -0
  25. package/ml-specs/commands/repo-refresh.md +58 -0
  26. package/ml-specs/commands/repo-rollout.md +84 -0
  27. package/ml-specs/commands/repo-status.md +59 -0
  28. package/ml-specs/commands/spec-advance.md +81 -0
  29. package/ml-specs/commands/spec-build.md +66 -0
  30. package/ml-specs/commands/spec-fanout.md +64 -0
  31. package/ml-specs/commands/spec-review.md +24 -0
  32. package/ml-specs/commands/spec-verify.md +55 -0
  33. package/ml-specs/commands/spec.md +73 -0
  34. package/ml-specs/hooks/hooks.json +23 -0
  35. package/ml-specs/hooks/knowledge-drift.sh +43 -0
  36. package/ml-specs/hooks/secret-scan.sh +56 -0
  37. package/ml-specs/mcp/README.md +173 -0
  38. package/ml-specs/mcp/ml-specs-server.mjs +708 -0
  39. package/ml-specs/mcp/ml-specs-server.test.mjs +225 -0
  40. package/ml-specs/package.json +44 -0
  41. package/ml-specs/scripts/branch-policy.mjs +71 -0
  42. package/ml-specs/scripts/fix-specs.mjs +289 -0
  43. package/ml-specs/scripts/lib/cli.mjs +43 -0
  44. package/ml-specs/scripts/lib/estate.mjs +108 -0
  45. package/ml-specs/scripts/lib/http.mjs +73 -0
  46. package/ml-specs/scripts/lib/knowledge.mjs +91 -0
  47. package/ml-specs/scripts/lib/knowledge.test.mjs +70 -0
  48. package/ml-specs/scripts/lib/nfr.mjs +119 -0
  49. package/ml-specs/scripts/lib/nfr.test.mjs +89 -0
  50. package/ml-specs/scripts/lib/policy.mjs +114 -0
  51. package/ml-specs/scripts/lib/policy.test.mjs +139 -0
  52. package/ml-specs/scripts/lib/scm.mjs +189 -0
  53. package/ml-specs/scripts/lib/scm.test.mjs +155 -0
  54. package/ml-specs/scripts/lib/specs.mjs +192 -0
  55. package/ml-specs/scripts/lib/specs.test.mjs +69 -0
  56. package/ml-specs/scripts/lib/trace.mjs +90 -0
  57. package/ml-specs/scripts/lib/trace.test.mjs +110 -0
  58. package/ml-specs/scripts/lib/tracker.mjs +257 -0
  59. package/ml-specs/scripts/lib/tracker.test.mjs +177 -0
  60. package/ml-specs/scripts/nfr-compile.mjs +120 -0
  61. package/ml-specs/scripts/scripts.test.mjs +182 -0
  62. package/ml-specs/scripts/spec-brief.mjs +127 -0
  63. package/ml-specs/scripts/spec-dashboard.mjs +331 -0
  64. package/ml-specs/scripts/spec-fanout.mjs +120 -0
  65. package/ml-specs/scripts/spec-gate.mjs +329 -0
  66. package/ml-specs/scripts/spec-trace.mjs +91 -0
  67. package/ml-specs/scripts/survey-estate.mjs +230 -0
  68. package/ml-specs/scripts/tracker-sync.mjs +91 -0
  69. package/ml-specs/skills/knowledge-retrieval/SKILL.md +75 -0
  70. package/ml-specs/templates/CLAUDE.fragment.md +87 -0
  71. package/ml-specs/templates/ci/azure-pipelines-spec-gate.yml +64 -0
  72. package/ml-specs/templates/ci/knowledge-check.mjs +176 -0
  73. package/ml-specs/templates/ci/knowledge-layer.yml +35 -0
  74. package/ml-specs/templates/ci/spec-gate.yml +69 -0
  75. package/ml-specs/templates/docs/ARCHITECTURE.template.md +110 -0
  76. package/ml-specs/templates/docs/ESTATE.template.md +55 -0
  77. package/ml-specs/templates/docs/PATTERNS.template.md +80 -0
  78. package/ml-specs/templates/gitattributes +18 -0
  79. package/ml-specs/templates/hooks/settings.hooks.example.json +32 -0
  80. package/ml-specs/templates/mcp/.mcp.json +34 -0
  81. package/ml-specs/templates/settings.json +18 -0
  82. package/ml-specs/templates/specs/AGENTS.md +57 -0
  83. package/ml-specs/templates/specs/README.md +104 -0
  84. package/ml-specs/templates/specs/TEMPLATE.md +114 -0
  85. package/ml-specs/templates/standards/.mlskills.json +24 -0
  86. package/package.json +36 -0
@@ -0,0 +1,139 @@
1
+ // Each test here is a way a gate stops being a gate without anything visibly
2
+ // breaking — which is exactly how this kind of governance dies.
3
+ import { test, describe } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { recorder } from './http.mjs';
6
+ import { adoPolicy, githubPolicy, auditOk, auditSummary, BUILD_VALIDATION_TYPE } from './policy.mjs';
7
+
8
+ const ADO = { org: 'contoso', project: 'Payments', pat: 'pat' };
9
+ const GH = { owner: 'motivity', token: 'ghp_x' };
10
+
11
+ const policy = (over = {}, settings = {}) => ({
12
+ type: { id: BUILD_VALIDATION_TYPE }, isEnabled: true, isBlocking: true,
13
+ settings: { buildDefinitionId: 42, manualQueueOnly: false, queueOnSourceUpdateOnly: true, ...settings },
14
+ ...over,
15
+ });
16
+ const audit = (value) => adoPolicy({ ...ADO, transport: recorder([{ value }]) }).audit('api-neelias', 'main');
17
+
18
+ describe('Azure DevOps audit', () => {
19
+ test('a healthy policy passes with no findings', async () => {
20
+ const a = await audit([policy()]);
21
+ assert.ok(auditOk(a));
22
+ assert.deepEqual(a.findings, []);
23
+ assert.match(auditSummary(a), /present and blocking/);
24
+ });
25
+
26
+ test('no policy at all', async () => {
27
+ const a = await audit([]);
28
+ assert.equal(a.present, false);
29
+ assert.ok(!auditOk(a));
30
+ assert.match(auditSummary(a), /no spec gate/);
31
+ });
32
+
33
+ test('a policy of another type is not a spec gate', async () => {
34
+ // A minimum-reviewers policy is not this.
35
+ const a = await audit([{ type: { id: 'fa4e907d-c16b-4a4c-9dfa-4906e5d171dd' }, isEnabled: true, isBlocking: true }]);
36
+ assert.equal(a.present, false);
37
+ });
38
+
39
+ test('an advisory policy is reported as not a gate', async () => {
40
+ // The silent failure: it runs, it reports, it cannot block.
41
+ const a = await audit([policy({ isBlocking: false })]);
42
+ assert.equal(a.present, true);
43
+ assert.equal(a.blocking, false);
44
+ assert.ok(!auditOk(a));
45
+ assert.match(auditSummary(a), /ADVISORY/);
46
+ assert.match(a.findings.map((f) => f.message).join(' '), /cannot fail the merge/);
47
+ });
48
+
49
+ test('a disabled policy is a blocker', async () => {
50
+ const a = await audit([policy({ isEnabled: false })]);
51
+ assert.ok(!auditOk(a));
52
+ assert.match(a.findings.map((f) => f.message).join(' '), /disabled/);
53
+ });
54
+
55
+ test('manualQueueOnly is a blocker — a gate that runs only when someone remembers', async () => {
56
+ const a = await audit([policy({}, { manualQueueOnly: true })]);
57
+ assert.ok(!auditOk(a));
58
+ assert.match(a.findings.map((f) => f.message).join(' '), /only runs if someone remembers/);
59
+ });
60
+
61
+ test('not re-running on push is a warning, not a blocker', async () => {
62
+ const a = await audit([policy({}, { queueOnSourceUpdateOnly: false })]);
63
+ assert.ok(auditOk(a));
64
+ assert.deepEqual(a.findings.map((f) => f.severity), ['warning']);
65
+ assert.match(a.findings[0].message, /later push is ungated/);
66
+ });
67
+
68
+ test('several build policies are ambiguous', async () => {
69
+ const a = await audit([policy(), policy()]);
70
+ assert.ok(auditOk(a));
71
+ assert.match(a.findings.map((f) => f.message).join(' '), /which one is the gate/);
72
+ });
73
+
74
+ test('the audit queries the specific branch and repo', async () => {
75
+ const t = recorder([{ value: [] }]);
76
+ await adoPolicy({ ...ADO, transport: t }).audit('api-neelias', 'release/24');
77
+ assert.match(t.last().url, /refName=refs\/heads\/release\/24/);
78
+ assert.match(t.last().url, /repositoryId=api-neelias/);
79
+ });
80
+ });
81
+
82
+ describe('Azure DevOps install', () => {
83
+ test('install always sets blocking — there is no flag to turn it off', async () => {
84
+ const t = recorder();
85
+ await adoPolicy({ ...ADO, transport: t }).install('api-neelias', 'main', '42');
86
+ assert.equal(t.last().body.isBlocking, true);
87
+ assert.equal(t.last().body.isEnabled, true);
88
+ assert.equal(t.last().body.type.id, BUILD_VALIDATION_TYPE);
89
+ });
90
+
91
+ test('it scopes to the exact branch and repo', async () => {
92
+ const t = recorder();
93
+ await adoPolicy({ ...ADO, transport: t }).install('api-neelias', 'main', '42');
94
+ const scope = t.last().body.settings.scope[0];
95
+ assert.equal(scope.refName, 'refs/heads/main');
96
+ assert.equal(scope.matchKind, 'Exact');
97
+ assert.equal(scope.repositoryId, 'api-neelias');
98
+ });
99
+
100
+ test('a numeric build id is sent as a number', async () => {
101
+ const t = recorder();
102
+ await adoPolicy({ ...ADO, transport: t }).install('api-neelias', 'main', '42');
103
+ assert.equal(t.last().body.settings.buildDefinitionId, 42);
104
+ });
105
+ });
106
+
107
+ describe('GitHub', () => {
108
+ const gh = (raw) => githubPolicy({ ...GH, transport: recorder([raw]) }).audit('api-neelias', 'main');
109
+
110
+ test('healthy protection passes', async () => {
111
+ const a = await gh({ required_status_checks: { strict: true, contexts: ['sdd-spec-gate'] }, enforce_admins: { enabled: true } });
112
+ assert.ok(auditOk(a));
113
+ });
114
+
115
+ test('no required check means no gate', async () => {
116
+ const a = await gh({ required_status_checks: { strict: true, contexts: [] } });
117
+ assert.equal(a.present, false);
118
+ });
119
+
120
+ test('an admin bypass is GitHub\'s version of an advisory gate', async () => {
121
+ const a = await gh({ required_status_checks: { strict: true, contexts: ['g'] }, enforce_admins: { enabled: false } });
122
+ assert.ok(!auditOk(a));
123
+ assert.match(a.findings.map((f) => f.message).join(' '), /administrators can merge past the gate/);
124
+ });
125
+
126
+ test('non-strict is a warning', async () => {
127
+ const a = await gh({ required_status_checks: { strict: false, contexts: ['g'] }, enforce_admins: { enabled: true } });
128
+ assert.ok(auditOk(a));
129
+ assert.deepEqual(a.findings.map((f) => f.severity), ['warning']);
130
+ });
131
+
132
+ test('install requires the check and enforces admins', async () => {
133
+ const t = recorder();
134
+ await githubPolicy({ ...GH, transport: t }).install('api-neelias', 'main', 'sdd-spec-gate');
135
+ assert.equal(t.last().method, 'PUT');
136
+ assert.deepEqual(t.last().body.required_status_checks.contexts, ['sdd-spec-gate']);
137
+ assert.equal(t.last().body.enforce_admins, true);
138
+ });
139
+ });
@@ -0,0 +1,189 @@
1
+ // Source control: branches and pull requests, for ADO Repos and GitHub.
2
+ //
3
+ // Deliberately separate from lib/tracker.mjs. A work item and a pull request are
4
+ // different objects owned by different systems, and they diverge under exactly
5
+ // the load this exists for: one spec fans out to N repositories but links to ONE
6
+ // work item, so the cardinality differs. GitHub-plus-Jira is also an ordinary
7
+ // combination that a merged interface could not express.
8
+ //
9
+ // GitHub ships alongside ADO because these customers migrate there for code
10
+ // while keeping Boards or Jira for work — the two boundaries move independently.
11
+ //
12
+ // Pure Node, no dependencies. Every call goes through an injected transport.
13
+
14
+ import { basicAuth, request } from './http.mjs';
15
+ import { branchName, prTitle, keyFrom, specId, testCaseId } from './trace.mjs';
16
+
17
+ const EMPTY_SHA = '0'.repeat(40);
18
+
19
+ // --- Azure DevOps Repos ------------------------------------------------------
20
+
21
+ export function adoRepos({ org, project, pat, apiVersion = '7.1', transport = request }) {
22
+ const repoBase = (repo) =>
23
+ `https://dev.azure.com/${org}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repo)}`;
24
+ const headers = { Authorization: basicAuth('', pat) };
25
+
26
+ return {
27
+ tool: 'ado-repos',
28
+
29
+ async defaultBranchSha(repo, branch = 'main') {
30
+ const res = await transport(`${repoBase(repo)}/refs?filter=heads/${branch}&api-version=${apiVersion}`, { headers });
31
+ const refs = res.value ?? [];
32
+ if (!refs.length) throw new Error(`ADO: ${repo} has no branch ${branch}`);
33
+ return String(refs[0].objectId);
34
+ },
35
+
36
+ async createBranch(repo, name, fromSha) {
37
+ // ADO has no "create branch" call — a branch is a ref updated from zeros.
38
+ await transport(`${repoBase(repo)}/refs?api-version=${apiVersion}`, {
39
+ method: 'POST', headers,
40
+ body: [{ name: `refs/heads/${name}`, oldObjectId: EMPTY_SHA, newObjectId: fromSha }],
41
+ });
42
+ return { repo, name, sha: fromSha };
43
+ },
44
+
45
+ async openPullRequest(repo, { source, target, title, body }) {
46
+ const raw = await transport(`${repoBase(repo)}/pullrequests?api-version=${apiVersion}`, {
47
+ method: 'POST', headers,
48
+ body: { sourceRefName: `refs/heads/${source}`, targetRefName: `refs/heads/${target}`,
49
+ title, description: body },
50
+ });
51
+ const id = String(raw.pullRequestId ?? '');
52
+ return { repo, id, title, source, target,
53
+ url: `https://dev.azure.com/${org}/${encodeURIComponent(project)}/_git/${encodeURIComponent(repo)}/pullrequest/${id}` };
54
+ },
55
+
56
+ async findPullRequests(repo, key) {
57
+ const res = await transport(
58
+ `${repoBase(repo)}/pullrequests?searchCriteria.status=all&api-version=${apiVersion}`, { headers });
59
+ return (res.value ?? [])
60
+ .filter((pr) => keyFrom('prTitle', pr.title ?? '') === key)
61
+ .map((pr) => ({ repo, id: String(pr.pullRequestId), title: pr.title,
62
+ source: String(pr.sourceRefName ?? '').replace(/^refs\/heads\//, ''),
63
+ target: String(pr.targetRefName ?? '').replace(/^refs\/heads\//, ''),
64
+ url: `https://dev.azure.com/${org}/_git/${encodeURIComponent(repo)}/pullrequest/${pr.pullRequestId}` }));
65
+ },
66
+ };
67
+ }
68
+
69
+ // --- GitHub ------------------------------------------------------------------
70
+
71
+ export function github({ owner, token, apiBase = 'https://api.github.com', transport = request }) {
72
+ const headers = { Authorization: `Bearer ${token}`, 'X-GitHub-Api-Version': '2022-11-28' };
73
+ const url = (repo, path) => `${apiBase}/repos/${owner}/${repo}${path}`;
74
+
75
+ return {
76
+ tool: 'github',
77
+
78
+ async defaultBranchSha(repo, branch = 'main') {
79
+ const raw = await transport(url(repo, `/git/ref/heads/${branch}`), { headers });
80
+ return String(raw.object.sha);
81
+ },
82
+
83
+ async createBranch(repo, name, fromSha) {
84
+ await transport(url(repo, '/git/refs'), {
85
+ method: 'POST', headers, body: { ref: `refs/heads/${name}`, sha: fromSha } });
86
+ return { repo, name, sha: fromSha };
87
+ },
88
+
89
+ async openPullRequest(repo, { source, target, title, body }) {
90
+ // The shape difference from ADO: bare head/base, and it is called `body`.
91
+ const raw = await transport(url(repo, '/pulls'), {
92
+ method: 'POST', headers, body: { title, head: source, base: target, body } });
93
+ return { repo, id: String(raw.number ?? ''), title, source, target,
94
+ url: String(raw.html_url ?? `https://github.com/${owner}/${repo}/pulls`) };
95
+ },
96
+
97
+ async findPullRequests(repo, key) {
98
+ const raw = await transport(url(repo, '/pulls?state=all&per_page=100'), { headers });
99
+ return (raw ?? [])
100
+ .filter((pr) => keyFrom('prTitle', pr.title ?? '') === key)
101
+ .map((pr) => ({ repo, id: String(pr.number), title: pr.title,
102
+ source: pr.head?.ref ?? '', target: pr.base?.ref ?? '', url: pr.html_url ?? '' }));
103
+ },
104
+ };
105
+ }
106
+
107
+ // --- the governed path -------------------------------------------------------
108
+
109
+ /** Reads only. Frozen, so the write methods are absent and stay absent. */
110
+ export const readOnlyScm = (scm) => Object.freeze({
111
+ tool: scm.tool,
112
+ defaultBranchSha: (repo, branch) => scm.defaultBranchSha(repo, branch),
113
+ findPullRequests: (repo, key) => scm.findPullRequests(repo, key),
114
+ });
115
+
116
+ /**
117
+ * The only way a branch or pull request gets created. A pull request that cannot
118
+ * be traced back to an approved contract is exactly what this exists to prevent,
119
+ * so it is refused here rather than reported later.
120
+ */
121
+ export function governedScm(scm, { target = 'main' } = {}) {
122
+ return {
123
+ tool: scm.tool,
124
+ async openFor(spec, repo, body) {
125
+ if (!spec.status || spec.status === 'Draft') {
126
+ throw new Error(`spec ${spec.id} is ${spec.status ?? 'unknown'}; no branch is cut before the approval gate`);
127
+ }
128
+ const name = spec.branch ?? branchName(spec.id, spec.title);
129
+ const title = prTitle(spec.id, spec.title);
130
+ // Both are derived, so these can only fail if a derivation changed without
131
+ // its parser changing with it.
132
+ if (keyFrom('branch', name) !== spec.id) throw new Error(`branch "${name}" does not carry spec ${spec.id}`);
133
+ if (keyFrom('prTitle', title) !== spec.id) throw new Error(`title "${title}" does not carry spec ${spec.id}`);
134
+
135
+ const sha = await scm.defaultBranchSha(repo, target);
136
+ const branch = await scm.createBranch(repo, name, sha);
137
+ const pr = await scm.openPullRequest(repo, { source: name, target, title, body });
138
+ return { branch, pr };
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * The pull request body. It carries the whole chain so a reviewer never has to
145
+ * hunt, and the criteria arrive as a checklist — their job is to confirm each
146
+ * one, not to infer what the change was for.
147
+ */
148
+ export function pullRequestBody(spec, reason, { constraints = [] } = {}) {
149
+ const branch = spec.branch ?? branchName(spec.id, spec.title);
150
+ const lines = [
151
+ `## ${specId(spec.id)} — ${spec.title}`, '',
152
+ `**Why this repo:** ${reason}`,
153
+ `**Spec:** \`${spec.file}\` (status: ${spec.status})`,
154
+ ];
155
+ if (spec.ticket) lines.push(`**Ticket:** ${spec.ticket}`);
156
+ if (spec.repos?.length) lines.push(`**Fan-out:** ${spec.repos.join(', ')}`);
157
+ if (spec.nfrs?.length) lines.push(`**NFRs in force:** ${spec.nfrs.join(', ')}`);
158
+
159
+ lines.push('', '## Acceptance criteria', '');
160
+ lines.push(...((spec.criteria ?? []).length
161
+ ? spec.criteria.map((ac) => `- [${ac.checked ? 'x' : ' '}] ${ac.id} (${testCaseId(spec.id, ac.ordinal)}) — ${ac.text}`)
162
+ : ['_none — this spec should not have been approved._']));
163
+
164
+ if (constraints.length) {
165
+ lines.push('', '## Constraints in force', '', ...constraints.map((c) => `- **${c.id}** — ${c.text}`));
166
+ }
167
+
168
+ lines.push('', '---',
169
+ `Every branch in this fan-out is \`${branch}\`. Same key, so these pull requests are one change.`);
170
+ return lines.join('\n');
171
+ }
172
+
173
+ /** Which repos this change reaches: those named on the spec, plus those the
174
+ * estate index says consume what it touches. */
175
+ export function fanOutTargets(spec, impact = null) {
176
+ const targets = new Map();
177
+ for (const repo of spec.repos ?? []) targets.set(repo, 'named on the spec');
178
+ for (const d of impact?.directlyAffected ?? []) {
179
+ if (!targets.has(d.repo)) targets.set(d.repo, `consumes ${d.via.join(', ')}`);
180
+ }
181
+ for (const t of impact?.transitivelyAffected ?? []) {
182
+ if (!targets.has(t.repo)) targets.set(t.repo, `one hop: consumes ${t.via.join(', ')}`);
183
+ }
184
+ return [...targets].map(([repo, reason]) => ({
185
+ repo, reason,
186
+ branch: spec.branch ?? branchName(spec.id, spec.title),
187
+ title: prTitle(spec.id, spec.title),
188
+ }));
189
+ }
@@ -0,0 +1,155 @@
1
+ // Contract tests for source control, and for the fan-out guarantee.
2
+ import { test, describe } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { recorder } from './http.mjs';
5
+ import { adoRepos, github, governedScm, readOnlyScm, pullRequestBody, fanOutTargets } from './scm.mjs';
6
+ import { parseEstate, impactOf } from './estate.mjs';
7
+
8
+ const ADO = { org: 'contoso', project: 'Payments Core', pat: 's3cret' };
9
+ const GH = { owner: 'motivity', token: 'ghp_x' };
10
+ const SPEC = {
11
+ id: '0031', title: 'Idempotent refund submission', file: 'specs/0031-x.md', status: 'Approved',
12
+ ticket: 'PAY-2204', repos: ['api-neelias'], nfrs: ['NFR-03'], branch: null,
13
+ criteria: [{ id: 'AC-1', ordinal: 1, text: 'replay returns the original', checked: false }],
14
+ };
15
+
16
+ describe('Azure DevOps Repos', () => {
17
+ test('a branch is a ref updated from the empty object id', async () => {
18
+ // ADO has no "create branch" call.
19
+ const t = recorder();
20
+ await adoRepos({ ...ADO, transport: t }).createBranch('api-neelias', 'feat/0031-x', 'abc123');
21
+ const row = t.last().body[0];
22
+ assert.equal(row.name, 'refs/heads/feat/0031-x');
23
+ assert.equal(row.oldObjectId, '0'.repeat(40));
24
+ assert.equal(row.newObjectId, 'abc123');
25
+ });
26
+
27
+ test('a pull request uses fully qualified ref names and calls the text description', async () => {
28
+ const t = recorder();
29
+ await adoRepos({ ...ADO, transport: t })
30
+ .openPullRequest('api-neelias', { source: 'feat/0031-x', target: 'main', title: '[SPEC-0031] x', body: 'b' });
31
+ assert.equal(t.last().body.sourceRefName, 'refs/heads/feat/0031-x');
32
+ assert.equal(t.last().body.targetRefName, 'refs/heads/main');
33
+ assert.equal(t.last().body.description, 'b');
34
+ assert.match(t.last().url, /\/Payments%20Core\/_apis\/git\/repositories\/api-neelias\/pullrequests/);
35
+ });
36
+
37
+ test('a missing default branch is an error, not a silent zero sha', async () => {
38
+ const t = recorder([{ value: [] }]);
39
+ await assert.rejects(() => adoRepos({ ...ADO, transport: t }).defaultBranchSha('api-neelias'), /no branch main/);
40
+ });
41
+
42
+ test('pull request search filters on the key and strips the ref prefix', async () => {
43
+ const t = recorder([{ value: [
44
+ { pullRequestId: 1, title: '[SPEC-0031] yes', sourceRefName: 'refs/heads/feat/0031-x', targetRefName: 'refs/heads/main' },
45
+ { pullRequestId: 2, title: 'unrelated hotfix', sourceRefName: 'refs/heads/hf', targetRefName: 'refs/heads/main' },
46
+ ] }]);
47
+ const found = await adoRepos({ ...ADO, transport: t }).findPullRequests('api-neelias', '0031');
48
+ assert.deepEqual(found.map((p) => p.id), ['1']);
49
+ assert.equal(found[0].source, 'feat/0031-x');
50
+ });
51
+ });
52
+
53
+ describe('GitHub', () => {
54
+ test('auth is a bearer token with a pinned api version', async () => {
55
+ const t = recorder();
56
+ await github({ ...GH, transport: t }).defaultBranchSha('api-neelias');
57
+ // headers are not recorded; assert the call shape instead
58
+ assert.match(t.last().url, /\/repos\/motivity\/api-neelias\/git\/ref\/heads\/main$/);
59
+ });
60
+
61
+ test('a pull request uses bare head and base, and calls the text body', async () => {
62
+ // The shape difference from ADO that a single-adapter design would have missed.
63
+ const t = recorder();
64
+ await github({ ...GH, transport: t })
65
+ .openPullRequest('api-neelias', { source: 'feat/0031-x', target: 'main', title: '[SPEC-0031] x', body: 'b' });
66
+ assert.equal(t.last().body.head, 'feat/0031-x'); // not refs/heads/
67
+ assert.equal(t.last().body.base, 'main');
68
+ assert.equal(t.last().body.body, 'b');
69
+ });
70
+
71
+ test('a branch posts a ref', async () => {
72
+ const t = recorder();
73
+ await github({ ...GH, transport: t }).createBranch('api-neelias', 'feat/0031-x', 'abc123');
74
+ assert.deepEqual(t.last().body, { ref: 'refs/heads/feat/0031-x', sha: 'abc123' });
75
+ });
76
+ });
77
+
78
+ describe('the governed path', () => {
79
+ test('no branch is cut before the approval gate', async () => {
80
+ const scm = governedScm(adoRepos({ ...ADO, transport: recorder() }));
81
+ await assert.rejects(() => scm.openFor({ ...SPEC, status: 'Draft' }, 'api-neelias', 'b'),
82
+ /before the approval gate/);
83
+ });
84
+
85
+ test('an approved spec produces a derived branch and title', async () => {
86
+ const { branch, pr } = await governedScm(adoRepos({ ...ADO, transport: recorder() }))
87
+ .openFor(SPEC, 'api-neelias', 'b');
88
+ assert.equal(branch.name, 'feat/0031-idempotent-refund-submission');
89
+ assert.equal(pr.title, '[SPEC-0031] Idempotent refund submission');
90
+ });
91
+
92
+ test('a branch that lost the key is refused before anything is created', async () => {
93
+ const t = recorder();
94
+ await assert.rejects(
95
+ () => governedScm(adoRepos({ ...ADO, transport: t })).openFor({ ...SPEC, branch: 'hotfix-urgent' }, 'r', 'b'),
96
+ /does not carry spec 0031/);
97
+ assert.equal(t.calls.length, 0); // nothing was sent
98
+ });
99
+
100
+ test('a reader has no write methods and they cannot be re-attached', () => {
101
+ const r = readOnlyScm(adoRepos({ ...ADO, transport: recorder() }));
102
+ assert.equal(r.createBranch, undefined);
103
+ assert.equal(r.openPullRequest, undefined);
104
+ assert.throws(() => { 'use strict'; r.createBranch = () => {}; });
105
+ });
106
+ });
107
+
108
+ describe('fan-out', () => {
109
+ const INDEX = parseEstate(`
110
+ | Service | Owns | Stack | Summary doc |
111
+ |---|---|---|---|
112
+ | api-neelias | payments | Node | x |
113
+
114
+ | Event / queue / topic | Producer | Consumer | Notes | Evidence |
115
+ |---|---|---|---|---|
116
+ | \`payment.captured\` | api-neelias | neelias-pos (\`SaleReconciler\`) | n | e |
117
+ | \`pos.sale.completed\` | neelias-pos | neelias-cms-portal (\`SalesFeed\`) | n | e |
118
+ `);
119
+
120
+ test('every target shares one branch name, so N pull requests are one change', () => {
121
+ const targets = fanOutTargets(SPEC, impactOf(INDEX, ['payment.captured']));
122
+ assert.deepEqual(targets.map((t) => t.repo), ['api-neelias', 'neelias-pos', 'neelias-cms-portal']);
123
+ assert.equal(new Set(targets.map((t) => t.branch)).size, 1);
124
+ assert.match(targets[2].reason, /one hop/);
125
+ });
126
+
127
+ test('a repo named on the spec is not duplicated by the impact query', () => {
128
+ const targets = fanOutTargets({ ...SPEC, repos: ['neelias-pos'] }, impactOf(INDEX, ['payment.captured']));
129
+ assert.equal(targets.filter((t) => t.repo === 'neelias-pos').length, 1);
130
+ assert.equal(targets[0].reason, 'named on the spec');
131
+ });
132
+ });
133
+
134
+ describe('the pull request body', () => {
135
+ test('carries the whole chain so a reviewer never has to hunt', () => {
136
+ const body = pullRequestBody(SPEC, 'consumes payment.captured',
137
+ { constraints: [{ id: 'INC-4412', text: 'Holds released early.' }] });
138
+ assert.match(body, /SPEC-0031/);
139
+ assert.match(body, /\*\*Why this repo:\*\* consumes payment\.captured/);
140
+ assert.match(body, /\*\*Ticket:\*\* PAY-2204/);
141
+ assert.match(body, /INC-4412/);
142
+ });
143
+
144
+ test('criteria arrive as a reviewer checklist paired with their test cases', () => {
145
+ assert.match(pullRequestBody(SPEC, 'x'), /- \[ \] AC-1 \(TC-0031\.1\) — replay returns the original/);
146
+ });
147
+
148
+ test('it states why these pull requests are one change', () => {
149
+ assert.match(pullRequestBody(SPEC, 'x'), /Same key, so these pull requests are one change/);
150
+ });
151
+
152
+ test('a spec with no criteria says so rather than rendering an empty list', () => {
153
+ assert.match(pullRequestBody({ ...SPEC, criteria: [] }, 'x'), /should not have been approved/);
154
+ });
155
+ });
@@ -0,0 +1,192 @@
1
+ // Shared spec parsing. Imported by the MCP server (mcp/ml-specs-server.mjs) and the dashboard
2
+ // generator (scripts/spec-dashboard.mjs) so there is exactly one implementation of "what is
3
+ // a spec and what does it say" — two copies of this would have drifted within a release.
4
+ //
5
+ // Pure Node, no dependencies. Read-only.
6
+
7
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ export const LIFECYCLE = ['Draft', 'Approved', 'Implemented', 'Verified', 'Archived'];
11
+
12
+ // `- [ ] **AC1** — text`, and the plainer `- [ ] AC-1: text`. Both appear in the
13
+ // wild: the template writes the first, people hand-write the second.
14
+ const AC_LINE = /^\s*-\s*\[([ xX])\]\s*(?:\*\*)?AC-?(\d+)(?:\*\*)?\s*[—–:-]\s*(.+?)\s*$/gm;
15
+
16
+ /** Acceptance criteria as structured rows, so a script can pair each with a test case. */
17
+ export function parseCriteria(text) {
18
+ const out = [];
19
+ for (const m of text.matchAll(AC_LINE)) {
20
+ out.push({
21
+ id: `AC-${Number(m[2])}`,
22
+ ordinal: Number(m[2]),
23
+ text: m[3],
24
+ checked: m[1].toLowerCase() === 'x',
25
+ });
26
+ }
27
+ return out.sort((a, b) => a.ordinal - b.ordinal);
28
+ }
29
+
30
+ // Template placeholders must read as absent. `<repo or service name>` treated as
31
+ // a real repo would fan a pull request out to a repository that does not exist.
32
+ const PLACEHOLDER = /^(<.*>|_TBD_|—|-|n\/?a|none|tbd)$/i;
33
+
34
+ /** A comma- or slash-separated header cell as a clean list. */
35
+ export function splitCell(value) {
36
+ if (!value) return [];
37
+ return value
38
+ .split(/[,/]/)
39
+ .map((v) => v.replace(/[`*]/g, '').trim())
40
+ .filter((v) => v && !PLACEHOLDER.test(v));
41
+ }
42
+
43
+ // A spec id is digits plus an OPTIONAL letter: 0165 and 0165b are different specs, not a clash.
44
+ const SPEC_FILE = /^(\d{4}[a-z]?)-(.+)\.md$/;
45
+
46
+ /**
47
+ * Resolve a Status cell that holds prose down to one lifecycle word.
48
+ *
49
+ * Two rules, both learned the hard way:
50
+ * 1. Match by position in the TEXT, not position in the enum. Picking the first enum member
51
+ * that appears anywhere means a status reading "Phase 1 ✓ … verified …" resolves to a stage
52
+ * its author never claimed — and this feeds a script that rewrites files.
53
+ * 2. Trust the word only when it LEADS the cell. "Implemented (2026-07-07) — …long prose…" is
54
+ * an author stating a status. A stage word buried mid-sentence is discussion, not a claim,
55
+ * so when several appear and none leads, say ambiguous and let a human decide.
56
+ */
57
+ export function resolveStatus(plain) {
58
+ const text = (plain ?? '').trim();
59
+ if (LIFECYCLE.includes(text)) {
60
+ return { status: text, canonical: true, leading: true, ambiguous: false, candidates: [text] };
61
+ }
62
+
63
+ const found = LIFECYCLE
64
+ .map((w) => ({ word: w, at: text.search(new RegExp(`\\b${w}\\b`, 'i')) }))
65
+ .filter((m) => m.at !== -1)
66
+ .sort((a, b) => a.at - b.at);
67
+
68
+ if (found.length === 0) {
69
+ return { status: null, canonical: false, leading: false, ambiguous: false, candidates: [] };
70
+ }
71
+
72
+ // "Leading" allows for markdown/whitespace the caller already stripped, plus a stray bullet.
73
+ const leading = found[0].at <= 2;
74
+ return {
75
+ status: found[0].word,
76
+ canonical: false,
77
+ leading,
78
+ ambiguous: found.length > 1 && !leading,
79
+ candidates: found.map((m) => m.word),
80
+ };
81
+ }
82
+
83
+ /** Every spec under specs/ and specs/archive/, sorted by id. */
84
+ export function listSpecs(root = process.cwd()) {
85
+ const out = [];
86
+ for (const dir of ['specs', 'specs/archive']) {
87
+ const abs = join(root, dir);
88
+ if (!existsSync(abs)) continue;
89
+ for (const f of readdirSync(abs)) {
90
+ const m = f.match(SPEC_FILE);
91
+ if (!m) continue;
92
+ const text = readFileSync(join(abs, f), 'utf8');
93
+
94
+ // Header-table cells: take to the LAST pipe on the line — the prose people write in
95
+ // Status often contains pipes, and stopping at the first would silently truncate it.
96
+ const field = (name) => {
97
+ const line = text.split('\n').find((l) =>
98
+ new RegExp(`^\\|\\s*\\*\\*${name}\\*\\*\\s*\\|`, 'i').test(l));
99
+ if (!line) return null;
100
+ const start = line.indexOf('|', line.indexOf(`**${name}**`)) + 1;
101
+ const end = line.lastIndexOf('|');
102
+ if (start <= 0 || end <= start) return null;
103
+ const v = line.slice(start, end).trim();
104
+ return v === '' || v === '—' ? null : v;
105
+ };
106
+
107
+ const rawStatus = field('Status');
108
+ const plain = (rawStatus ?? '').replace(/\*\*/g, '').replace(/`/g, '').trim();
109
+ const resolved = resolveStatus(plain);
110
+
111
+ const boxes = [...text.matchAll(/^\s*-\s*\[([ xX])\]/gm)];
112
+ const criteria = parseCriteria(text);
113
+
114
+ out.push({
115
+ file: `${dir}/${f}`,
116
+ id: m[1],
117
+ slug: m[2],
118
+ title: (text.match(/^#\s+(?:Spec:\s*)?(.+)$/m) ?? [, m[2]])[1].trim(),
119
+ status: resolved.status,
120
+ rawStatus,
121
+ statusIsCanonical: resolved.canonical,
122
+ statusLeading: resolved.leading, // the word opens the cell — safe to normalize
123
+ statusAmbiguous: resolved.ambiguous, // several stage words present, none clearly leading
124
+ statusCandidates: resolved.candidates,
125
+ branch: field('Branch'),
126
+ ticket: field('Ticket'),
127
+ acTotal: boxes.length,
128
+ acChecked: boxes.filter((b) => b[1].toLowerCase() === 'x').length,
129
+ archived: dir.endsWith('archive'),
130
+ // Added for the traceability and brief scripts. Existing fields are
131
+ // untouched — the MCP server and the dashboard both read this shape.
132
+ criteria,
133
+ repos: splitCell(field('Project / service') ?? field('Project') ?? field('Repos')),
134
+ nfrs: splitCell(field('NFRs') ?? field('NFR')),
135
+ approvedBy: field('Approved by') ?? field('Approver'),
136
+ author: field('Author'),
137
+ });
138
+ }
139
+ }
140
+ return out.sort((a, b) => a.id.localeCompare(b.id));
141
+ }
142
+
143
+ /**
144
+ * Everything a human should look at, derived once so the dashboard, /repo-status and
145
+ * /repo-doctor all flag the same things.
146
+ */
147
+ export function analyze(specs) {
148
+ const byStatus = Object.fromEntries(LIFECYCLE.map((s) => [s, 0]));
149
+ let unknownStatus = 0;
150
+ for (const s of specs) {
151
+ if (s.status) byStatus[s.status]++;
152
+ else unknownStatus++;
153
+ }
154
+
155
+ const byId = new Map();
156
+ for (const s of specs) {
157
+ if (!byId.has(s.id)) byId.set(s.id, []);
158
+ byId.get(s.id).push(s.file);
159
+ }
160
+
161
+ const acTotal = specs.reduce((n, s) => n + s.acTotal, 0);
162
+ const acChecked = specs.reduce((n, s) => n + s.acChecked, 0);
163
+
164
+ return {
165
+ total: specs.length,
166
+ active: specs.filter((s) => !s.archived).length,
167
+ byStatus,
168
+ unknownStatus,
169
+ acTotal,
170
+ acChecked,
171
+ duplicateIds: [...byId.entries()].filter(([, f]) => f.length > 1).map(([id, files]) => ({ id, files })),
172
+ nonCanonical: specs.filter((s) => s.rawStatus && !s.statusIsCanonical),
173
+ // Claims the repo can't back up, in severity order.
174
+ attention: [
175
+ ...specs
176
+ .filter((s) => s.status === 'Implemented' && s.acTotal > 0 && s.acChecked < s.acTotal)
177
+ .map((s) => ({ level: 'critical', spec: s, why: `Implemented but ${s.acTotal - s.acChecked} of ${s.acTotal} criteria unchecked` })),
178
+ ...specs
179
+ .filter((s) => s.status === 'Verified' && !s.archived && s.acTotal > 0 && s.acChecked < s.acTotal)
180
+ .map((s) => ({ level: 'critical', spec: s, why: 'Verified with unchecked criteria — the status claims evidence that is not there' })),
181
+ ...specs
182
+ .filter((s) => !s.status && s.rawStatus)
183
+ .map((s) => ({ level: 'serious', spec: s, why: `Status has no lifecycle word: "${s.rawStatus.slice(0, 60)}"` })),
184
+ ...specs
185
+ .filter((s) => !s.archived && (s.status === 'Draft' || s.status === 'Approved') && !s.branch)
186
+ .map((s) => ({ level: 'warning', spec: s, why: `${s.status} with no branch recorded — not started` })),
187
+ ...specs
188
+ .filter((s) => s.acTotal === 0 && !s.archived)
189
+ .map((s) => ({ level: 'warning', spec: s, why: 'No acceptance criteria — nothing to verify against' })),
190
+ ],
191
+ };
192
+ }