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.
@@ -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 suffix).
474
- const bareHostRe = /(?<![@/\\\w.])(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?![\w.-])/g;
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);
@@ -110,27 +110,39 @@ function truncateToBytes(text, maxBytes) {
110
110
  }
111
111
 
112
112
  /**
113
- * Render the `+1 from another reporter` comment body used on the
114
- * dedupe path (slice 4). Kept in this module because triage sees one
115
- * shape from `create` and `comment`.
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, includeBody?: boolean }} meta
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('+1 from another reporter.');
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(...renderEnvironmentTable(entry).split('\n'));
127
- if (meta.includeBody) {
128
- lines.push('');
129
- lines.push('Report body:');
130
- lines.push('');
131
- lines.push(redacted.body);
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
- const manifest = tree?.manifest ?? {};
77
- const nextManifest = { ...manifest, intakeClassification: record };
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?.detail === 'string' && typeof f?.operatorResponse === 'string') {
89
- inputResponses.set(f.detail, f.operatorResponse);
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 = allFindings.map((f) => {
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.detail) ?? input?.operatorResponse;
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
- const manifest = tree.manifest ?? {};
214
- const nextManifest = { ...manifest };
215
-
216
- const existingPfc = Array.isArray(nextManifest.preFlightConfig) ? nextManifest.preFlightConfig : [];
217
- nextManifest.preFlightConfig = [...existingPfc, record];
218
-
219
- if (optOuts.length > 0) {
220
- const existingOptOuts = Array.isArray(nextManifest.baselineAcOptOuts) ? nextManifest.baselineAcOptOuts : [];
221
- nextManifest.baselineAcOptOuts = [...existingOptOuts, ...optOuts];
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
- return usNum ? `AC-${usNum}-${maxN + 1}` : `AC-${(Math.floor(Math.random() * 999)).toString().padStart(3, '0')}-1`;
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
- const manifest = tree.manifest ?? {};
154
- const nextManifest = { ...manifest };
155
- const priorBaseline = nextManifest.uiBaseline ?? null;
156
-
157
- if (priorBaseline && options.reset) {
158
- const history = Array.isArray(nextManifest.uiBaselineHistory) ? nextManifest.uiBaselineHistory : [];
159
- nextManifest.uiBaselineHistory = [...history, priorBaseline];
160
- }
161
- nextManifest.uiBaseline = record;
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
- const rows = Object.entries(section).map(([k, v]) => {
67
- if (typeof v === 'string') return `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v)}</dd>`;
68
- if (Array.isArray(v)) return `<dt>${escapeHtml(k)}</dt><dd>${v.map((x) => `<code>${escapeHtml(typeof x === 'object' ? JSON.stringify(x) : x)}</code>`).join(', ')}</dd>`;
69
- return `<dt>${escapeHtml(k)}</dt><dd><pre>${escapeHtml(JSON.stringify(v, null, 2))}</pre></dd>`;
70
- }).join('\n');
71
- out.push(`<section class="field-list"><h4>${escapeHtml(label)}</h4><dl>${rows}</dl></section>`);
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
+ }
@@ -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)