rcf-lite 0.7.1 → 0.9.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/CHANGELOG.md +97 -0
- package/bin/rcf.js +6 -0
- package/fixtures/canary-manifest.json +9 -9
- package/guidance/harness-template.md +11 -0
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +11 -0
- package/package.json +5 -3
- package/rcf/adrs/adr-010.json +30 -0
- package/rcf/code-nodes/cn-058.json +18 -0
- package/rcf/code-nodes/cn-059.json +14 -0
- package/rcf/code-nodes/cn-060.json +14 -0
- package/rcf/code-nodes/cn-061.json +14 -0
- package/rcf/code-nodes/cn-062.json +14 -0
- package/rcf/code-nodes/cn-063.json +15 -0
- package/rcf/code-nodes/cn-064.json +15 -0
- package/rcf/code-nodes/cn-065.json +16 -0
- package/rcf/code-nodes/cn-066.json +14 -0
- package/rcf/code-nodes/cn-067.json +15 -0
- package/rcf/code-nodes/cn-068.json +15 -0
- package/rcf/code-nodes/cn-069.json +16 -0
- package/rcf/fbs/fbs-016.json +39 -0
- package/rcf/fbs/fbs-017.json +40 -0
- package/rcf/fbs/fbs-018.json +34 -0
- package/rcf/fbs/fbs-019.json +33 -0
- package/rcf/requirements/req-010.json +20 -0
- package/rcf/test-suites/ts-026.json +54 -0
- package/rcf/test-suites/ts-027.json +115 -0
- package/rcf/test-suites/ts-028.json +46 -0
- package/rcf/test-suites/ts-029.json +46 -0
- package/rcf/user-stories/us-1001.json +56 -0
- package/rcf/user-stories/us-1002.json +96 -0
- package/rcf/user-stories/us-1003.json +48 -0
- package/rcf/user-stories/us-1004.json +48 -0
- package/src/admissibility/enforce.js +142 -0
- package/src/admissibility/index.js +8 -0
- package/src/admissibility/markers.js +104 -0
- package/src/admissibility/scope-lint.js +163 -0
- package/src/blueprint/apply.js +464 -0
- package/src/blueprint/conflicts.js +351 -0
- package/src/blueprint/diff.js +82 -0
- package/src/blueprint/index.js +12 -0
- package/src/blueprint/list.js +21 -0
- package/src/blueprint/loader.js +163 -0
- package/src/blueprint/manifest-writer.js +49 -0
- package/src/blueprint/namespace.js +145 -0
- package/src/blueprint/remove.js +105 -0
- package/src/blueprint/resolutions.js +83 -0
- package/src/blueprint/standards.js +148 -0
- package/src/blueprint/supersede.js +318 -0
- package/src/browser-verify/invariants.js +33 -6
- package/src/build/bundle.js +34 -11
- package/src/build/standards-selector.js +52 -0
- package/src/cli/blueprint.js +325 -0
- package/src/cli/create.js +49 -1
- package/src/cli/help.js +8 -0
- package/src/cli/init.js +20 -5
- package/src/cli/read.js +7 -1
- package/src/cli/standards.js +127 -0
- package/src/cli/test-suite.js +7 -2
- package/src/core/store/ids.js +168 -18
- package/src/core/store/loader.js +31 -17
- package/src/core/store/walker.js +62 -4
- package/src/core/store/writer.js +41 -11
- package/src/deployment/index.js +13 -0
- package/src/deployment/placeholder-detector.js +113 -0
- package/src/finalise/detect.js +51 -29
- package/src/finalise/index.js +16 -2
- package/src/finalise/ingest.js +41 -0
- package/src/mcp/tools.js +10 -2
- package/src/query/formatters/table.js +7 -10
- package/src/query/index.js +4 -0
- package/src/query/refuse-on-admissibility.js +73 -0
- package/src/query/trace.js +45 -4
- package/src/ruleset/index.js +140 -0
- package/src/ruleset/ruleset.json +146 -0
- package/src/verify/chain/index.js +31 -0
- package/src/verify/verdict/index.js +67 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// `rcf blueprint supersede <topic> [--incoming <source>]` implementation.
|
|
2
|
+
//
|
|
3
|
+
// Scaffolds a project-level ADR at `rcf/adrs/adr-NNN-<kebab-topic>.json`
|
|
4
|
+
// that supersedes the CONFLICT PAIR on the topic (one currently-applied
|
|
5
|
+
// blueprint's scope:global ADR + one incoming (would-be-applied)
|
|
6
|
+
// blueprint's scope:global ADR — Baz ruling, w-2026-08-19-008 round 3),
|
|
7
|
+
// and appends a matching `manifest.resolutions[]` record so the conflict
|
|
8
|
+
// detector honours the resolution when the operator re-runs
|
|
9
|
+
// `rcf blueprint add <incoming source>`.
|
|
10
|
+
//
|
|
11
|
+
// The `incomingSource` argument (mapped to `--incoming <source>` on the
|
|
12
|
+
// CLI) is REQUIRED when the applied-blueprints count on the topic is
|
|
13
|
+
// < 2, and silently accepted (informational) when it is already >= 2.
|
|
14
|
+
// This is what makes option 3 as printed by the reshaped conflict
|
|
15
|
+
// message executable VERBATIM from the refused-add state: the operator
|
|
16
|
+
// runs `rcf blueprint supersede <topic> --incoming <source>` immediately
|
|
17
|
+
// after the refused add, with zero prep; the verb loads the incoming
|
|
18
|
+
// blueprint from disk, finds its scope:global ADR on <topic>, stamps
|
|
19
|
+
// the id into the incoming blueprint's namespace, and uses that
|
|
20
|
+
// {slug, adrId} as the second side of supersedes[]. Round-2 shipped
|
|
21
|
+
// with the writer requiring 2 already-applied ADRs, which meant option
|
|
22
|
+
// 3 as printed exited 2 in the refused-add state; the escalation the
|
|
23
|
+
// worker adapted around was that AC-1002-5 could not pass as written.
|
|
24
|
+
//
|
|
25
|
+
// Two side effects, both governed by dryRun:
|
|
26
|
+
// 1. Writes the project ADR file (JSON, minimally valid — the operator
|
|
27
|
+
// is expected to fill out the context / decision / consequences
|
|
28
|
+
// body; the file lands with prefilled stubs that are valid against
|
|
29
|
+
// adr.schema.json so no adr-shaped tool errors on the freshly
|
|
30
|
+
// scaffolded file).
|
|
31
|
+
// 2. Appends `manifest.resolutions[]` via the shared manifest writer.
|
|
32
|
+
//
|
|
33
|
+
// The two writes are ordered: ADR first, manifest second. If the ADR
|
|
34
|
+
// write succeeds and the manifest write fails, the operator is left
|
|
35
|
+
// with a well-formed project ADR they can either re-run supersede
|
|
36
|
+
// against (idempotent by resolvedByAdrId) or hand-clean; the reverse
|
|
37
|
+
// order would leave a manifest resolution pointing at a non-existent
|
|
38
|
+
// ADR, which is the worse failure mode.
|
|
39
|
+
//
|
|
40
|
+
// Error-message prefix discipline (round-3 mechanical P1):
|
|
41
|
+
// every rcfError returned from this module is PREFIX-FREE — no
|
|
42
|
+
// `blueprint supersede: ` in the message body. The CLI edge prepends
|
|
43
|
+
// `[error] blueprint supersede: ` on every rcfError from the supersede
|
|
44
|
+
// verb, and doubling the prefix reads badly on the terminal.
|
|
45
|
+
|
|
46
|
+
import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
47
|
+
import { dirname, join } from 'node:path';
|
|
48
|
+
|
|
49
|
+
import { rcfError } from '../core/errors/index.js';
|
|
50
|
+
import { loadBlueprint } from './loader.js';
|
|
51
|
+
import { updateManifest } from './manifest-writer.js';
|
|
52
|
+
import { stampId } from './namespace.js';
|
|
53
|
+
import { nextResolutionId } from './resolutions.js';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {object} SupersedeResult
|
|
57
|
+
* @property {boolean} superseded
|
|
58
|
+
* @property {string} topic
|
|
59
|
+
* @property {string} resolvedByAdrId
|
|
60
|
+
* @property {string} resolvedByAdrPath
|
|
61
|
+
* @property {Array<{ slug: string, adrId: string, path: string }>} supersedes
|
|
62
|
+
* @property {string} resolutionId
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} args
|
|
67
|
+
* @param {string} args.projectRoot
|
|
68
|
+
* @param {import('#core/store/walker.js').TreeModel} args.tree
|
|
69
|
+
* @param {string} args.topic
|
|
70
|
+
* @param {string} [args.incomingSource]
|
|
71
|
+
* Path to a blueprint source directory (a `blueprint.json` + a
|
|
72
|
+
* `contributions/` tree). Required when the applied-blueprints count
|
|
73
|
+
* on `topic` is < 2 — the verb loads the blueprint from disk, finds
|
|
74
|
+
* its scope:global ADR on `topic`, stamps its id into the incoming
|
|
75
|
+
* blueprint's namespace, and uses that {slug, adrId} as the second
|
|
76
|
+
* side of `supersedes[]`. Silently accepted (informational, does not
|
|
77
|
+
* contribute a second entry beyond dedupe) when >= 2 applied ADRs on
|
|
78
|
+
* the topic already exist.
|
|
79
|
+
* @param {Date} [args.now]
|
|
80
|
+
* @param {boolean} [args.dryRun]
|
|
81
|
+
* @param {string} [args.reason]
|
|
82
|
+
* @returns {Promise<SupersedeResult | import('../core/errors/index.js').RcfError>}
|
|
83
|
+
*/
|
|
84
|
+
export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomingSource, now = new Date(), dryRun = false, reason }) {
|
|
85
|
+
if (typeof topic !== 'string' || topic.trim().length === 0) {
|
|
86
|
+
// Schema minLength:1 accepts whitespace-only; the writer refuses
|
|
87
|
+
// it up-front so a whitespace-only topic never lands on disk.
|
|
88
|
+
return rcfError({ kind: 'usage', message: `topic is required (e.g. rcf blueprint supersede errorEnvelope --incoming ./blueprints/rest)` });
|
|
89
|
+
}
|
|
90
|
+
// Topic is a LOOKUP KEY into applied ADR topics, not a slug (ADR-010).
|
|
91
|
+
// Schema is minLength:1 with no character constraint and the schema
|
|
92
|
+
// docs explicitly cite camelCase examples ('errorEnvelope'). The
|
|
93
|
+
// shipped SPA + REST blueprints declare camelCase topics; we accept
|
|
94
|
+
// any topic string that exact-matches an applied or incoming ADR
|
|
95
|
+
// topic below, and kebab-ify only when projecting into the
|
|
96
|
+
// scaffolded project-ADR id slug tail (adrId grammar requires
|
|
97
|
+
// lowercase kebab after the digits).
|
|
98
|
+
if (reason !== undefined && typeof reason === 'string' && reason.length > 0 && reason.trim().length === 0) {
|
|
99
|
+
// Non-empty whitespace-only reason: refuse before write. An
|
|
100
|
+
// undefined / empty-string reason is fine (the field is optional
|
|
101
|
+
// on the schema; empty-string simply becomes 'omit').
|
|
102
|
+
return rcfError({ kind: 'usage', message: `--reason must not be whitespace-only.` });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const applied = Array.isArray(tree.manifest?.blueprints) ? tree.manifest.blueprints : [];
|
|
106
|
+
const supersedes = [];
|
|
107
|
+
for (const bp of applied) {
|
|
108
|
+
for (const c of bp.contributions ?? []) {
|
|
109
|
+
if (c.kind === 'adr' && c.scope === 'global' && c.topic === topic) {
|
|
110
|
+
supersedes.push({ slug: bp.slug, adrId: c.id, path: c.path });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Baz ruling (round 3): the supersession record's two sides are the
|
|
116
|
+
// CONFLICT PAIR — one applied + one incoming — and this verb must
|
|
117
|
+
// accept the incoming blueprint as the second side. `--incoming
|
|
118
|
+
// <source>` loads the incoming blueprint from disk, finds its
|
|
119
|
+
// scope:global ADR on the topic, and adds it to supersedes[]. When
|
|
120
|
+
// supersedes[] already has >= 2 entries from applied blueprints, an
|
|
121
|
+
// incomingSource is informational only (skipped silently unless it
|
|
122
|
+
// would add a distinct {slug, adrId} pair, in which case it is
|
|
123
|
+
// appended for a >= 3-blueprint scenario).
|
|
124
|
+
if (typeof incomingSource === 'string' && incomingSource.length > 0) {
|
|
125
|
+
const loaded = await loadBlueprint(incomingSource);
|
|
126
|
+
if (loaded.kind) {
|
|
127
|
+
// Preserve the loader's own rcfError but drop any 'blueprint: '
|
|
128
|
+
// narrator prefix so the CLI's `[error] blueprint supersede: `
|
|
129
|
+
// reads clean.
|
|
130
|
+
return rcfError({
|
|
131
|
+
kind: loaded.kind === 'usage' ? 'usage' : loaded.kind,
|
|
132
|
+
message: `--incoming ${incomingSource}: ${loaded.message.replace(/^blueprint(?:\s+add|\s+supersede)?:\s*/, '')}`,
|
|
133
|
+
filePath: loaded.filePath,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
let matched = null;
|
|
137
|
+
for (const c of loaded.contributions ?? []) {
|
|
138
|
+
if (c.kind === 'adr' && c.scope === 'global' && c.topic === topic) {
|
|
139
|
+
matched = c;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!matched) {
|
|
144
|
+
return rcfError({
|
|
145
|
+
kind: 'usage',
|
|
146
|
+
message: `--incoming ${incomingSource}: blueprint '${loaded.slug}' declares no scope:global ADR on topic '${topic}'.`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const stamped = stampId(matched.id, loaded.slug);
|
|
150
|
+
if ('error' in stamped) {
|
|
151
|
+
return rcfError({ kind: 'validation', message: `--incoming ${incomingSource}: ${stamped.error}` });
|
|
152
|
+
}
|
|
153
|
+
const incomingPair = { slug: loaded.slug, adrId: stamped.id, path: `rcf/adrs/${stamped.id.toLowerCase()}.json` };
|
|
154
|
+
// Dedupe against the applied side: an incoming blueprint that is
|
|
155
|
+
// also currently applied (unusual — refused-add state means it is
|
|
156
|
+
// NOT applied) would otherwise be double-listed.
|
|
157
|
+
const alreadyListed = supersedes.some((s) => s.slug === incomingPair.slug && s.adrId === incomingPair.adrId);
|
|
158
|
+
if (!alreadyListed) supersedes.push(incomingPair);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (supersedes.length < 2) {
|
|
162
|
+
return rcfError({
|
|
163
|
+
kind: 'usage',
|
|
164
|
+
message: `topic '${topic}' has ${supersedes.length} scope:global ADR(s) across applied+incoming; at least two are required for a supersession record. If the incoming blueprint has not been named, add \`--incoming <source>\` (the same source you passed to \`rcf blueprint add\`).`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Mint the project ADR id: numeric-only in the ADR-NNN-<topic> shape
|
|
169
|
+
// (topic slug appended as the ADR grammar's optional suffix). Read
|
|
170
|
+
// every ADR the walker already loaded and pick max(NNN) + 1 across
|
|
171
|
+
// all ADR ids regardless of blueprint namespace. NNN is zero-padded
|
|
172
|
+
// to three digits (adrId pattern requires \d{3,}).
|
|
173
|
+
const projectAdrId = mintProjectAdrId(tree, topic);
|
|
174
|
+
const projectAdrPath = `rcf/adrs/${projectAdrId.toLowerCase()}.json`;
|
|
175
|
+
const absAdrPath = join(projectRoot, projectAdrPath);
|
|
176
|
+
|
|
177
|
+
// Compose the project ADR body. Required fields on adr.schema.json:
|
|
178
|
+
// adrId, prdId, tadId, version, status, title, context, decision,
|
|
179
|
+
// consequences, createdAt, updatedAt. The prdId + tadId come from the
|
|
180
|
+
// tree's roots; the rest carry prefilled operator-editable stubs
|
|
181
|
+
// (minLength 1 satisfied, no <PLACEHOLDER> tokens the tree would
|
|
182
|
+
// choke on later).
|
|
183
|
+
const prdId = tree.manifest?.prd?.id;
|
|
184
|
+
const tadId = tree.manifest?.tad?.id;
|
|
185
|
+
if (typeof prdId !== 'string' || typeof tadId !== 'string') {
|
|
186
|
+
return rcfError({ kind: 'usage', message: `manifest is missing prd or tad; cannot scaffold a project ADR without prdId / tadId.` });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const isoNow = now.toISOString();
|
|
190
|
+
const relatedAdrs = supersedes.map((s) => s.adrId).filter((id) => /^ADR-\d{3,}(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?$/.test(id));
|
|
191
|
+
const superseededSlugs = supersedes.map((s) => `${s.adrId} (blueprint ${s.slug})`).join(', ');
|
|
192
|
+
const adrBody = {
|
|
193
|
+
adrId: projectAdrId,
|
|
194
|
+
prdId,
|
|
195
|
+
tadId,
|
|
196
|
+
version: '1.0.0',
|
|
197
|
+
status: 'accepted',
|
|
198
|
+
title: `Project ruling on ${topic} (supersedes ${supersedes.length} blueprint ADR${supersedes.length === 1 ? '' : 's'})`,
|
|
199
|
+
context: `Two or more applied blueprints each contributed a scope:global ADR on the '${topic}' topic: ${superseededSlugs}. Composition of these blueprints on one project needs a single project-level decision on ${topic}; the blueprint ADRs are retained on disk as superseded history.`,
|
|
200
|
+
decision: `Adopt a project-level ruling on ${topic}. This ADR is the live decision; the blueprint ADRs listed under relatedAdrs are superseded and their content stands as historical context only.`,
|
|
201
|
+
consequences: `The blueprint conflict on topic '${topic}' is honoured via manifest.resolutions[]. Any future blueprint added with a scope:global ADR on '${topic}' must be listed on the resolution (or a fresh resolution must be recorded) before rcf blueprint add proceeds.`,
|
|
202
|
+
...(relatedAdrs.length > 0 ? { relatedAdrs } : {}),
|
|
203
|
+
createdAt: isoNow,
|
|
204
|
+
updatedAt: isoNow,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const resolutionId = nextResolutionId(tree.manifest, now);
|
|
208
|
+
const resolutionRecord = {
|
|
209
|
+
id: resolutionId,
|
|
210
|
+
createdAt: isoNow,
|
|
211
|
+
kind: 'globalAdrTopic',
|
|
212
|
+
topic,
|
|
213
|
+
resolvedByAdrId: projectAdrId,
|
|
214
|
+
supersedes: supersedes.map((s) => ({ slug: s.slug, adrId: s.adrId })),
|
|
215
|
+
...(typeof reason === 'string' && reason.length > 0 ? { reason } : {}),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (!dryRun) {
|
|
219
|
+
// Refuse to clobber an existing file at the scaffolded ADR path.
|
|
220
|
+
const already = await stat(absAdrPath).catch(() => null);
|
|
221
|
+
if (already) {
|
|
222
|
+
return rcfError({
|
|
223
|
+
kind: 'duplicateId',
|
|
224
|
+
message: `refuse to overwrite existing file at ${projectAdrPath}. Re-run supersede after removing or renaming that file, or edit it directly if it is the intended supersession record.`,
|
|
225
|
+
filePath: projectAdrPath,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
await mkdir(dirname(absAdrPath), { recursive: true });
|
|
230
|
+
const tmp = `${absAdrPath}.tmp`;
|
|
231
|
+
await writeFile(tmp, `${JSON.stringify(adrBody, null, 2)}\n`, 'utf8');
|
|
232
|
+
try {
|
|
233
|
+
await rename(tmp, absAdrPath);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
await unlink(tmp).catch(() => {});
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return rcfError({ kind: 'ioFailure', message: `ADR write failed: ${err.message}`, filePath: projectAdrPath });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const manifestResult = await updateManifest({
|
|
244
|
+
projectRoot,
|
|
245
|
+
manifest: tree.manifest,
|
|
246
|
+
mutate: (next) => {
|
|
247
|
+
const list = Array.isArray(next.resolutions) ? next.resolutions : [];
|
|
248
|
+
list.push(resolutionRecord);
|
|
249
|
+
next.resolutions = list;
|
|
250
|
+
},
|
|
251
|
+
dryRun,
|
|
252
|
+
});
|
|
253
|
+
if (manifestResult.kind) return manifestResult;
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
superseded: true,
|
|
257
|
+
topic,
|
|
258
|
+
resolvedByAdrId: projectAdrId,
|
|
259
|
+
resolvedByAdrPath: projectAdrPath,
|
|
260
|
+
supersedes: supersedes.map((s) => ({ slug: s.slug, adrId: s.adrId, path: s.path })),
|
|
261
|
+
resolutionId,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function mintProjectAdrId(tree, topic) {
|
|
266
|
+
let maxN = 0;
|
|
267
|
+
const walk = (id) => {
|
|
268
|
+
if (typeof id !== 'string') return;
|
|
269
|
+
const m = id.match(/^ADR-(\d{3,})(?:-|$)/);
|
|
270
|
+
if (!m) return;
|
|
271
|
+
const n = Number.parseInt(m[1], 10);
|
|
272
|
+
if (Number.isFinite(n) && n > maxN) maxN = n;
|
|
273
|
+
};
|
|
274
|
+
if (tree.byId instanceof Map) {
|
|
275
|
+
for (const id of tree.byId.keys()) walk(id);
|
|
276
|
+
}
|
|
277
|
+
// Belt and braces: also scan applied blueprint contribution ids in
|
|
278
|
+
// case an ADR sits recorded but the walker did not surface it under
|
|
279
|
+
// byId (broken tree path, mid-migration state, etc.).
|
|
280
|
+
for (const bp of tree.manifest?.blueprints ?? []) {
|
|
281
|
+
for (const c of bp.contributions ?? []) walk(c.id);
|
|
282
|
+
}
|
|
283
|
+
const next = (maxN + 1).toString().padStart(3, '0');
|
|
284
|
+
// adrId grammar requires lowercase kebab after the digits
|
|
285
|
+
// (^ADR-\d{3,}(-[a-z0-9]+(?:-[a-z0-9]+)*)?$), so a camelCase topic
|
|
286
|
+
// like 'authModel' becomes the slug tail 'auth-model'. Topic itself
|
|
287
|
+
// remains stored verbatim on manifest.resolutions[].topic — it is a
|
|
288
|
+
// lookup key, not a slug.
|
|
289
|
+
return `ADR-${next}-${kebabise(topic)}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Convert a topic string to a well-formed kebab slug suitable for use
|
|
294
|
+
* as the adrId slug-tail (lowercase alnum segments joined by single
|
|
295
|
+
* hyphens; leading letter enforced by the ADR grammar's optional-tail
|
|
296
|
+
* regex when the tail is non-empty). Handles:
|
|
297
|
+
* - camelCase -> kebab-case (`authModel` -> `auth-model`)
|
|
298
|
+
* - snake / SCREAMING_SNAKE -> kebab (`error_envelope` -> `error-envelope`)
|
|
299
|
+
* - existing kebab (`auth-model`) -> unchanged
|
|
300
|
+
* - spaces / punctuation collapsed to single hyphens; leading/trailing
|
|
301
|
+
* hyphens trimmed
|
|
302
|
+
*
|
|
303
|
+
* Guaranteed to return a string that matches `[a-z0-9]+(?:-[a-z0-9]+)*`
|
|
304
|
+
* or empty; the empty case is refused by the caller (topic non-empty
|
|
305
|
+
* check runs first).
|
|
306
|
+
*/
|
|
307
|
+
export function kebabise(topic) {
|
|
308
|
+
if (typeof topic !== 'string') return '';
|
|
309
|
+
let s = topic;
|
|
310
|
+
// camelCase -> insert hyphen before each uppercase letter that
|
|
311
|
+
// follows a lowercase letter or digit (`authModel` -> `auth-Model`).
|
|
312
|
+
s = s.replace(/([a-z0-9])([A-Z])/g, '$1-$2');
|
|
313
|
+
// SCREAMING_CASE runs: `ABc` -> `A-Bc` so acronyms split cleanly.
|
|
314
|
+
s = s.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2');
|
|
315
|
+
// Lowercase, collapse non-alnum runs to single hyphen, trim.
|
|
316
|
+
s = s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
317
|
+
return s;
|
|
318
|
+
}
|
|
@@ -85,6 +85,12 @@ const V1_INVARIANTS = [
|
|
|
85
85
|
perThemeOnly: false,
|
|
86
86
|
run: checkSharedLayoutStructural,
|
|
87
87
|
},
|
|
88
|
+
{
|
|
89
|
+
name: 'noInlineStyleBlocks',
|
|
90
|
+
severity: 'block',
|
|
91
|
+
perThemeOnly: false,
|
|
92
|
+
run: checkNoInlineStyleBlocks,
|
|
93
|
+
},
|
|
88
94
|
];
|
|
89
95
|
|
|
90
96
|
/** Versioned constant, module-encoded per §12 O-6. */
|
|
@@ -253,13 +259,16 @@ function checkThemeDefaultsToLight(ctx) {
|
|
|
253
259
|
}
|
|
254
260
|
|
|
255
261
|
function checkFocusRingsVisible(ctx) {
|
|
256
|
-
// Cheap chain-shaped heuristic: the response HTML mentions a
|
|
257
|
-
// style
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
262
|
+
// Cheap chain-shaped heuristic: the response HTML either mentions a
|
|
263
|
+
// focus style inline OR links a same-origin stylesheet where the
|
|
264
|
+
// rule can live (per the styled-under-shipped-CSP baseline: no
|
|
265
|
+
// inline <style>, ship rules via <link rel="stylesheet">). The real
|
|
266
|
+
// check happens agent-side against a tab-focused screenshot; v1
|
|
267
|
+
// records `warn` when the heuristic is inconclusive so operators
|
|
268
|
+
// know to eyeball. spec §8.3 severity `warn`.
|
|
261
269
|
if (/focus-visible|focus-ring|outline\s*:/i.test(ctx.dom)) return { verdict: 'pass' };
|
|
262
|
-
return { verdict: '
|
|
270
|
+
if (/<link\b[^>]*rel=["']?stylesheet\b/i.test(ctx.dom)) return { verdict: 'pass' };
|
|
271
|
+
return { verdict: 'warn', detail: 'no focus-ring style detected in the response HTML and no <link rel="stylesheet"> reference (visually confirm agent-side)' };
|
|
263
272
|
}
|
|
264
273
|
|
|
265
274
|
function checkSharedLayoutStructural(ctx) {
|
|
@@ -299,6 +308,24 @@ export function compareTopLevelStructure(captures, baseline) {
|
|
|
299
308
|
};
|
|
300
309
|
}
|
|
301
310
|
|
|
311
|
+
/**
|
|
312
|
+
* The captured DOM MUST NOT carry an inline <style> block. A <style>
|
|
313
|
+
* block requires the shipping CSP to allow style-src 'unsafe-inline';
|
|
314
|
+
* a project that ships one either weakens the CSP baseline (a security
|
|
315
|
+
* regression) or renders unstyled at deploy (the watchpost run4 class
|
|
316
|
+
* defect this invariant refuses). Stylesheets must be served from
|
|
317
|
+
* same-origin routes and referenced via <link rel="stylesheet"> so
|
|
318
|
+
* style-src 'self' remains sufficient.
|
|
319
|
+
*/
|
|
320
|
+
function checkNoInlineStyleBlocks(ctx) {
|
|
321
|
+
const dom = typeof ctx.dom === 'string' ? ctx.dom : '';
|
|
322
|
+
if (!/<style\b/i.test(dom)) return { verdict: 'pass' };
|
|
323
|
+
return {
|
|
324
|
+
verdict: 'fail',
|
|
325
|
+
detail: 'inline <style> block present in the rendered HTML; ship style-src \'unsafe-inline\' would be required for it to render (watchpost run4 class defect, w-2026-08-24-003). Serve the stylesheet from a same-origin route and reference it via <link rel="stylesheet">.',
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
302
329
|
function topLevelChildTagSequence(dom) {
|
|
303
330
|
// Extract the tag-name sequence of direct children of <body>. Cheap
|
|
304
331
|
// regex-based; deliberately conservative (misses nested body-siblings
|
package/src/build/bundle.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// authored acIds.
|
|
12
12
|
|
|
13
13
|
import { byBuildOrder, computeQueue } from './queue.js';
|
|
14
|
+
import { selectStandards } from './standards-selector.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Copy `fields` from `source` onto a new object, skipping fields not
|
|
@@ -106,18 +107,23 @@ export function assembleBundle(tree, { fbsId }) {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
// Section 5 - architectural context (§D11). Omitted entirely when
|
|
109
|
-
// contextRequirements is absent
|
|
110
|
-
|
|
110
|
+
// contextRequirements is absent, EXCEPT when the FBS drives an agentic
|
|
111
|
+
// standards selection off manifest.standards[] alone (0.4.4). In that
|
|
112
|
+
// case a minimal context is synthesised so the standards payload has
|
|
113
|
+
// somewhere to land.
|
|
114
|
+
const manifestStandards = tree.manifest?.standards ?? [];
|
|
111
115
|
const ctx = fbs.contextRequirements;
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
let context;
|
|
117
|
+
if (ctx || (manifestStandards.length > 0 && !fbs.contextRequirements)) {
|
|
118
|
+
const safeCtx = ctx ?? {};
|
|
119
|
+
const tacs = (safeCtx.tacIds ?? [])
|
|
114
120
|
.map((id) => tree.byId.get(id))
|
|
115
121
|
.filter(Boolean)
|
|
116
122
|
.map((tac) => pick(tac, [
|
|
117
123
|
'tacId', 'name', 'purpose', 'responsibilities', 'interfaces',
|
|
118
124
|
'dependencies', 'tradeoffs', 'notes',
|
|
119
125
|
]));
|
|
120
|
-
const adrs = (
|
|
126
|
+
const adrs = (safeCtx.adrIds ?? [])
|
|
121
127
|
.map((id) => tree.byId.get(id))
|
|
122
128
|
.filter(Boolean)
|
|
123
129
|
.map((adr) => pick(adr, [
|
|
@@ -136,19 +142,36 @@ export function assembleBundle(tree, { fbsId }) {
|
|
|
136
142
|
}
|
|
137
143
|
return out;
|
|
138
144
|
};
|
|
139
|
-
const tadSections = resolveSections(
|
|
140
|
-
const prdSections = resolveSections(
|
|
145
|
+
const tadSections = resolveSections(safeCtx.tadSections, tree.tad);
|
|
146
|
+
const prdSections = resolveSections(safeCtx.prdSections, tree.prd);
|
|
147
|
+
|
|
148
|
+
// 0.4.4 selective retrieval. Operator-authored standardIds override
|
|
149
|
+
// the agentic selection; empty selection is legitimate and never
|
|
150
|
+
// blocks the assembler.
|
|
151
|
+
const authoredStandardIds = Array.isArray(safeCtx.standardIds) ? safeCtx.standardIds : null;
|
|
152
|
+
const selection = authoredStandardIds
|
|
153
|
+
? { standardIds: authoredStandardIds, source: 'operatorOverride' }
|
|
154
|
+
: { standardIds: selectStandards(fbs, manifestStandards).standardIds, source: 'agenticSelection' };
|
|
155
|
+
const standardsById = new Map(manifestStandards.map((s) => [s.slug, s]));
|
|
156
|
+
const standardsPayload = selection.standardIds
|
|
157
|
+
.map((slug) => standardsById.get(slug))
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.map((pack) => pick(pack, ['id', 'slug', 'tags', 'summary', 'testsProvidedBy', 'provenance', 'sourcePath', 'copyPath']));
|
|
160
|
+
|
|
141
161
|
context = {
|
|
142
162
|
tacs,
|
|
143
163
|
adrs,
|
|
144
164
|
tadSections,
|
|
145
165
|
prdSections,
|
|
146
166
|
unresolvedSections,
|
|
167
|
+
standardIds: selection.standardIds,
|
|
168
|
+
standardsSource: selection.source,
|
|
169
|
+
standards: standardsPayload,
|
|
147
170
|
passThrough: {
|
|
148
|
-
existingModules:
|
|
149
|
-
schemas:
|
|
150
|
-
externalDocs:
|
|
151
|
-
other:
|
|
171
|
+
existingModules: safeCtx.existingModules ?? [],
|
|
172
|
+
schemas: safeCtx.schemas ?? [],
|
|
173
|
+
externalDocs: safeCtx.externalDocs ?? [],
|
|
174
|
+
other: safeCtx.other ?? [],
|
|
152
175
|
},
|
|
153
176
|
};
|
|
154
177
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Deterministic tag-scoring standards selector for the bundle assembler.
|
|
2
|
+
//
|
|
3
|
+
// Design brief §Selective per-FBS retrieval (agentic): a small model
|
|
4
|
+
// call is allowed but not required. Phase 1 ships a pure heuristic —
|
|
5
|
+
// deterministic tag-hit counting over the FBS work text against the
|
|
6
|
+
// registered standards' tags, slugs and summaries. This satisfies the
|
|
7
|
+
// selective-retrieval ACs (deterministic, hermetic, never blocks) and
|
|
8
|
+
// keeps the seam clean for a smarter selector to slot in.
|
|
9
|
+
//
|
|
10
|
+
// Selection order: standards manifest order (authored); ties inside a
|
|
11
|
+
// score break by manifest order too. Silence on the FBS side returns an
|
|
12
|
+
// empty selection; the bundle assembler treats that as "no standards".
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} fbs - the FBS document
|
|
16
|
+
* @param {Array<{ slug: string, tags?: string[], summary?: string }>} standards
|
|
17
|
+
* @returns {{ standardIds: string[], scoresById: Record<string, number> }}
|
|
18
|
+
*/
|
|
19
|
+
export function selectStandards(fbs, standards) {
|
|
20
|
+
const source = normaliseText([
|
|
21
|
+
fbs?.title,
|
|
22
|
+
fbs?.summary,
|
|
23
|
+
fbs?.approach,
|
|
24
|
+
fbs?.notes,
|
|
25
|
+
(fbs?.acceptanceCriteria ?? []).map((ac) => `${ac?.description ?? ''} ${ac?.given ?? ''} ${ac?.when ?? ''} ${ac?.then ?? ''}`).join(' '),
|
|
26
|
+
].filter(Boolean).join(' '));
|
|
27
|
+
const packs = Array.isArray(standards) ? standards : [];
|
|
28
|
+
const scoresById = {};
|
|
29
|
+
const selected = [];
|
|
30
|
+
for (const pack of packs) {
|
|
31
|
+
const score = scorePack(source, pack);
|
|
32
|
+
scoresById[pack.slug] = score;
|
|
33
|
+
if (score > 0) selected.push(pack.slug);
|
|
34
|
+
}
|
|
35
|
+
return { standardIds: selected, scoresById };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function scorePack(source, pack) {
|
|
39
|
+
let score = 0;
|
|
40
|
+
const tokens = new Set();
|
|
41
|
+
for (const tag of pack.tags ?? []) tokens.add(String(tag).toLowerCase());
|
|
42
|
+
tokens.add(String(pack.slug ?? '').toLowerCase());
|
|
43
|
+
for (const token of tokens) {
|
|
44
|
+
if (!token) continue;
|
|
45
|
+
if (source.includes(token)) score += 1;
|
|
46
|
+
}
|
|
47
|
+
return score;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normaliseText(s) {
|
|
51
|
+
return String(s ?? '').toLowerCase();
|
|
52
|
+
}
|