rcf-lite 0.28.0 → 0.28.2
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 +115 -0
- package/README.md +9 -0
- package/fixtures/canary-manifest.json +9 -9
- package/package.json +1 -1
- package/rcf/code-nodes/cn-600.json +2 -1
- package/rcf/code-nodes/cn-602.json +4 -2
- package/rcf/code-nodes/cn-605.json +2 -1
- package/rcf/fbs/fbs-181.json +4 -2
- package/rcf/fbs/fbs-182.json +2 -1
- package/rcf/tacs/tac-4103-feedback-redact.json +3 -2
- package/rcf/tacs/tac-4106-feedback-destination.json +3 -2
- package/rcf/test-suites/ts-188.json +18 -2
- package/rcf/test-suites/ts-189.json +24 -2
- package/rcf/test-suites/ts-191.json +19 -5
- package/rcf/user-stories/us-15601.json +19 -1
- package/rcf/user-stories/us-15701.json +4 -4
- package/rcf/user-stories/us-15801.json +10 -1
- package/releases/releases.yaml +21 -1
- package/src/browser-verify/manifest-writer.js +9 -5
- package/src/cli/browser-verify.js +9 -1
- package/src/cli/feedback.js +201 -24
- package/src/cli/intake.js +25 -4
- package/src/cli/preflight.js +24 -3
- package/src/cli/review.js +9 -1
- package/src/cli/ui-baseline.js +14 -1
- package/src/core/store/index.js +5 -0
- package/src/core/store/validate-composed-record.js +95 -0
- package/src/feedback/destination.js +98 -4
- package/src/feedback/gh.js +27 -1
- package/src/feedback/redact-allowlist.json +7 -0
- package/src/feedback/redact.js +75 -6
- package/src/feedback/render.js +34 -12
- package/src/intake/manifest-writer.js +6 -3
- package/src/intake/orchestrator.js +44 -7
- package/src/preflight/manifest-writer.js +11 -11
- package/src/req-baseline/sweep.js +34 -5
- package/src/ui-baseline/manifest-writer.js +11 -10
- package/src/view/doc-renderers/helpers.js +37 -0
- package/src/view/doc-renderers/tad.js +67 -6
- package/src/view/style.css +5 -0
package/src/cli/feedback.js
CHANGED
|
@@ -136,8 +136,10 @@ Common options:
|
|
|
136
136
|
--body <text> | --body-file <path>
|
|
137
137
|
Body markdown, <= ${BODY_CAP_BYTES / 1024} KB
|
|
138
138
|
after UTF-8 encoding.
|
|
139
|
-
--evidence <pointer>
|
|
140
|
-
inside the project, or an id.
|
|
139
|
+
--evidence <pointer> Required, repeatable: a command, a
|
|
140
|
+
path:line inside the project, or an id.
|
|
141
|
+
At least one --evidence is required
|
|
142
|
+
(AC-15501-1); absence exits 2.
|
|
141
143
|
--harness claude-code|codex|other
|
|
142
144
|
Optional; inferred from env when absent.
|
|
143
145
|
--ask-now Blocker shortcut; flags the batch so the
|
|
@@ -954,6 +956,13 @@ async function handlePreview(argv, ctx) {
|
|
|
954
956
|
const reason = p.destination.reason ?? 'unresolved';
|
|
955
957
|
const contact = p.destination.publisherContact ? ` (library contact: ${p.destination.publisherContact})` : '';
|
|
956
958
|
stdout.write(` no issue destination declared${p.destination.reason ? ` (${reason})` : ''}; bundle will be written${contact}\n`);
|
|
959
|
+
// AC-15801-4 / design amendment R5b: for ambiguous-library-slug
|
|
960
|
+
// surface the candidate library prefixes so the operator sees
|
|
961
|
+
// exactly which qualified refs would resolve it.
|
|
962
|
+
if (Array.isArray(p.destination.candidates) && p.destination.candidates.length > 0) {
|
|
963
|
+
const qualified = p.destination.candidates.map((prefix) => `${prefix}:<slug>`).join(', ');
|
|
964
|
+
stdout.write(` candidate libraries owning this slug: ${p.destination.candidates.join(', ')} (re-apply with a qualified ref, e.g. ${qualified})\n`);
|
|
965
|
+
}
|
|
957
966
|
}
|
|
958
967
|
stdout.write(`fingerprint: ${p.fingerprint}\n`);
|
|
959
968
|
if (p.fingerprintFallback) {
|
|
@@ -1024,11 +1033,21 @@ async function buildRedactionContext(projectRoot) {
|
|
|
1024
1033
|
} catch { /* no git or no remotes is fine */ }
|
|
1025
1034
|
|
|
1026
1035
|
let allowHostsExt = [];
|
|
1036
|
+
let allowExtensionsExt = [];
|
|
1027
1037
|
try {
|
|
1028
1038
|
const settings = JSON.parse(await readFile(resolve(projectRoot, 'rcf', 'feedback-settings.json'), 'utf8'));
|
|
1029
1039
|
if (Array.isArray(settings?.redaction?.allowHosts)) {
|
|
1030
1040
|
allowHostsExt = settings.redaction.allowHosts;
|
|
1031
1041
|
}
|
|
1042
|
+
// 0.28.2 (Codex review follow-up on #233): the operator's
|
|
1043
|
+
// per-project extension shortlist must reach the redactor the
|
|
1044
|
+
// same way `allowHosts` does. Previously the CLI dropped
|
|
1045
|
+
// `settings.redaction.allowExtensions` on the floor, so an
|
|
1046
|
+
// operator adding `xyz` to keep `file.xyz` intact saw the fold
|
|
1047
|
+
// fire in preview and submit.
|
|
1048
|
+
if (Array.isArray(settings?.redaction?.allowExtensions)) {
|
|
1049
|
+
allowExtensionsExt = settings.redaction.allowExtensions;
|
|
1050
|
+
}
|
|
1032
1051
|
} catch { /* absent is fine */ }
|
|
1033
1052
|
// Assemble the extended allow-list once so downstream callers can
|
|
1034
1053
|
// print it if they want to; the redactor itself accepts the raw
|
|
@@ -1050,6 +1069,7 @@ async function buildRedactionContext(projectRoot) {
|
|
|
1050
1069
|
operatorName,
|
|
1051
1070
|
gitRemote: [...remotes].filter((r) => typeof r === 'string' && r.length > 0),
|
|
1052
1071
|
allowHosts: allowHostsExt,
|
|
1072
|
+
allowExtensions: allowExtensionsExt,
|
|
1053
1073
|
};
|
|
1054
1074
|
}
|
|
1055
1075
|
|
|
@@ -1206,11 +1226,21 @@ const SUBMIT_OPTIONS = /** @type {const} */ ({
|
|
|
1206
1226
|
*
|
|
1207
1227
|
* Dedupe (per entry passing preflight): gh search issues on the
|
|
1208
1228
|
* destination for `rcf-feedback-fingerprint: <fp>` (open, in:body,
|
|
1209
|
-
* limit 5).
|
|
1210
|
-
*
|
|
1211
|
-
*
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1229
|
+
* limit 5). Every returned candidate is then re-read via
|
|
1230
|
+
* ghIssueGetBody, and only candidates whose body carries
|
|
1231
|
+
* `rcf-feedback-fingerprint: <fp>` on its own line AND whose anchor /
|
|
1232
|
+
* kind / target markers do not disagree with the entry's are counted
|
|
1233
|
+
* as verified matches (0.28.2, issue #234, Barry ruling 2026-09-21).
|
|
1234
|
+
* A search hit that does not verify is a NON-MATCH and is dropped
|
|
1235
|
+
* before the fold decision. Zero verified hits -> create; one -> fold
|
|
1236
|
+
* comment on that number; many -> fold on the lowest and mention the
|
|
1237
|
+
* others. The fold comment carries the full report (title, body,
|
|
1238
|
+
* evidence, environment) prefaced with "Apparent duplicate of the
|
|
1239
|
+
* issue subject; posting the full report so a human can judge." The
|
|
1240
|
+
* retired "+1 from another reporter." payload is gone. Search
|
|
1241
|
+
* failure -> create with dedupe:unchecked. Closed search runs only
|
|
1242
|
+
* after a zero-hit open search so the create body can reference the
|
|
1243
|
+
* prior number.
|
|
1214
1244
|
*
|
|
1215
1245
|
* Create/comment failure (network/4xx/5xx/ratelimit or unexpected
|
|
1216
1246
|
* label rejection): retry once WITHOUT labels; still failing, bundle
|
|
@@ -1372,18 +1402,47 @@ async function handleSubmit(argv, ctx) {
|
|
|
1372
1402
|
let dedupe = 'new';
|
|
1373
1403
|
let closedRef = null;
|
|
1374
1404
|
let matches = [];
|
|
1405
|
+
let searchMatches = [];
|
|
1375
1406
|
if (!searchOpen.ok) {
|
|
1376
1407
|
dedupe = 'unchecked';
|
|
1377
1408
|
} else {
|
|
1378
|
-
|
|
1409
|
+
searchMatches = searchOpen.value?.matches ?? [];
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// 0.28.2 (issue #234, Barry ruling 2026-09-21): the search hit
|
|
1413
|
+
// alone is not a fold decision. Re-read each candidate body via
|
|
1414
|
+
// ghIssueGetBody and keep only those whose body carries
|
|
1415
|
+
// `rcf-feedback-fingerprint: <fp>` on its own line AND whose
|
|
1416
|
+
// anchor / kind / target markers do not disagree with the
|
|
1417
|
+
// entry's. A search hit whose body does not verify is a
|
|
1418
|
+
// NON-MATCH and is dropped before the fold decision.
|
|
1419
|
+
if (searchMatches.length > 0 && typeof gh.ghIssueGetBody === 'function') {
|
|
1420
|
+
const verified = [];
|
|
1421
|
+
for (const m of searchMatches) {
|
|
1422
|
+
const v = await gh.ghIssueGetBody({ repo: destination.repo, number: m.number });
|
|
1423
|
+
if (!v.ok || typeof v.value?.body !== 'string') continue;
|
|
1424
|
+
const body = v.value.body;
|
|
1425
|
+
if (!hasFingerprintLine(body, built.fingerprint)) continue;
|
|
1426
|
+
if (!candidateMatchesEntry(body, e)) continue;
|
|
1427
|
+
verified.push({ ...m, title: m.title ?? extractTitle(body) ?? null });
|
|
1428
|
+
}
|
|
1429
|
+
matches = verified;
|
|
1430
|
+
} else {
|
|
1431
|
+
matches = searchMatches;
|
|
1379
1432
|
}
|
|
1380
1433
|
|
|
1381
1434
|
if (flags['dry-run']) {
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1435
|
+
let plan;
|
|
1436
|
+
if (matches.length === 1) {
|
|
1437
|
+
const t = matches[0].title;
|
|
1438
|
+
plan = `commented on #${matches[0].number} (fingerprint match${t ? `: ${t}` : ''})`;
|
|
1439
|
+
} else if (matches.length > 1) {
|
|
1440
|
+
const sorted = matches.slice().sort((a, b) => a.number - b.number);
|
|
1441
|
+
const t = sorted[0]?.title;
|
|
1442
|
+
plan = `commented on #${sorted[0].number} (fingerprint match${t ? `: ${t}` : ''}, ${matches.length} verified)`;
|
|
1443
|
+
} else {
|
|
1444
|
+
plan = `created (title: ${built.rendered.title})`;
|
|
1445
|
+
}
|
|
1387
1446
|
stdout.write(`${e.id} -> dry-run ${plan} (${dedupe === 'unchecked' ? 'unchecked' : matches.length === 0 ? 'new' : 'comment'})\n`);
|
|
1388
1447
|
continue;
|
|
1389
1448
|
}
|
|
@@ -1416,26 +1475,28 @@ async function handleSubmit(argv, ctx) {
|
|
|
1416
1475
|
labels: usedLabels,
|
|
1417
1476
|
});
|
|
1418
1477
|
} else if (matches.length === 1) {
|
|
1419
|
-
// Comment
|
|
1478
|
+
// Verified fingerprint match: comment. Comment carries the
|
|
1479
|
+
// full report per Barry's 2026-09-21 ruling on #234.
|
|
1420
1480
|
dedupe = 'comment';
|
|
1421
|
-
const commentBody = renderComment(e, {
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
}
|
|
1481
|
+
const commentBody = renderComment(e, built.redacted, {
|
|
1482
|
+
fingerprint: built.fingerprint,
|
|
1483
|
+
matchedTitle: matches[0].title ?? null,
|
|
1484
|
+
}).body;
|
|
1425
1485
|
result = await commentWithRetry(gh, {
|
|
1426
1486
|
repo: destination.repo,
|
|
1427
1487
|
number: matches[0].number,
|
|
1428
1488
|
body: commentBody,
|
|
1429
1489
|
});
|
|
1430
1490
|
} else {
|
|
1431
|
-
// Many: comment on lowest, mention the
|
|
1491
|
+
// Many verified matches: comment on lowest, mention the
|
|
1492
|
+
// others. The comment still carries the full report.
|
|
1432
1493
|
dedupe = 'comment';
|
|
1433
1494
|
const sorted = matches.slice().sort((a, b) => a.number - b.number);
|
|
1434
1495
|
const others = sorted.slice(1).map((m) => `#${m.number}`).join(', ');
|
|
1435
|
-
const base = renderComment(e, {
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
}
|
|
1496
|
+
const base = renderComment(e, built.redacted, {
|
|
1497
|
+
fingerprint: built.fingerprint,
|
|
1498
|
+
matchedTitle: sorted[0].title ?? null,
|
|
1499
|
+
}).body;
|
|
1439
1500
|
const commentBody = `${base}\nalso see ${others}\n`;
|
|
1440
1501
|
result = await commentWithRetry(gh, {
|
|
1441
1502
|
repo: destination.repo,
|
|
@@ -1454,7 +1515,23 @@ async function handleSubmit(argv, ctx) {
|
|
|
1454
1515
|
droppedLabels,
|
|
1455
1516
|
sessionId,
|
|
1456
1517
|
});
|
|
1457
|
-
|
|
1518
|
+
// 0.28.2 (issue #234): row shape names the outcome and the
|
|
1519
|
+
// matched issue's title so a wrong fold is visible on the
|
|
1520
|
+
// same line a human already sees. The create path prints the
|
|
1521
|
+
// title of the entry we just filed; the comment path prints
|
|
1522
|
+
// the matched candidate's title.
|
|
1523
|
+
const number = extractIssueNumberFromUrl(result.url);
|
|
1524
|
+
const numberSuffix = number ? `#${number}` : '';
|
|
1525
|
+
if (dedupe === 'comment') {
|
|
1526
|
+
const sorted = matches.slice().sort((a, b) => a.number - b.number);
|
|
1527
|
+
const matchedTitle = sorted[0]?.title ?? null;
|
|
1528
|
+
const foldTail = matchedTitle ? `: ${matchedTitle}` : '';
|
|
1529
|
+
stdout.write(`${e.id} -> commented on ${numberSuffix} (fingerprint match${foldTail}) ${result.url}\n`);
|
|
1530
|
+
} else if (dedupe === 'unchecked') {
|
|
1531
|
+
stdout.write(`${e.id} -> created ${numberSuffix} (${built.rendered.title}, dedupe unchecked) ${result.url}\n`);
|
|
1532
|
+
} else {
|
|
1533
|
+
stdout.write(`${e.id} -> created ${numberSuffix} (${built.rendered.title}) ${result.url}\n`);
|
|
1534
|
+
}
|
|
1458
1535
|
summary.submitted += 1;
|
|
1459
1536
|
} else {
|
|
1460
1537
|
// Retry-without-labels failed too: bundle this one entry.
|
|
@@ -1784,6 +1861,106 @@ function at(now) {
|
|
|
1784
1861
|
return now().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
1785
1862
|
}
|
|
1786
1863
|
|
|
1864
|
+
// -- 0.28.2 (issue #234) fold-decision helpers ----------------------------
|
|
1865
|
+
//
|
|
1866
|
+
// A search hit alone is not a fold: submit re-reads the candidate
|
|
1867
|
+
// body and folds only when the fingerprint line is literally present
|
|
1868
|
+
// on its own line AND the anchor / kind / target markers do not
|
|
1869
|
+
// contradict the entry's. This is the code side of Barry's ruling on
|
|
1870
|
+
// #234 (2026-09-21) and of the amended AC-15701-2.
|
|
1871
|
+
|
|
1872
|
+
/**
|
|
1873
|
+
* True when `body` contains a line reading exactly
|
|
1874
|
+
* `rcf-feedback-fingerprint: <fp>` (case-sensitive, own line).
|
|
1875
|
+
*
|
|
1876
|
+
* @param {string} body
|
|
1877
|
+
* @param {string} fp
|
|
1878
|
+
* @returns {boolean}
|
|
1879
|
+
*/
|
|
1880
|
+
function hasFingerprintLine(body, fp) {
|
|
1881
|
+
if (typeof body !== 'string' || typeof fp !== 'string' || fp.length === 0) return false;
|
|
1882
|
+
const target = `rcf-feedback-fingerprint: ${fp}`;
|
|
1883
|
+
const lines = body.split(/\r?\n/);
|
|
1884
|
+
for (const l of lines) {
|
|
1885
|
+
if (l.trim() === target) return true;
|
|
1886
|
+
}
|
|
1887
|
+
return false;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* Defence-in-depth: with a truncated fingerprint (12 hex chars) a
|
|
1892
|
+
* collision is negligible but not zero. We refuse to fold when the
|
|
1893
|
+
* candidate body's environment-table markers for anchor, kind or the
|
|
1894
|
+
* blueprint slug disagree with the entry's own values.
|
|
1895
|
+
*
|
|
1896
|
+
* @param {string} body
|
|
1897
|
+
* @param {object} entry
|
|
1898
|
+
* @returns {boolean}
|
|
1899
|
+
*/
|
|
1900
|
+
function candidateMatchesEntry(body, entry) {
|
|
1901
|
+
if (typeof body !== 'string') return false;
|
|
1902
|
+
const marker = (label) => {
|
|
1903
|
+
const re = new RegExp(`\\|\\s*${label}\\s*\\|\\s*([^|\\n]+?)\\s*\\|`, 'i');
|
|
1904
|
+
const m = body.match(re);
|
|
1905
|
+
return m ? m[1].trim() : null;
|
|
1906
|
+
};
|
|
1907
|
+
const bodyAnchor = marker('anchor');
|
|
1908
|
+
const bodyKind = marker('kind');
|
|
1909
|
+
const bodyBlueprint = marker('blueprint');
|
|
1910
|
+
const bodyTarget = marker('target');
|
|
1911
|
+
const entryAnchor = (entry?.anchor ?? '-').trim();
|
|
1912
|
+
const entryKind = (entry?.kind ?? 'core').trim();
|
|
1913
|
+
const entryTargetRef = typeof entry?.target?.ref === 'string' ? entry.target.ref.trim() : '';
|
|
1914
|
+
const entryBlueprintSlug = entry?.kind === 'blueprint'
|
|
1915
|
+
? String(entry?.target?.effectiveSlug ?? entry?.target?.ref ?? '').trim()
|
|
1916
|
+
: null;
|
|
1917
|
+
if (bodyAnchor !== null && bodyAnchor.toUpperCase() !== entryAnchor.toUpperCase()) return false;
|
|
1918
|
+
if (bodyKind !== null && bodyKind.toLowerCase() !== entryKind.toLowerCase()) return false;
|
|
1919
|
+
if (entryBlueprintSlug && bodyBlueprint !== null && bodyBlueprint !== '-') {
|
|
1920
|
+
// The blueprint cell carries the slug as its first whitespace-
|
|
1921
|
+
// separated token, followed by an optional version and library
|
|
1922
|
+
// trail. Compare only the head.
|
|
1923
|
+
const head = bodyBlueprint.split(/\s+/)[0];
|
|
1924
|
+
if (head && head !== entryBlueprintSlug) return false;
|
|
1925
|
+
}
|
|
1926
|
+
// 0.28.2 (Codex review follow-up on #234): core entries also carry
|
|
1927
|
+
// a "target" line on the environment table. Refuse to fold across a
|
|
1928
|
+
// differing target (verb path) even when kind and anchor agree,
|
|
1929
|
+
// matching the amended AC-15701-2 anchor-and-target rule for both
|
|
1930
|
+
// kinds.
|
|
1931
|
+
if (entryKind.toLowerCase() === 'core' && entryTargetRef && bodyTarget !== null && bodyTarget !== '-') {
|
|
1932
|
+
if (bodyTarget !== entryTargetRef) return false;
|
|
1933
|
+
}
|
|
1934
|
+
return true;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Pull the first `## Report: <title>` header from a candidate body,
|
|
1939
|
+
* used to name the matched issue on the stdout row when the search
|
|
1940
|
+
* response did not carry one.
|
|
1941
|
+
*
|
|
1942
|
+
* @param {string} body
|
|
1943
|
+
* @returns {string | null}
|
|
1944
|
+
*/
|
|
1945
|
+
function extractTitle(body) {
|
|
1946
|
+
if (typeof body !== 'string') return null;
|
|
1947
|
+
const m = body.match(/^##\s+Report:\s+(.+)$/m);
|
|
1948
|
+
return m ? m[1].trim() : null;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
/**
|
|
1952
|
+
* Extract the numeric issue id from a github.com issue URL. Used only
|
|
1953
|
+
* to shape the stdout row.
|
|
1954
|
+
*
|
|
1955
|
+
* @param {string | undefined} url
|
|
1956
|
+
* @returns {number | null}
|
|
1957
|
+
*/
|
|
1958
|
+
function extractIssueNumberFromUrl(url) {
|
|
1959
|
+
if (typeof url !== 'string') return null;
|
|
1960
|
+
const m = url.match(/\/issues\/(\d+)/);
|
|
1961
|
+
return m ? Number(m[1]) : null;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1787
1964
|
// -- opt-in / opt-out (slice 5, FBS-184) ----------------------------------
|
|
1788
1965
|
|
|
1789
1966
|
const OPT_OPTIONS = /** @type {const} */ ({
|
package/src/cli/intake.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
import { readFile } from 'node:fs/promises';
|
|
13
13
|
import { parseArgs } from 'node:util';
|
|
14
14
|
|
|
15
|
-
import { walkTree } from '#core/store';
|
|
15
|
+
import { walkTree, validateComposedRecord } from '#core/store';
|
|
16
|
+
import { FIDELITY_LEVELS } from '../intake/fidelity.js';
|
|
16
17
|
|
|
17
18
|
import { findProjectRoot } from '../view/index.js';
|
|
18
19
|
import {
|
|
@@ -44,16 +45,25 @@ Options:
|
|
|
44
45
|
--kind <kind> Hint the artefact kind: napkin |
|
|
45
46
|
productBrief | prd | prdPlusTad | other
|
|
46
47
|
--input <config.json> Non-interactive mode: pre-filled artefacts
|
|
47
|
-
+ findings + acknowledgements
|
|
48
|
-
|
|
48
|
+
+ findings + acknowledgements. The
|
|
49
|
+
fidelity in the input file must be one
|
|
50
|
+
of the allowed values below.
|
|
51
|
+
--dry-run Print the plan without writing the record;
|
|
52
|
+
the composed record is still run through
|
|
53
|
+
the same manifest schema pass the write
|
|
54
|
+
path runs (0.28.2 fix for #230), so a
|
|
55
|
+
preview fails on the same schema misses.
|
|
49
56
|
--json Emit the intakeClassification block as JSON
|
|
50
57
|
--quiet Suppress non-error confirmations
|
|
51
58
|
--help Print this help
|
|
52
59
|
|
|
60
|
+
Fidelity enum (allowed values on --input.fidelity):
|
|
61
|
+
${FIDELITY_LEVELS.join(', ')}
|
|
62
|
+
|
|
53
63
|
Exit codes:
|
|
54
64
|
0 success
|
|
55
65
|
2 usage error (missing --artefact/--input, unreadable file)
|
|
56
|
-
3 validation failure on the
|
|
66
|
+
3 validation failure on the composed record (including --dry-run)
|
|
57
67
|
`;
|
|
58
68
|
|
|
59
69
|
/**
|
|
@@ -124,6 +134,17 @@ export async function main(argv, deps = {}) {
|
|
|
124
134
|
}
|
|
125
135
|
|
|
126
136
|
if (flags['dry-run']) {
|
|
137
|
+
// 0.28.2 (issue #230): run the same schema pass the writer runs so
|
|
138
|
+
// a preview no longer passes on a value the real run refuses.
|
|
139
|
+
const dryValidation = validateComposedRecord({
|
|
140
|
+
tree: walkResult.tree,
|
|
141
|
+
record: outcome.record,
|
|
142
|
+
verb: 'intake',
|
|
143
|
+
});
|
|
144
|
+
if (dryValidation) {
|
|
145
|
+
stderr.write(`[error] ${dryValidation.kind} ${dryValidation.message}\n`);
|
|
146
|
+
return 3;
|
|
147
|
+
}
|
|
127
148
|
if (flags.json) {
|
|
128
149
|
stdout.write(`${JSON.stringify(outcome.record, null, 2)}\n`);
|
|
129
150
|
} else {
|
package/src/cli/preflight.js
CHANGED
|
@@ -23,7 +23,7 @@ import { parseArgs } from 'node:util';
|
|
|
23
23
|
import { createInterface } from 'node:readline/promises';
|
|
24
24
|
import process from 'node:process';
|
|
25
25
|
|
|
26
|
-
import { walkTree } from '#core/store';
|
|
26
|
+
import { walkTree, validateComposedRecord } from '#core/store';
|
|
27
27
|
import { writeUnexpectedFailure, rcfError } from '#core/errors';
|
|
28
28
|
|
|
29
29
|
import { findProjectRoot } from '../view/index.js';
|
|
@@ -63,11 +63,18 @@ Options:
|
|
|
63
63
|
--input <path> Non-interactive: read pre-filled session
|
|
64
64
|
--non-interactive Force non-interactive mode (default when
|
|
65
65
|
not on a TTY or when piped)
|
|
66
|
-
--dry-run Print the composed record; do not write
|
|
66
|
+
--dry-run Print the composed record; do not write.
|
|
67
|
+
The composed record is still run through
|
|
68
|
+
the same manifest schema pass the write
|
|
69
|
+
path runs (0.28.2 fix for #232), so a
|
|
70
|
+
preview fails on the same schema misses.
|
|
67
71
|
--json Emit the composed record as JSON to stdout
|
|
68
72
|
--quiet Suppress non-error stdout
|
|
69
73
|
--help Print this help
|
|
70
74
|
|
|
75
|
+
Service id shape: ^[a-z][a-zA-Z0-9]*$ (camelCase, no hyphens; example:
|
|
76
|
+
"stripeCheckout" is valid, "stripe-checkout" is not).
|
|
77
|
+
|
|
71
78
|
Credentials NEVER enter the chain. The session prompts for env var
|
|
72
79
|
NAMES only; values are read from the shell at test / finalise time.
|
|
73
80
|
The name-metadata is written to .rcf/preflight-secrets.local.json,
|
|
@@ -77,7 +84,7 @@ Exit codes:
|
|
|
77
84
|
0 success
|
|
78
85
|
1 IO / unexpected runtime failure
|
|
79
86
|
2 usage error (bad flags, unresolvable PRD id)
|
|
80
|
-
3 schema validation on the composed record
|
|
87
|
+
3 schema validation on the composed record (including --dry-run)
|
|
81
88
|
4 operator cancelled the session at the confirm step
|
|
82
89
|
`;
|
|
83
90
|
|
|
@@ -189,6 +196,20 @@ export async function main(argv, deps = {}) {
|
|
|
189
196
|
});
|
|
190
197
|
|
|
191
198
|
if (flags['dry-run']) {
|
|
199
|
+
// 0.28.2 (issue #232): dry-run now runs the same schema pass the
|
|
200
|
+
// writer runs. The service-id pattern `^[a-z][a-zA-Z0-9]*$` is
|
|
201
|
+
// enforced by the manifest schema on the write path; the preview
|
|
202
|
+
// is no longer allowed to lie about a kebab-case id.
|
|
203
|
+
const dryValidation = validateComposedRecord({
|
|
204
|
+
tree,
|
|
205
|
+
record,
|
|
206
|
+
verb: 'preflight',
|
|
207
|
+
extraRecords: optOuts,
|
|
208
|
+
});
|
|
209
|
+
if (dryValidation) {
|
|
210
|
+
stderr.write(`[error] ${dryValidation.kind} ${dryValidation.message}\n`);
|
|
211
|
+
return 3;
|
|
212
|
+
}
|
|
192
213
|
if (flags.json) stdout.write(`${JSON.stringify({ record, optOuts }, null, 2)}\n`);
|
|
193
214
|
if (!flags.quiet) {
|
|
194
215
|
stdout.write(`[dry-run] preflight would write record ${record.id} (${record.servicesInScope.length} services, ${record.designShapeAnswers?.length ?? 0} design-shape answers, ${optOuts.length} opt-outs)\n`);
|
package/src/cli/review.js
CHANGED
|
@@ -21,7 +21,7 @@ import { parseArgs } from 'node:util';
|
|
|
21
21
|
import process from 'node:process';
|
|
22
22
|
|
|
23
23
|
import { rcfError, writeUnexpectedFailure } from '#core/errors';
|
|
24
|
-
import { resolveTestPointers, validateDocument, walkTree } from '#core/store';
|
|
24
|
+
import { resolveTestPointers, validateDocument, validateComposedRecord, walkTree } from '#core/store';
|
|
25
25
|
|
|
26
26
|
import { findProjectRoot } from '../view/index.js';
|
|
27
27
|
import {
|
|
@@ -170,6 +170,14 @@ export async function main(argv, deps = {}) {
|
|
|
170
170
|
if (flags.json) stdout.write(`${JSON.stringify(record, null, 2)}\n`);
|
|
171
171
|
|
|
172
172
|
if (flags['dry-run']) {
|
|
173
|
+
// 0.28.2 (folded into #230, per Barry ruling 2026-09-21):
|
|
174
|
+
// dry-run runs the same schema pass the writer runs so a preview
|
|
175
|
+
// reports the same validation error the write path would refuse.
|
|
176
|
+
const dryValidation = validateComposedRecord({ tree, record, verb: 'review' });
|
|
177
|
+
if (dryValidation) {
|
|
178
|
+
stderr.write(`[error] ${dryValidation.kind} ${dryValidation.message}\n`);
|
|
179
|
+
return 3;
|
|
180
|
+
}
|
|
173
181
|
if (!flags.quiet) {
|
|
174
182
|
stdout.write(`[dry-run] review ${fbsId}: ${findings.length} finding(s); verdict=${record.verdict}\n`);
|
|
175
183
|
for (const f of findings) {
|
package/src/cli/ui-baseline.js
CHANGED
|
@@ -21,7 +21,7 @@ import { parseArgs } from 'node:util';
|
|
|
21
21
|
import { createInterface } from 'node:readline/promises';
|
|
22
22
|
import process from 'node:process';
|
|
23
23
|
|
|
24
|
-
import { walkTree } from '#core/store';
|
|
24
|
+
import { walkTree, validateComposedRecord } from '#core/store';
|
|
25
25
|
import { writeUnexpectedFailure, rcfError } from '#core/errors';
|
|
26
26
|
|
|
27
27
|
import { findProjectRoot } from '../view/index.js';
|
|
@@ -250,6 +250,19 @@ async function runInit({ tree, projectRoot, stdin, stdout, stderr, flags, now })
|
|
|
250
250
|
});
|
|
251
251
|
|
|
252
252
|
if (flags['dry-run']) {
|
|
253
|
+
// 0.28.2 (folded into #230): dry-run runs the same schema pass
|
|
254
|
+
// the writer runs so a preview reports the same validation error
|
|
255
|
+
// the write path would refuse.
|
|
256
|
+
const dryValidation = validateComposedRecord({
|
|
257
|
+
tree,
|
|
258
|
+
record,
|
|
259
|
+
verb: 'uiBaselineInit',
|
|
260
|
+
options: { reset: Boolean(flags.reset) },
|
|
261
|
+
});
|
|
262
|
+
if (dryValidation) {
|
|
263
|
+
stderr.write(`[error] ${dryValidation.kind} ${dryValidation.message}\n`);
|
|
264
|
+
return 3;
|
|
265
|
+
}
|
|
253
266
|
stdout.write(`${JSON.stringify(record, null, 2)}\n`);
|
|
254
267
|
if (!flags.quiet) stdout.write(`[dry-run] ui-baseline init: would write ${record.id} (${session.optOuts.length} opt-outs).\n`);
|
|
255
268
|
return 0;
|
package/src/core/store/index.js
CHANGED
|
@@ -18,3 +18,8 @@ export { checkCodeNodeResolution, splitCnPath } from './cn-resolve.js';
|
|
|
18
18
|
// never counted as covering an AC.
|
|
19
19
|
export { resolveTestPointers, splitTestPointer, testCaseKey } from './tp-resolve.js';
|
|
20
20
|
export { nextIdForKind, createDocument, updateDocument, deleteDocument, deriveSlug } from './writer.js';
|
|
21
|
+
// 0.28.2 (issues #230/#232 + review/ui-baseline init/browser-verify):
|
|
22
|
+
// shared "compose then schema-validate without writing" helper the
|
|
23
|
+
// dry-run branches of the five discover verbs run so a preview reports
|
|
24
|
+
// the same schema error the writer would refuse on.
|
|
25
|
+
export { validateComposedRecord, buildNextManifest } from './validate-composed-record.js';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Shared validate-composed-record helper (0.28.2, issues #230 / #232
|
|
2
|
+
// and the three folded verbs review / ui-baseline init / browser-verify,
|
|
3
|
+
// per Barry's 2026-09-21 ruling).
|
|
4
|
+
//
|
|
5
|
+
// The bug: five discover verbs shipped a `--dry-run` branch that
|
|
6
|
+
// composed a manifest record and printed it without ever running the
|
|
7
|
+
// same schema pass the write path runs. A `--dry-run` that passed and
|
|
8
|
+
// a real run that failed on schema made the preview untrustworthy for
|
|
9
|
+
// exactly the non-interactive case it exists to serve.
|
|
10
|
+
//
|
|
11
|
+
// The fix: this helper synthesises the "next manifest" for each verb
|
|
12
|
+
// the same way the writer would, then runs `validateDocument` without
|
|
13
|
+
// touching disk. The dry-run branch of each CLI calls this helper and
|
|
14
|
+
// exits 3 on a validation miss, matching the exit code the write path
|
|
15
|
+
// returns today.
|
|
16
|
+
|
|
17
|
+
import { validateDocument } from './validator.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {'intake' | 'preflight' | 'review' | 'uiBaselineInit' | 'browserVerify'} DryRunVerb
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} args
|
|
25
|
+
* @param {object} args.tree the walked tree (carries `manifest`)
|
|
26
|
+
* @param {object} args.record the composed record to be written
|
|
27
|
+
* @param {DryRunVerb} args.verb which verb is composing
|
|
28
|
+
* @param {object[]} [args.extraRecords] preflight-only: the composed opt-outs
|
|
29
|
+
* @param {object} [args.options] per-verb write options (e.g. `{ reset }` for uiBaselineInit)
|
|
30
|
+
* @returns {import('../errors/index.js').RcfError | null}
|
|
31
|
+
* Returns the same shape `validateDocument` returns, so the CLI can
|
|
32
|
+
* forward the error to stderr as it would on the write path.
|
|
33
|
+
*/
|
|
34
|
+
export function validateComposedRecord({ tree, record, verb, extraRecords = [], options = {} }) {
|
|
35
|
+
const manifest = tree?.manifest ?? {};
|
|
36
|
+
const nextManifest = buildNextManifest({ manifest, record, verb, extraRecords, options });
|
|
37
|
+
return validateDocument({ doc: nextManifest, kind: 'manifest', filePath: 'rcf/manifest.json' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Return the next-manifest shape each writer synthesises before its own
|
|
42
|
+
* `validateDocument` call. Kept in this module so a single test can
|
|
43
|
+
* assert the dry-run branch and the write branch stay in step across
|
|
44
|
+
* every verb.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} args
|
|
47
|
+
* @param {object} args.manifest
|
|
48
|
+
* @param {object} args.record
|
|
49
|
+
* @param {DryRunVerb} args.verb
|
|
50
|
+
* @param {object[]} [args.extraRecords]
|
|
51
|
+
* @param {object} [args.options]
|
|
52
|
+
* @returns {object}
|
|
53
|
+
*/
|
|
54
|
+
export function buildNextManifest({ manifest, record, verb, extraRecords = [], options = {} }) {
|
|
55
|
+
const base = { ...(manifest ?? {}) };
|
|
56
|
+
switch (verb) {
|
|
57
|
+
case 'intake': {
|
|
58
|
+
return { ...base, intakeClassification: record };
|
|
59
|
+
}
|
|
60
|
+
case 'preflight': {
|
|
61
|
+
const existing = Array.isArray(base.preFlightConfig) ? base.preFlightConfig : [];
|
|
62
|
+
const next = { ...base, preFlightConfig: [...existing, record] };
|
|
63
|
+
if (Array.isArray(extraRecords) && extraRecords.length > 0) {
|
|
64
|
+
const existingOptOuts = Array.isArray(next.baselineAcOptOuts) ? next.baselineAcOptOuts : [];
|
|
65
|
+
next.baselineAcOptOuts = [...existingOptOuts, ...extraRecords];
|
|
66
|
+
}
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
case 'review': {
|
|
70
|
+
const existing = Array.isArray(base.reviewAudit) ? base.reviewAudit : [];
|
|
71
|
+
return { ...base, reviewAudit: [...existing, record] };
|
|
72
|
+
}
|
|
73
|
+
case 'uiBaselineInit': {
|
|
74
|
+
// Mirror writeUiBaselineRecord: on `--reset` the prior
|
|
75
|
+
// uiBaseline moves into uiBaselineHistory. Without --reset the
|
|
76
|
+
// prior is replaced in place. The dry-run path calls this
|
|
77
|
+
// helper without options and matches the reset=false branch.
|
|
78
|
+
const next = { ...base };
|
|
79
|
+
const prior = next.uiBaseline ?? null;
|
|
80
|
+
const reset = Boolean(options && options.reset);
|
|
81
|
+
if (prior && reset) {
|
|
82
|
+
const history = Array.isArray(next.uiBaselineHistory) ? next.uiBaselineHistory : [];
|
|
83
|
+
next.uiBaselineHistory = [...history, prior];
|
|
84
|
+
}
|
|
85
|
+
next.uiBaseline = record;
|
|
86
|
+
return next;
|
|
87
|
+
}
|
|
88
|
+
case 'browserVerify': {
|
|
89
|
+
const existing = Array.isArray(base.browserVerification) ? base.browserVerification : [];
|
|
90
|
+
return { ...base, browserVerification: [...existing, record] };
|
|
91
|
+
}
|
|
92
|
+
default:
|
|
93
|
+
throw new Error(`validateComposedRecord: unknown verb ${verb}`);
|
|
94
|
+
}
|
|
95
|
+
}
|