@transitrix/diagrams 1.8.13 → 1.8.18

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.
Files changed (39) hide show
  1. package/dist/activities/validate.js +9 -1
  2. package/dist/blocks/index.d.ts +5 -4
  3. package/dist/blocks/index.js +3 -2
  4. package/dist/blocks/layout.d.ts +8 -1
  5. package/dist/blocks/layout.js +57 -0
  6. package/dist/blocks/templates/index.d.ts +9 -0
  7. package/dist/blocks/templates/index.js +11 -0
  8. package/dist/blocks/templates/raci.d.ts +9 -0
  9. package/dist/blocks/templates/raci.js +24 -0
  10. package/dist/blocks/types.d.ts +58 -0
  11. package/dist/blocks/validate.d.ts +31 -1
  12. package/dist/blocks/validate.js +127 -0
  13. package/dist/capability-map/validate.js +41 -0
  14. package/dist/fgca/parse-canonical.js +8 -0
  15. package/dist/repo-validate/check-element-hygiene.d.ts +8 -0
  16. package/dist/repo-validate/check-element-hygiene.js +211 -0
  17. package/dist/repo-validate/check-strategy-chain.d.ts +8 -0
  18. package/dist/repo-validate/check-strategy-chain.js +491 -0
  19. package/dist/repo-validate/types.d.ts +14 -1
  20. package/dist/repo-validate/types.js +22 -3
  21. package/dist/repo-validate/validate-repo.d.ts +3 -2
  22. package/dist/repo-validate/validate-repo.js +16 -4
  23. package/dist/webview/render-blocks.d.ts +23 -1
  24. package/dist/webview/render-blocks.js +100 -2
  25. package/package.json +1 -1
  26. package/src/activities/validate.ts +9 -1
  27. package/src/blocks/index.ts +16 -2
  28. package/src/blocks/layout.ts +70 -0
  29. package/src/blocks/templates/index.ts +13 -0
  30. package/src/blocks/templates/raci.ts +28 -0
  31. package/src/blocks/types.ts +67 -0
  32. package/src/blocks/validate.ts +157 -1
  33. package/src/capability-map/validate.ts +40 -0
  34. package/src/fgca/parse-canonical.ts +8 -0
  35. package/src/repo-validate/check-element-hygiene.ts +236 -0
  36. package/src/repo-validate/check-strategy-chain.ts +528 -0
  37. package/src/repo-validate/types.ts +36 -4
  38. package/src/repo-validate/validate-repo.ts +16 -4
  39. package/src/webview/render-blocks.ts +159 -2
@@ -12,10 +12,10 @@
12
12
  * the shared theme CSS embedded in a `<style>` element and a simple optional
13
13
  * title `<text>`.
14
14
  */
15
- import { layoutNestedBlocks } from '../blocks/layout.js';
15
+ import { layoutNestedBlocks, layoutGrid } from '../blocks/layout.js';
16
16
  import { parseNodeSizePreset, resolveBlocksLeafSize } from '../node-size-presets.js';
17
17
  import { generateSvgEmbedCss } from '../theme/index.js';
18
- import { emitCenteredTextSvg, layoutHeaderBlockText, layoutLeafBlockText, } from './entity-text-layout.js';
18
+ import { CHAR_W_PRIMARY, emitCenteredTextSvg, layoutHeaderBlockText, layoutLeafBlockText, maxCharsForInnerWidth, wrapWords, } from './entity-text-layout.js';
19
19
  import { escXml } from './render-util.js';
20
20
  import { ENTITY_NODE_RX } from './notation-style.js';
21
21
  const PAD = 24;
@@ -105,3 +105,101 @@ export function renderBlocksSvg(doc, options = {}) {
105
105
  : '';
106
106
  return renderBlocksLayoutSvg(layout, { title: titleSvg, embedCssTheme: 'transitrix' });
107
107
  }
