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/feedback/redact.js
CHANGED
|
@@ -448,6 +448,18 @@ export function redact(input, context = {}) {
|
|
|
448
448
|
...(Array.isArray(context.allowHosts) ? context.allowHosts : []),
|
|
449
449
|
].map((h) => h.toLowerCase()));
|
|
450
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
|
+
|
|
451
463
|
// Rule 9: URL-embedded credentials (design amendment R3b, added
|
|
452
464
|
// round 4 for F-P0-B). Runs BEFORE the rule-4 hostname pass so a
|
|
453
465
|
// Slack/Discord webhook URL whose path IS the credential has that
|
|
@@ -470,9 +482,26 @@ export function redact(input, context = {}) {
|
|
|
470
482
|
// preceded by @ (already handled by rule 3), a slash or backslash
|
|
471
483
|
// (path fragment; the winAbsRe substitution leaves `<path>\file.ext`
|
|
472
484
|
// which must not fold to `<host>`), a digit-dot (IP handled by
|
|
473
|
-
// rule 5b), or another word character (a longer host
|
|
474
|
-
|
|
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;
|
|
475
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
|
+
}
|
|
476
505
|
if (isHostAllowed(host, allowHosts)) return host;
|
|
477
506
|
return '<host>';
|
|
478
507
|
}, 'hostname', ledger);
|
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 });
|
|
@@ -77,6 +77,43 @@ export function fieldList(label, items) {
|
|
|
77
77
|
return `<section class="field-list"><h4>${escapeHtml(label)}</h4><ul>${li}</ul></section>`;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* 0.28.2 (issue #235): render an array-of-object field as a HTML
|
|
82
|
+
* table with the schema-known columns. Empty columns are suppressed so
|
|
83
|
+
* schema-optional fields do not surface as blank columns. Every cell
|
|
84
|
+
* is escaped. Used by the TAD renderer for the three structured
|
|
85
|
+
* fields the TAD schema names (dataStores, coreEntities,
|
|
86
|
+
* externalSystems) so a review-surface reader sees a legible table
|
|
87
|
+
* instead of a single-line `JSON.stringify` dump.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} label section heading
|
|
90
|
+
* @param {object[]} rows array of row objects (dropped when empty)
|
|
91
|
+
* @param {Array<{ key: string, label?: string }>} columns
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function fieldObjectTable(label, rows, columns) {
|
|
95
|
+
if (!Array.isArray(rows) || rows.length === 0) return '';
|
|
96
|
+
if (!Array.isArray(columns) || columns.length === 0) return '';
|
|
97
|
+
// Drop columns that are empty across every row so a schema-optional
|
|
98
|
+
// field (`purpose`, `description`, `protocol`) does not surface as
|
|
99
|
+
// a blank column.
|
|
100
|
+
const usedColumns = columns.filter((col) => rows.some((r) => {
|
|
101
|
+
const v = r?.[col.key];
|
|
102
|
+
return v !== undefined && v !== null && String(v).length > 0;
|
|
103
|
+
}));
|
|
104
|
+
if (usedColumns.length === 0) return '';
|
|
105
|
+
const thead = usedColumns.map((c) => `<th>${escapeHtml(c.label ?? c.key)}</th>`).join('');
|
|
106
|
+
const tbody = rows.map((r) => {
|
|
107
|
+
const cells = usedColumns.map((c) => {
|
|
108
|
+
const v = r?.[c.key];
|
|
109
|
+
const text = v === undefined || v === null ? '' : String(v);
|
|
110
|
+
return `<td>${escapeHtml(text)}</td>`;
|
|
111
|
+
}).join('');
|
|
112
|
+
return `<tr>${cells}</tr>`;
|
|
113
|
+
}).join('');
|
|
114
|
+
return `<section class="field-list"><h4>${escapeHtml(label)}</h4><table class="field-table"><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table></section>`;
|
|
115
|
+
}
|
|
116
|
+
|
|
80
117
|
/**
|
|
81
118
|
* Render the "Show raw JSON" disclosure block for a document.
|
|
82
119
|
*
|
|
@@ -9,10 +9,34 @@ import {
|
|
|
9
9
|
docLinkList,
|
|
10
10
|
escapeHtml,
|
|
11
11
|
fieldList,
|
|
12
|
+
fieldObjectTable,
|
|
12
13
|
fieldPara,
|
|
13
14
|
rawJsonDisclosure,
|
|
14
15
|
} from './helpers.js';
|
|
15
16
|
|
|
17
|
+
// 0.28.2 (issue #235): structured fields the TAD schema explicitly
|
|
18
|
+
// types as arrays of objects. Each entry maps a field-key to the
|
|
19
|
+
// schema-documented column layout so `renderOptionalSections` can
|
|
20
|
+
// pick a table renderer over the raw-JSON dump the pre-fix code
|
|
21
|
+
// emitted. Columns follow @stravica-ai/rcf-schemas@0.6.3
|
|
22
|
+
// schemas/tad.schema.json.
|
|
23
|
+
const TAD_STRUCTURED_FIELDS = {
|
|
24
|
+
dataStores: [
|
|
25
|
+
{ key: 'name', label: 'Name' },
|
|
26
|
+
{ key: 'kind', label: 'Kind' },
|
|
27
|
+
{ key: 'purpose', label: 'Purpose' },
|
|
28
|
+
],
|
|
29
|
+
coreEntities: [
|
|
30
|
+
{ key: 'name', label: 'Name' },
|
|
31
|
+
{ key: 'description', label: 'Description' },
|
|
32
|
+
],
|
|
33
|
+
externalSystems: [
|
|
34
|
+
{ key: 'name', label: 'Name' },
|
|
35
|
+
{ key: 'purpose', label: 'Purpose' },
|
|
36
|
+
{ key: 'protocol', label: 'Protocol' },
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
16
40
|
/**
|
|
17
41
|
* @param {object} tad
|
|
18
42
|
* @param {object} ctx
|
|
@@ -63,12 +87,49 @@ function renderOptionalSections(tad) {
|
|
|
63
87
|
for (const [key, label] of Object.entries(named)) {
|
|
64
88
|
const section = tad[key];
|
|
65
89
|
if (!section || typeof section !== 'object') continue;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
// 0.28.2 (issue #235): pull the schema-known structured fields
|
|
91
|
+
// (dataStores, coreEntities, externalSystems) out of the generic
|
|
92
|
+
// dl loop and render them as tables. The remaining scalar and
|
|
93
|
+
// array-of-string fields keep their dl treatment.
|
|
94
|
+
const structuredBlocks = [];
|
|
95
|
+
const dlEntries = [];
|
|
96
|
+
for (const [k, v] of Object.entries(section)) {
|
|
97
|
+
if (TAD_STRUCTURED_FIELDS[k] && Array.isArray(v) && v.length > 0 && v.every((x) => x && typeof x === 'object')) {
|
|
98
|
+
const table = fieldObjectTable(prettyStructuredLabel(k), v, TAD_STRUCTURED_FIELDS[k]);
|
|
99
|
+
if (table) structuredBlocks.push(table);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (typeof v === 'string') {
|
|
103
|
+
dlEntries.push(`<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v)}</dd>`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(v)) {
|
|
107
|
+
// Array-of-string keeps the existing inline shape. An
|
|
108
|
+
// unknown array-of-object surface falls through to a nested
|
|
109
|
+
// disclosure so it stays legible even when the schema grows
|
|
110
|
+
// a new field before this renderer knows about it.
|
|
111
|
+
if (v.every((x) => typeof x !== 'object' || x === null)) {
|
|
112
|
+
dlEntries.push(`<dt>${escapeHtml(k)}</dt><dd>${v.map((x) => `<code>${escapeHtml(typeof x === 'object' ? JSON.stringify(x) : x)}</code>`).join(', ')}</dd>`);
|
|
113
|
+
} else {
|
|
114
|
+
const items = v.map((x) => `<li><pre>${escapeHtml(JSON.stringify(x, null, 2))}</pre></li>`).join('');
|
|
115
|
+
dlEntries.push(`<dt>${escapeHtml(k)}</dt><dd><ul>${items}</ul></dd>`);
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
dlEntries.push(`<dt>${escapeHtml(k)}</dt><dd><pre>${escapeHtml(JSON.stringify(v, null, 2))}</pre></dd>`);
|
|
120
|
+
}
|
|
121
|
+
const dl = dlEntries.length > 0 ? `<dl>${dlEntries.join('\n')}</dl>` : '';
|
|
122
|
+
const structured = structuredBlocks.join('\n');
|
|
123
|
+
out.push(`<section class="field-list"><h4>${escapeHtml(label)}</h4>${dl}${structured}</section>`);
|
|
72
124
|
}
|
|
73
125
|
return out.join('\n');
|
|
74
126
|
}
|
|
127
|
+
|
|
128
|
+
function prettyStructuredLabel(key) {
|
|
129
|
+
const map = {
|
|
130
|
+
dataStores: 'Data stores',
|
|
131
|
+
coreEntities: 'Core entities',
|
|
132
|
+
externalSystems: 'External systems',
|
|
133
|
+
};
|
|
134
|
+
return map[key] ?? key;
|
|
135
|
+
}
|
package/src/view/style.css
CHANGED
|
@@ -294,6 +294,11 @@ article.doc h5 { margin: 0.6rem 0 0.3rem; font-size: 0.925rem; font-weight: 600;
|
|
|
294
294
|
.field-list ul li { margin: 0.2rem 0; }
|
|
295
295
|
.field-list dl dt { font-weight: 600; margin-top: 0.5rem; color: var(--colour-heading); }
|
|
296
296
|
.field-list dl dd { margin-left: 0; margin-bottom: 0.35rem; }
|
|
297
|
+
/* 0.28.2 (issue #235): structured TAD fields render as a table. */
|
|
298
|
+
.field-list table.field-table { border-collapse: collapse; width: 100%; margin: 0.35rem 0 0.85rem; font-size: 0.95em; }
|
|
299
|
+
.field-list table.field-table th, .field-list table.field-table td { text-align: left; padding: 0.35rem 0.55rem; border-bottom: 1px solid var(--colour-rule, rgba(0,0,0,0.12)); vertical-align: top; }
|
|
300
|
+
.field-list table.field-table th { font-weight: 600; color: var(--colour-heading); background: var(--colour-tint, rgba(0,0,0,0.03)); }
|
|
301
|
+
.field-list table.field-table tr:last-child td { border-bottom: 0; }
|
|
297
302
|
|
|
298
303
|
/* --------------------------------------------------------------------
|
|
299
304
|
* Acceptance criteria list (inside a US)
|