rcf-lite 0.30.0 → 0.32.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 +67 -0
- package/blueprints/deploy-cloudflare-workers/contributions/user-stories/deploy-cloudflare-workers-us-12113.json +1 -1
- package/fixtures/canary-manifest.json +9 -9
- package/guidance/define-draft-shapes.md +15 -10
- package/package.json +1 -1
- package/rcf/adrs/adr-4131-gates-bite-ack-recorded-override.json +3 -3
- package/rcf/adrs/adr-4137-standalone-theme-persistence.json +30 -0
- package/rcf/adrs/adr-4138-tree-wide-checks-before-scope-shortcircuit.json +35 -0
- package/rcf/code-nodes/cn-017.json +4 -2
- package/rcf/code-nodes/cn-771.json +15 -0
- package/rcf/code-nodes/cn-772.json +14 -0
- package/rcf/code-nodes/cn-773.json +16 -0
- package/rcf/code-nodes/cn-774.json +20 -0
- package/rcf/code-nodes/cn-775.json +15 -0
- package/rcf/code-nodes/cn-776.json +17 -0
- package/rcf/code-nodes/cn-777.json +21 -0
- package/rcf/code-nodes/cn-778.json +14 -0
- package/rcf/fbs/fbs-004.json +4 -1
- package/rcf/fbs/fbs-201.json +2 -1
- package/rcf/fbs/fbs-202.json +18 -2
- package/rcf/fbs/fbs-203.json +12 -4
- package/rcf/requirements/req-002.json +2 -2
- package/rcf/requirements/req-173.json +2 -2
- package/rcf/requirements/req-174.json +2 -2
- package/rcf/tacs/tac-4122-define-gates.json +7 -2
- package/rcf/tacs/tac-4127-readiness-view.json +3 -2
- package/rcf/test-suites/ts-004.json +82 -2
- package/rcf/test-suites/ts-007.json +1 -1
- package/rcf/test-suites/ts-215.json +64 -1
- package/rcf/test-suites/ts-216.json +18 -2
- package/rcf/test-suites/ts-219.json +10 -2
- package/rcf/test-suites/ts-220.json +18 -2
- package/rcf/test-suites/ts-223.json +24 -2
- package/rcf/test-suites/ts-225.json +10 -2
- package/rcf/test-suites/ts-229.json +49 -3
- package/rcf/test-suites/ts-230.json +26 -2
- package/rcf/test-suites/ts-231.json +16 -1
- package/rcf/test-suites/ts-236.json +44 -0
- package/rcf/user-stories/us-17302.json +36 -1
- package/rcf/user-stories/us-17401.json +11 -1
- package/rcf/user-stories/us-17402.json +6 -1
- package/rcf/user-stories/us-17403.json +11 -1
- package/rcf/user-stories/us-17404.json +16 -1
- package/rcf/user-stories/us-17405.json +7 -2
- package/rcf/user-stories/us-18001.json +6 -1
- package/rcf/user-stories/us-202.json +25 -1
- package/rcf/user-stories/us-205.json +6 -1
- package/rcf/user-stories/us-206.json +25 -4
- package/rcf/user-stories/us-207.json +36 -0
- package/releases/releases.yaml +20 -1
- package/src/cli/create.js +22 -2
- package/src/cli/freeze.js +8 -0
- package/src/cli/ledger.js +96 -4
- package/src/cli/readiness.js +17 -39
- package/src/core/errors/index.js +7 -1
- package/src/core/store/writer.js +181 -13
- package/src/define/interface-paths.js +47 -0
- package/src/define/ledgers.js +86 -7
- package/src/mcp/tools.js +1 -1
- package/src/query/gates.js +431 -163
- package/src/view/html-page.js +43 -13
- package/src/view/live-client.js +18 -10
- package/src/view/page-init.js +149 -8
- package/src/view/style.css +107 -20
- package/src/view/view-index.js +22 -0
package/src/cli/create.js
CHANGED
|
@@ -64,8 +64,11 @@ Options:
|
|
|
64
64
|
every non-root child carries a mandatory
|
|
65
65
|
parentId-style field). See the parent table
|
|
66
66
|
above for which id each kind expects.
|
|
67
|
-
--id <id> Override auto-assigned id
|
|
68
|
-
|
|
67
|
+
--id <id> Override auto-assigned id. Idempotent by id:
|
|
68
|
+
an existing id with identical content is a
|
|
69
|
+
no-op ('<id>: unchanged', exit 0); with
|
|
70
|
+
different content it refuses (exit 3, names
|
|
71
|
+
the differing fields). Use update to patch
|
|
69
72
|
--title <string> Required for req / us / tac / adr / fbs / ts
|
|
70
73
|
(ac / tc use --description)
|
|
71
74
|
--description <string> Body description; required for ac / tc
|
|
@@ -297,6 +300,15 @@ export async function main(argv, deps = {}) {
|
|
|
297
300
|
if (isRcfError(result)) {
|
|
298
301
|
return handleWriterError(result, stderr);
|
|
299
302
|
}
|
|
303
|
+
// Ruling R1 extension 2026-10-05 (PR 292 follow-up, w-2026-10-05-dave-013):
|
|
304
|
+
// a same-id replay with identical content exits 0 with 'unchanged';
|
|
305
|
+
// the file is not rewritten. Mirrors `rcf define ledger add --id`.
|
|
306
|
+
if (result.status === 'unchanged') {
|
|
307
|
+
if (!flags.quiet) {
|
|
308
|
+
stdout.write(`${result.id}: unchanged (${result.filePath})\n`);
|
|
309
|
+
}
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
300
312
|
if (options.dryRun) {
|
|
301
313
|
if (!flags.quiet) stdout.write(`[dry-run] would create ${result.id} at ${result.filePath}\n`);
|
|
302
314
|
return 0;
|
|
@@ -358,6 +370,10 @@ const ERROR_KINDS = new Set([
|
|
|
358
370
|
'parseFailure',
|
|
359
371
|
'ioFailure',
|
|
360
372
|
'usage',
|
|
373
|
+
// Ruling R1 extension 2026-10-05 (PR 292 follow-up): a same-id
|
|
374
|
+
// create whose would-be body differs from the existing doc refuses
|
|
375
|
+
// with `kind: 'conflict'` and exits 3 (never silently overwrites).
|
|
376
|
+
'conflict',
|
|
361
377
|
]);
|
|
362
378
|
|
|
363
379
|
function isRcfError(value) {
|
|
@@ -412,6 +428,10 @@ function handleWriterError(err, stderr) {
|
|
|
412
428
|
stderr.write(`[error] ${kind} ${err.message}\n`);
|
|
413
429
|
if (kind === 'usage') return 2;
|
|
414
430
|
if (kind === 'validation' || kind === 'brokenReference') return 3;
|
|
431
|
+
// Ruling R1 extension 2026-10-05 (PR 292 follow-up): a same-id
|
|
432
|
+
// create with different content refuses with exit 3; `update` is
|
|
433
|
+
// the verb for patching.
|
|
434
|
+
if (kind === 'conflict') return 3;
|
|
415
435
|
if (kind === 'missingFile' || kind === 'parseFailure') return 2;
|
|
416
436
|
return 1;
|
|
417
437
|
}
|
package/src/cli/freeze.js
CHANGED
|
@@ -35,6 +35,7 @@ import { checkCodeNodeResolution } from '#core/store';
|
|
|
35
35
|
import { findProjectRoot } from '../view/index.js';
|
|
36
36
|
import { loadFreezeRecord, saveFreezeRecord } from '../define/freeze-record.js';
|
|
37
37
|
import { loadAllLedgers } from '../define/ledgers.js';
|
|
38
|
+
import { resolveInterfacePaths } from '../define/interface-paths.js';
|
|
38
39
|
import {
|
|
39
40
|
STAGE_ALIASES,
|
|
40
41
|
STAGE_GATES,
|
|
@@ -368,6 +369,12 @@ async function runFreeze({
|
|
|
368
369
|
// freezeableAfterAck check below is the real gate (freeze still
|
|
369
370
|
// exits 4 on an unfrozen tree); the wrap's refusal is informational
|
|
370
371
|
// here as it is on readiness.
|
|
372
|
+
// Issue 302 (2026-10-06): freeze now pre-resolves the TAC
|
|
373
|
+
// interface `path:` tokens the way readiness does, so the D3
|
|
374
|
+
// `shapes:pathsResolve` check inside freeze agrees with the
|
|
375
|
+
// readiness verdict instead of defaulting to an empty set and
|
|
376
|
+
// false-failing resolvable paths.
|
|
377
|
+
const resolvedPaths = await resolveInterfacePaths(projectRoot, tree);
|
|
371
378
|
/** @type {import('../query/readiness.js').ReadinessResult} */
|
|
372
379
|
const readiness = computeReadiness(tree, {
|
|
373
380
|
freeze: freezeForCompute,
|
|
@@ -375,6 +382,7 @@ async function runFreeze({
|
|
|
375
382
|
profileText,
|
|
376
383
|
testPointers,
|
|
377
384
|
validateErrors,
|
|
385
|
+
resolvedPaths,
|
|
378
386
|
});
|
|
379
387
|
const gated = await runWithAdmissibilityGate({
|
|
380
388
|
tree,
|
package/src/cli/ledger.js
CHANGED
|
@@ -27,6 +27,7 @@ import { runIntakeScansOnDelta } from '../intake/orchestrator.js';
|
|
|
27
27
|
import { findProjectRoot } from '../view/index.js';
|
|
28
28
|
|
|
29
29
|
const OPTION_SPEC = {
|
|
30
|
+
id: { type: 'string' },
|
|
30
31
|
kind: { type: 'string' },
|
|
31
32
|
text: { type: 'string' },
|
|
32
33
|
from: { type: 'string' },
|
|
@@ -74,6 +75,15 @@ Common flags:
|
|
|
74
75
|
--help Print this help
|
|
75
76
|
|
|
76
77
|
brief add flags:
|
|
78
|
+
--id <n> Optional positive integer. When set, the add
|
|
79
|
+
is idempotent by id (ruling R1 2026-10-05):
|
|
80
|
+
an entry at that id with identical content
|
|
81
|
+
exits 0 printing 'unchanged' (--json emits
|
|
82
|
+
{status:"unchanged",id:n}); different content
|
|
83
|
+
at the same id exits 3 naming the differing
|
|
84
|
+
fields; a free id is honoured as the override.
|
|
85
|
+
The harness carries --id back when it replays
|
|
86
|
+
a turn so the add is a no-op on replay.
|
|
77
87
|
--kind <${BRIEF_KINDS.join('|')}>
|
|
78
88
|
Statement kind (default: capability)
|
|
79
89
|
--text <text> The statement text (mutually exclusive with --from)
|
|
@@ -193,6 +203,14 @@ export async function main(argv, deps = {}) {
|
|
|
193
203
|
return await runResolve({ projectRoot, name, positionals, flags, stdout, stderr });
|
|
194
204
|
} catch (err) {
|
|
195
205
|
if (err instanceof LedgerError) {
|
|
206
|
+
// Ruling R1 2026-10-05: a same-id-different-content refusal
|
|
207
|
+
// exits 3 (not 1 and not 2) so a replaying harness can
|
|
208
|
+
// distinguish 'you tried to overwrite a prior answer' from a
|
|
209
|
+
// schema failure or a usage error. Update is the verb for that.
|
|
210
|
+
if (err.code === 'conflict') {
|
|
211
|
+
stderr.write(`[error] ledger: ${err.message}\n`);
|
|
212
|
+
return 3;
|
|
213
|
+
}
|
|
196
214
|
stderr.write(`[error] ${err.code === 'usage' ? 'usage ' : ''}ledger: ${err.message}\n`);
|
|
197
215
|
return err.code === 'usage' ? 2 : 1;
|
|
198
216
|
}
|
|
@@ -285,6 +303,19 @@ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
|
|
|
285
303
|
const body = await loadLedger({ projectRoot, name });
|
|
286
304
|
const now = new Date().toISOString();
|
|
287
305
|
|
|
306
|
+
// Ruling R1 2026-10-05: parse --id up front; a non-integer or negative
|
|
307
|
+
// value is a usage error BEFORE loading / scanning the ledger.
|
|
308
|
+
/** @type {number | undefined} */
|
|
309
|
+
let idOverride;
|
|
310
|
+
if (flags.id !== undefined) {
|
|
311
|
+
const parsed = Number(flags.id);
|
|
312
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
313
|
+
stderr.write(`[error] usage ledger: --id must be a positive integer, got '${flags.id}'\n`);
|
|
314
|
+
return 2;
|
|
315
|
+
}
|
|
316
|
+
idOverride = parsed;
|
|
317
|
+
}
|
|
318
|
+
|
|
288
319
|
if (name === 'brief') {
|
|
289
320
|
const kind = /** @type {string} */ (flags.kind ?? 'capability');
|
|
290
321
|
if (!BRIEF_KINDS.includes(/** @type {any} */ (kind))) {
|
|
@@ -299,6 +330,12 @@ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
|
|
|
299
330
|
stderr.write('[error] usage ledger: brief add requires --text <text> or --from <path>\n');
|
|
300
331
|
return 2;
|
|
301
332
|
}
|
|
333
|
+
if (idOverride !== undefined && flags.from) {
|
|
334
|
+
// R1 identity is per entry; --from mints N entries, so --id has no
|
|
335
|
+
// single target. Refuse the combination to keep the harness honest.
|
|
336
|
+
stderr.write('[error] usage ledger: brief add --id applies to --text only (--from mints one entry per line and has no single id)\n');
|
|
337
|
+
return 2;
|
|
338
|
+
}
|
|
302
339
|
if (flags['no-scan'] && flags.findings) {
|
|
303
340
|
stderr.write('[error] usage ledger: brief add --no-scan and --findings cannot be combined\n');
|
|
304
341
|
return 2;
|
|
@@ -336,10 +373,32 @@ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
|
|
|
336
373
|
// Compute what WOULD be minted (for both the real path and dry-run).
|
|
337
374
|
let cur = body;
|
|
338
375
|
const added = [];
|
|
376
|
+
/** @type {'added' | 'unchanged'} */
|
|
377
|
+
let primaryStatus = 'added';
|
|
339
378
|
for (const d of drafts) {
|
|
340
|
-
|
|
379
|
+
// Only the single --text path reaches here with an idOverride (we
|
|
380
|
+
// refused --id with --from above); a per-call idOverride is passed
|
|
381
|
+
// on the first and only draft.
|
|
382
|
+
const step = addEntry({
|
|
383
|
+
name: 'brief', body: cur, entry: d, now,
|
|
384
|
+
...(idOverride !== undefined ? { id: idOverride } : {}),
|
|
385
|
+
});
|
|
341
386
|
cur = step.body;
|
|
342
387
|
added.push(step.entry);
|
|
388
|
+
if (step.status === 'unchanged') primaryStatus = 'unchanged';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Ruling R1 2026-10-05 short-circuit: when --id landed on an entry
|
|
392
|
+
// that already carried identical content, exit 0 with the unchanged
|
|
393
|
+
// signal before any scan / finding work. A replayed turn is a no-op.
|
|
394
|
+
if (primaryStatus === 'unchanged' && added.length === 1) {
|
|
395
|
+
const existing = added[0];
|
|
396
|
+
if (flags.json) {
|
|
397
|
+
stdout.write(`${JSON.stringify({ status: 'unchanged', id: existing.id, entry: existing }, null, 2)}\n`);
|
|
398
|
+
} else {
|
|
399
|
+
stdout.write(`brief-ledger: unchanged ${existing.id}\n`);
|
|
400
|
+
}
|
|
401
|
+
return 0;
|
|
343
402
|
}
|
|
344
403
|
|
|
345
404
|
// Convert operator-supplied --findings into openQuestion statements.
|
|
@@ -428,7 +487,18 @@ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
|
|
|
428
487
|
default: flags.default ? String(flags.default) : null,
|
|
429
488
|
};
|
|
430
489
|
if (flags.blocks) entry.blocks = String(flags.blocks);
|
|
431
|
-
const step = addEntry({
|
|
490
|
+
const step = addEntry({
|
|
491
|
+
name: 'decisions', body, entry, now,
|
|
492
|
+
...(idOverride !== undefined ? { id: idOverride } : {}),
|
|
493
|
+
});
|
|
494
|
+
if (step.status === 'unchanged') {
|
|
495
|
+
if (flags.json) {
|
|
496
|
+
stdout.write(`${JSON.stringify({ status: 'unchanged', id: step.entry.id, entry: step.entry }, null, 2)}\n`);
|
|
497
|
+
} else {
|
|
498
|
+
stdout.write(`decisions-ledger: unchanged ${step.entry.id}\n`);
|
|
499
|
+
}
|
|
500
|
+
return 0;
|
|
501
|
+
}
|
|
432
502
|
await saveLedger({ projectRoot, name: 'decisions', body: step.body });
|
|
433
503
|
stdout.write(`decisions-ledger: added decision ${step.entry.id}\n`);
|
|
434
504
|
return 0;
|
|
@@ -449,7 +519,18 @@ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
|
|
|
449
519
|
disposition: String(flags.disposition),
|
|
450
520
|
};
|
|
451
521
|
if (flags.reason) entry.reason = String(flags.reason);
|
|
452
|
-
const step = addEntry({
|
|
522
|
+
const step = addEntry({
|
|
523
|
+
name: 'concerns', body, entry, now,
|
|
524
|
+
...(idOverride !== undefined ? { id: idOverride } : {}),
|
|
525
|
+
});
|
|
526
|
+
if (step.status === 'unchanged') {
|
|
527
|
+
if (flags.json) {
|
|
528
|
+
stdout.write(`${JSON.stringify({ status: 'unchanged', id: step.entry.id, entry: step.entry }, null, 2)}\n`);
|
|
529
|
+
} else {
|
|
530
|
+
stdout.write(`concern-ledger: unchanged ${step.entry.id}\n`);
|
|
531
|
+
}
|
|
532
|
+
return 0;
|
|
533
|
+
}
|
|
453
534
|
await saveLedger({ projectRoot, name: 'concerns', body: step.body });
|
|
454
535
|
stdout.write(`concern-ledger: added concern ${step.entry.id}\n`);
|
|
455
536
|
return 0;
|
|
@@ -465,7 +546,18 @@ async function runAdd({ projectRoot, name, flags, stdout, stderr }) {
|
|
|
465
546
|
finding: String(flags.finding),
|
|
466
547
|
severity: flags.severity ? String(flags.severity) : 'medium',
|
|
467
548
|
};
|
|
468
|
-
const step = addEntry({
|
|
549
|
+
const step = addEntry({
|
|
550
|
+
name: 'probes', body, entry, now,
|
|
551
|
+
...(idOverride !== undefined ? { id: idOverride } : {}),
|
|
552
|
+
});
|
|
553
|
+
if (step.status === 'unchanged') {
|
|
554
|
+
if (flags.json) {
|
|
555
|
+
stdout.write(`${JSON.stringify({ status: 'unchanged', id: step.entry.id, entry: step.entry }, null, 2)}\n`);
|
|
556
|
+
} else {
|
|
557
|
+
stdout.write(`probe-ledger: unchanged ${step.entry.id}\n`);
|
|
558
|
+
}
|
|
559
|
+
return 0;
|
|
560
|
+
}
|
|
469
561
|
await saveLedger({ projectRoot, name: 'probes', body: step.body });
|
|
470
562
|
stdout.write(`probe-ledger: added probe ${step.entry.id}\n`);
|
|
471
563
|
return 0;
|
package/src/cli/readiness.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// every other query verb has.
|
|
17
17
|
|
|
18
18
|
import { parseArgs } from 'node:util';
|
|
19
|
-
import { readFile
|
|
19
|
+
import { readFile } from 'node:fs/promises';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
|
|
22
22
|
import { resolveTestPointers, walkTree } from '#core/store';
|
|
@@ -30,9 +30,9 @@ import {
|
|
|
30
30
|
STAGE_ORDER,
|
|
31
31
|
STAGE_SHORT_NAMES,
|
|
32
32
|
ackable,
|
|
33
|
-
extractInterfacePathTokens,
|
|
34
33
|
stagePolicy,
|
|
35
34
|
} from '../query/gates.js';
|
|
35
|
+
import { resolveInterfacePaths } from '../define/interface-paths.js';
|
|
36
36
|
import {
|
|
37
37
|
computeReadiness,
|
|
38
38
|
countLitmusReadersAtHash,
|
|
@@ -129,42 +129,6 @@ const STATE_LABEL = {
|
|
|
129
129
|
notApplicable: 'notApplicable',
|
|
130
130
|
};
|
|
131
131
|
|
|
132
|
-
/**
|
|
133
|
-
* ADR-4131 (0.30.0 PR 5): walk the tree's TAC interfaces, extract the
|
|
134
|
-
* `path:` tokens the engineer named, resolve each one against
|
|
135
|
-
* `projectRoot` on disk, and return the set of tokens that resolve.
|
|
136
|
-
* `shapes:pathsResolve` reads the set from the stage context and
|
|
137
|
-
* decides the D3 bite without I/O. The compute is pure; this helper is
|
|
138
|
-
* the one place I/O happens for the check.
|
|
139
|
-
*
|
|
140
|
-
* @param {string} projectRoot
|
|
141
|
-
* @param {import('#core/store/walker.js').TreeModel} tree
|
|
142
|
-
* @returns {Promise<Set<string>>}
|
|
143
|
-
*/
|
|
144
|
-
async function resolveInterfacePaths(projectRoot, tree) {
|
|
145
|
-
const resolved = new Set();
|
|
146
|
-
/** @type {Set<string>} */
|
|
147
|
-
const candidates = new Set();
|
|
148
|
-
for (const tac of tree.tacs ?? []) {
|
|
149
|
-
for (const iface of tac.interfaces ?? []) {
|
|
150
|
-
const desc = typeof iface?.description === 'string' ? iface.description : '';
|
|
151
|
-
for (const token of extractInterfacePathTokens(desc)) {
|
|
152
|
-
candidates.add(token);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
await Promise.all([...candidates].map(async (token) => {
|
|
157
|
-
try {
|
|
158
|
-
await stat(join(projectRoot, token));
|
|
159
|
-
resolved.add(token);
|
|
160
|
-
} catch {
|
|
161
|
-
// Missing path stays out of the set; the gate decides whether
|
|
162
|
-
// `authoredAt: D3` lets it through.
|
|
163
|
-
}
|
|
164
|
-
}));
|
|
165
|
-
return resolved;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
132
|
/**
|
|
169
133
|
* Read `rcf/.identity/profile.md` when it exists; return null on
|
|
170
134
|
* ENOENT. Any other read error surfaces as null so the profile
|
|
@@ -390,7 +354,7 @@ function renderText(stdout, stderr, result, wallMs, checkStage, level, persona,
|
|
|
390
354
|
|
|
391
355
|
for (const stage of result.stages) {
|
|
392
356
|
if (checkStage && checkStage !== 'all' && stage.stage !== checkStage) continue;
|
|
393
|
-
stdout.write(`\n${
|
|
357
|
+
stdout.write(`\n${formatStageLine(stage)}\n`);
|
|
394
358
|
for (const c of stage.checks) {
|
|
395
359
|
if (persona && c.persona !== persona) continue;
|
|
396
360
|
const mark = c.ok ? 'ok' : 'FAIL';
|
|
@@ -405,6 +369,20 @@ function renderText(stdout, stderr, result, wallMs, checkStage, level, persona,
|
|
|
405
369
|
}
|
|
406
370
|
}
|
|
407
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Format the per-stage header line the text report prints, e.g.
|
|
374
|
+
* `D3 (define.shapes): failing`. A `reason` trailer is appended only
|
|
375
|
+
* when the envelope carries one (the `notApplicable` envelopes do; the
|
|
376
|
+
* ADR-4138 tree-wide failing envelope does not). Exported so the
|
|
377
|
+
* printed line is testable without driving the whole CLI.
|
|
378
|
+
*
|
|
379
|
+
* @param {{ stage: string, gate: string, state: string, reason?: string }} stage
|
|
380
|
+
* @returns {string}
|
|
381
|
+
*/
|
|
382
|
+
export function formatStageLine(stage) {
|
|
383
|
+
return `${stage.stage} (${stage.gate}): ${STATE_LABEL[stage.state] ?? stage.state}${stage.reason ? ` (${stage.reason})` : ''}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
408
386
|
/**
|
|
409
387
|
* Render the per-persona next-action lines (spec section 4). When a
|
|
410
388
|
* `--level` is given the matching persona's line heads the pair; when
|
package/src/core/errors/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// renders them inline in the page so an owner can see what is broken.
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* @typedef {('validation'|'missingFile'|'brokenReference'|'parseFailure'|'ioFailure'|'usage'|'staleCode'|'missingCodeNodes'|'duplicateId')} ErrorKind
|
|
10
|
+
* @typedef {('validation'|'missingFile'|'brokenReference'|'parseFailure'|'ioFailure'|'usage'|'staleCode'|'missingCodeNodes'|'duplicateId'|'conflict')} ErrorKind
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -49,6 +49,12 @@ const VALID_KINDS = new Set([
|
|
|
49
49
|
// with the spec-verbatim refusal message; the CLI translates it to
|
|
50
50
|
// exit 3 (not exit 2) with the message printed as-is on stderr.
|
|
51
51
|
'requiresAppliedCapabilities',
|
|
52
|
+
// Ruling R1 extension 2026-10-05 (DEFINE step 3, PR 292 follow-up):
|
|
53
|
+
// a same-id `rcf define create` replay whose would-be body differs
|
|
54
|
+
// from the on-disk entry refuses with `kind: 'conflict'`; the CLI
|
|
55
|
+
// translates it to exit 3 with the id and the differing field keys
|
|
56
|
+
// named in the message. Mirrors `src/define/ledgers.js` R1 add path.
|
|
57
|
+
'conflict',
|
|
52
58
|
]);
|
|
53
59
|
|
|
54
60
|
/**
|
package/src/core/store/writer.js
CHANGED
|
@@ -52,6 +52,111 @@ function canonicalKind(kind) {
|
|
|
52
52
|
return KIND_ALIASES[kind] ?? kind;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Fields the R1 create-replay compare ignores: every field the writer or
|
|
57
|
+
* a post-create side effect stamps on its own, never from the caller's
|
|
58
|
+
* input. A verbatim replay of a create must compare equal even after
|
|
59
|
+
* those stamps landed; every other field is deep-compared. Add a field
|
|
60
|
+
* here whenever a writer path or a post-create hook starts stamping one.
|
|
61
|
+
* Ruling R1 extension (PR 292 follow-up; list made exhaustive on the
|
|
62
|
+
* PR 293 landing review, d-2026-10-05-015 finding 2).
|
|
63
|
+
*/
|
|
64
|
+
export const REPLAY_IGNORE_FIELDS = Object.freeze([
|
|
65
|
+
// Writer clock: assembleBody / createCn stamp it on every create and
|
|
66
|
+
// strip any caller-supplied value (B1 fix).
|
|
67
|
+
'createdAt',
|
|
68
|
+
// Writer clock: stamped on every create and bumped by every update,
|
|
69
|
+
// including the classifier's own write-back below.
|
|
70
|
+
'updatedAt',
|
|
71
|
+
// REQ shape classifier (src/req-detection/persist.js
|
|
72
|
+
// classifyAndPersistReq): src/cli/create.js runs it after every
|
|
73
|
+
// `create req` and persists the block through updateDocument, so the
|
|
74
|
+
// first create leaves a field the replay body never carries.
|
|
75
|
+
'shapeClassification',
|
|
76
|
+
]);
|
|
77
|
+
const REPLAY_IGNORE_SET = new Set(REPLAY_IGNORE_FIELDS);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Deep-equality over JSON-shaped values. Pure. Mirrors
|
|
81
|
+
* `src/define/ledgers.js` `deepEqual` so the R1 extension to `rcf
|
|
82
|
+
* define create` compares content the same way the add path does.
|
|
83
|
+
*/
|
|
84
|
+
function deepEqual(a, b) {
|
|
85
|
+
if (Object.is(a, b)) return true;
|
|
86
|
+
if (a === null || b === null) return false;
|
|
87
|
+
if (typeof a !== typeof b) return false;
|
|
88
|
+
if (Array.isArray(a)) {
|
|
89
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
90
|
+
for (let i = 0; i < a.length; i += 1) if (!deepEqual(a[i], b[i])) return false;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (typeof a === 'object') {
|
|
94
|
+
if (typeof b !== 'object' || Array.isArray(b)) return false;
|
|
95
|
+
const ak = Object.keys(a);
|
|
96
|
+
const bk = Object.keys(b);
|
|
97
|
+
if (ak.length !== bk.length) return false;
|
|
98
|
+
for (const k of ak) if (!deepEqual(a[k], b[k])) return false;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Compare an on-disk doc body to a would-be create body and return
|
|
106
|
+
* the sorted list of differing field keys. Ignores every field in
|
|
107
|
+
* REPLAY_IGNORE_FIELDS (writer- and classifier-owned stamps), so a
|
|
108
|
+
* verbatim replay does not show up as "updatedAt differs" or
|
|
109
|
+
* "shapeClassification differs". A key present on only one side counts as
|
|
110
|
+
* differing. R1 extension 2026-10-05 (PR 292 follow-up).
|
|
111
|
+
*
|
|
112
|
+
* @param {Record<string, unknown>} existing
|
|
113
|
+
* @param {Record<string, unknown>} candidate
|
|
114
|
+
* @returns {string[]}
|
|
115
|
+
*/
|
|
116
|
+
function diffDocFields(existing, candidate) {
|
|
117
|
+
/** @type {Set<string>} */
|
|
118
|
+
const differing = new Set();
|
|
119
|
+
const keys = new Set([...Object.keys(existing ?? {}), ...Object.keys(candidate ?? {})]);
|
|
120
|
+
for (const key of keys) {
|
|
121
|
+
if (REPLAY_IGNORE_SET.has(key)) continue;
|
|
122
|
+
if (!deepEqual(existing?.[key], candidate?.[key])) differing.add(key);
|
|
123
|
+
}
|
|
124
|
+
return [...differing];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Ruling R1 extension (PR 292 follow-up, amended on the PR 293 landing
|
|
129
|
+
* review): resolve a same-id create replay against the entry already on
|
|
130
|
+
* the tree. Returns null when the id is free; otherwise the
|
|
131
|
+
* `{ status: 'unchanged' }` result (identical content, nothing is
|
|
132
|
+
* written) or a `kind: 'conflict'` refusal naming the differing field
|
|
133
|
+
* keys (exit 3; never a silent overwrite). Shared by the document path
|
|
134
|
+
* (createDocument), the inline AC / TC paths and the CN path so every
|
|
135
|
+
* `rcf define create <kind> --id` follows one rule.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} args
|
|
138
|
+
* @param {string} args.kind - the CLI kind, for the message
|
|
139
|
+
* @param {string} args.id
|
|
140
|
+
* @param {object | null | undefined} args.existing - the entry on the tree
|
|
141
|
+
* @param {object} args.candidate - the would-be entry
|
|
142
|
+
* @param {string} args.filePath - repo-relative file the entry lives in
|
|
143
|
+
* @param {object} [args.extra] - extra fields carried on the unchanged result
|
|
144
|
+
* @returns {object | null}
|
|
145
|
+
*/
|
|
146
|
+
function resolveReplay({ kind, id, existing, candidate, filePath, extra = {} }) {
|
|
147
|
+
if (!existing) return null;
|
|
148
|
+
const differing = diffDocFields(existing, candidate);
|
|
149
|
+
if (differing.length === 0) {
|
|
150
|
+
return { id, filePath, ...extra, body: existing, status: 'unchanged' };
|
|
151
|
+
}
|
|
152
|
+
return rcfError({
|
|
153
|
+
kind: 'conflict',
|
|
154
|
+
message: `create ${kind}: id ${id} already exists with different content; differing fields: ${differing.sort().join(', ')}. Use the update verb to patch.`,
|
|
155
|
+
documentId: id,
|
|
156
|
+
filePath,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
55
160
|
// ---------------------------------------------------------------------------
|
|
56
161
|
// B5: post-write validation gate
|
|
57
162
|
// ---------------------------------------------------------------------------
|
|
@@ -496,17 +601,28 @@ export async function createDocument({ projectRoot, tree, kind, body, options =
|
|
|
496
601
|
});
|
|
497
602
|
}
|
|
498
603
|
|
|
499
|
-
// Allocate id (or take the override).
|
|
604
|
+
// Allocate id (or take the override). Ruling R1 2026-10-05 extended
|
|
605
|
+
// under w-2026-10-05-dave-013 (PR 292 follow-up): a replayed turn
|
|
606
|
+
// that carries `--id <existing>` with identical content is a no-op
|
|
607
|
+
// that returns `{ status: 'unchanged', body: existing }`; different
|
|
608
|
+
// content refuses with `kind: 'conflict'` naming the differing
|
|
609
|
+
// field keys. An unloadable (schema-invalid) doc still refuses
|
|
610
|
+
// unconditionally -- we cannot compare content against a doc we
|
|
611
|
+
// failed to load.
|
|
500
612
|
let id = options.id;
|
|
613
|
+
/** @type {object | null} */
|
|
614
|
+
let existingDocForReplay = null;
|
|
501
615
|
if (id) {
|
|
502
|
-
|
|
503
|
-
if (tree.byId.has(id) || tree.invalidDocs?.has(id)) {
|
|
616
|
+
if (tree.invalidDocs?.has(id)) {
|
|
504
617
|
return rcfError({
|
|
505
618
|
kind: 'usage',
|
|
506
|
-
message: `create ${kind}: id ${id} is already taken`,
|
|
619
|
+
message: `create ${kind}: id ${id} is already taken (file on disk failed to load; repair before replaying)`,
|
|
507
620
|
documentId: id,
|
|
508
621
|
});
|
|
509
622
|
}
|
|
623
|
+
if (tree.byId.has(id)) {
|
|
624
|
+
existingDocForReplay = tree.byId.get(id);
|
|
625
|
+
}
|
|
510
626
|
} else {
|
|
511
627
|
try {
|
|
512
628
|
id = nextIdForKind(tree, canonical, { parentId });
|
|
@@ -528,7 +644,7 @@ export async function createDocument({ projectRoot, tree, kind, body, options =
|
|
|
528
644
|
}
|
|
529
645
|
providedOrder = maxOrder + 1;
|
|
530
646
|
} else if (Number.isInteger(providedOrder)) {
|
|
531
|
-
const collision = siblings.find((sib) => sib.buildOrder === providedOrder);
|
|
647
|
+
const collision = siblings.find((sib) => sib.buildOrder === providedOrder && sib.fbsId !== id);
|
|
532
648
|
if (collision) {
|
|
533
649
|
return rcfError({
|
|
534
650
|
kind: 'usage',
|
|
@@ -553,6 +669,16 @@ export async function createDocument({ projectRoot, tree, kind, body, options =
|
|
|
553
669
|
});
|
|
554
670
|
if (validation) return { ...validation, documentId: id };
|
|
555
671
|
|
|
672
|
+
// Ruling R1 extension 2026-10-05 (PR 292 follow-up): if the id
|
|
673
|
+
// already exists on the loaded tree, compare the would-be body to
|
|
674
|
+
// the existing doc (ignoring writer-owned timestamps) and either
|
|
675
|
+
// return `{ status: 'unchanged' }` or refuse with
|
|
676
|
+
// `kind: 'conflict'` naming the differing field keys.
|
|
677
|
+
const replay = resolveReplay({
|
|
678
|
+
kind, id, existing: existingDocForReplay, candidate: finalBody, filePath: relPath,
|
|
679
|
+
});
|
|
680
|
+
if (replay) return replay;
|
|
681
|
+
|
|
556
682
|
// Cross-link ACs must resolve to existing ACs for fbs / ts.
|
|
557
683
|
if (canonical === 'fbs' || canonical === 'testSuite') {
|
|
558
684
|
const allAcIds = collectAllAcIds(tree);
|
|
@@ -644,10 +770,22 @@ async function createCn({ projectRoot, tree, options, body, walkErrors = [] }) {
|
|
|
644
770
|
return rcfError({ kind: 'usage', message: 'create cn: --path is required' });
|
|
645
771
|
}
|
|
646
772
|
|
|
773
|
+
// Ruling R1 extension (PR 293 landing review): a same-id replay is
|
|
774
|
+
// compared against the existing CN once the body is assembled below.
|
|
775
|
+
// An unloadable (schema-invalid) CN, or an id held by another kind,
|
|
776
|
+
// still refuses: there is no CN body to compare against.
|
|
647
777
|
let id = options.id;
|
|
778
|
+
/** @type {object | null} */
|
|
779
|
+
let existingCnForReplay = null;
|
|
648
780
|
if (id) {
|
|
649
|
-
if (tree.
|
|
650
|
-
return rcfError({ kind: 'usage', message: `create cn: id ${id} is already taken`, documentId: id });
|
|
781
|
+
if (tree.invalidDocs?.has(id)) {
|
|
782
|
+
return rcfError({ kind: 'usage', message: `create cn: id ${id} is already taken (file on disk failed to load; repair before replaying)`, documentId: id });
|
|
783
|
+
}
|
|
784
|
+
if (tree.byId.has(id)) {
|
|
785
|
+
if (tree.kindById.get(id) !== 'codeNode') {
|
|
786
|
+
return rcfError({ kind: 'usage', message: `create cn: id ${id} is already taken`, documentId: id });
|
|
787
|
+
}
|
|
788
|
+
existingCnForReplay = tree.byId.get(id);
|
|
651
789
|
}
|
|
652
790
|
} else {
|
|
653
791
|
id = nextIdForKind(tree, 'codeNode');
|
|
@@ -706,6 +844,11 @@ async function createCn({ projectRoot, tree, options, body, walkErrors = [] }) {
|
|
|
706
844
|
const validation = validateDocument({ doc: finalBody, kind: 'codeNode', filePath: relPath });
|
|
707
845
|
if (validation) return { ...validation, documentId: id };
|
|
708
846
|
|
|
847
|
+
const replay = resolveReplay({
|
|
848
|
+
kind: 'cn', id, existing: existingCnForReplay, candidate: finalBody, filePath: relPath,
|
|
849
|
+
});
|
|
850
|
+
if (replay) return replay;
|
|
851
|
+
|
|
709
852
|
const absPath = pathForKindFile(projectRoot, 'codeNode', id);
|
|
710
853
|
if (await fileExistsOnDisk(absPath)) {
|
|
711
854
|
return rcfError({
|
|
@@ -919,12 +1062,14 @@ async function createInlineAc({ projectRoot, tree, options, body, walkErrors = [
|
|
|
919
1062
|
const seedIndex = detectSeededPhantomAcIndex(currentAcs, parentUsId);
|
|
920
1063
|
const replacingPhantom = seedIndex !== -1 && !options.id;
|
|
921
1064
|
|
|
1065
|
+
// Ruling R1 extension (PR 293 landing review): an explicit --id that
|
|
1066
|
+
// already names an AC on the parent US is a replay, resolved against
|
|
1067
|
+
// that AC once the would-be entry is built below.
|
|
922
1068
|
let acId = options.id;
|
|
1069
|
+
/** @type {object | null} */
|
|
1070
|
+
let existingAcForReplay = null;
|
|
923
1071
|
if (acId) {
|
|
924
|
-
|
|
925
|
-
if (existing) {
|
|
926
|
-
return rcfError({ kind: 'usage', message: `create ac: id ${acId} already exists on ${parentUsId}`, documentId: acId });
|
|
927
|
-
}
|
|
1072
|
+
existingAcForReplay = currentAcs.find((ac) => ac.id === acId) ?? null;
|
|
928
1073
|
} else if (replacingPhantom) {
|
|
929
1074
|
// Reuse the seed's id (AC-<us>-1) so the operator's first AC is -1.
|
|
930
1075
|
acId = currentAcs[seedIndex].id;
|
|
@@ -947,6 +1092,15 @@ async function createInlineAc({ projectRoot, tree, options, body, walkErrors = [
|
|
|
947
1092
|
// consumer, so we only serialise when the CLI passed a value.
|
|
948
1093
|
...(body?.determinism !== undefined ? { determinism: body.determinism } : {}),
|
|
949
1094
|
};
|
|
1095
|
+
const acReplay = resolveReplay({
|
|
1096
|
+
kind: 'ac',
|
|
1097
|
+
id: acId,
|
|
1098
|
+
existing: existingAcForReplay,
|
|
1099
|
+
candidate: acEntry,
|
|
1100
|
+
filePath: `rcf/user-stories/${parentUsId.toLowerCase()}.json`,
|
|
1101
|
+
extra: { parentId: parentUsId },
|
|
1102
|
+
});
|
|
1103
|
+
if (acReplay) return acReplay;
|
|
950
1104
|
const nextAcs = replacingPhantom
|
|
951
1105
|
? currentAcs.map((ac, i) => (i === seedIndex ? acEntry : ac))
|
|
952
1106
|
: [...currentAcs, acEntry];
|
|
@@ -1073,9 +1227,14 @@ async function createInlineTc({ projectRoot, tree, options, body, walkErrors = [
|
|
|
1073
1227
|
return rcfError({ kind: 'usage', message: `create tc: parent ${parentTsId} has an unrecognised id shape` });
|
|
1074
1228
|
}
|
|
1075
1229
|
const tcId = options.id ?? `TC-${tsSuffix}-${slug}`;
|
|
1076
|
-
// Slug collision fails explicitly (§D10 OQ-P4-R-1).
|
|
1077
1230
|
const existing = (ts.testCases ?? []).find((tc) => tc.id === tcId);
|
|
1078
|
-
|
|
1231
|
+
// Ruling R1 extension (PR 293 landing review): idempotency is
|
|
1232
|
+
// id-carried. An explicit --id that already names a TC on the parent
|
|
1233
|
+
// TS is a replay, resolved against that TC once the would-be entry is
|
|
1234
|
+
// built below. A derived-slug collision (no --id) still refuses
|
|
1235
|
+
// explicitly (§D10 OQ-P4-R-1).
|
|
1236
|
+
const replayingTc = Boolean(options.id) && Boolean(existing);
|
|
1237
|
+
if (existing && !replayingTc) {
|
|
1079
1238
|
return rcfError({
|
|
1080
1239
|
kind: 'usage',
|
|
1081
1240
|
message: `create tc: slug collision on ${tcId}, supply --slug explicitly`,
|
|
@@ -1089,6 +1248,15 @@ async function createInlineTc({ projectRoot, tree, options, body, walkErrors = [
|
|
|
1089
1248
|
status: body?.status ?? 'pending',
|
|
1090
1249
|
testPointer,
|
|
1091
1250
|
};
|
|
1251
|
+
const tcReplay = resolveReplay({
|
|
1252
|
+
kind: 'tc',
|
|
1253
|
+
id: tcId,
|
|
1254
|
+
existing: replayingTc ? existing : null,
|
|
1255
|
+
candidate: tcEntry,
|
|
1256
|
+
filePath: `rcf/test-suites/${parentTsId.toLowerCase()}.json`,
|
|
1257
|
+
extra: { parentId: parentTsId },
|
|
1258
|
+
});
|
|
1259
|
+
if (tcReplay) return tcReplay;
|
|
1092
1260
|
const nextTs = {
|
|
1093
1261
|
...ts,
|
|
1094
1262
|
testCases: [...(ts.testCases ?? []), tcEntry],
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Shared define-time helper: resolve the `path:` tokens an engineer
|
|
2
|
+
// named on TAC interfaces against the project root on disk. The pure
|
|
3
|
+
// D3 `shapes:pathsResolve` check reads the resulting set from the
|
|
4
|
+
// stage context; this is the one place I/O happens for the check.
|
|
5
|
+
//
|
|
6
|
+
// Lifted out of `src/cli/readiness.js` in issue 302 (2026-10-06) so
|
|
7
|
+
// the freeze CLI can pre-resolve the same tokens and D3 agrees with
|
|
8
|
+
// readiness instead of false-failing from an empty fallback set.
|
|
9
|
+
// ADR-4131 (0.30.0 PR 5) still names readiness as the origin.
|
|
10
|
+
|
|
11
|
+
import { stat } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { extractInterfacePathTokens } from '../query/gates.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Walk the tree's TAC interfaces, extract the `path:` tokens the
|
|
18
|
+
* engineer named, resolve each one against `projectRoot` on disk, and
|
|
19
|
+
* return the set of tokens that resolve.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} projectRoot
|
|
22
|
+
* @param {import('#core/store/walker.js').TreeModel} tree
|
|
23
|
+
* @returns {Promise<Set<string>>}
|
|
24
|
+
*/
|
|
25
|
+
export async function resolveInterfacePaths(projectRoot, tree) {
|
|
26
|
+
const resolved = new Set();
|
|
27
|
+
/** @type {Set<string>} */
|
|
28
|
+
const candidates = new Set();
|
|
29
|
+
for (const tac of tree.tacs ?? []) {
|
|
30
|
+
for (const iface of tac.interfaces ?? []) {
|
|
31
|
+
const desc = typeof iface?.description === 'string' ? iface.description : '';
|
|
32
|
+
for (const token of extractInterfacePathTokens(desc)) {
|
|
33
|
+
candidates.add(token);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
await Promise.all([...candidates].map(async (token) => {
|
|
38
|
+
try {
|
|
39
|
+
await stat(join(projectRoot, token));
|
|
40
|
+
resolved.add(token);
|
|
41
|
+
} catch {
|
|
42
|
+
// Missing path stays out of the set; the gate decides whether
|
|
43
|
+
// `authoredAt: D3` lets it through.
|
|
44
|
+
}
|
|
45
|
+
}));
|
|
46
|
+
return resolved;
|
|
47
|
+
}
|