108
+ /**
109
+ * Matrix subset (08-blocks.md §4a) — first cut: a single-layer rectangular
110
+ * `grid:` document (the RACI-style matrix case) rendered as a plain
111
+ * HTML/CSS-table-like SVG grid. Layers, arbitrary
112
+ * (non-rectangular) cell sets, and nested sub-grids belong to the general
113
+ * layered-grid superset, which is still in design upstream (methodology) and
114
+ * out of scope here — see 08-blocks.md §4a / §8.
115
+ */
116
+ const GRID_CELL_PAD_X = 8;
117
+ const GRID_LINE_HEIGHT = 15;
118
+ const GRID_EMBED_CSS = `
119
+ .blocks-grid-border { fill: none; stroke: var(--ts-border, #cbd5e1); }`;
120
+ function centeredGridCellTextSvg(lines, cls, cx, cellTop, cellHeight) {
121
+ const ls = lines.length > 0 ? lines : [''];
122
+ const firstY = cellTop + cellHeight / 2 - ((ls.length - 1) / 2) * GRID_LINE_HEIGHT;
123
+ const tspans = ls
124
+ .map((ln, i) => `<tspan x="${cx}" y="${firstY + i * GRID_LINE_HEIGHT}">${escXml(ln)}</tspan>`)
125
+ .join('');
126
+ return `<text class="${cls}" text-anchor="middle" dominant-baseline="central">${tspans}</text>`;
127
+ }
128
+ /**
129
+ * Emit the grid body (headers, cells, grid lines) at the given origin offset.
130
+ * Shared by the host-neutral wrapper below; VS Code's blocks preview wraps
131
+ * this the same way it wraps {@link renderBlocksLayoutSvg} for the tree form.
132
+ */
133
+ export function renderGridBody(layout, ox, oy) {
134
+ const tw = layout.bounds.width;
135
+ const th = layout.bounds.height;
136
+ const parts = [];
137
+ parts.push(`<rect class="diagram-node level-0" x="${ox}" y="${oy}" width="${tw}" height="${th}" stroke="none"/>`);
138
+ // Corner cell (above the row headers, left of the column headers).
139
+ parts.push(`<rect class="diagram-node level-1" x="${ox}" y="${oy}" width="${layout.rowHeaderWidth}" height="${layout.headerHeight}" stroke="none"/>`);
140
+ for (const col of layout.columns) {
141
+ const x = col.x + ox;
142
+ parts.push(`<rect class="diagram-node level-1" x="${x}" y="${oy}" width="${col.width}" height="${layout.headerHeight}" stroke="none"/>`);
143
+ const maxChars = maxCharsForInnerWidth(col.width - GRID_CELL_PAD_X * 2, CHAR_W_PRIMARY);
144
+ const lines = wrapWords(col.name, maxChars, 2);
145
+ parts.push(centeredGridCellTextSvg(lines, 'text-header', x + col.width / 2, oy, layout.headerHeight));
146
+ }
147
+ for (const row of layout.rows) {
148
+ const y = row.y + oy;
149
+ parts.push(`<rect class="diagram-node level-2" x="${ox}" y="${y}" width="${layout.rowHeaderWidth}" height="${row.height}" stroke="none"/>`);
150
+ const maxChars = maxCharsForInnerWidth(layout.rowHeaderWidth - GRID_CELL_PAD_X * 2, CHAR_W_PRIMARY);
151
+ const lines = wrapWords(row.name, maxChars, 2);
152
+ parts.push(centeredGridCellTextSvg(lines, 'text-primary', ox + layout.rowHeaderWidth / 2, y, row.height));
153
+ }
154
+ for (const cell of layout.cells) {
155
+ if (cell.value === undefined)
156
+ continue;
157
+ const cx = cell.x + ox + cell.width / 2;
158
+ const cy = cell.y + oy + cell.height / 2;
159
+ parts.push(`<text class="text-secondary" x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central">${escXml(cell.value)}</text>`);
160
+ }
161
+ const gridX1 = ox;
162
+ const gridX2 = ox + tw;
163
+ const gridY1 = oy;
164
+ const gridY2 = oy + th;
165
+ const headerBottomY = oy + layout.headerHeight;
166
+ parts.push(`<line class="diagram-edge" x1="${gridX1}" y1="${headerBottomY}" x2="${gridX2}" y2="${headerBottomY}"/>`);
167
+ for (const row of layout.rows) {
168
+ const rowBottomY = oy + row.y + row.height;
169
+ parts.push(`<line class="diagram-edge" x1="${gridX1}" y1="${rowBottomY}" x2="${gridX2}" y2="${rowBottomY}"/>`);
170
+ }
171
+ const rowHeaderLineX = ox + layout.rowHeaderWidth;
172
+ parts.push(`<line class="diagram-edge" x1="${rowHeaderLineX}" y1="${gridY1}" x2="${rowHeaderLineX}" y2="${gridY2}"/>`);
173
+ for (const col of layout.columns) {
174
+ const colLineX = ox + col.x + col.width;
175
+ parts.push(`<line class="diagram-edge" x1="${colLineX}" y1="${gridY1}" x2="${colLineX}" y2="${gridY2}"/>`);
176
+ }
177
+ parts.push(`<rect class="blocks-grid-border" x="${ox}" y="${oy}" width="${tw}" height="${th}"/>`);
178
+ return parts.join('\n');
179
+ }
180
+ /** The single grid (matrix subset) SVG emitter shared by every host. */
181
+ export function renderGridLayoutSvg(layout, options = {}) {
182
+ const { topInset = 0, title = '', embedCssTheme } = options;
183
+ const w = layout.bounds.width + PAD * 2;
184
+ const h = layout.bounds.height + PAD * 2 + topInset;
185
+ const ox = PAD;
186
+ const oy = PAD + topInset;
187
+ const body = renderGridBody(layout, ox, oy);
188
+ const styleLine = embedCssTheme ? `\n<style>${generateSvgEmbedCss(embedCssTheme)}${GRID_EMBED_CSS}</style>` : '';
189
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${styleLine}
190
+ ${title}
191
+ ${body}
192
+ </svg>`;
193
+ }
194
+ /** Host-neutral grid (matrix subset) renderer (IntelliJ/UI). */
195
+ export function renderGridSvg(doc, options = {}) {
196
+ const { title = '', layoutOptions } = options;
197
+ const layout = layoutGrid(doc, layoutOptions);
198
+ if (layout.columns.length === 0 || layout.rows.length === 0) {
199
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
200
+ }
201
+ const titleSvg = title
202
+ ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Nested Blocks — ${title}`)}</text>`
203
+ : '';
204
+ return renderGridLayoutSvg(layout, { title: titleSvg, embedCssTheme: 'transitrix' });
205
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.8.13",
3
+ "version": "1.8.18",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -19,7 +19,15 @@ export function validateActivities(input: unknown): ActivityValidationResult {
19
19
 
20
20
  const raw = input as Record<string, unknown>;
21
21
 
22
- // ACT-001: notation must be "action"
22
+ // ACT-001: notation must be "action". ACT-020 is deliberately NOT ported
23
+ // here (DSM's api02/internal/importer/activities.go ValidateActivities
24
+ // still accepts the pre-2026-06-25 "activities" notation/root-key alias
25
+ // with a warning) — PR #320 ("drop deprecated notation aliases
26
+ // fgca/fga/activities/activity-card") already removed that leniency from
27
+ // this validator on purpose, with tests asserting it is "no longer
28
+ // accepted". Re-introducing it would reverse a deliberate, shipped
29
+ // decision, not fill a gap; flagged in the PR for a call rather than
30
+ // silently reversed either way.
23
31
  if (raw.notation === undefined) {
24
32
  errors.push({ code: 'ACT-001', message: 'notation field is required' });
25
33
  return { valid: false, errors, warnings };
@@ -5,13 +5,27 @@ export type {
5
5
  BlocksLayoutOptions,
6
6
  LaidOutBlock,
7
7
  BlocksLayout,
8
+ GridColumn,
9
+ GridRow,
10
+ GridHeader,
11
+ GridFile,
12
+ GridLayoutOptions,
13
+ LaidOutGridColumn,
14
+ LaidOutGridRow,
15
+ LaidOutGridCell,
16
+ GridLayout,
8
17
  } from './types.js';
9
18
 
10
- export { validateNestedBlocks, isWellFormedBlock } from './validate.js';
19
+ export { validateNestedBlocks, validateGrid, validateBlocks, isWellFormedBlock } from './validate.js';
11
20
  export type {
12
21
  ValidationError as BlocksValidationError,
13
22
  ValidationWarning as BlocksValidationWarning,
14
23
  ValidationResult as BlocksValidationResult,
24
+ GridRule,
25
+ ValidateGridOptions,
26
+ ValidateBlocksOptions,
15
27
  } from './validate.js';
16
28
 
17
- export { layoutNestedBlocks, iterateBlocks } from './layout.js';
29
+ export { layoutNestedBlocks, layoutGrid, iterateBlocks } from './layout.js';
30
+
31
+ export { GRID_TEMPLATE_RULES } from './templates/index.js';
@@ -3,7 +3,13 @@ import type {
3
3
  BlocksFile,
4
4
  BlocksLayout,
5
5
  BlocksLayoutOptions,
6
+ GridFile,
7
+ GridLayout,
8
+ GridLayoutOptions,
6
9
  LaidOutBlock,
10
+ LaidOutGridCell,
11
+ LaidOutGridColumn,
12
+ LaidOutGridRow,
7
13
  } from './types.js';
8
14
  import { ENTITY_NODE_SIZE } from '../node-size-presets.js';
9
15
 
@@ -16,6 +22,13 @@ const DEFAULTS: Required<BlocksLayoutOptions> = {
16
22
  topLevelGap: 24,
17
23
  };
18
24
 
25
+ const GRID_DEFAULTS: Required<GridLayoutOptions> = {
26
+ columnWidth: 130,
27
+ rowHeight: 44,
28
+ rowHeaderWidth: 200,
29
+ headerHeight: 56,
30
+ };
31
+
19
32
  interface MeasuredBlock {
20
33
  width: number;
21
34
  height: number;
@@ -185,3 +198,60 @@ export function* iterateBlocks(layout: BlocksLayout): Iterable<LaidOutBlock> {
185
198
  }
186
199
  for (const b of layout.blocks) yield* walk(b);
187
200
  }
201
+
202
+ /**
203
+ * Matrix subset (08-blocks.md §4a): lay out a `grid:` root document as a
204
+ * single-layer rectangular table — a row-header column, a column-header row,
205
+ * and one cell per `(row, column)` pair. Assumes the document is already
206
+ * well-formed ({@link validateGrid}); callers validate first.
207
+ */
208
+ export function layoutGrid(file: GridFile, options?: GridLayoutOptions): GridLayout {
209
+ const opts = { ...GRID_DEFAULTS, ...(options ?? {}) };
210
+ const grid = file?.grid;
211
+ const rawColumns = Array.isArray(grid?.columns) ? grid.columns : [];
212
+ const rawRows = Array.isArray(grid?.rows) ? grid.rows : [];
213
+
214
+ const columns: LaidOutGridColumn[] = rawColumns.map((c, i) => ({
215
+ id: c.id,
216
+ name: c.name,
217
+ x: opts.rowHeaderWidth + i * opts.columnWidth,
218
+ width: opts.columnWidth,
219
+ }));
220
+
221
+ const rows: LaidOutGridRow[] = rawRows.map((r, i) => ({
222
+ id: r.id,
223
+ name: r.name,
224
+ y: opts.headerHeight + i * opts.rowHeight,
225
+ height: opts.rowHeight,
226
+ }));
227
+
228
+ const cells: LaidOutGridCell[] = [];
229
+ for (let ri = 0; ri < rawRows.length; ri++) {
230
+ const row = rawRows[ri];
231
+ for (let ci = 0; ci < rawColumns.length; ci++) {
232
+ const col = rawColumns[ci];
233
+ const raw = row.assign?.[col.id];
234
+ cells.push({
235
+ rowId: row.id,
236
+ colId: col.id,
237
+ x: columns[ci].x,
238
+ y: rows[ri].y,
239
+ width: opts.columnWidth,
240
+ height: opts.rowHeight,
241
+ value: raw === undefined ? undefined : String(raw),
242
+ });
243
+ }
244
+ }
245
+
246
+ const width = opts.rowHeaderWidth + rawColumns.length * opts.columnWidth;
247
+ const height = opts.headerHeight + rawRows.length * opts.rowHeight;
248
+
249
+ return {
250
+ bounds: { x: 0, y: 0, width, height },
251
+ rowHeaderWidth: opts.rowHeaderWidth,
252
+ headerHeight: opts.headerHeight,
253
+ columns,
254
+ rows,
255
+ cells,
256
+ };
257
+ }
@@ -0,0 +1,13 @@
1
+ import type { GridRule } from '../validate.js';
2
+ import { RACI_GRID_RULES } from './raci.js';
3
+
4
+ /**
5
+ * Closed registry of built-in grid templates (§6a): each entry is a template
6
+ * name (as passed to `--template <name>`) mapped to its `GridRule`s. Adding a
7
+ * new template means adding one rule module + one entry here — the same
8
+ * shape as adding a new notation validator, not a plugin/config-loading
9
+ * mechanism (deliberately out of scope — see the design note on `RACI-001`).
10
+ */
11
+ export const GRID_TEMPLATE_RULES: Record<string, GridRule[]> = {
12
+ raci: RACI_GRID_RULES,
13
+ };
@@ -0,0 +1,28 @@
1
+ import type { GridRule } from '../validate.js';
2
+
3
+ /**
4
+ * RACI-as-matrix template (methodology `templates/raci/`), built on the
5
+ * `blocks` matrix subset (08-blocks.md §4a). The base grid validator does not
6
+ * fix a cell-value vocabulary, so this invariant — exactly one Accountable
7
+ * owner per row — lives here, not in `validateGrid` (see §6a).
8
+ */
9
+ export const raciExactlyOneAccountableRule: GridRule = {
10
+ ruleId: 'RACI-001',
11
+ severity: 'error',
12
+ check(grid) {
13
+ const findings: Array<{ message: string; path?: string }> = [];
14
+ grid.rows.forEach((row, i) => {
15
+ const accountableCount = Object.values(row.assign ?? {}).filter((v) => v === 'A').length;
16
+ if (accountableCount !== 1) {
17
+ findings.push({
18
+ message:
19
+ `Row "${row.name}" (${row.id}) must have exactly one column assigned "A" (Accountable); found ${accountableCount}`,
20
+ path: `grid.rows[${i}]`,
21
+ });
22
+ }
23
+ });
24
+ return findings;
25
+ },
26
+ };
27
+
28
+ export const RACI_GRID_RULES: GridRule[] = [raciExactlyOneAccountableRule];
@@ -21,6 +21,73 @@ export interface BlocksFile {
21
21
  nested_blocks: NestedBlocksHeader;
22
22
  }
23
23
 
24
+ export interface GridColumn {
25
+ id: string;
26
+ name: string;
27
+ }
28
+
29
+ export interface GridRow {
30
+ id: string;
31
+ name: string;
32
+ assign?: Record<string, unknown>;
33
+ }
34
+
35
+ export interface GridHeader {
36
+ columns: GridColumn[];
37
+ rows: GridRow[];
38
+ }
39
+
40
+ export interface GridFile {
41
+ notation: string;
42
+ spec_version?: string;
43
+ grid: GridHeader;
44
+ }
45
+
46
+ export interface GridLayoutOptions {
47
+ /** Width of each data column (px). */
48
+ columnWidth?: number;
49
+ /** Height of each data row (px). */
50
+ rowHeight?: number;
51
+ /** Width of the row-header column carrying each row's name (px). */
52
+ rowHeaderWidth?: number;
53
+ /** Height of the column-header row carrying each column's name (px). */
54
+ headerHeight?: number;
55
+ }
56
+
57
+ export interface LaidOutGridColumn {
58
+ id: string;
59
+ name: string;
60
+ x: number;
61
+ width: number;
62
+ }
63
+
64
+ export interface LaidOutGridRow {
65
+ id: string;
66
+ name: string;
67
+ y: number;
68
+ height: number;
69
+ }
70
+
71
+ export interface LaidOutGridCell {
72
+ rowId: string;
73
+ colId: string;
74
+ x: number;
75
+ y: number;
76
+ width: number;
77
+ height: number;
78
+ /** The cell's `assign` value, stringified; omitted (blank) when unassigned. */
79
+ value?: string;
80
+ }
81
+
82
+ export interface GridLayout {
83
+ bounds: LayoutBounds;
84
+ rowHeaderWidth: number;
85
+ headerHeight: number;
86
+ columns: LaidOutGridColumn[];
87
+ rows: LaidOutGridRow[];
88
+ cells: LaidOutGridCell[];
89
+ }
90
+
24
91
  export interface BlocksLayoutOptions {
25
92
  /** Width of a leaf block (a block with no children). */
26
93
  leafWidth?: number;
@@ -1,8 +1,27 @@
1
- import type { Block } from './types.js';
1
+ import type { Block, GridColumn, GridHeader, GridRow } from './types.js';
2
2
  import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
3
3
 
4
4
  export type { ValidationError, ValidationWarning, ValidationResult };
5
5
 
6
+ /**
7
+ * A template-level check over an already well-formed grid document (matrix
8
+ * subset, §4a). Templates built on the `grid:` root (e.g. RACI) supply their
9
+ * own `GridRule`s for vocabulary-specific invariants — the base validator
10
+ * intentionally does not fix what `assign` values mean (08-blocks.md §6a), so
11
+ * those invariants never live inside `validateGrid` itself.
12
+ */
13
+ export interface GridRule {
14
+ /** Template-namespaced rule code, e.g. "RACI-001" — never a BL-0xx code. */
15
+ ruleId: string;
16
+ severity: 'error' | 'warning';
17
+ check(grid: GridHeader): Array<{ message: string; path?: string }>;
18
+ }
19
+
20
+ export interface ValidateGridOptions {
21
+ /** Extra template-level rules to run once the base grid is well-formed. */
22
+ rules?: GridRule[];
23
+ }
24
+
6
25
  /** Document-level ID grammar: BLOCKS-[<middle>-]<INTEGER>. */
7
26
  const BLOCKS_DOC_ID_RE = /^BLOCKS(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
8
27
 
@@ -188,6 +207,143 @@ export function validateNestedBlocks(input: unknown): ValidationResult {
188
207
  return { valid: errors.length === 0, errors, warnings };
189
208
  }
190
209
 
210
+ /** Matrix subset (§4a): validate a `grid:` root document — BL-021..BL-025. */
211
+ export function validateGrid(input: unknown, options: ValidateGridOptions = {}): ValidationResult {
212
+ const errors: ValidationError[] = [];
213
+ const warnings: ValidationWarning[] = [];
214
+
215
+ if (!input || typeof input !== 'object') {
216
+ return { valid: false, errors: [{ code: 'BL-001', message: 'Input must be an object' }], warnings };
217
+ }
218
+
219
+ const raw = input as Record<string, unknown>;
220
+
221
+ if ('notation' in raw && raw['notation'] !== 'blocks') {
222
+ errors.push({
223
+ code: 'BL-001',
224
+ message: `notation must be "blocks", got "${String(raw['notation'])}"`,
225
+ });
226
+ }
227
+
228
+ if (!('grid' in raw) || !raw['grid'] || typeof raw['grid'] !== 'object') {
229
+ errors.push({ code: 'BL-020', message: 'Missing required root key: grid' });
230
+ return { valid: false, errors, warnings };
231
+ }
232
+
233
+ const grid = raw['grid'] as Record<string, unknown>;
234
+ const rawColumns = grid['columns'];
235
+ const rawRows = grid['rows'];
236
+
237
+ if (!Array.isArray(rawColumns) || rawColumns.length === 0) {
238
+ errors.push({ code: 'BL-021', message: 'grid.columns must be a non-empty array' });
239
+ }
240
+ if (!Array.isArray(rawRows) || rawRows.length === 0) {
241
+ errors.push({ code: 'BL-022', message: 'grid.rows must be a non-empty array' });
242
+ }
243
+ if (errors.length > 0) return { valid: false, errors, warnings };
244
+
245
+ const columnIds = new Set<string>();
246
+ const columns: GridColumn[] = [];
247
+ (rawColumns as unknown[]).forEach((entry, i) => {
248
+ const path = `grid.columns[${i}]`;
249
+ const c = entry as Record<string, unknown>;
250
+ if (!entry || typeof entry !== 'object' || !isNonEmptyString(c['id']) || !isNonEmptyString(c['name'])) {
251
+ errors.push({ code: 'BL-023', message: `${path} must have non-empty id and name`, path });
252
+ return;
253
+ }
254
+ const id = c['id'].trim();
255
+ if (columnIds.has(id)) {
256
+ errors.push({ code: 'BL-024', message: `Duplicate column id: "${id}"`, path });
257
+ } else {
258
+ columnIds.add(id);
259
+ }
260
+ columns.push({ id, name: c['name'] });
261
+ });
262
+
263
+ const rowIds = new Set<string>();
264
+ const rows: GridRow[] = [];
265
+ (rawRows as unknown[]).forEach((entry, i) => {
266
+ const path = `grid.rows[${i}]`;
267
+ const r = entry as Record<string, unknown>;
268
+ if (!entry || typeof entry !== 'object' || !isNonEmptyString(r['id']) || !isNonEmptyString(r['name'])) {
269
+ errors.push({ code: 'BL-023', message: `${path} must have non-empty id and name`, path });
270
+ return;
271
+ }
272
+ const id = r['id'].trim();
273
+ if (rowIds.has(id)) {
274
+ errors.push({ code: 'BL-024', message: `Duplicate row id: "${id}"`, path });
275
+ } else {
276
+ rowIds.add(id);
277
+ }
278
+
279
+ const rawAssign = r['assign'];
280
+ let assign: Record<string, unknown> | undefined;
281
+ if (rawAssign !== undefined) {
282
+ if (!rawAssign || typeof rawAssign !== 'object' || Array.isArray(rawAssign)) {
283
+ errors.push({ code: 'BL-023', message: `${path}.assign must be a map when present`, path });
284
+ } else {
285
+ assign = rawAssign as Record<string, unknown>;
286
+ for (const colId of Object.keys(assign)) {
287
+ if (!columnIds.has(colId)) {
288
+ errors.push({
289
+ code: 'BL-025',
290
+ message: `${path}.assign references unknown column id "${colId}"`,
291
+ path,
292
+ });
293
+ }
294
+ }
295
+ }
296
+ }
297
+ rows.push({ id, name: r['name'], assign });
298
+ });
299
+
300
+ if (errors.length > 0) return { valid: false, errors, warnings };
301
+
302
+ const grid_: GridHeader = { columns, rows };
303
+ for (const rule of options.rules ?? []) {
304
+ for (const finding of rule.check(grid_)) {
305
+ const entry = { code: rule.ruleId, message: finding.message, path: finding.path };
306
+ if (rule.severity === 'error') errors.push(entry);
307
+ else warnings.push(entry);
308
+ }
309
+ }
310
+
311
+ return { valid: errors.length === 0, errors, warnings };
312
+ }
313
+
314
+ export interface ValidateBlocksOptions extends ValidateGridOptions {}
315
+
316
+ /**
317
+ * Root-form dispatcher (§4/§4a): a document declares exactly one of
318
+ * `nested_blocks` (tree form) or `grid` (matrix subset) — never both, never
319
+ * neither (BL-020). Delegates to whichever form is present.
320
+ */
321
+ export function validateBlocks(input: unknown, options: ValidateBlocksOptions = {}): ValidationResult {
322
+ if (!input || typeof input !== 'object') {
323
+ return { valid: false, errors: [{ code: 'BL-001', message: 'Input must be an object' }], warnings: [] };
324
+ }
325
+
326
+ const raw = input as Record<string, unknown>;
327
+ const hasNested = raw['nested_blocks'] !== undefined && raw['nested_blocks'] !== null;
328
+ const hasGrid = raw['grid'] !== undefined && raw['grid'] !== null;
329
+
330
+ if (hasNested && hasGrid) {
331
+ return {
332
+ valid: false,
333
+ errors: [{ code: 'BL-020', message: 'Document root must declare exactly one of nested_blocks or grid, not both' }],
334
+ warnings: [],
335
+ };
336
+ }
337
+ if (hasGrid) return validateGrid(input, options);
338
+ if (hasNested) return validateNestedBlocks(input);
339
+
340
+ return {
341
+ valid: false,
342
+ errors: [{ code: 'BL-020', message: 'Document root must declare exactly one of nested_blocks or grid' }],
343
+ warnings: [],
344
+ };
345
+ }
346
+
191
347
  /**
192
348
  * Convenience: validate a single `Block` subtree. Used by tests and by
193
349
  * downstream consumers that need to check a sub-document.
@@ -21,10 +21,17 @@ export function validateCapabilityMap(input: unknown): ValidationResult {
21
21
  }
22
22
  const raw = input as Record<string, unknown>;
23
23
 
24
+ // HDR-001/002 — DSM's own rule codes for the same header check CMAP-001
25
+ // already makes (api02/internal/importer/capabilities.go ValidateCapMap),
26
+ // added alongside it (not instead of it) so DSM can map a finding straight
27
+ // back onto its own import-log taxonomy while CMAP-001 stays the richer,
28
+ // pre-existing Studio-native code for this notation's own validator.
24
29
  if (!('notation' in raw)) {
25
30
  errors.push({ code: 'CMAP-001', message: 'Missing required field: notation' });
31
+ errors.push({ code: 'HDR-001', message: 'notation field is missing' });
26
32
  } else if (raw['notation'] !== 'capability-map') {
27
33
  errors.push({ code: 'CMAP-001', message: `notation must be "capability-map", got "${raw['notation']}"` });
34
+ errors.push({ code: 'HDR-002', message: `notation must be "capability-map", got "${raw['notation']}"` });
28
35
  }
29
36
  if (errors.length > 0) return { valid: false, errors, warnings };
30
37
 
@@ -116,6 +123,39 @@ function validateCapabilityTree(
116
123
  if (node['applications'] !== undefined && !Array.isArray(node['applications']))
117
124
  errors.push({ code: 'CMAP-003', message: `${nodePath}: applications must be an array` });
118
125
 
126
+ // LIFECYCLE-001/004 — DSM's own rule codes (capabilities.go
127
+ // ValidateCapMap/validateCapMapNodes) for `valid_from`/`valid_to`, the
128
+ // CONTRACT.md §7 primitive-lifecycle fields. Net new: this notation's own
129
+ // validator has no equivalent check today — `CapabilityNode` (types.ts)
130
+ // does not even declare these fields, since Studio's own schema tracks
131
+ // lifecycle via `target_date` instead. The fields are still read directly
132
+ // off the raw untyped node here because real capability-map documents
133
+ // (e.g. organizations/acme_corp) author them inline per §7 regardless.
134
+ {
135
+ const id = typeof node['id'] === 'string' ? (node['id'] as string) : nodePath;
136
+ const validFrom = node['valid_from'];
137
+ const validTo = node['valid_to'];
138
+ let fromDate: string | undefined;
139
+ let toDate: string | undefined;
140
+ if (validFrom !== undefined && validFrom !== null) {
141
+ if (typeof validFrom !== 'string' || !DATE_RE.test(validFrom)) {
142
+ errors.push({ code: 'LIFECYCLE-001', message: `capability ${id} valid_from "${String(validFrom)}" is not a valid YYYY-MM-DD date (must be quoted)` });
143
+ } else {
144
+ fromDate = validFrom;
145
+ }
146
+ }
147
+ if (validTo !== undefined && validTo !== null) {
148
+ if (typeof validTo !== 'string' || !DATE_RE.test(validTo)) {
149
+ errors.push({ code: 'LIFECYCLE-001', message: `capability ${id} valid_to "${String(validTo)}" is not a valid YYYY-MM-DD date (must be quoted)` });
150
+ } else {
151
+ toDate = validTo;
152
+ }
153
+ }
154
+ if (fromDate && toDate && toDate < fromDate) {
155
+ errors.push({ code: 'LIFECYCLE-004', message: `capability ${id} valid_to ${toDate} is before valid_from ${fromDate}` });
156
+ }
157
+ }
158
+
119
159
  if (node['children'] !== undefined) {
120
160
  if (!Array.isArray(node['children'])) {
121
161
  errors.push({ code: 'CMAP-003', message: `${nodePath}: children must be an array` });
@@ -130,6 +130,14 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
130
130
  }
131
131
  const activitiesRaw = asObjectArray(raw['actions']);
132
132
  if (activitiesRaw === null && 'activities' in raw) {
133
+ // DSM's Go ValidateFGCA (fgca.go) still accepts this as the deprecated
134
+ // "activities" key and promotes it with a DGCA-DEPR warning, not an
135
+ // error. Deliberately NOT matched here — PR #320 ("drop deprecated
136
+ // notation aliases fgca/fga/activities/activity-card") already removed
137
+ // that leniency from this validator on purpose. Re-introducing
138
+ // DGCA-DEPR would reverse a deliberate, shipped decision, not fill a
139
+ // gap; see docs/validation.md's "Standalone-element envelope rules"
140
+ // section for the full note.
133
141
  errors.push({ code: 'FGCA-004', message: '"activities" key is not accepted — rename to "actions"', path: 'actions' });
134
142
  }
135
143