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
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Resolution order (design section 3.3):
|
|
9
9
|
// 1. --kind core -> CORE_REPO (public)
|
|
10
|
-
// 2. --kind blueprint, no libraryPrefix ->
|
|
10
|
+
// 2. --kind blueprint, no libraryPrefix -> library-slug inference
|
|
11
|
+
// (AC-15801-4 / design amendment R5a): if the applied slug is
|
|
12
|
+
// owned by exactly one registered library that resolves to a
|
|
13
|
+
// destination (issuesRepo or derivable sourceRef), route there
|
|
14
|
+
// with source: 'library-slug-inference'; if two or more libraries
|
|
15
|
+
// own the slug and resolve, return unresolved with reason
|
|
16
|
+
// 'ambiguous-library-slug' and a candidates[] array of prefixes;
|
|
17
|
+
// otherwise fall through to CORE_REPO (shelf; public).
|
|
11
18
|
// 3. --kind blueprint, libraryPrefix -> registry entry issuesRepo
|
|
12
19
|
// (declared destination wins;
|
|
13
20
|
// visibility from the entry)
|
|
@@ -210,9 +217,10 @@ function recordEffectiveSlug(record) {
|
|
|
210
217
|
* @property {'public' | 'private' | 'unresolved'} visibility
|
|
211
218
|
* @property {'core' | 'shelf' | 'library'} kind
|
|
212
219
|
* @property {boolean} derived true when repo came from sourceRef derivation
|
|
213
|
-
* @property {string} [reason] filled on unresolved: 'no-issues-field' | 'no-github-source' | 'no-libraryprefix-record' | 'unknown-target'
|
|
220
|
+
* @property {string} [reason] filled on unresolved: 'no-issues-field' | 'no-github-source' | 'no-libraryprefix-record' | 'unknown-target' | 'ambiguous-library-slug'
|
|
221
|
+
* @property {string[]} [candidates] filled on unresolved with reason 'ambiguous-library-slug': sorted library prefixes owning the slug
|
|
214
222
|
* @property {string} [publisherContact] registry entry's publisher.contact (bundle uses it)
|
|
215
|
-
* @property {string} [source] 'library-manifest' | 'sourceRef-derivation' | 'package-bugs-url' | 'shelf-constant'
|
|
223
|
+
* @property {string} [source] 'library-manifest' | 'sourceRef-derivation' | 'package-bugs-url' | 'shelf-constant' | 'library-slug-inference'
|
|
216
224
|
*/
|
|
217
225
|
|
|
218
226
|
/**
|
|
@@ -263,8 +271,27 @@ export async function resolve(entry, context = {}) {
|
|
|
263
271
|
|
|
264
272
|
const libraryPrefix = record.libraryPrefix ?? entry?.target?.libraryPrefix ?? null;
|
|
265
273
|
|
|
266
|
-
// Shelf blueprint (no libraryPrefix
|
|
274
|
+
// Shelf blueprint (no libraryPrefix on the manifest record). Before
|
|
275
|
+
// falling through to the shelf constant, cross-check the registry:
|
|
276
|
+
// if exactly one registered library owns a blueprint with the applied
|
|
277
|
+
// slug AND that library resolves to a destination, route to that
|
|
278
|
+
// library so a bare-slug apply of a library-shared name still lands
|
|
279
|
+
// on the library owner's repo (design amendment R5, AC-15801-4).
|
|
267
280
|
if (!libraryPrefix) {
|
|
281
|
+
const inferred = inferLibraryFromSlug(record, registry);
|
|
282
|
+
if (inferred.kind === 'single') {
|
|
283
|
+
return inferred.destination;
|
|
284
|
+
}
|
|
285
|
+
if (inferred.kind === 'ambiguous') {
|
|
286
|
+
return {
|
|
287
|
+
repo: null,
|
|
288
|
+
visibility: 'unresolved',
|
|
289
|
+
kind: 'library',
|
|
290
|
+
derived: false,
|
|
291
|
+
reason: 'ambiguous-library-slug',
|
|
292
|
+
candidates: inferred.candidates,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
268
295
|
return {
|
|
269
296
|
repo: CORE_REPO,
|
|
270
297
|
visibility: 'public',
|
|
@@ -359,3 +386,70 @@ function findLibraryEntry(registry, libraryPrefix) {
|
|
|
359
386
|
const libs = Array.isArray(registry?.libraries) ? registry.libraries : [];
|
|
360
387
|
return libs.find((l) => l?.libraryPrefix === libraryPrefix) ?? null;
|
|
361
388
|
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* AC-15801-4 / design amendment R5a: given a manifest record with no
|
|
392
|
+
* libraryPrefix, cross-check the registry to see whether the applied
|
|
393
|
+
* slug is owned by exactly one registered library that resolves to a
|
|
394
|
+
* destination. Returns:
|
|
395
|
+
* { kind: 'none' } no library owns the slug (or none resolve)
|
|
396
|
+
* { kind: 'single', destination } exactly one library owns the slug and resolves
|
|
397
|
+
* { kind: 'ambiguous', candidates: string[] } two or more libraries own the slug and resolve
|
|
398
|
+
*
|
|
399
|
+
* The resolver only calls this on the shelf-constant fall-through
|
|
400
|
+
* branch, so it never overrides an existing libraryPrefix resolution.
|
|
401
|
+
*
|
|
402
|
+
* @param {object} record manifest.blueprints[] record
|
|
403
|
+
* @param {object | null | undefined} registry parsed rcf/blueprint-libraries.json
|
|
404
|
+
*/
|
|
405
|
+
function inferLibraryFromSlug(record, registry) {
|
|
406
|
+
const slug = typeof record?.slug === 'string' ? record.slug : null;
|
|
407
|
+
if (!slug) return { kind: 'none' };
|
|
408
|
+
const libs = Array.isArray(registry?.libraries) ? registry.libraries : [];
|
|
409
|
+
const owners = [];
|
|
410
|
+
for (const lib of libs) {
|
|
411
|
+
if (!lib || typeof lib !== 'object') continue;
|
|
412
|
+
if (typeof lib.libraryPrefix !== 'string' || lib.libraryPrefix.length === 0) continue;
|
|
413
|
+
const bps = Array.isArray(lib.blueprints) ? lib.blueprints : [];
|
|
414
|
+
const owns = bps.some((b) => b && typeof b.slug === 'string' && b.slug === slug);
|
|
415
|
+
if (!owns) continue;
|
|
416
|
+
const declared = typeof lib.issuesRepo === 'string' && lib.issuesRepo.length > 0 ? lib.issuesRepo : null;
|
|
417
|
+
const derivedRepo = declared ? null : parseGithubSource(lib.sourceRef ?? '');
|
|
418
|
+
if (!declared && !derivedRepo) continue;
|
|
419
|
+
const contact = typeof lib?.publisher?.contact === 'string' ? lib.publisher.contact : undefined;
|
|
420
|
+
owners.push({ lib, declared, derivedRepo, contact });
|
|
421
|
+
}
|
|
422
|
+
if (owners.length === 0) return { kind: 'none' };
|
|
423
|
+
if (owners.length === 1) {
|
|
424
|
+
const [o] = owners;
|
|
425
|
+
if (o.declared) {
|
|
426
|
+
const declaredVisibility = o.lib.issuesVisibility === 'private' ? 'private'
|
|
427
|
+
: o.lib.issuesVisibility === 'public' ? 'public'
|
|
428
|
+
: 'public';
|
|
429
|
+
return {
|
|
430
|
+
kind: 'single',
|
|
431
|
+
destination: {
|
|
432
|
+
repo: o.declared,
|
|
433
|
+
visibility: declaredVisibility,
|
|
434
|
+
kind: 'library',
|
|
435
|
+
derived: false,
|
|
436
|
+
source: 'library-slug-inference',
|
|
437
|
+
...(o.contact ? { publisherContact: o.contact } : {}),
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
kind: 'single',
|
|
443
|
+
destination: {
|
|
444
|
+
repo: o.derivedRepo,
|
|
445
|
+
visibility: 'public',
|
|
446
|
+
kind: 'library',
|
|
447
|
+
derived: true,
|
|
448
|
+
source: 'library-slug-inference',
|
|
449
|
+
...(o.contact ? { publisherContact: o.contact } : {}),
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
const candidates = owners.map((o) => o.lib.libraryPrefix).sort();
|
|
454
|
+
return { kind: 'ambiguous', candidates };
|
|
455
|
+
}
|
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,
|
package/src/feedback/redact.js
CHANGED
|
@@ -32,6 +32,26 @@ const BUNDLED_ALLOWLIST = JSON.parse(
|
|
|
32
32
|
/** Body size cap (bytes). Mirrors src/cli/feedback.js:BODY_CAP_BYTES. */
|
|
33
33
|
export const BODY_CAP_BYTES = 8 * 1024;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Common-noun stopwords the operator-identity substitution never folds
|
|
37
|
+
* (AC-15601-5 / design amendment R5c). The rcf-lite ecosystem uses
|
|
38
|
+
* `agent` as a canonical noun (RULE 17, docs paths such as
|
|
39
|
+
* `how-the-agent-files-feedback`); if the operator's identity name
|
|
40
|
+
* happens to contain one of these words, the token loop skips it so
|
|
41
|
+
* the docs URL and prose like `when the agent stops` survive. Real
|
|
42
|
+
* given names in the identity still fold. The list is intentionally
|
|
43
|
+
* conservative: anything a normal profile.md ## Name line might carry
|
|
44
|
+
* as a common English or engineering noun.
|
|
45
|
+
*/
|
|
46
|
+
export const RESERVED_IDENTITY_TOKENS = Object.freeze(new Set([
|
|
47
|
+
'agent', 'agents', 'user', 'users', 'operator', 'operators',
|
|
48
|
+
'admin', 'admins', 'root', 'client', 'clients', 'server', 'servers',
|
|
49
|
+
'service', 'services', 'system', 'systems', 'name', 'main', 'test',
|
|
50
|
+
'code', 'api', 'apis', 'anon', 'anonymous', 'human', 'humans',
|
|
51
|
+
'dev', 'ops', 'sre', 'docs', 'developer', 'developers', 'engineer',
|
|
52
|
+
'engineers', 'team', 'teams',
|
|
53
|
+
]));
|
|
54
|
+
|
|
35
55
|
/**
|
|
36
56
|
* Open secret-key vocabulary (design amendment R3a). A key/value pair
|
|
37
57
|
* is treated as a secret when the key, right-bounded at a word edge so
|
|
@@ -389,9 +409,20 @@ export function redact(input, context = {}) {
|
|
|
389
409
|
if (context.operatorName) {
|
|
390
410
|
const opName = context.operatorName.trim();
|
|
391
411
|
if (opName.length > 0) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
412
|
+
// AC-15601-5 / design amendment R5c: never fold a common noun
|
|
413
|
+
// that happens to appear in the identity string. The rcf-lite
|
|
414
|
+
// ecosystem uses 'agent' as a canonical noun (RULE 17, docs
|
|
415
|
+
// paths); folding it as operator-identity mangles the docs URL
|
|
416
|
+
// `how-the-agent-files-feedback` and prose like `when the
|
|
417
|
+
// agent stops`. Substitution still fires for a real given name
|
|
418
|
+
// (Alice, Smith) even when the full identity string carries a
|
|
419
|
+
// stopword. When the identity contains ONLY stopwords (a
|
|
420
|
+
// placeholder-in-disguise), skip identity folds entirely.
|
|
421
|
+
const rawTokens = opName.split(/\s+/);
|
|
422
|
+
const foldable = rawTokens.filter((t) => t.length > 3 && !RESERVED_IDENTITY_TOKENS.has(t.toLowerCase()));
|
|
423
|
+
if (foldable.length > 0) {
|
|
424
|
+
text = replaceAndLog(text, escapedLiteral(opName), '<operator>', 'operator-identity', ledger);
|
|
425
|
+
for (const tok of foldable) {
|
|
395
426
|
text = replaceAndLog(text, `\\b${escapeRe(tok)}\\b`, '<operator>', 'operator-identity', ledger);
|
|
396
427
|
}
|
|
397
428
|
}
|
|
@@ -399,7 +430,7 @@ export function redact(input, context = {}) {
|
|
|
399
430
|
}
|
|
400
431
|
if (context.projectName) {
|
|
401
432
|
const pn = context.projectName.trim();
|
|
402
|
-
if (pn.length > 0) {
|
|
433
|
+
if (pn.length > 0 && !RESERVED_IDENTITY_TOKENS.has(pn.toLowerCase())) {
|
|
403
434
|
text = replaceAndLog(text, `\\b${escapeRe(pn)}\\b`, '<project>', 'operator-identity', ledger);
|
|
404
435
|
}
|
|
405
436
|
}
|
|
@@ -417,6 +448,18 @@ export function redact(input, context = {}) {
|
|
|
417
448
|
...(Array.isArray(context.allowHosts) ? context.allowHosts : []),
|
|
418
449
|
].map((h) => h.toLowerCase()));
|
|
419
450
|
|
|
451
|
+
// Rule 4 narrowing (0.28.2, issue #233, design amendment R5d): a
|
|
452
|
+
// token whose trailing dot-label is a known file extension is NOT
|
|
453
|
+
// a hostname. This kills the `Backstory-product-brief.md` and
|
|
454
|
+
// `intake.json` false positives Barry hit in the WSD round-trip.
|
|
455
|
+
// The bundled shortlist lives in redact-allowlist.json under the
|
|
456
|
+
// new `extensions` field; the operator overlay merges via
|
|
457
|
+
// context.allowExtensions.
|
|
458
|
+
const allowExtensions = new Set([
|
|
459
|
+
...(Array.isArray(BUNDLED_ALLOWLIST.extensions) ? BUNDLED_ALLOWLIST.extensions : []),
|
|
460
|
+
...(Array.isArray(context.allowExtensions) ? context.allowExtensions : []),
|
|
461
|
+
].map((e) => String(e).toLowerCase()));
|
|
462
|
+
|
|
420
463
|
// Rule 9: URL-embedded credentials (design amendment R3b, added
|
|
421
464
|
// round 4 for F-P0-B). Runs BEFORE the rule-4 hostname pass so a
|
|
422
465
|
// Slack/Discord webhook URL whose path IS the credential has that
|
|
@@ -439,9 +482,26 @@ export function redact(input, context = {}) {
|
|
|
439
482
|
// preceded by @ (already handled by rule 3), a slash or backslash
|
|
440
483
|
// (path fragment; the winAbsRe substitution leaves `<path>\file.ext`
|
|
441
484
|
// which must not fold to `<host>`), a digit-dot (IP handled by
|
|
442
|
-
// rule 5b), or another word character (a longer host
|
|
443
|
-
|
|
485
|
+
// rule 5b), a hyphen or another word character (a longer host
|
|
486
|
+
// suffix or a filename-internal segment). Adding `-` to the
|
|
487
|
+
// lookbehind is the 0.28.2 (issue #233) fix that stops
|
|
488
|
+
// `Backstory-product-brief.md` folding on its internal
|
|
489
|
+
// `product-brief.md` slice; the extension-refusal step below is the
|
|
490
|
+
// second layer.
|
|
491
|
+
const bareHostRe = /(?<![@/\\\w.\-])(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?![\w.-])/g;
|
|
444
492
|
text = replaceRegex(text, bareHostRe, (host) => {
|
|
493
|
+
// 0.28.2 (issue #233, design amendment R5d): a candidate whose
|
|
494
|
+
// trailing dot-label is a known file extension is not a hostname;
|
|
495
|
+
// leave it intact and record no ledger row (we are not redacting,
|
|
496
|
+
// we are correctly leaving alone). This narrowing catches shapes
|
|
497
|
+
// like `report.log`, `intake.json` and `Backstory-example.md`
|
|
498
|
+
// that pass the lookbehind guard because they start after a
|
|
499
|
+
// whitespace or line boundary.
|
|
500
|
+
const lastDot = host.lastIndexOf('.');
|
|
501
|
+
if (lastDot > 0) {
|
|
502
|
+
const trailingLabel = host.slice(lastDot + 1).toLowerCase();
|
|
503
|
+
if (allowExtensions.has(trailingLabel)) return host;
|
|
504
|
+
}
|
|
445
505
|
if (isHostAllowed(host, allowHosts)) return host;
|
|
446
506
|
return '<host>';
|
|
447
507
|
}, 'hostname', ledger);
|
|
@@ -846,6 +906,15 @@ function looksHighEntropyPath(value) {
|
|
|
846
906
|
if (typeof value !== 'string') return false;
|
|
847
907
|
if (value.length < 16) return false;
|
|
848
908
|
if (!/^[A-Za-z0-9._~+/=-]+$/.test(value)) return false;
|
|
909
|
+
// AC-15601-5 canon-phrase guard: a lowercase kebab-case segment with
|
|
910
|
+
// three or more hyphen-separated word parts is a documentation slug,
|
|
911
|
+
// not a credential. The rcf-lite docs URL
|
|
912
|
+
// `how-the-agent-files-feedback` is 28 chars, 2 classes (lowercase
|
|
913
|
+
// and separator), and would otherwise trip the 16-char, 2-class rule
|
|
914
|
+
// and produce `<url-credential>` inside the docs URL. Real credential
|
|
915
|
+
// segments in URL paths (webhook tokens, session ids, presigned
|
|
916
|
+
// credentials) are mixed-case-alphanumeric, not kebab-case English.
|
|
917
|
+
if (/^[a-z]+(-[a-z]+){2,}$/.test(value)) return false;
|
|
849
918
|
let classes = 0;
|
|
850
919
|
if (/[A-Z]/.test(value)) classes += 1;
|
|
851
920
|
if (/[a-z]/.test(value)) classes += 1;
|
package/src/feedback/render.js
CHANGED
|
@@ -110,27 +110,39 @@ function truncateToBytes(text, maxBytes) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
* Render the
|
|
114
|
-
*
|
|
115
|
-
*
|
|
113
|
+
* Render the fold comment body used on the dedupe path (slice 4).
|
|
114
|
+
* 0.28.2 (issue #234, Barry ruling 2026-09-21): the retired
|
|
115
|
+
* "+1 from another reporter." payload is gone; the comment always
|
|
116
|
+
* carries the full report so a human can judge whether the fold is
|
|
117
|
+
* correct. The header line names the fold decision. The visible
|
|
118
|
+
* fingerprint line at the bottom stays so a later submit can still
|
|
119
|
+
* match on it.
|
|
116
120
|
*
|
|
117
121
|
* @param {object} entry
|
|
118
|
-
* @param {{ body: string, ledger: Array<{ rule: string, count: number }> }} redacted
|
|
119
|
-
* @param {{ fingerprint: string,
|
|
122
|
+
* @param {{ title: string, body: string, evidence: Array<{ kind: string, value: string }>, ledger: Array<{ rule: string, count: number }> }} redacted
|
|
123
|
+
* @param {{ fingerprint: string, matchedTitle?: string }} meta
|
|
120
124
|
* @returns {RenderedComment}
|
|
121
125
|
*/
|
|
122
126
|
export function renderComment(entry, redacted, meta) {
|
|
123
127
|
const lines = [];
|
|
124
|
-
lines.push('
|
|
128
|
+
lines.push('Apparent duplicate of the issue subject; posting the full report so a human can judge.');
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push(`## Report: ${renderTitle(entry, redacted.title)}`);
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push(redacted.body);
|
|
125
133
|
lines.push('');
|
|
126
|
-
lines.push(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
lines.push('---');
|
|
135
|
+
lines.push('**Evidence**');
|
|
136
|
+
if (Array.isArray(redacted.evidence) && redacted.evidence.length > 0) {
|
|
137
|
+
for (const ev of redacted.evidence) {
|
|
138
|
+
lines.push(`- \`${ev.value}\` (${ev.kind})`);
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
lines.push('- (none)');
|
|
132
142
|
}
|
|
133
143
|
lines.push('');
|
|
144
|
+
lines.push(renderEnvironmentTable(entry));
|
|
145
|
+
lines.push('');
|
|
134
146
|
lines.push(`rcf-feedback-fingerprint: ${meta.fingerprint}`);
|
|
135
147
|
return { body: `${lines.join('\n')}\n` };
|
|
136
148
|
}
|
|
@@ -241,10 +253,20 @@ function renderEnvironmentTable(entry) {
|
|
|
241
253
|
t.libraryPrefix ? `(library ${t.libraryPrefix}${t.libraryRef ? ` ${t.libraryRef}` : ''}${t.resolvedSha ? `, git ${String(t.resolvedSha).slice(0, 7)}` : t.tarballSha256 ? `, tarball ${String(t.tarballSha256).slice(0, 7)}` : ''})` : null,
|
|
242
254
|
].filter(Boolean).join(' ')
|
|
243
255
|
: 'n/a';
|
|
256
|
+
// 0.28.2 (issue #234): a "target" row goes on the environment
|
|
257
|
+
// table for core entries too, so submit's fingerprint-verification
|
|
258
|
+
// step can compare the entry's verb path against a candidate's
|
|
259
|
+
// recorded target and refuse to fold across differing targets. For
|
|
260
|
+
// blueprint entries the blueprint cell already carries the target
|
|
261
|
+
// identity, so the target row is left as "-" to avoid duplication.
|
|
262
|
+
const targetCell = kind === 'blueprint'
|
|
263
|
+
? '-'
|
|
264
|
+
: (typeof t.ref === 'string' && t.ref.length > 0 ? t.ref : '-');
|
|
244
265
|
const rows = [
|
|
245
266
|
['field', 'value'],
|
|
246
267
|
['kind', kind],
|
|
247
268
|
['blueprint', blueprintCell],
|
|
269
|
+
['target', targetCell],
|
|
248
270
|
['anchor', entry?.anchor ?? '-'],
|
|
249
271
|
['symptom class', entry?.symptomClass ?? '-'],
|
|
250
272
|
['severity', entry?.severity ?? '-'],
|
|
@@ -9,7 +9,7 @@ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
|
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
|
|
11
11
|
import { rcfError } from '#core/errors';
|
|
12
|
-
import { validateDocument } from '#core/store';
|
|
12
|
+
import { buildNextManifest, validateDocument } from '#core/store';
|
|
13
13
|
|
|
14
14
|
const ID_RE = /^ic-\d{4}-\d{2}-\d{2}-(\d{3})$/;
|
|
15
15
|
|
|
@@ -73,8 +73,11 @@ export function composeIntakeRecord({ manifest, fidelity, artefacts, validationF
|
|
|
73
73
|
* @returns {Promise<{ record: object } | import('#core/errors').RcfError>}
|
|
74
74
|
*/
|
|
75
75
|
export async function writeIntakeRecord({ projectRoot, tree, record }) {
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// 0.28.2 (Codex review follow-up on #230): the writer synthesises
|
|
77
|
+
// the next manifest via the same `buildNextManifest` helper the
|
|
78
|
+
// dry-run branch uses, so preview and write share one composition
|
|
79
|
+
// path. `validateDocument` then runs on the identical shape.
|
|
80
|
+
const nextManifest = buildNextManifest({ manifest: tree?.manifest ?? {}, record, verb: 'intake' });
|
|
78
81
|
const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: 'rcf/manifest.json' });
|
|
79
82
|
if (validation) return validation;
|
|
80
83
|
const abs = join(projectRoot, 'rcf', 'manifest.json');
|
|
@@ -82,23 +82,60 @@ export async function runIntakePhases({ projectRoot, artefactPaths, kindHint = n
|
|
|
82
82
|
|
|
83
83
|
// Fold non-interactive input: extra findings, operator responses,
|
|
84
84
|
// and elicitationScope hints all land here.
|
|
85
|
+
//
|
|
86
|
+
// 0.28.2 (issue #229): findings are material-scoped, not
|
|
87
|
+
// artefact-scoped. Two artefacts that both trip the same branch
|
|
88
|
+
// ("web UI but no sign-in surface named") emit the same finding
|
|
89
|
+
// twice with byte-identical detail text, and downstream that
|
|
90
|
+
// inflated `findings=2` on a record with one finding. Dedupe by
|
|
91
|
+
// `${kind}::${detail}` (first-wins) before timestamping so the
|
|
92
|
+
// material carries one finding, not N.
|
|
93
|
+
//
|
|
94
|
+
// Same commit fixes the second half of #229: operator-supplied
|
|
95
|
+
// `validationFindings` on --input were dropped unless the kind was
|
|
96
|
+
// `otherDeclared`. Fold every input finding into the same map so
|
|
97
|
+
// an operator-authored `impliedButNotStated`, `contradiction` or
|
|
98
|
+
// `missingLoadBearingConstraint` survives the round-trip and
|
|
99
|
+
// coalesces with a scanned duplicate (the operator's response is
|
|
100
|
+
// preserved when it does).
|
|
85
101
|
const inputFindings = Array.isArray(input?.validationFindings) ? input.validationFindings : [];
|
|
102
|
+
|
|
103
|
+
/** @type {Map<string, object>} */
|
|
104
|
+
const foldedFindings = new Map();
|
|
105
|
+
const foldKey = (f) => `${String(f.kind ?? '')}::${String(f.detail ?? '')}`;
|
|
106
|
+
for (const f of allFindings) {
|
|
107
|
+
const k = foldKey(f);
|
|
108
|
+
if (!foldedFindings.has(k)) foldedFindings.set(k, f);
|
|
109
|
+
}
|
|
110
|
+
for (const f of inputFindings) {
|
|
111
|
+
if (typeof f?.kind !== 'string' || typeof f?.detail !== 'string') continue;
|
|
112
|
+
const k = foldKey(f);
|
|
113
|
+
if (!foldedFindings.has(k)) foldedFindings.set(k, f);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 0.28.2 (Codex review follow-up on #229): the response lookup is
|
|
117
|
+
// keyed by (kind, detail) too, so resolving one finding never
|
|
118
|
+
// silently marks a different-kind finding with the same detail as
|
|
119
|
+
// resolved. Previously the lookup was `detail` only and an
|
|
120
|
+
// answered `impliedButNotStated` leaked its response onto an
|
|
121
|
+
// unanswered `contradiction` with the same detail string.
|
|
86
122
|
const inputResponses = new Map();
|
|
87
123
|
for (const f of inputFindings) {
|
|
88
|
-
if (typeof f?.
|
|
89
|
-
|
|
124
|
+
if (typeof f?.kind === 'string'
|
|
125
|
+
&& typeof f?.detail === 'string'
|
|
126
|
+
&& typeof f?.operatorResponse === 'string') {
|
|
127
|
+
inputResponses.set(foldKey(f), f.operatorResponse);
|
|
90
128
|
}
|
|
91
|
-
if (f?.kind === 'otherDeclared') allFindings.push(f);
|
|
92
129
|
}
|
|
93
130
|
|
|
94
131
|
const isoNow = now.toISOString();
|
|
95
|
-
const findingsWithTimestamps =
|
|
96
|
-
const entry = { kind: f.kind, detail: f.detail, raisedAt: isoNow };
|
|
132
|
+
const findingsWithTimestamps = Array.from(foldedFindings.values()).map((f) => {
|
|
133
|
+
const entry = { kind: f.kind, detail: f.detail, raisedAt: f.raisedAt ?? isoNow };
|
|
97
134
|
if (f.kindDescription) entry.kindDescription = f.kindDescription;
|
|
98
|
-
const response = inputResponses.get(f
|
|
135
|
+
const response = f.operatorResponse ?? inputResponses.get(foldKey(f)) ?? input?.operatorResponse;
|
|
99
136
|
if (typeof response === 'string' && response.length > 0) {
|
|
100
137
|
entry.operatorResponse = response;
|
|
101
|
-
entry.resolvedAt = isoNow;
|
|
138
|
+
entry.resolvedAt = f.resolvedAt ?? isoNow;
|
|
102
139
|
}
|
|
103
140
|
return entry;
|
|
104
141
|
});
|
|
@@ -19,7 +19,7 @@ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
|
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
20
|
|
|
21
21
|
import { rcfError } from '#core/errors';
|
|
22
|
-
import { validateDocument } from '#core/store';
|
|
22
|
+
import { buildNextManifest, validateDocument } from '#core/store';
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* @typedef {object} PreflightServiceRuling
|
|
@@ -210,16 +210,16 @@ export function composeOptOutRecord({
|
|
|
210
210
|
export async function writePreflightRecord({
|
|
211
211
|
projectRoot, tree, record, optOuts = [], options = {},
|
|
212
212
|
}) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
nextManifest
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
213
|
+
// 0.28.2 (Codex review follow-up on #232): compose the next
|
|
214
|
+
// manifest via the shared `buildNextManifest` helper so the write
|
|
215
|
+
// and dry-run branches produce byte-identical shapes; validation
|
|
216
|
+
// then runs on the same result.
|
|
217
|
+
const nextManifest = buildNextManifest({
|
|
218
|
+
manifest: tree.manifest ?? {},
|
|
219
|
+
record,
|
|
220
|
+
verb: 'preflight',
|
|
221
|
+
extraRecords: optOuts,
|
|
222
|
+
});
|
|
223
223
|
|
|
224
224
|
const relPath = 'rcf/manifest.json';
|
|
225
225
|
const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: relPath });
|
|
@@ -87,10 +87,20 @@ export async function applySweepDecisions({ projectRoot, tree, decisions, now =
|
|
|
87
87
|
const usDoc = tree.byId.get(usId);
|
|
88
88
|
if (!usDoc) continue;
|
|
89
89
|
|
|
90
|
+
// 0.28.2 (issue #231): a per-US allocator seeded ONCE from the
|
|
91
|
+
// current maxN on `usDoc.acceptanceCriteria`, then handed to each
|
|
92
|
+
// `composeBaselineAc` for the run. `nextAcId` was stateless
|
|
93
|
+
// across iterations, so every candidate accepted for one US
|
|
94
|
+
// received the identical `AC-<usNum>-<maxN+1>` and the writer's
|
|
95
|
+
// post-write validation refused the tree with a duplicate-id
|
|
96
|
+
// breakage. Ordinal allocation is monotone within a batch and
|
|
97
|
+
// across batches (a later sweep on the same US resumes from the
|
|
98
|
+
// new maxN in the tree).
|
|
99
|
+
const allocator = makeAcIdAllocator(usDoc);
|
|
90
100
|
const acsToAppend = [];
|
|
91
101
|
for (const d of list) {
|
|
92
102
|
if (d.action === 'accept') {
|
|
93
|
-
const ac = composeBaselineAc({ usDoc, candidate: d.candidate, now });
|
|
103
|
+
const ac = composeBaselineAc({ usDoc, candidate: d.candidate, now, idOverride: allocator() });
|
|
94
104
|
acsToAppend.push(ac);
|
|
95
105
|
writtenAcIds.push(ac.id);
|
|
96
106
|
accepted += 1;
|
|
@@ -172,9 +182,9 @@ export async function applyPendingBaselinesForUs({ tree, usId }) {
|
|
|
172
182
|
* @param {Date} args.now
|
|
173
183
|
* @returns {object}
|
|
174
184
|
*/
|
|
175
|
-
export function composeBaselineAc({ usDoc, candidate, now }) {
|
|
185
|
+
export function composeBaselineAc({ usDoc, candidate, now, idOverride = null }) {
|
|
176
186
|
const isoNow = now.toISOString();
|
|
177
|
-
const id = nextAcId(usDoc);
|
|
187
|
+
const id = typeof idOverride === 'string' && idOverride.length > 0 ? idOverride : nextAcId(usDoc);
|
|
178
188
|
const canonicalText = candidate.canonicalText;
|
|
179
189
|
const gwt = candidate.given || candidate.when || candidate.then
|
|
180
190
|
? { given: candidate.given ?? '', when: candidate.when ?? '', then: candidate.then ?? '' }
|
|
@@ -200,8 +210,22 @@ export function composeBaselineAc({ usDoc, candidate, now }) {
|
|
|
200
210
|
const AC_HIER_ID_RE = /^AC-(\d{3,})-(\d+)$/;
|
|
201
211
|
|
|
202
212
|
function nextAcId(usDoc) {
|
|
213
|
+
const alloc = makeAcIdAllocator(usDoc);
|
|
214
|
+
return alloc();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 0.28.2 (issue #231): return a monotone AC-id allocator seeded once
|
|
219
|
+
* from `usDoc.acceptanceCriteria`. Every subsequent call returns
|
|
220
|
+
* `AC-<usNum>-<n++>`; a caller that accepts N baseline candidates for
|
|
221
|
+
* one US in one sweep now gets N distinct ids, and the writer's
|
|
222
|
+
* duplicate-id refusal on `updateDocument` no longer bites.
|
|
223
|
+
*
|
|
224
|
+
* @param {object} usDoc
|
|
225
|
+
* @returns {() => string}
|
|
226
|
+
*/
|
|
227
|
+
export function makeAcIdAllocator(usDoc) {
|
|
203
228
|
const acs = Array.isArray(usDoc.acceptanceCriteria) ? usDoc.acceptanceCriteria : [];
|
|
204
|
-
// Prefer hierarchical form matching the US id, e.g. US-201 → AC-201-1.
|
|
205
229
|
const usNum = (usDoc.usId ?? '').match(/^US-(\d+)$/)?.[1];
|
|
206
230
|
let maxN = 0;
|
|
207
231
|
for (const ac of acs) {
|
|
@@ -212,7 +236,12 @@ function nextAcId(usDoc) {
|
|
|
212
236
|
const n = Number.parseInt(tail, 10);
|
|
213
237
|
if (Number.isFinite(n) && n > maxN) maxN = n;
|
|
214
238
|
}
|
|
215
|
-
|
|
239
|
+
let counter = maxN;
|
|
240
|
+
return () => {
|
|
241
|
+
counter += 1;
|
|
242
|
+
if (!usNum) return `AC-${(Math.floor(Math.random() * 999)).toString().padStart(3, '0')}-${counter}`;
|
|
243
|
+
return `AC-${usNum}-${counter}`;
|
|
244
|
+
};
|
|
216
245
|
}
|
|
217
246
|
|
|
218
247
|
function extractGwt(text) {
|
|
@@ -18,7 +18,7 @@ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
|
|
|
18
18
|
import { dirname, join } from 'node:path';
|
|
19
19
|
|
|
20
20
|
import { rcfError } from '#core/errors';
|
|
21
|
-
import { validateDocument } from '#core/store';
|
|
21
|
+
import { buildNextManifest, validateDocument } from '#core/store';
|
|
22
22
|
|
|
23
23
|
import { composeDefaults, deepGet, deepSet } from './defaults.js';
|
|
24
24
|
import { CATALOGUE_V1 } from '../preflight/design-shapes.js';
|
|
@@ -150,15 +150,16 @@ export function preflightSeamOverrides(manifest, catalogue = CATALOGUE_V1) {
|
|
|
150
150
|
export async function writeUiBaselineRecord({
|
|
151
151
|
projectRoot, tree, record, options = {},
|
|
152
152
|
}) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
153
|
+
// 0.28.2 (Codex review follow-up on the shared helper): compose via
|
|
154
|
+
// the shared `buildNextManifest` helper so write and dry-run
|
|
155
|
+
// produce the same shape. `options.reset` still drives the
|
|
156
|
+
// history-move branch inside the helper.
|
|
157
|
+
const nextManifest = buildNextManifest({
|
|
158
|
+
manifest: tree.manifest ?? {},
|
|
159
|
+
record,
|
|
160
|
+
verb: 'uiBaselineInit',
|
|
161
|
+
options: { reset: Boolean(options.reset) },
|
|
162
|
+
});
|
|
162
163
|
|
|
163
164
|
const relPath = 'rcf/manifest.json';
|
|
164
165
|
const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: relPath });
|