rcf-lite 0.28.1 → 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 +99 -0
- package/fixtures/canary-manifest.json +9 -9
- package/package.json +1 -1
- package/rcf/code-nodes/cn-602.json +3 -2
- package/rcf/fbs/fbs-181.json +3 -2
- package/rcf/tacs/tac-4103-feedback-redact.json +2 -2
- package/rcf/test-suites/ts-188.json +10 -2
- package/rcf/test-suites/ts-191.json +19 -5
- package/rcf/user-stories/us-15601.json +9 -0
- package/rcf/user-stories/us-15701.json +4 -4
- package/releases/releases.yaml +11 -1
- package/src/browser-verify/manifest-writer.js +9 -5
- package/src/cli/browser-verify.js +9 -1
- package/src/cli/feedback.js +190 -22
- 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/gh.js +27 -1
- package/src/feedback/redact-allowlist.json +7 -0
- package/src/feedback/redact.js +31 -2
- 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
|
@@ -1033,11 +1033,21 @@ async function buildRedactionContext(projectRoot) {
|
|
|
1033
1033
|
} catch { /* no git or no remotes is fine */ }
|
|
1034
1034
|
|
|
1035
1035
|
let allowHostsExt = [];
|
|
1036
|
+
let allowExtensionsExt = [];
|
|
1036
1037
|
try {
|
|
1037
1038
|
const settings = JSON.parse(await readFile(resolve(projectRoot, 'rcf', 'feedback-settings.json'), 'utf8'));
|
|
1038
1039
|
if (Array.isArray(settings?.redaction?.allowHosts)) {
|
|
1039
1040
|
allowHostsExt = settings.redaction.allowHosts;
|
|
1040
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
|
+
}
|
|
1041
1051
|
} catch { /* absent is fine */ }
|
|
1042
1052
|
// Assemble the extended allow-list once so downstream callers can
|
|
1043
1053
|
// print it if they want to; the redactor itself accepts the raw
|
|
@@ -1059,6 +1069,7 @@ async function buildRedactionContext(projectRoot) {
|
|
|
1059
1069
|
operatorName,
|
|
1060
1070
|
gitRemote: [...remotes].filter((r) => typeof r === 'string' && r.length > 0),
|
|
1061
1071
|
allowHosts: allowHostsExt,
|
|
1072
|
+
allowExtensions: allowExtensionsExt,
|
|
1062
1073
|
};
|
|
1063
1074
|
}
|
|
1064
1075
|
|
|
@@ -1215,11 +1226,21 @@ const SUBMIT_OPTIONS = /** @type {const} */ ({
|
|
|
1215
1226
|
*
|
|
1216
1227
|
* Dedupe (per entry passing preflight): gh search issues on the
|
|
1217
1228
|
* destination for `rcf-feedback-fingerprint: <fp>` (open, in:body,
|
|
1218
|
-
* limit 5).
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
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.
|
|
1223
1244
|
*
|
|
1224
1245
|
* Create/comment failure (network/4xx/5xx/ratelimit or unexpected
|
|
1225
1246
|
* label rejection): retry once WITHOUT labels; still failing, bundle
|
|
@@ -1381,18 +1402,47 @@ async function handleSubmit(argv, ctx) {
|
|
|
1381
1402
|
let dedupe = 'new';
|
|
1382
1403
|
let closedRef = null;
|
|
1383
1404
|
let matches = [];
|
|
1405
|
+
let searchMatches = [];
|
|
1384
1406
|
if (!searchOpen.ok) {
|
|
1385
1407
|
dedupe = 'unchecked';
|
|
1386
1408
|
} else {
|
|
1387
|
-
|
|
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;
|
|
1388
1432
|
}
|
|
1389
1433
|
|
|
1390
1434
|
if (flags['dry-run']) {
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
+
}
|
|
1396
1446
|
stdout.write(`${e.id} -> dry-run ${plan} (${dedupe === 'unchecked' ? 'unchecked' : matches.length === 0 ? 'new' : 'comment'})\n`);
|
|
1397
1447
|
continue;
|
|
1398
1448
|
}
|
|
@@ -1425,26 +1475,28 @@ async function handleSubmit(argv, ctx) {
|
|
|
1425
1475
|
labels: usedLabels,
|
|
1426
1476
|
});
|
|
1427
1477
|
} else if (matches.length === 1) {
|
|
1428
|
-
// Comment
|
|
1478
|
+
// Verified fingerprint match: comment. Comment carries the
|
|
1479
|
+
// full report per Barry's 2026-09-21 ruling on #234.
|
|
1429
1480
|
dedupe = 'comment';
|
|
1430
|
-
const commentBody = renderComment(e, {
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
}
|
|
1481
|
+
const commentBody = renderComment(e, built.redacted, {
|
|
1482
|
+
fingerprint: built.fingerprint,
|
|
1483
|
+
matchedTitle: matches[0].title ?? null,
|
|
1484
|
+
}).body;
|
|
1434
1485
|
result = await commentWithRetry(gh, {
|
|
1435
1486
|
repo: destination.repo,
|
|
1436
1487
|
number: matches[0].number,
|
|
1437
1488
|
body: commentBody,
|
|
1438
1489
|
});
|
|
1439
1490
|
} else {
|
|
1440
|
-
// Many: comment on lowest, mention the
|
|
1491
|
+
// Many verified matches: comment on lowest, mention the
|
|
1492
|
+
// others. The comment still carries the full report.
|
|
1441
1493
|
dedupe = 'comment';
|
|
1442
1494
|
const sorted = matches.slice().sort((a, b) => a.number - b.number);
|
|
1443
1495
|
const others = sorted.slice(1).map((m) => `#${m.number}`).join(', ');
|
|
1444
|
-
const base = renderComment(e, {
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
}
|
|
1496
|
+
const base = renderComment(e, built.redacted, {
|
|
1497
|
+
fingerprint: built.fingerprint,
|
|
1498
|
+
matchedTitle: sorted[0].title ?? null,
|
|
1499
|
+
}).body;
|
|
1448
1500
|
const commentBody = `${base}\nalso see ${others}\n`;
|
|
1449
1501
|
result = await commentWithRetry(gh, {
|
|
1450
1502
|
repo: destination.repo,
|
|
@@ -1463,7 +1515,23 @@ async function handleSubmit(argv, ctx) {
|
|
|
1463
1515
|
droppedLabels,
|
|
1464
1516
|
sessionId,
|
|
1465
1517
|
});
|
|
1466
|
-
|
|
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
|
+
}
|
|
1467
1535
|
summary.submitted += 1;
|
|
1468
1536
|
} else {
|
|
1469
1537
|
// Retry-without-labels failed too: bundle this one entry.
|
|
@@ -1793,6 +1861,106 @@ function at(now) {
|
|
|
1793
1861
|
return now().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
1794
1862
|
}
|
|
1795
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
|
+
|
|
1796
1964
|
// -- opt-in / opt-out (slice 5, FBS-184) ----------------------------------
|
|
1797
1965
|
|
|
1798
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
|
+
}
|
package/src/feedback/gh.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// - ghLabelList({ repo }) list existing label names
|
|
6
6
|
// - ghRepoView({ repo }) visibility + viewerPermission + hasIssuesEnabled
|
|
7
7
|
// - ghIssueSearch({ repo, query, state, limit }) dedupe search
|
|
8
|
+
// - ghIssueGetBody({ repo, number }) re-read a candidate body for fingerprint verification (0.28.2 / #234)
|
|
8
9
|
// - ghIssueCreate({ repo, title, body, labels }) create one issue
|
|
9
|
-
// - ghIssueComment({ repo, number, body }) +1
|
|
10
|
+
// - ghIssueComment({ repo, number, body }) fold comment (carries the full report; the "+1 from another reporter" payload retired in 0.28.2 per Barry's 2026-09-21 ruling on #234)
|
|
10
11
|
// - ghLabelCreate({ repo, name, description, color }) bootstrap only
|
|
11
12
|
//
|
|
12
13
|
// Every function returns a typed result. Errors are classified into
|
|
@@ -68,6 +69,7 @@ export async function loadGhAdapter(env = process.env) {
|
|
|
68
69
|
* @property {(opts: { repo: string }) => Promise<GhResult>} ghLabelList
|
|
69
70
|
* @property {(opts: { repo: string }) => Promise<GhResult>} ghRepoView
|
|
70
71
|
* @property {(opts: { repo: string, query: string, state?: 'open' | 'closed', limit?: number }) => Promise<GhResult>} ghIssueSearch
|
|
72
|
+
* @property {(opts: { repo: string, number: number }) => Promise<GhResult>} ghIssueGetBody
|
|
71
73
|
* @property {(opts: { repo: string, title: string, body: string, labels: string[] }) => Promise<GhResult>} ghIssueCreate
|
|
72
74
|
* @property {(opts: { repo: string, number: number, body: string }) => Promise<GhResult>} ghIssueComment
|
|
73
75
|
* @property {(opts: { repo: string, name: string, description?: string, color?: string }) => Promise<GhResult>} ghLabelCreate
|
|
@@ -189,6 +191,29 @@ export async function ghIssueSearch(opts) {
|
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
193
|
|
|
194
|
+
/**
|
|
195
|
+
* `gh issue view <n> --repo <repo> --json body`. Re-reads the body of
|
|
196
|
+
* a candidate issue for the fingerprint-verification step introduced
|
|
197
|
+
* in 0.28.2 (issue #234): a search hit whose body does not carry the
|
|
198
|
+
* fingerprint line on its own line is a non-match and is discarded
|
|
199
|
+
* before the fold decision. Returns { body }.
|
|
200
|
+
*
|
|
201
|
+
* @param {{ repo: string, number: number }} opts
|
|
202
|
+
* @returns {Promise<GhResult>}
|
|
203
|
+
*/
|
|
204
|
+
export async function ghIssueGetBody(opts) {
|
|
205
|
+
const args = ['issue', 'view', String(opts.number), '--repo', opts.repo, '--json', 'body'];
|
|
206
|
+
const r = await runGh(args);
|
|
207
|
+
if (!r.ok) return r;
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(r.value.stdout);
|
|
210
|
+
const body = typeof parsed?.body === 'string' ? parsed.body : '';
|
|
211
|
+
return { ok: true, value: { body } };
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return { ok: false, kind: 'unknown', message: `issue-view parse failed: ${err.message}` };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
192
217
|
/**
|
|
193
218
|
* `gh issue create --repo <repo> --title <t> --body-file - --label
|
|
194
219
|
* <l1> --label <l2>...` reads the body from stdin so a long body is
|
|
@@ -370,6 +395,7 @@ export const DEFAULT_ADAPTER = Object.freeze({
|
|
|
370
395
|
ghLabelList,
|
|
371
396
|
ghRepoView,
|
|
372
397
|
ghIssueSearch,
|
|
398
|
+
ghIssueGetBody,
|
|
373
399
|
ghIssueCreate,
|
|
374
400
|
ghIssueComment,
|
|
375
401
|
ghLabelCreate,
|