@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.
- package/.claude-plugin/marketplace.json +15 -0
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/ml-specs/.claude-plugin/plugin.json +11 -0
- package/ml-specs/.mcp.json +17 -0
- package/ml-specs/CHANGELOG.md +860 -0
- package/ml-specs/LICENSE +21 -0
- package/ml-specs/README.md +347 -0
- package/ml-specs/agents/coder.md +76 -0
- package/ml-specs/agents/developer.md +78 -0
- package/ml-specs/agents/pr-author.md +36 -0
- package/ml-specs/agents/reviewer.md +65 -0
- package/ml-specs/agents/scanner.md +66 -0
- package/ml-specs/agents/spec-author.md +91 -0
- package/ml-specs/agents/spec-reviewer.md +59 -0
- package/ml-specs/commands/code.md +29 -0
- package/ml-specs/commands/fix.md +67 -0
- package/ml-specs/commands/nfr.md +114 -0
- package/ml-specs/commands/pr.md +32 -0
- package/ml-specs/commands/repo-adopt.md +86 -0
- package/ml-specs/commands/repo-doctor.md +57 -0
- package/ml-specs/commands/repo-estate.md +79 -0
- package/ml-specs/commands/repo-impact.md +77 -0
- package/ml-specs/commands/repo-init.md +155 -0
- package/ml-specs/commands/repo-refresh.md +58 -0
- package/ml-specs/commands/repo-rollout.md +84 -0
- package/ml-specs/commands/repo-status.md +59 -0
- package/ml-specs/commands/spec-advance.md +81 -0
- package/ml-specs/commands/spec-build.md +66 -0
- package/ml-specs/commands/spec-fanout.md +64 -0
- package/ml-specs/commands/spec-review.md +24 -0
- package/ml-specs/commands/spec-verify.md +55 -0
- package/ml-specs/commands/spec.md +73 -0
- package/ml-specs/hooks/hooks.json +23 -0
- package/ml-specs/hooks/knowledge-drift.sh +43 -0
- package/ml-specs/hooks/secret-scan.sh +56 -0
- package/ml-specs/mcp/README.md +173 -0
- package/ml-specs/mcp/ml-specs-server.mjs +708 -0
- package/ml-specs/mcp/ml-specs-server.test.mjs +225 -0
- package/ml-specs/package.json +44 -0
- package/ml-specs/scripts/branch-policy.mjs +71 -0
- package/ml-specs/scripts/fix-specs.mjs +289 -0
- package/ml-specs/scripts/lib/cli.mjs +43 -0
- package/ml-specs/scripts/lib/estate.mjs +108 -0
- package/ml-specs/scripts/lib/http.mjs +73 -0
- package/ml-specs/scripts/lib/knowledge.mjs +91 -0
- package/ml-specs/scripts/lib/knowledge.test.mjs +70 -0
- package/ml-specs/scripts/lib/nfr.mjs +119 -0
- package/ml-specs/scripts/lib/nfr.test.mjs +89 -0
- package/ml-specs/scripts/lib/policy.mjs +114 -0
- package/ml-specs/scripts/lib/policy.test.mjs +139 -0
- package/ml-specs/scripts/lib/scm.mjs +189 -0
- package/ml-specs/scripts/lib/scm.test.mjs +155 -0
- package/ml-specs/scripts/lib/specs.mjs +192 -0
- package/ml-specs/scripts/lib/specs.test.mjs +69 -0
- package/ml-specs/scripts/lib/trace.mjs +90 -0
- package/ml-specs/scripts/lib/trace.test.mjs +110 -0
- package/ml-specs/scripts/lib/tracker.mjs +257 -0
- package/ml-specs/scripts/lib/tracker.test.mjs +177 -0
- package/ml-specs/scripts/nfr-compile.mjs +120 -0
- package/ml-specs/scripts/scripts.test.mjs +182 -0
- package/ml-specs/scripts/spec-brief.mjs +127 -0
- package/ml-specs/scripts/spec-dashboard.mjs +331 -0
- package/ml-specs/scripts/spec-fanout.mjs +120 -0
- package/ml-specs/scripts/spec-gate.mjs +329 -0
- package/ml-specs/scripts/spec-trace.mjs +91 -0
- package/ml-specs/scripts/survey-estate.mjs +230 -0
- package/ml-specs/scripts/tracker-sync.mjs +91 -0
- package/ml-specs/skills/knowledge-retrieval/SKILL.md +75 -0
- package/ml-specs/templates/CLAUDE.fragment.md +87 -0
- package/ml-specs/templates/ci/azure-pipelines-spec-gate.yml +64 -0
- package/ml-specs/templates/ci/knowledge-check.mjs +176 -0
- package/ml-specs/templates/ci/knowledge-layer.yml +35 -0
- package/ml-specs/templates/ci/spec-gate.yml +69 -0
- package/ml-specs/templates/docs/ARCHITECTURE.template.md +110 -0
- package/ml-specs/templates/docs/ESTATE.template.md +55 -0
- package/ml-specs/templates/docs/PATTERNS.template.md +80 -0
- package/ml-specs/templates/gitattributes +18 -0
- package/ml-specs/templates/hooks/settings.hooks.example.json +32 -0
- package/ml-specs/templates/mcp/.mcp.json +34 -0
- package/ml-specs/templates/settings.json +18 -0
- package/ml-specs/templates/specs/AGENTS.md +57 -0
- package/ml-specs/templates/specs/README.md +104 -0
- package/ml-specs/templates/specs/TEMPLATE.md +114 -0
- package/ml-specs/templates/standards/.mlskills.json +24 -0
- package/package.json +36 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The knowledge layer: standing constraints on disk, in docs/CONSTRAINTS.md.
|
|
2
|
+
//
|
|
3
|
+
// Pure Node, no dependencies. Writes one file, only when asked.
|
|
4
|
+
//
|
|
5
|
+
// Why a markdown file and not a database: this is read by people during spec
|
|
6
|
+
// review far more often than by this code, and a change to what is in force
|
|
7
|
+
// should diff in a pull request like any other change. A store only a machine
|
|
8
|
+
// can read becomes a store nobody checks — which is worse than none, because it
|
|
9
|
+
// looks like governance while enforcing nothing.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const BEGIN = '<!-- sdd:constraints -->';
|
|
15
|
+
const END = '<!-- /sdd:constraints -->';
|
|
16
|
+
const REFRESHED = '<!-- sdd:refreshed ';
|
|
17
|
+
const LINE = /^-\s+`([A-Za-z0-9_.-]+)`\s+\[([^\]]*)\]\s+(.+)$/;
|
|
18
|
+
|
|
19
|
+
const HEADER = `# Standing constraints
|
|
20
|
+
|
|
21
|
+
Constraints in force while authoring any spec. Generated — change the source
|
|
22
|
+
(the NFR table, or the incident that produced a lesson) rather than this file.
|
|
23
|
+
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
export function read(path) {
|
|
27
|
+
if (!existsSync(path)) return [];
|
|
28
|
+
const body = readFileSync(path, 'utf8');
|
|
29
|
+
if (!body.includes(BEGIN)) return [];
|
|
30
|
+
const block = body.split(BEGIN)[1].split(END)[0];
|
|
31
|
+
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const line of block.split('\n')) {
|
|
34
|
+
const m = LINE.exec(line.trim());
|
|
35
|
+
if (!m) continue;
|
|
36
|
+
const scope = m[2].split(',').map((s) => s.trim()).filter(Boolean);
|
|
37
|
+
out.push({ id: m[1], scope: scope.length ? scope : ['*'], text: m[3] });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Days since the file was last written, or null if it never was. */
|
|
43
|
+
export function staleness(path, today = new Date()) {
|
|
44
|
+
if (!existsSync(path)) return null;
|
|
45
|
+
const m = readFileSync(path, 'utf8').match(new RegExp(`${REFRESHED}(\\d{4}-\\d{2}-\\d{2})`));
|
|
46
|
+
if (!m) return null;
|
|
47
|
+
const then = new Date(`${m[1]}T00:00:00Z`);
|
|
48
|
+
return Math.floor((today - then) / 86400000);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What a spec touching these repos inherits. A constraint scoped `*` applies
|
|
53
|
+
* everywhere; a spec that names no repo yet still inherits the estate-wide ones,
|
|
54
|
+
* which is the common case early in a spec's life.
|
|
55
|
+
*/
|
|
56
|
+
export function forRepos(constraints, repos = []) {
|
|
57
|
+
if (!repos.length) return constraints.filter((c) => c.scope.includes('*'));
|
|
58
|
+
return constraints.filter((c) => c.scope.includes('*') || repos.some((r) => c.scope.includes(r)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Upsert by id. Returns the ids that actually changed, so a caller need not
|
|
62
|
+
* claim a write that was a no-op. */
|
|
63
|
+
export function write(path, incoming, today = new Date()) {
|
|
64
|
+
const existing = new Map(read(path).map((c) => [c.id, c]));
|
|
65
|
+
const changed = [];
|
|
66
|
+
|
|
67
|
+
for (const c of incoming) {
|
|
68
|
+
const prev = existing.get(c.id);
|
|
69
|
+
if (!prev || prev.text !== c.text || prev.scope.join() !== c.scope.join()) changed.push(c.id);
|
|
70
|
+
existing.set(c.id, c);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const rows = [...existing.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
74
|
+
const lines = rows.length
|
|
75
|
+
? rows.map((c) => `- \`${c.id}\` [${c.scope.join(', ')}] ${c.text}`)
|
|
76
|
+
: ['_none — no NFR has been compiled and no incident has produced a lesson._'];
|
|
77
|
+
|
|
78
|
+
const stamp = today.toISOString().slice(0, 10);
|
|
79
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
80
|
+
writeFileSync(path, `${HEADER}${REFRESHED}${stamp} -->\n\n${BEGIN}\n${lines.join('\n')}\n${END}\n`);
|
|
81
|
+
return changed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The block stamped into a new or briefed spec, so constraints are in front of
|
|
85
|
+
* whoever writes it rather than in a file they are trusted to have read. */
|
|
86
|
+
export function renderForSpec(constraints) {
|
|
87
|
+
if (!constraints.length) return '';
|
|
88
|
+
return ['## Constraints in force', '',
|
|
89
|
+
'_Read while authoring. Generated from the knowledge layer._', '',
|
|
90
|
+
...constraints.map((c) => `- **${c.id}** — ${c.text}`)].join('\n') + '\n';
|
|
91
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { read, write, forRepos, staleness, renderForSpec } from './knowledge.mjs';
|
|
7
|
+
|
|
8
|
+
let dir, path;
|
|
9
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'sdd-')); path = join(dir, 'docs', 'CONSTRAINTS.md'); });
|
|
10
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
11
|
+
|
|
12
|
+
const PERF = { id: 'NFR-03', scope: ['api-neelias'], text: 'stays responsive (p95 < 400ms)' };
|
|
13
|
+
const GLOBAL = { id: 'NFR-07', scope: ['*'], text: 'no high findings' };
|
|
14
|
+
|
|
15
|
+
describe('round trip', () => {
|
|
16
|
+
test('a constraint survives write and read', () => {
|
|
17
|
+
write(path, [PERF]);
|
|
18
|
+
assert.deepEqual(read(path), [PERF]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('an absent store reads as empty, not as an error', () => {
|
|
22
|
+
assert.deepEqual(read(path), []);
|
|
23
|
+
assert.equal(staleness(path), null);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('write is an upsert and reports only real changes', () => {
|
|
27
|
+
assert.deepEqual(write(path, [PERF]), ['NFR-03']);
|
|
28
|
+
assert.deepEqual(write(path, [PERF]), []); // a no-op says so
|
|
29
|
+
assert.deepEqual(write(path, [{ ...PERF, text: 'tightened to 200ms' }]), ['NFR-03']);
|
|
30
|
+
assert.equal(read(path).length, 1); // replaced, not duplicated
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('an empty store still writes a readable file', () => {
|
|
34
|
+
write(path, []);
|
|
35
|
+
assert.match(readFileSync(path, 'utf8'), /_none —/);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('scope decides what a spec inherits', () => {
|
|
40
|
+
test('estate-wide plus its own', () => {
|
|
41
|
+
write(path, [PERF, GLOBAL]);
|
|
42
|
+
assert.deepEqual(forRepos(read(path), ['api-neelias']).map((c) => c.id), ['NFR-03', 'NFR-07']);
|
|
43
|
+
assert.deepEqual(forRepos(read(path), ['neelias-mobile']).map((c) => c.id), ['NFR-07']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a spec with no repos yet still inherits the estate-wide ones', () => {
|
|
47
|
+
write(path, [PERF, GLOBAL]);
|
|
48
|
+
assert.deepEqual(forRepos(read(path), []).map((c) => c.id), ['NFR-07']);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('staleness', () => {
|
|
53
|
+
test('is measured from the recorded date', () => {
|
|
54
|
+
write(path, [PERF], new Date('2026-08-01T00:00:00Z'));
|
|
55
|
+
assert.equal(staleness(path, new Date('2026-09-04T00:00:00Z')), 34);
|
|
56
|
+
assert.equal(staleness(path, new Date('2026-08-01T00:00:00Z')), 0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('rendering into a spec', () => {
|
|
61
|
+
test('constraints are put in front of the author', () => {
|
|
62
|
+
const block = renderForSpec([PERF]);
|
|
63
|
+
assert.match(block, /## Constraints in force/);
|
|
64
|
+
assert.match(block, /\*\*NFR-03\*\*/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('nothing in force renders nothing at all', () => {
|
|
68
|
+
assert.equal(renderForSpec([]), '');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Non-functional requirements: reading them, and compiling them into the two
|
|
2
|
+
// things that actually enforce them.
|
|
3
|
+
//
|
|
4
|
+
// Pure Node, no dependencies. Read-only; the caller decides what to write.
|
|
5
|
+
//
|
|
6
|
+
// Why this exists: NFRs enter a project at the top — "the API shall be
|
|
7
|
+
// performant" — and then vanish. They do not decompose into user stories, so a
|
|
8
|
+
// story breakdown flattens them into prose that nothing checks. They decompose
|
|
9
|
+
// into exactly two things: a standing constraint read while authoring every
|
|
10
|
+
// spec, and a blocking pipeline gate. `compile` produces both or throws, so an
|
|
11
|
+
// NFR cannot be admitted and then quietly lost.
|
|
12
|
+
|
|
13
|
+
const TOOL_FOR = {
|
|
14
|
+
performance: 'load',
|
|
15
|
+
security: 'sast',
|
|
16
|
+
availability: 'policy',
|
|
17
|
+
compliance: 'policy',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const NFR_KINDS = Object.keys(TOOL_FOR);
|
|
21
|
+
const OPERATORS = ['<', '<=', '>', '>=', '=='];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An NFR without a machine-checkable threshold is a wish. Refusing it here is
|
|
25
|
+
* the whole point: a wish gets flattened into a feature spec and lost, and
|
|
26
|
+
* nobody notices until the load test that was never written would have caught it.
|
|
27
|
+
*/
|
|
28
|
+
export function compile(nfr) {
|
|
29
|
+
const t = nfr.threshold;
|
|
30
|
+
if (!t || !Number.isFinite(Number(t.value)) || !t.metric) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`${nfr.id} has no machine-checkable threshold, so it cannot become a pipeline gate. ` +
|
|
33
|
+
`Give it metric/operator/value, or drop it — an NFR nothing can fail is not a requirement.`);
|
|
34
|
+
}
|
|
35
|
+
if (!OPERATORS.includes(t.operator)) {
|
|
36
|
+
throw new Error(`${nfr.id} has operator "${t.operator}"; expected one of ${OPERATORS.join(' ')}`);
|
|
37
|
+
}
|
|
38
|
+
const tool = TOOL_FOR[nfr.kind];
|
|
39
|
+
if (!tool) {
|
|
40
|
+
throw new Error(`${nfr.id} has kind "${nfr.kind}"; expected one of ${NFR_KINDS.join(', ')}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const assertion = `${t.metric} ${t.operator} ${t.value}${t.unit ?? ''}`;
|
|
44
|
+
return {
|
|
45
|
+
constraint: { id: nfr.id, scope: nfr.appliesTo?.length ? nfr.appliesTo : ['*'],
|
|
46
|
+
text: `${nfr.statement} (${assertion})` },
|
|
47
|
+
gate: { nfr: nfr.id, tool, metric: t.metric, operator: t.operator,
|
|
48
|
+
value: Number(t.value), unit: t.unit ?? '', assertion, blocking: true },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The guard that keeps this honest: an NFR id must never appear inside an
|
|
54
|
+
* acceptance criterion. That is the exact failure mode — the NFR flattened into
|
|
55
|
+
* a feature spec, where it stops being enforced and starts being a sentence.
|
|
56
|
+
*/
|
|
57
|
+
export function findFlattened(specs) {
|
|
58
|
+
const hits = [];
|
|
59
|
+
for (const spec of specs) {
|
|
60
|
+
for (const ac of spec.criteria ?? []) {
|
|
61
|
+
for (const id of spec.nfrs ?? []) {
|
|
62
|
+
if (ac.text.includes(id)) hits.push({ spec: spec.id, criterion: ac.id, nfr: id, text: ac.text });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return hits;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The gate block a pipeline consumes. */
|
|
70
|
+
export const toPipelineYaml = (gates) =>
|
|
71
|
+
gates.length === 0
|
|
72
|
+
? '# no NFR gates compiled\n'
|
|
73
|
+
: gates.map((g) =>
|
|
74
|
+
`- gate: ${g.nfr}\n tool: ${g.tool}\n assert: ${g.assertion}\n blocking: true`).join('\n');
|
|
75
|
+
|
|
76
|
+
// --- reading -----------------------------------------------------------------
|
|
77
|
+
// A markdown table, to match docs/ESTATE.md — this file is read by people during
|
|
78
|
+
// spec review far more often than by this code, and a format only a machine can
|
|
79
|
+
// read becomes a format nobody checks.
|
|
80
|
+
|
|
81
|
+
const ROW = /^\|(.+)\|\s*$/;
|
|
82
|
+
const SEP = /^\|[\s:|-]+\|\s*$/;
|
|
83
|
+
const clean = (v) => (v ?? '').replace(/[`*]/g, '').trim();
|
|
84
|
+
const PLACEHOLDER = /^(<.*>|_TBD_|—|-|)$/;
|
|
85
|
+
|
|
86
|
+
export function parseNfrMarkdown(text) {
|
|
87
|
+
const out = [];
|
|
88
|
+
let header = null;
|
|
89
|
+
for (const line of text.split('\n')) {
|
|
90
|
+
const trimmed = line.trim();
|
|
91
|
+
if (SEP.test(trimmed)) continue;
|
|
92
|
+
const m = ROW.exec(trimmed);
|
|
93
|
+
if (!m) { header = null; continue; }
|
|
94
|
+
const cells = m[1].split('|').map(clean);
|
|
95
|
+
|
|
96
|
+
if (!header) {
|
|
97
|
+
const lower = cells.map((c) => c.toLowerCase());
|
|
98
|
+
header = lower.includes('nfr') && lower.some((c) => c.includes('metric')) ? lower : null;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const at = (name) => {
|
|
103
|
+
const i = header.findIndex((h) => h.includes(name));
|
|
104
|
+
return i === -1 ? '' : (cells[i] ?? '');
|
|
105
|
+
};
|
|
106
|
+
const id = at('nfr');
|
|
107
|
+
if (!id || PLACEHOLDER.test(id)) continue;
|
|
108
|
+
|
|
109
|
+
out.push({
|
|
110
|
+
id,
|
|
111
|
+
kind: (at('kind') || 'compliance').toLowerCase(),
|
|
112
|
+
statement: at('statement'),
|
|
113
|
+
threshold: { metric: at('metric'), operator: at('op') || '<',
|
|
114
|
+
value: Number(at('value')), unit: at('unit') },
|
|
115
|
+
appliesTo: at('applies').split(',').map(clean).filter((v) => v && !PLACEHOLDER.test(v)),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { compile, findFlattened, toPipelineYaml, parseNfrMarkdown, NFR_KINDS } from './nfr.mjs';
|
|
4
|
+
|
|
5
|
+
const PERF = { id: 'NFR-03', kind: 'performance', statement: 'stays responsive',
|
|
6
|
+
threshold: { metric: 'p95_latency', operator: '<', value: 400, unit: 'ms' },
|
|
7
|
+
appliesTo: ['api-neelias'] };
|
|
8
|
+
|
|
9
|
+
describe('compiling an NFR', () => {
|
|
10
|
+
test('produces both halves — a constraint and a gate', () => {
|
|
11
|
+
const { constraint, gate } = compile(PERF);
|
|
12
|
+
assert.match(constraint.text, /p95_latency < 400ms/);
|
|
13
|
+
assert.deepEqual(constraint.scope, ['api-neelias']);
|
|
14
|
+
assert.equal(gate.tool, 'load');
|
|
15
|
+
assert.equal(gate.blocking, true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('each kind maps to the tool that can actually enforce it', () => {
|
|
19
|
+
assert.equal(compile({ ...PERF, kind: 'security', threshold: { metric: 'x', operator: '==', value: 0 } }).gate.tool, 'sast');
|
|
20
|
+
assert.equal(compile({ ...PERF, kind: 'availability', threshold: { metric: 'x', operator: '>=', value: 99 } }).gate.tool, 'policy');
|
|
21
|
+
assert.deepEqual(NFR_KINDS.sort(), ['availability', 'compliance', 'performance', 'security']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('no threshold is refused, not silently accepted', () => {
|
|
25
|
+
// A wish gets flattened into a feature spec and lost.
|
|
26
|
+
assert.throws(() => compile({ ...PERF, threshold: null }), /no machine-checkable threshold/);
|
|
27
|
+
assert.throws(() => compile({ ...PERF, threshold: { metric: 'x', operator: '<', value: 'soon' } }), /threshold/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a nonsense operator or kind is refused with what was expected', () => {
|
|
31
|
+
assert.throws(() => compile({ ...PERF, threshold: { ...PERF.threshold, operator: '~' } }), /expected one of/);
|
|
32
|
+
assert.throws(() => compile({ ...PERF, kind: 'vibes' }), /expected one of/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('an NFR with no scope applies everywhere rather than nowhere', () => {
|
|
36
|
+
assert.deepEqual(compile({ ...PERF, appliesTo: [] }).constraint.scope, ['*']);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('the flattening guard', () => {
|
|
41
|
+
test('an NFR id inside an acceptance criterion is caught', () => {
|
|
42
|
+
// The exact failure mode: the NFR stops being enforced and becomes a sentence.
|
|
43
|
+
const hits = findFlattened([{ id: '0031', nfrs: ['NFR-03'],
|
|
44
|
+
criteria: [{ id: 'AC-1', text: 'The system satisfies NFR-03 under load.' }] }]);
|
|
45
|
+
assert.equal(hits.length, 1);
|
|
46
|
+
assert.equal(hits[0].nfr, 'NFR-03');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('an ordinary criterion is not a false positive', () => {
|
|
50
|
+
assert.equal(findFlattened([{ id: '0031', nfrs: ['NFR-03'],
|
|
51
|
+
criteria: [{ id: 'AC-1', text: 'A partial capture holds the remainder.' }] }]).length, 0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('reading the NFR table', () => {
|
|
56
|
+
const MD = `| NFR | Kind | Statement | Metric | Op | Value | Unit | Applies to |
|
|
57
|
+
|-----|------|-----------|--------|----|-------|------|------------|
|
|
58
|
+
| NFR-03 | performance | stays responsive | p95_latency | < | 400 | ms | api-neelias |
|
|
59
|
+
| NFR-07 | security | no high findings | sast_high | == | 0 | | * |
|
|
60
|
+
| <NFR-id> | performance | <statement> | x | < | 1 | | _TBD_ |`;
|
|
61
|
+
|
|
62
|
+
test('rows parse into compilable NFRs', () => {
|
|
63
|
+
const nfrs = parseNfrMarkdown(MD);
|
|
64
|
+
assert.deepEqual(nfrs.map((n) => n.id), ['NFR-03', 'NFR-07']);
|
|
65
|
+
assert.equal(nfrs[0].threshold.value, 400);
|
|
66
|
+
assert.deepEqual(nfrs[0].appliesTo, ['api-neelias']);
|
|
67
|
+
assert.doesNotThrow(() => compile(nfrs[0]));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('unfilled template rows are skipped', () => {
|
|
71
|
+
assert.ok(!parseNfrMarkdown(MD).some((n) => n.id.startsWith('<')));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the separator row does not end the table', () => {
|
|
75
|
+
assert.equal(parseNfrMarkdown(MD).length, 2);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('pipeline output', () => {
|
|
80
|
+
test('gates render as blocking yaml', () => {
|
|
81
|
+
const yaml = toPipelineYaml([compile(PERF).gate]);
|
|
82
|
+
assert.match(yaml, /- gate: NFR-03/);
|
|
83
|
+
assert.match(yaml, /blocking: true/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('no gates says so rather than emitting an empty block', () => {
|
|
87
|
+
assert.match(toPipelineYaml([]), /no NFR gates compiled/);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The branch policy that makes a pipeline into a gate.
|
|
2
|
+
//
|
|
3
|
+
// A build that runs and reports changes nothing. The load-bearing artifact is
|
|
4
|
+
// the POLICY: build validation, scoped to the protected branch, blocking.
|
|
5
|
+
//
|
|
6
|
+
// The audit half matters more than the install half. A gate quietly demoted to
|
|
7
|
+
// advisory — to unblock a release, reasonably, on a Friday — is how this kind of
|
|
8
|
+
// governance dies: nothing breaks, no test fails, and the first anyone notices
|
|
9
|
+
// is an audit months later.
|
|
10
|
+
//
|
|
11
|
+
// Pure Node, no dependencies. Every call goes through an injected transport.
|
|
12
|
+
|
|
13
|
+
import { basicAuth, request } from './http.mjs';
|
|
14
|
+
|
|
15
|
+
/** ADO's well-known policy type for "Build validation". */
|
|
16
|
+
export const BUILD_VALIDATION_TYPE = '0609b952-1397-4640-95ec-e00a01b2c241';
|
|
17
|
+
|
|
18
|
+
export function adoPolicy({ org, project, pat, apiVersion = '7.1', transport = request }) {
|
|
19
|
+
const base = `https://dev.azure.com/${org}/${encodeURIComponent(project)}/_apis`;
|
|
20
|
+
const headers = { Authorization: basicAuth('', pat) };
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
tool: 'ado-repos',
|
|
24
|
+
|
|
25
|
+
async install(repo, branch, buildRef) {
|
|
26
|
+
await transport(`${base}/policy/configurations?api-version=${apiVersion}`, {
|
|
27
|
+
method: 'POST', headers,
|
|
28
|
+
body: {
|
|
29
|
+
isEnabled: true,
|
|
30
|
+
isBlocking: true, // the whole point; there is no flag to turn this off here
|
|
31
|
+
type: { id: BUILD_VALIDATION_TYPE },
|
|
32
|
+
settings: {
|
|
33
|
+
buildDefinitionId: /^\d+$/.test(String(buildRef)) ? Number(buildRef) : buildRef,
|
|
34
|
+
displayName: 'SDD spec gate',
|
|
35
|
+
manualQueueOnly: false,
|
|
36
|
+
queueOnSourceUpdateOnly: true,
|
|
37
|
+
validDuration: 720,
|
|
38
|
+
scope: [{ refName: `refs/heads/${branch}`, matchKind: 'Exact', repositoryId: repo }],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
return { repo, branch, tool: 'ado-repos', present: true, blocking: true, findings: [] };
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async audit(repo, branch) {
|
|
46
|
+
const res = await transport(
|
|
47
|
+
`${base}/policy/configurations?repositoryId=${encodeURIComponent(repo)}` +
|
|
48
|
+
`&refName=refs/heads/${branch}&api-version=${apiVersion}`, { headers });
|
|
49
|
+
|
|
50
|
+
const builds = (res.value ?? []).filter((c) => c.type?.id === BUILD_VALIDATION_TYPE);
|
|
51
|
+
if (!builds.length) {
|
|
52
|
+
return { repo, branch, tool: 'ado-repos', present: false, blocking: false,
|
|
53
|
+
findings: [{ severity: 'blocker', message: `no build-validation policy on refs/heads/${branch}` }] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const p = builds[0];
|
|
57
|
+
const s = p.settings ?? {};
|
|
58
|
+
const findings = [];
|
|
59
|
+
if (!p.isEnabled) findings.push({ severity: 'blocker', message: 'policy exists but is disabled' });
|
|
60
|
+
if (!p.isBlocking) findings.push({ severity: 'blocker', message: 'policy is advisory — the build reports but cannot fail the merge' });
|
|
61
|
+
if (s.manualQueueOnly) findings.push({ severity: 'blocker', message: 'manualQueueOnly is set — the gate only runs if someone remembers' });
|
|
62
|
+
if (s.queueOnSourceUpdateOnly === false) findings.push({ severity: 'warning', message: 'does not re-run on source update; a later push is ungated' });
|
|
63
|
+
if (builds.length > 1) findings.push({ severity: 'warning', message: `${builds.length} build policies on this branch — which one is the gate?` });
|
|
64
|
+
|
|
65
|
+
return { repo, branch, tool: 'ado-repos', present: true,
|
|
66
|
+
blocking: Boolean(p.isBlocking && p.isEnabled), findings };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function githubPolicy({ 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, branch) => `${apiBase}/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}/protection`;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
tool: 'github',
|
|
77
|
+
|
|
78
|
+
async install(repo, branch, buildRef) {
|
|
79
|
+
await transport(url(repo, branch), { method: 'PUT', headers, body: {
|
|
80
|
+
required_status_checks: { strict: true, contexts: [buildRef] },
|
|
81
|
+
enforce_admins: true,
|
|
82
|
+
required_pull_request_reviews: { required_approving_review_count: 1 },
|
|
83
|
+
restrictions: null,
|
|
84
|
+
} });
|
|
85
|
+
return { repo, branch, tool: 'github', present: true, blocking: true, findings: [] };
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async audit(repo, branch) {
|
|
89
|
+
const raw = await transport(url(repo, branch), { headers });
|
|
90
|
+
const contexts = raw.required_status_checks?.contexts ?? [];
|
|
91
|
+
if (!contexts.length) {
|
|
92
|
+
return { repo, branch, tool: 'github', present: false, blocking: false,
|
|
93
|
+
findings: [{ severity: 'blocker', message: `no required status check on ${branch}` }] };
|
|
94
|
+
}
|
|
95
|
+
const findings = [];
|
|
96
|
+
if (!raw.required_status_checks?.strict) {
|
|
97
|
+
findings.push({ severity: 'warning', message: 'strict is off — a stale branch can merge without re-running the gate' });
|
|
98
|
+
}
|
|
99
|
+
// On GitHub an admin bypass is the equivalent of an advisory policy.
|
|
100
|
+
if (!raw.enforce_admins?.enabled) {
|
|
101
|
+
findings.push({ severity: 'blocker', message: 'enforce_admins is off — administrators can merge past the gate' });
|
|
102
|
+
}
|
|
103
|
+
return { repo, branch, tool: 'github', present: true,
|
|
104
|
+
blocking: !findings.some((f) => f.severity === 'blocker'), findings };
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const auditOk = (a) => a.present && a.blocking && !a.findings.some((f) => f.severity === 'blocker');
|
|
110
|
+
|
|
111
|
+
export const auditSummary = (a) =>
|
|
112
|
+
!a.present ? 'no spec gate on this branch'
|
|
113
|
+
: !a.blocking ? 'spec gate is ADVISORY — it cannot fail the merge, so it is not a gate'
|
|
114
|
+
: 'spec gate present and blocking';
|