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.
@@ -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). Zero hits -> create. One hit -> +1 comment. Many hits ->
1219
- * comment on the lowest number and mention the others. Search failure
1220
- * -> create with dedupe:unchecked. Closed search runs only after a
1221
- * zero-hit open search so the create body can reference the prior
1222
- * number.
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
- matches = searchOpen.value?.matches ?? [];
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
- const plan = matches.length === 1
1392
- ? `comment on #${matches[0].number}`
1393
- : matches.length > 1
1394
- ? `comment on #${matches.sort((a, b) => a.number - b.number)[0].number} (${matches.length} matches)`
1395
- : 'create';
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
- body: built.redacted.body,
1432
- ledger: built.redacted.ledger,
1433
- }, { fingerprint: built.fingerprint, includeBody: false }).body;
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 others.
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
- body: built.redacted.body,
1446
- ledger: built.redacted.ledger,
1447
- }, { fingerprint: built.fingerprint, includeBody: false }).body;
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
- stdout.write(`${e.id} -> ${result.url} (${dedupe})\n`);
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
- --dry-run Print the plan without writing the record
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 written manifest
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 {
@@ -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) {
@@ -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;
@@ -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
+ }
@@ -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 comment
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,
@@ -10,5 +10,12 @@
10
10
  "developer.mozilla.org",
11
11
  "localhost",
12
12
  "127.0.0.1"
13
+ ],
14
+ "extensions": [
15
+ "md", "mdx", "json", "yml", "yaml", "txt",
16
+ "js", "mjs", "cjs", "ts", "tsx",
17
+ "html", "css",
18
+ "png", "svg",
19
+ "sh", "csv", "log", "lock"
13
20
  ]
14
21
  }