@geraldmaron/construct 1.0.18 → 1.0.20
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/README.md +8 -4
- package/bin/construct +105 -3
- package/db/schema/003_observation_reconciliation.sql +14 -0
- package/lib/bootstrap/resources.mjs +0 -1
- package/lib/cli-commands.mjs +57 -6
- package/lib/comment-lint.mjs +44 -0
- package/lib/config/schema.mjs +31 -1
- package/lib/contracts/validate.mjs +106 -0
- package/lib/decisions/enforced-baseline.json +25 -0
- package/lib/decisions/golden.mjs +87 -0
- package/lib/decisions/precedence.mjs +46 -0
- package/lib/decisions/registry.mjs +469 -0
- package/lib/deployment/parity-contract.mjs +148 -0
- package/lib/document-ingest.mjs +71 -6
- package/lib/embed/cli.mjs +11 -0
- package/lib/embed/conflict-detection.mjs +4 -4
- package/lib/embed/customer-profiles.mjs +1 -1
- package/lib/embed/reconcile.mjs +60 -0
- package/lib/embedded-contract/capability.mjs +3 -3
- package/lib/embedded-contract/contract-version.mjs +1 -1
- package/lib/embedded-contract/execution.mjs +184 -0
- package/lib/embedded-contract/index.mjs +16 -0
- package/lib/embedded-contract/ingest.mjs +61 -7
- package/lib/gates-audit.mjs +2 -2
- package/lib/hooks/_lib/output-mode.mjs +101 -0
- package/lib/hooks/config-protection.mjs +22 -3
- package/lib/hooks/guard-bash.mjs +1 -1
- package/lib/hooks/session-start.mjs +13 -1
- package/lib/ingest/provider-extract.mjs +200 -0
- package/lib/ingest/strategy.mjs +95 -0
- package/lib/init-docs.mjs +1 -0
- package/lib/mcp/server.mjs +72 -4
- package/lib/mcp/tools/embedded-contract.mjs +17 -1
- package/lib/mode-commands.mjs +6 -8
- package/lib/observation-store.mjs +16 -2
- package/lib/opencode-telemetry.mjs +1 -1
- package/lib/orchestration/run-store.mjs +82 -0
- package/lib/orchestration/runtime.mjs +240 -0
- package/lib/roles/cli.mjs +10 -2
- package/lib/roles/gateway.mjs +50 -1
- package/lib/scheduler/index.mjs +31 -0
- package/lib/server/index.mjs +13 -3
- package/lib/server/static/index.html +1 -1
- package/lib/setup.mjs +6 -0
- package/lib/storage/hybrid-query.mjs +49 -38
- package/lib/storage/rrf.mjs +42 -0
- package/lib/storage/vector-client.mjs +18 -3
- package/lib/telemetry/backends/local.mjs +1 -1
- package/lib/telemetry/skill-calls.mjs +1 -1
- package/lib/templates/visual-requirements.mjs +84 -0
- package/package.json +10 -1
- package/rules/common/comments.md +3 -0
- package/rules/common/no-fabrication.md +3 -0
- package/rules/common/precedence.md +19 -0
- package/rules/common/research-sources.md +32 -0
- package/rules/common/research.md +59 -2
- package/skills/roles/data-engineer.pipeline.md +13 -1
- package/skills/roles/debugger.md +9 -0
- package/skills/roles/designer.accessibility.md +13 -3
- package/skills/roles/designer.md +10 -0
- package/skills/roles/engineer.platform.md +8 -0
- package/skills/roles/operator.md +10 -1
- package/skills/roles/operator.release.md +8 -0
- package/skills/roles/operator.sre.md +10 -1
- package/skills/roles/orchestrator.md +9 -2
- package/skills/roles/product-manager.business-strategy.md +10 -1
- package/skills/roles/researcher.explorer.md +12 -1
- package/skills/roles/researcher.ux.md +13 -1
- package/skills/roles/reviewer.devil-advocate.md +14 -2
- package/skills/roles/reviewer.evaluator.md +17 -4
- package/skills/roles/reviewer.trace.md +12 -1
- package/skills/roles/security.legal-compliance.md +8 -0
- package/skills/roles/security.md +11 -0
- package/specialists/contracts.json +18 -0
- package/specialists/prompts/cx-researcher.md +4 -2
- package/templates/docs/backlog-proposal.md +1 -1
- package/templates/docs/customer-profile.md +1 -1
- package/templates/docs/evidence-brief.md +5 -1
- package/templates/docs/incident-report.md +37 -21
- package/templates/docs/prfaq.md +2 -2
- package/templates/docs/product-intelligence-report.md +1 -1
- package/templates/docs/research-brief.md +8 -6
- package/templates/docs/research-finding.md +32 -7
- package/templates/docs/rfc.md +13 -1
- package/templates/docs/runbook.md +20 -1
- package/templates/docs/signal-brief.md +4 -1
- package/templates/docs/skill-artifact.md +27 -7
- package/templates/docs/strategy.md +23 -2
- package/lib/bootstrap/lazy-install.mjs +0 -161
- package/lib/embed/jobs/vector-sync.mjs +0 -198
- package/lib/knowledge/postgres-search.mjs +0 -132
- package/lib/services/pattern-promotion-service.mjs +0 -167
- package/lib/storage/unified-storage.mjs +0 -550
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/decisions/golden.mjs — behavioral golden snapshot of core surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Pins the surfaces whose silent change is a drift hazard: the CLI command set,
|
|
5
|
+
* the specialist roster, and hook execution order. buildSurfaceSnapshot computes
|
|
6
|
+
* the live surface from source; a committed snapshot (tests/fixtures/golden/
|
|
7
|
+
* surface.json) is the expectation. A mismatch fails the suite until the snapshot
|
|
8
|
+
* is regenerated on purpose (`construct decisions golden --write`) — the same
|
|
9
|
+
* conscious-update discipline as the enforced baseline (bead wvbf.5).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join, dirname, resolve } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
17
|
+
|
|
18
|
+
const HOOK_EVENTS = ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop'];
|
|
19
|
+
|
|
20
|
+
function hookName(command) {
|
|
21
|
+
const m = command.match(/hooks\/([\w-]+)\.mjs/);
|
|
22
|
+
if (m) return m[1];
|
|
23
|
+
if (/node\s+-e/.test(command)) return 'inline';
|
|
24
|
+
return 'cmd';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
28
|
+
const snapshot = { commands: [], agents: [], hooks: {} };
|
|
29
|
+
|
|
30
|
+
const cli = readFileSync(join(repoRoot, 'lib', 'cli-commands.mjs'), 'utf8');
|
|
31
|
+
const nameRe = /name:\s*'([^']+)'[\s\S]*?core:\s*(true|false)/g;
|
|
32
|
+
let m;
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
while ((m = nameRe.exec(cli)) !== null) {
|
|
35
|
+
if (seen.has(m[1])) continue;
|
|
36
|
+
seen.add(m[1]);
|
|
37
|
+
snapshot.commands.push({ name: m[1], core: m[2] === 'true' });
|
|
38
|
+
}
|
|
39
|
+
snapshot.commands.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
40
|
+
|
|
41
|
+
const registry = JSON.parse(readFileSync(join(repoRoot, 'specialists', 'registry.json'), 'utf8'));
|
|
42
|
+
snapshot.agents = (registry.specialists || []).map((s) => s.name).sort();
|
|
43
|
+
|
|
44
|
+
const settings = JSON.parse(readFileSync(join(repoRoot, 'platforms', 'claude', 'settings.template.json'), 'utf8'));
|
|
45
|
+
for (const event of HOOK_EVENTS) {
|
|
46
|
+
const entries = settings.hooks?.[event] || [];
|
|
47
|
+
const ordered = [];
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
for (const h of entry.hooks || []) ordered.push(hookName(h.command));
|
|
50
|
+
}
|
|
51
|
+
snapshot.hooks[event] = ordered;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return snapshot;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function snapshotPath(repoRoot) {
|
|
58
|
+
return join(repoRoot, 'tests', 'fixtures', 'golden', 'surface.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function compareSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
62
|
+
const file = snapshotPath(repoRoot);
|
|
63
|
+
if (!existsSync(file)) return { ok: false, diffs: ['no committed snapshot — run: construct decisions golden --write'] };
|
|
64
|
+
const expected = JSON.stringify(JSON.parse(readFileSync(file, 'utf8')));
|
|
65
|
+
const actual = JSON.stringify(buildSurfaceSnapshot({ repoRoot }));
|
|
66
|
+
if (expected === actual) return { ok: true, diffs: [] };
|
|
67
|
+
return { ok: false, diffs: ['surface snapshot drift — review the change, then regenerate with: construct decisions golden --write'] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function writeSurfaceSnapshot({ repoRoot = REPO_ROOT } = {}) {
|
|
71
|
+
writeFileSync(snapshotPath(repoRoot), JSON.stringify(buildSurfaceSnapshot({ repoRoot }), null, 2) + '\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runGoldenCli(args = []) {
|
|
75
|
+
if (args.includes('--write')) {
|
|
76
|
+
writeSurfaceSnapshot();
|
|
77
|
+
process.stdout.write('✓ wrote surface golden snapshot\n');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const { ok, diffs } = compareSurfaceSnapshot();
|
|
81
|
+
if (ok) {
|
|
82
|
+
process.stdout.write('✓ surface matches the golden snapshot\n');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const d of diffs) process.stderr.write(`✗ ${d}\n`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/decisions/precedence.mjs — explicit precedence for conflicting guidance.
|
|
3
|
+
*
|
|
4
|
+
* When two rules give contradictory direction, the conflict must resolve the same
|
|
5
|
+
* way every time, not by whichever the model read last. PRECEDENCE_TIERS is the
|
|
6
|
+
* canonical order (index 0 wins); a rule declares its tier in frontmatter
|
|
7
|
+
* (`precedence_tier`), and resolvePrecedence answers which of two tiers governs.
|
|
8
|
+
*
|
|
9
|
+
* Scope is deliberate: this provides deterministic TIER-based precedence, not
|
|
10
|
+
* natural-language contradiction detection. It makes the resolution order
|
|
11
|
+
* explicit and validates that declared tiers are real; judging whether two rules
|
|
12
|
+
* actually contradict remains human work. See rules/common/precedence.md.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const PRECEDENCE_TIERS = ['safety', 'security', 'correctness', 'durability', 'performance', 'style'];
|
|
16
|
+
|
|
17
|
+
export function tierRank(tier) {
|
|
18
|
+
const i = PRECEDENCE_TIERS.indexOf(tier);
|
|
19
|
+
return i === -1 ? Number.POSITIVE_INFINITY : i;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve which tier governs. Returns -1 if a wins, 1 if b wins, 0 if equal rank.
|
|
24
|
+
* A lower index is higher priority (safety beats style).
|
|
25
|
+
*/
|
|
26
|
+
export function resolvePrecedence(a, b) {
|
|
27
|
+
const ra = tierRank(a);
|
|
28
|
+
const rb = tierRank(b);
|
|
29
|
+
if (ra < rb) return -1;
|
|
30
|
+
if (ra > rb) return 1;
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate declared precedence tiers over a decisions array (bead wvbf.8): any
|
|
36
|
+
* rule that declares a `precedence_tier` must use one from PRECEDENCE_TIERS.
|
|
37
|
+
*/
|
|
38
|
+
export function validatePrecedenceTiersOn(decisions) {
|
|
39
|
+
const violations = [];
|
|
40
|
+
for (const d of decisions) {
|
|
41
|
+
if (d.precedenceTier && !PRECEDENCE_TIERS.includes(d.precedenceTier)) {
|
|
42
|
+
violations.push(`${d.id}: unknown precedence_tier "${d.precedenceTier}" (expected one of ${PRECEDENCE_TIERS.join(', ')})`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { ok: violations.length === 0, violations };
|
|
46
|
+
}
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/decisions/registry.mjs — machine-readable index of load-bearing decisions.
|
|
3
|
+
*
|
|
4
|
+
* The registry is a derived VIEW computed from source on every call, never a
|
|
5
|
+
* committed snapshot — the same discipline as lib/doctor/watchers/consistency.mjs.
|
|
6
|
+
* A snapshot would itself drift; computing from source keeps the ADRs, rules, and
|
|
7
|
+
* contracts as the single source of truth.
|
|
8
|
+
*
|
|
9
|
+
* Three decision sources are indexed:
|
|
10
|
+
* - ADRs docs/adr/*.md (status + supersedes parsed from body)
|
|
11
|
+
* - Contracts specialists/contracts.json (executable postconditions = enforcement)
|
|
12
|
+
* - Rules rules/**\/*.md (enforcement link via frontmatter; bead wvbf.4)
|
|
13
|
+
*
|
|
14
|
+
* Enforcement bindings are discovered by scanning tests/ and lib/hooks/ for an
|
|
15
|
+
* `@enforces <decision-id>` marker. A decision with no binding is `advisory: true`.
|
|
16
|
+
* Orphan detection (binding ↔ decision) is owned by bead wvbf.2; supersede-chain
|
|
17
|
+
* validation by bead wvbf.3. This module supplies the data both build on.
|
|
18
|
+
*
|
|
19
|
+
* Pure and deterministic: same tree → same registry, ordered by id.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
23
|
+
import { join, dirname, resolve, relative } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
export const DECISION_STATUSES = ['proposed', 'accepted', 'superseded', 'deprecated'];
|
|
27
|
+
|
|
28
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
29
|
+
|
|
30
|
+
// The binding marker is a dedicated annotation line, not a mid-sentence mention:
|
|
31
|
+
// a line whose only content (after a comment leader) is `@enforces <decision-id>`.
|
|
32
|
+
// Anchoring to the whole line keeps prose that merely names the marker from
|
|
33
|
+
// registering as a false binding.
|
|
34
|
+
|
|
35
|
+
const ENFORCES_LINE_RE = /^(?:\/\/|#|\*)?\s*@enforces\s+(\S+)\s*$/;
|
|
36
|
+
|
|
37
|
+
// The body carries Status and Supersedes as markdown definition lines, not
|
|
38
|
+
// frontmatter (see docs/adr/_template.md); parse them positionally.
|
|
39
|
+
|
|
40
|
+
// ADR field styles vary across the lane: inline bold (`- **Status**: x`,
|
|
41
|
+
// `**Status:** X`) and heading-then-value (`## Status` followed by the value on
|
|
42
|
+
// the next line). Extract a field across all three rather than pinning one style.
|
|
43
|
+
|
|
44
|
+
function extractField(body, field) {
|
|
45
|
+
const inline = body.match(new RegExp(`\\*\\*${field}:?\\*\\*:?\\s*(.+)`, 'i'));
|
|
46
|
+
if (inline && inline[1].trim()) return inline[1].trim();
|
|
47
|
+
const lines = body.split('\n');
|
|
48
|
+
const headingIdx = lines.findIndex((l) => new RegExp(`^#{1,6}\\s*${field}\\s*$`, 'i').test(l));
|
|
49
|
+
if (headingIdx !== -1) {
|
|
50
|
+
for (let i = headingIdx + 1; i < lines.length; i++) {
|
|
51
|
+
const v = lines[i].trim();
|
|
52
|
+
if (!v) continue;
|
|
53
|
+
if (/^#{1,6}\s/.test(v)) break;
|
|
54
|
+
return v.replace(/^[-*]\s*/, '');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseAdr(body) {
|
|
61
|
+
const heading = body.match(/^#\s+ADR[-\s]?\d+:\s*(.+)$/m);
|
|
62
|
+
const title = heading ? heading[1].trim() : null;
|
|
63
|
+
const statusRaw = extractField(body, 'Status');
|
|
64
|
+
const status = statusRaw ? statusRaw.split('|')[0].trim().replace(/[*_`]/g, '').toLowerCase() : null;
|
|
65
|
+
let supersedes = extractField(body, 'Supersedes');
|
|
66
|
+
if (supersedes && /^none$/i.test(supersedes)) supersedes = null;
|
|
67
|
+
const supersededMatch = supersedes && supersedes.match(/ADR[-\s]?(\d+)/);
|
|
68
|
+
const supersedesId = supersededMatch ? `ADR-${supersededMatch[1].padStart(4, '0')}` : null;
|
|
69
|
+
return { title, status, supersedes: supersedesId };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseFrontmatter(text) {
|
|
73
|
+
if (!text.startsWith('---\n')) return {};
|
|
74
|
+
const end = text.indexOf('\n---\n', 4);
|
|
75
|
+
if (end === -1) return {};
|
|
76
|
+
const block = text.slice(4, end);
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const line of block.split('\n')) {
|
|
79
|
+
const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
|
80
|
+
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Walk a directory tree collecting files that match a predicate.
|
|
86
|
+
|
|
87
|
+
function walk(dir, predicate, acc = []) {
|
|
88
|
+
if (!existsSync(dir)) return acc;
|
|
89
|
+
for (const entry of readdirSync(dir)) {
|
|
90
|
+
const full = join(dir, entry);
|
|
91
|
+
const st = statSync(full);
|
|
92
|
+
if (st.isDirectory()) walk(full, predicate, acc);
|
|
93
|
+
else if (predicate(full)) acc.push(full);
|
|
94
|
+
}
|
|
95
|
+
return acc;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Build the @enforces binding map: decision-id → [ "relpath:line", ... ].
|
|
99
|
+
// Scans the surfaces where enforcement actually lives: tests and hooks.
|
|
100
|
+
|
|
101
|
+
function collectEnforcementBindings({ repoRoot }) {
|
|
102
|
+
const bindings = new Map();
|
|
103
|
+
const files = [
|
|
104
|
+
...walk(join(repoRoot, 'tests'), (f) => f.endsWith('.mjs') || f.endsWith('.js')),
|
|
105
|
+
...walk(join(repoRoot, 'lib', 'hooks'), (f) => f.endsWith('.mjs')),
|
|
106
|
+
];
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
109
|
+
lines.forEach((line, i) => {
|
|
110
|
+
const stripped = line.trim().replace(/^(?:\/\/|#|\*)\s?/, '');
|
|
111
|
+
const m = stripped.match(ENFORCES_LINE_RE);
|
|
112
|
+
if (!m) return;
|
|
113
|
+
const id = m[1];
|
|
114
|
+
if (!bindings.has(id)) bindings.set(id, []);
|
|
115
|
+
bindings.get(id).push(`${relative(repoRoot, file)}:${i + 1}`);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return bindings;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function adrDecisions({ repoRoot, bindings }) {
|
|
122
|
+
const dir = join(repoRoot, 'docs', 'adr');
|
|
123
|
+
const out = [];
|
|
124
|
+
if (!existsSync(dir)) return out;
|
|
125
|
+
for (const name of readdirSync(dir)) {
|
|
126
|
+
if (!name.endsWith('.md') || name === '_template.md' || name === 'README.md') continue;
|
|
127
|
+
const numMatch = name.match(/^(\d{4})-/);
|
|
128
|
+
if (!numMatch) continue;
|
|
129
|
+
const id = `ADR-${numMatch[1]}`;
|
|
130
|
+
const file = join(dir, name);
|
|
131
|
+
const text = readFileSync(file, 'utf8');
|
|
132
|
+
const fm = parseFrontmatter(text);
|
|
133
|
+
const { title, status, supersedes } = parseAdr(text);
|
|
134
|
+
const enforcingTests = bindings.get(id) || [];
|
|
135
|
+
out.push({
|
|
136
|
+
id,
|
|
137
|
+
kind: 'adr',
|
|
138
|
+
title,
|
|
139
|
+
status,
|
|
140
|
+
supersedes,
|
|
141
|
+
supersededBy: null,
|
|
142
|
+
file: relative(repoRoot, file),
|
|
143
|
+
lastVerifiedAt: fm.last_verified_at || null,
|
|
144
|
+
enforcingTests,
|
|
145
|
+
enforcingChecks: [],
|
|
146
|
+
advisory: enforcingTests.length === 0,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function contractDecisions({ repoRoot, bindings }) {
|
|
153
|
+
const file = join(repoRoot, 'specialists', 'contracts.json');
|
|
154
|
+
if (!existsSync(file)) return [];
|
|
155
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const c of parsed.contracts || []) {
|
|
158
|
+
const checks = [];
|
|
159
|
+
let advisoryPostconditions = 0;
|
|
160
|
+
for (const pc of c.postconditions || []) {
|
|
161
|
+
if (typeof pc === 'string') advisoryPostconditions += 1;
|
|
162
|
+
else if (pc && pc.check) checks.push(pc.id || pc.check);
|
|
163
|
+
}
|
|
164
|
+
const enforcingTests = bindings.get(c.id) || [];
|
|
165
|
+
out.push({
|
|
166
|
+
id: c.id,
|
|
167
|
+
kind: 'contract',
|
|
168
|
+
title: `${c.producer} → ${c.consumer}`,
|
|
169
|
+
status: 'accepted',
|
|
170
|
+
supersedes: null,
|
|
171
|
+
supersededBy: null,
|
|
172
|
+
file: relative(repoRoot, file),
|
|
173
|
+
lastVerifiedAt: null,
|
|
174
|
+
enforcingTests,
|
|
175
|
+
enforcingChecks: checks,
|
|
176
|
+
advisoryPostconditions,
|
|
177
|
+
advisory: checks.length === 0 && enforcingTests.length === 0,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function ruleDecisions({ repoRoot, bindings }) {
|
|
184
|
+
const dir = join(repoRoot, 'rules');
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const file of walk(dir, (f) => f.endsWith('.md'))) {
|
|
187
|
+
const text = readFileSync(file, 'utf8');
|
|
188
|
+
const fm = parseFrontmatter(text);
|
|
189
|
+
const rel = relative(repoRoot, file);
|
|
190
|
+
const id = `rule:${relative(join(repoRoot, 'rules'), file).replace(/\.md$/, '')}`;
|
|
191
|
+
const enforcedBy = fm.enforced_by ? fm.enforced_by.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
192
|
+
const enforcingTests = bindings.get(id) || [];
|
|
193
|
+
out.push({
|
|
194
|
+
id,
|
|
195
|
+
kind: 'rule',
|
|
196
|
+
title: fm.description || rel,
|
|
197
|
+
status: 'accepted',
|
|
198
|
+
supersedes: null,
|
|
199
|
+
supersededBy: null,
|
|
200
|
+
file: rel,
|
|
201
|
+
lastVerifiedAt: null,
|
|
202
|
+
enforcedBy,
|
|
203
|
+
adrReference: fm.adr_reference || null,
|
|
204
|
+
precedenceTier: fm.precedence_tier || null,
|
|
205
|
+
enforcingTests,
|
|
206
|
+
enforcingChecks: [],
|
|
207
|
+
advisory: enforcedBy.length === 0 && enforcingTests.length === 0,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build the full decision registry from source. Returns { decisions, byId }.
|
|
215
|
+
* `supersededBy` is back-filled by inverting each decision's `supersedes` edge.
|
|
216
|
+
*/
|
|
217
|
+
export function buildRegistry({ repoRoot = REPO_ROOT } = {}) {
|
|
218
|
+
const bindings = collectEnforcementBindings({ repoRoot });
|
|
219
|
+
const decisions = [
|
|
220
|
+
...adrDecisions({ repoRoot, bindings }),
|
|
221
|
+
...contractDecisions({ repoRoot, bindings }),
|
|
222
|
+
...ruleDecisions({ repoRoot, bindings }),
|
|
223
|
+
].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
224
|
+
|
|
225
|
+
const byId = new Map(decisions.map((d) => [d.id, d]));
|
|
226
|
+
for (const d of decisions) {
|
|
227
|
+
if (d.supersedes && byId.has(d.supersedes)) byId.get(d.supersedes).supersededBy = d.id;
|
|
228
|
+
}
|
|
229
|
+
return { decisions, byId };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Structural validation of the registry itself: every ADR id is well-formed and
|
|
234
|
+
* every declared status is in the canonical enum. Binding-orphan checks (wvbf.2)
|
|
235
|
+
* and supersede-chain checks (wvbf.3) are deliberately out of scope here.
|
|
236
|
+
*/
|
|
237
|
+
export function validateRegistry({ repoRoot = REPO_ROOT } = {}) {
|
|
238
|
+
const { decisions } = buildRegistry({ repoRoot });
|
|
239
|
+
const errors = [];
|
|
240
|
+
for (const d of decisions) {
|
|
241
|
+
if (d.status && !DECISION_STATUSES.includes(d.status)) {
|
|
242
|
+
errors.push(`${d.id}: invalid status "${d.status}" (expected one of ${DECISION_STATUSES.join(', ')})`);
|
|
243
|
+
}
|
|
244
|
+
if (d.kind === 'adr' && !/^ADR-\d{4}$/.test(d.id)) {
|
|
245
|
+
errors.push(`${d.id}: ADR id is not in ADR-NNNN form (${d.file})`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { ok: errors.length === 0, errors, count: decisions.length };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const BASELINE_FILE = join(REPO_ROOT, 'lib', 'decisions', 'enforced-baseline.json');
|
|
252
|
+
|
|
253
|
+
// A decision is enforced when something mechanical guards it: an @enforces test
|
|
254
|
+
// binding, an executable contract check, or a declared rule enforcer. Kept in
|
|
255
|
+
// lockstep with the `advisory` flag set per decision in the builders.
|
|
256
|
+
|
|
257
|
+
function isEnforced(d) {
|
|
258
|
+
return (d.enforcingTests && d.enforcingTests.length > 0)
|
|
259
|
+
|| (d.enforcingChecks && d.enforcingChecks.length > 0)
|
|
260
|
+
|| (d.enforcedBy && d.enforcedBy.length > 0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function enforcedIds({ repoRoot = REPO_ROOT } = {}) {
|
|
264
|
+
return buildRegistry({ repoRoot }).decisions.filter(isEnforced).map((d) => d.id).sort();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function loadBaseline(repoRoot) {
|
|
268
|
+
const file = repoRoot === REPO_ROOT ? BASELINE_FILE : join(repoRoot, 'lib', 'decisions', 'enforced-baseline.json');
|
|
269
|
+
if (!existsSync(file)) return [];
|
|
270
|
+
return JSON.parse(readFileSync(file, 'utf8')).enforced || [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The durability gate (bead wvbf.2). Two failure classes, each a silent reversal
|
|
275
|
+
* of a decision:
|
|
276
|
+
* - dangling: an @enforces marker points at a decision id absent from the
|
|
277
|
+
* registry (a typo, or a binding that outlived its decision).
|
|
278
|
+
* - regression: a decision in the enforced baseline lacks enforcement in the
|
|
279
|
+
* live tree (its enforcing test or contract check is gone). This is the
|
|
280
|
+
* ratchet — the baseline is the floor; enforcement may only be added by
|
|
281
|
+
* regenerating it, and dropping below it fails the gate.
|
|
282
|
+
*/
|
|
283
|
+
export function checkBindings({ repoRoot = REPO_ROOT } = {}) {
|
|
284
|
+
const { decisions, byId } = buildRegistry({ repoRoot });
|
|
285
|
+
const dangling = [];
|
|
286
|
+
const bindings = collectEnforcementBindings({ repoRoot });
|
|
287
|
+
for (const [id, locations] of bindings) {
|
|
288
|
+
if (!byId.has(id)) dangling.push({ id, locations });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const enforcedNow = new Set(decisions.filter(isEnforced).map((d) => d.id));
|
|
292
|
+
const regressions = loadBaseline(repoRoot).filter((id) => !enforcedNow.has(id));
|
|
293
|
+
|
|
294
|
+
return { ok: dangling.length === 0 && regressions.length === 0, dangling, regressions };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Supersede-chain validation (bead wvbf.3), pure over a decisions array so the
|
|
299
|
+
* failure cases are testable without fixtures on disk. Four violation classes:
|
|
300
|
+
* - a Supersedes pointing at an unknown decision;
|
|
301
|
+
* - a superseding edge whose target is not marked status `superseded`;
|
|
302
|
+
* - a decision marked `superseded` that nothing supersedes (a dangling claim);
|
|
303
|
+
* - a cycle in the supersede graph.
|
|
304
|
+
*/
|
|
305
|
+
export function checkSupersessionOn(decisions) {
|
|
306
|
+
const byId = new Map(decisions.map((d) => [d.id, d]));
|
|
307
|
+
const supersededByMap = new Map();
|
|
308
|
+
for (const d of decisions) {
|
|
309
|
+
if (d.supersedes && byId.has(d.supersedes)) supersededByMap.set(d.supersedes, d.id);
|
|
310
|
+
}
|
|
311
|
+
const violations = [];
|
|
312
|
+
|
|
313
|
+
for (const d of decisions) {
|
|
314
|
+
if (!d.supersedes) continue;
|
|
315
|
+
const target = byId.get(d.supersedes);
|
|
316
|
+
if (!target) {
|
|
317
|
+
violations.push(`${d.id} supersedes unknown decision ${d.supersedes}`);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (target.status !== 'superseded') {
|
|
321
|
+
violations.push(`${d.id} supersedes ${target.id}, but ${target.id} status is "${target.status || 'n/a'}" (expected superseded)`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (const d of decisions) {
|
|
326
|
+
if (d.status === 'superseded' && !supersededByMap.has(d.id)) {
|
|
327
|
+
violations.push(`${d.id} is marked superseded but no decision supersedes it`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const start of decisions) {
|
|
332
|
+
const seen = new Set();
|
|
333
|
+
let cur = start;
|
|
334
|
+
while (cur && cur.supersedes) {
|
|
335
|
+
if (seen.has(cur.id)) {
|
|
336
|
+
violations.push(`supersede cycle through ${cur.id}`);
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
seen.add(cur.id);
|
|
340
|
+
cur = byId.get(cur.supersedes);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { ok: violations.length === 0, violations: [...new Set(violations)] };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function checkSupersession({ repoRoot = REPO_ROOT } = {}) {
|
|
348
|
+
return checkSupersessionOn(buildRegistry({ repoRoot }).decisions);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// A rule may declare its enforcer in frontmatter (enforced_by: path[, path],
|
|
352
|
+
// adr_reference: ADR-NNNN). Declaring it is optional; declaring it wrong is not.
|
|
353
|
+
|
|
354
|
+
function looksLikePath(value) {
|
|
355
|
+
return value.includes('/') || /\.(mjs|js|json|sh)$/.test(value);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Validate rule → enforcer linkage (bead wvbf.4). For every rule that declares
|
|
360
|
+
* `enforced_by`, each path-like reference must exist; a declared `adr_reference`
|
|
361
|
+
* must resolve to a known ADR. Broken linkage fails; absent linkage does not.
|
|
362
|
+
*/
|
|
363
|
+
export function checkRuleLinkage({ repoRoot = REPO_ROOT } = {}) {
|
|
364
|
+
const { decisions, byId } = buildRegistry({ repoRoot });
|
|
365
|
+
const violations = [];
|
|
366
|
+
for (const d of decisions) {
|
|
367
|
+
if (d.kind !== 'rule') continue;
|
|
368
|
+
for (const ref of d.enforcedBy || []) {
|
|
369
|
+
if (looksLikePath(ref) && !existsSync(resolve(repoRoot, ref))) {
|
|
370
|
+
violations.push(`${d.id}: enforced_by path not found: ${ref}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (d.adrReference && !byId.has(d.adrReference)) {
|
|
374
|
+
violations.push(`${d.id}: adr_reference ${d.adrReference} does not resolve to a known decision`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return { ok: violations.length === 0, violations };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function summarize(decisions) {
|
|
381
|
+
const byKind = {};
|
|
382
|
+
let advisory = 0;
|
|
383
|
+
for (const d of decisions) {
|
|
384
|
+
byKind[d.kind] = (byKind[d.kind] || 0) + 1;
|
|
385
|
+
if (d.advisory) advisory += 1;
|
|
386
|
+
}
|
|
387
|
+
return { total: decisions.length, byKind, advisory, enforced: decisions.length - advisory };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* CLI entry: `construct decisions [list|validate|json]`.
|
|
392
|
+
* list (default) prints a table; validate runs validateRegistry and exits 1 on
|
|
393
|
+
* error; json emits the full registry for downstream tooling.
|
|
394
|
+
*/
|
|
395
|
+
export async function runDecisionsCli(args = []) {
|
|
396
|
+
const sub = args[0] && !args[0].startsWith('-') ? args[0] : 'list';
|
|
397
|
+
|
|
398
|
+
if (sub === 'json' || args.includes('--json')) {
|
|
399
|
+
const { decisions } = buildRegistry();
|
|
400
|
+
process.stdout.write(JSON.stringify({ decisions, summary: summarize(decisions) }, null, 2) + '\n');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (sub === 'validate') {
|
|
405
|
+
const { ok, errors, count } = validateRegistry();
|
|
406
|
+
if (ok) {
|
|
407
|
+
process.stdout.write(`✓ decision registry valid — ${count} decisions\n`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
process.stderr.write(`✗ decision registry has ${errors.length} error(s):\n`);
|
|
411
|
+
for (const e of errors) process.stderr.write(` - ${e}\n`);
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (sub === 'check') {
|
|
416
|
+
const { validatePrecedenceTiersOn } = await import('./precedence.mjs');
|
|
417
|
+
const bindings = checkBindings();
|
|
418
|
+
const supersession = checkSupersession();
|
|
419
|
+
const linkage = checkRuleLinkage();
|
|
420
|
+
const precedence = validatePrecedenceTiersOn(buildRegistry().decisions);
|
|
421
|
+
if (bindings.ok && supersession.ok && linkage.ok && precedence.ok) {
|
|
422
|
+
process.stdout.write('✓ decision graph intact — bindings bound, supersede chains valid, rule linkage resolves, precedence tiers valid\n');
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
for (const d of bindings.dangling) {
|
|
426
|
+
process.stderr.write(`✗ dangling @enforces "${d.id}" at ${d.locations.join(', ')}\n`);
|
|
427
|
+
}
|
|
428
|
+
for (const id of bindings.regressions) {
|
|
429
|
+
process.stderr.write(`✗ enforcement regression: "${id}" is in the baseline but no longer enforced\n`);
|
|
430
|
+
}
|
|
431
|
+
for (const v of supersession.violations) {
|
|
432
|
+
process.stderr.write(`✗ supersede: ${v}\n`);
|
|
433
|
+
}
|
|
434
|
+
for (const v of linkage.violations) {
|
|
435
|
+
process.stderr.write(`✗ rule-linkage: ${v}\n`);
|
|
436
|
+
}
|
|
437
|
+
for (const v of precedence.violations) {
|
|
438
|
+
process.stderr.write(`✗ precedence: ${v}\n`);
|
|
439
|
+
}
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (sub === 'golden') {
|
|
444
|
+
const { runGoldenCli } = await import('./golden.mjs');
|
|
445
|
+
return runGoldenCli(args.slice(1));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (sub === 'baseline') {
|
|
449
|
+
const enforced = enforcedIds();
|
|
450
|
+
if (args.includes('--write')) {
|
|
451
|
+
const { writeFileSync } = await import('node:fs');
|
|
452
|
+
writeFileSync(BASELINE_FILE, JSON.stringify({ enforced }, null, 2) + '\n');
|
|
453
|
+
process.stdout.write(`✓ wrote enforced baseline — ${enforced.length} decisions\n`);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
process.stdout.write(JSON.stringify({ enforced }, null, 2) + '\n');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const { decisions } = buildRegistry();
|
|
461
|
+
const s = summarize(decisions);
|
|
462
|
+
process.stdout.write(`Decisions: ${s.total} (${s.enforced} enforced, ${s.advisory} advisory)\n`);
|
|
463
|
+
process.stdout.write(` by kind: ${Object.entries(s.byKind).map(([k, n]) => `${k}=${n}`).join(' ')}\n\n`);
|
|
464
|
+
for (const d of decisions) {
|
|
465
|
+
const mark = d.advisory ? '○' : '●';
|
|
466
|
+
const sup = d.supersededBy ? ` superseded-by ${d.supersededBy}` : '';
|
|
467
|
+
process.stdout.write(`${mark} ${d.id} [${d.kind}/${d.status || 'n/a'}] ${d.title || ''}${sup}\n`);
|
|
468
|
+
}
|
|
469
|
+
}
|