@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.
- package/dist/activities/validate.js +9 -1
- package/dist/blocks/index.d.ts +5 -4
- package/dist/blocks/index.js +3 -2
- package/dist/blocks/layout.d.ts +8 -1
- package/dist/blocks/layout.js +57 -0
- package/dist/blocks/templates/index.d.ts +9 -0
- package/dist/blocks/templates/index.js +11 -0
- package/dist/blocks/templates/raci.d.ts +9 -0
- package/dist/blocks/templates/raci.js +24 -0
- package/dist/blocks/types.d.ts +58 -0
- package/dist/blocks/validate.d.ts +31 -1
- package/dist/blocks/validate.js +127 -0
- package/dist/capability-map/validate.js +41 -0
- package/dist/fgca/parse-canonical.js +8 -0
- package/dist/repo-validate/check-element-hygiene.d.ts +8 -0
- package/dist/repo-validate/check-element-hygiene.js +211 -0
- package/dist/repo-validate/check-strategy-chain.d.ts +8 -0
- package/dist/repo-validate/check-strategy-chain.js +491 -0
- package/dist/repo-validate/types.d.ts +14 -1
- package/dist/repo-validate/types.js +22 -3
- package/dist/repo-validate/validate-repo.d.ts +3 -2
- package/dist/repo-validate/validate-repo.js +16 -4
- package/dist/webview/render-blocks.d.ts +23 -1
- package/dist/webview/render-blocks.js +100 -2
- package/package.json +1 -1
- package/src/activities/validate.ts +9 -1
- package/src/blocks/index.ts +16 -2
- package/src/blocks/layout.ts +70 -0
- package/src/blocks/templates/index.ts +13 -0
- package/src/blocks/templates/raci.ts +28 -0
- package/src/blocks/types.ts +67 -0
- package/src/blocks/validate.ts +157 -1
- package/src/capability-map/validate.ts +40 -0
- package/src/fgca/parse-canonical.ts +8 -0
- package/src/repo-validate/check-element-hygiene.ts +236 -0
- package/src/repo-validate/check-strategy-chain.ts +528 -0
- package/src/repo-validate/types.ts +36 -4
- package/src/repo-validate/validate-repo.ts +16 -4
- 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
|
@@ -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 };
|
package/src/blocks/index.ts
CHANGED
|
@@ -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';
|
package/src/blocks/layout.ts
CHANGED
|
@@ -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];
|
package/src/blocks/types.ts
CHANGED
|
@@ -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;
|
package/src/blocks/validate.ts
CHANGED
|
@@ -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
|
|