@transitrix/diagrams 1.0.0 → 1.0.1
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/LICENSE +21 -21
- package/README.md +47 -47
- package/dist/__tests__/edge-path.test.js +8 -0
- package/dist/activity-card/__tests__/example.test.js +20 -1
- package/dist/activity-card/__tests__/layout.test.js +102 -11
- package/dist/activity-card/__tests__/resolver.test.js +139 -6
- package/dist/activity-card/index.d.ts +1 -1
- package/dist/activity-card/layout.d.ts +1 -1
- package/dist/activity-card/layout.js +220 -103
- package/dist/activity-card/resolver.js +107 -26
- package/dist/activity-card/types.d.ts +150 -27
- package/dist/activity-card/validate.js +3 -0
- package/dist/actor/__tests__/validate.test.js +63 -31
- package/dist/actor/index.d.ts +1 -1
- package/dist/actor/index.js +1 -1
- package/dist/actor/types.d.ts +7 -3
- package/dist/actor/types.js +17 -3
- package/dist/actor/validate.js +55 -26
- package/dist/assertion/types.d.ts +3 -1
- package/dist/assertion/types.js +1 -1
- package/dist/change/__tests__/validate.test.js +40 -34
- package/dist/change/index.d.ts +1 -0
- package/dist/change/types.d.ts +1 -1
- package/dist/change/types.js +2 -2
- package/dist/change/validate.d.ts +6 -1
- package/dist/change/validate.js +68 -41
- package/dist/compliance/__tests__/classify.test.js +34 -0
- package/dist/compliance/__tests__/coverage-metric.test.js +146 -46
- package/dist/compliance/__tests__/html.test.js +2 -2
- package/dist/compliance/__tests__/impact.test.js +311 -6
- package/dist/compliance/classify.d.ts +6 -0
- package/dist/compliance/classify.js +9 -1
- package/dist/compliance/coverage-metric.d.ts +31 -11
- package/dist/compliance/coverage-metric.js +149 -39
- package/dist/compliance/html.js +77 -74
- package/dist/compliance/impact.d.ts +77 -16
- package/dist/compliance/impact.js +193 -25
- package/dist/compliance/index.d.ts +2 -2
- package/dist/compliance/index.js +1 -1
- package/dist/compliance/markdown.js +1 -0
- package/dist/compliance/types.d.ts +25 -6
- package/dist/edge-path.d.ts +8 -4
- package/dist/edge-path.js +11 -6
- package/dist/factor/__tests__/validate.test.js +74 -45
- package/dist/factor/index.d.ts +3 -2
- package/dist/factor/index.js +1 -1
- package/dist/factor/types.d.ts +4 -7
- package/dist/factor/types.js +10 -5
- package/dist/factor/validate.d.ts +6 -1
- package/dist/factor/validate.js +68 -50
- package/dist/fgca/__tests__/resolver.test.js +185 -0
- package/dist/fgca/index.d.ts +2 -0
- package/dist/fgca/index.js +1 -0
- package/dist/fgca/resolver.d.ts +49 -0
- package/dist/fgca/resolver.js +124 -0
- package/dist/goals/index.d.ts +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/process-blueprint/__tests__/layout.test.js +37 -0
- package/dist/process-blueprint/layout.js +5 -1
- package/dist/process-blueprint/types.d.ts +22 -1
- package/dist/stakeholder/__tests__/validate.test.js +49 -47
- package/dist/stakeholder/index.d.ts +1 -0
- package/dist/stakeholder/types.d.ts +3 -4
- package/dist/stakeholder/types.js +2 -3
- package/dist/stakeholder/validate.d.ts +6 -1
- package/dist/stakeholder/validate.js +71 -50
- package/dist/target-state/__tests__/validate.test.js +43 -27
- package/dist/target-state/index.d.ts +2 -0
- package/dist/target-state/index.js +1 -0
- package/dist/target-state/types.d.ts +6 -1
- package/dist/target-state/types.js +8 -7
- package/dist/target-state/validate.d.ts +6 -1
- package/dist/target-state/validate.js +69 -45
- package/dist/theme/themes.js +69 -69
- package/dist/webview/__tests__/entry.test.js +11 -11
- package/dist/webview/render-activities.d.ts +3 -1
- package/dist/webview/render-activities.js +2 -2
- package/dist/webview/render-activity-card.js +104 -77
- package/dist/webview/render-fgca.d.ts +4 -0
- package/dist/webview/render-fgca.js +2 -5
- package/dist/webview/render-goals.d.ts +1 -0
- package/dist/webview/render-goals.js +15 -15
- package/dist/webview/render-process-blueprint.js +10 -10
- package/package.json +40 -40
- package/src/__tests__/edge-path.test.ts +58 -46
- package/src/__tests__/scope.test.ts +27 -27
- package/src/__tests__/yaml-normalize.test.ts +158 -158
- package/src/activities/__tests__/cpm.test.ts +164 -164
- package/src/activities/__tests__/gantt.test.ts +245 -245
- package/src/activities/__tests__/layout.test.ts +112 -112
- package/src/activities/__tests__/validate.test.ts +409 -409
- package/src/activities/cpm.ts +95 -95
- package/src/activities/gantt.ts +276 -276
- package/src/activities/index.ts +28 -28
- package/src/activities/layout.ts +112 -112
- package/src/activities/types.ts +148 -148
- package/src/activities/validate.ts +357 -357
- package/src/activity-card/__tests__/example.test.ts +85 -66
- package/src/activity-card/__tests__/layout.test.ts +169 -63
- package/src/activity-card/__tests__/resolver.test.ts +307 -161
- package/src/activity-card/__tests__/validate.test.ts +131 -131
- package/src/activity-card/index.ts +38 -32
- package/src/activity-card/layout.ts +324 -194
- package/src/activity-card/resolver.ts +415 -337
- package/src/activity-card/types.ts +376 -237
- package/src/activity-card/validate.ts +162 -158
- package/src/actor/__tests__/validate.test.ts +70 -31
- package/src/actor/index.ts +1 -1
- package/src/actor/types.ts +21 -7
- package/src/actor/validate.ts +59 -27
- package/src/applications/__tests__/validate.test.ts +256 -256
- package/src/applications/index.ts +11 -11
- package/src/applications/types.ts +43 -43
- package/src/applications/validate.ts +131 -131
- package/src/assertion/__tests__/example.test.ts +37 -37
- package/src/assertion/__tests__/validate.test.ts +142 -142
- package/src/assertion/index.ts +12 -12
- package/src/assertion/types.ts +60 -58
- package/src/assertion/validate.ts +145 -145
- package/src/blocks/__tests__/layout.test.ts +169 -169
- package/src/blocks/__tests__/validate.test.ts +260 -260
- package/src/blocks/index.ts +17 -17
- package/src/blocks/layout.ts +186 -186
- package/src/blocks/types.ts +62 -62
- package/src/blocks/validate.ts +206 -206
- package/src/capability-map/__tests__/validate.test.ts +238 -238
- package/src/capability-map/index.ts +8 -8
- package/src/capability-map/types.ts +29 -29
- package/src/capability-map/validate.ts +127 -127
- package/src/change/__tests__/validate.test.ts +43 -34
- package/src/change/index.ts +1 -0
- package/src/change/types.ts +5 -3
- package/src/change/validate.ts +75 -43
- package/src/compliance/__tests__/classify.test.ts +64 -27
- package/src/compliance/__tests__/confidence.test.ts +98 -98
- package/src/compliance/__tests__/coverage-metric.test.ts +300 -188
- package/src/compliance/__tests__/demo-scenario.test.ts +273 -273
- package/src/compliance/__tests__/example.test.ts +57 -57
- package/src/compliance/__tests__/gap-report.test.ts +128 -128
- package/src/compliance/__tests__/html.test.ts +141 -141
- package/src/compliance/__tests__/impact.test.ts +840 -481
- package/src/compliance/__tests__/markdown.test.ts +94 -94
- package/src/compliance/__tests__/views.test.ts +64 -64
- package/src/compliance/classify.ts +100 -84
- package/src/compliance/confidence.ts +69 -69
- package/src/compliance/coverage-metric.ts +404 -263
- package/src/compliance/gap-report.ts +94 -94
- package/src/compliance/html.ts +298 -295
- package/src/compliance/impact.ts +861 -677
- package/src/compliance/index.ts +57 -56
- package/src/compliance/markdown.ts +136 -135
- package/src/compliance/reverse-index.ts +33 -33
- package/src/compliance/types.ts +136 -120
- package/src/compliance/views.ts +46 -46
- package/src/compliance-matrix/__tests__/build.test.ts +200 -200
- package/src/compliance-matrix/__tests__/example.test.ts +57 -57
- package/src/compliance-matrix/index.ts +11 -11
- package/src/compliance-matrix/types.ts +102 -102
- package/src/edge-path.ts +50 -44
- package/src/factor/__tests__/validate.test.ts +80 -45
- package/src/factor/index.ts +3 -7
- package/src/factor/types.ts +14 -18
- package/src/factor/validate.ts +77 -51
- package/src/fgca/__tests__/chain-table.test.ts +172 -172
- package/src/fgca/__tests__/layout.test.ts +179 -179
- package/src/fgca/__tests__/parse-canonical.test.ts +248 -248
- package/src/fgca/__tests__/preview-layout.test.ts +124 -124
- package/src/fgca/__tests__/resolver.test.ts +209 -0
- package/src/fgca/__tests__/validate.test.ts +142 -142
- package/src/fgca/chain-table.ts +225 -225
- package/src/fgca/index.ts +47 -45
- package/src/fgca/layout.ts +204 -204
- package/src/fgca/nodeTypes.ts +11 -11
- package/src/fgca/nodes/FGCAActivityNode.tsx +65 -65
- package/src/fgca/nodes/FGCAChangeNode.tsx +64 -64
- package/src/fgca/nodes/FGCAFactorNode.tsx +64 -64
- package/src/fgca/nodes/FGCAGoalNode.tsx +72 -72
- package/src/fgca/parse-canonical.ts +400 -400
- package/src/fgca/preview-layout.ts +217 -217
- package/src/fgca/resolver.ts +157 -0
- package/src/fgca/types.ts +50 -50
- package/src/fgca/validate.ts +126 -126
- package/src/geometry.ts +9 -9
- package/src/goals/__tests__/layout.test.ts +175 -175
- package/src/goals/__tests__/mutations.test.ts +111 -111
- package/src/goals/__tests__/parse-canonical.test.ts +145 -145
- package/src/goals/__tests__/validate.test.ts +202 -202
- package/src/goals/index.ts +20 -21
- package/src/goals/layout.ts +172 -172
- package/src/goals/mutations.ts +123 -123
- package/src/goals/parse-canonical.ts +286 -286
- package/src/goals/types.ts +85 -85
- package/src/goals/validate.ts +136 -136
- package/src/index.ts +28 -23
- package/src/process-blueprint/__tests__/compliance-lane.test.ts +320 -320
- package/src/process-blueprint/__tests__/layout.test.ts +240 -195
- package/src/process-blueprint/__tests__/validate.test.ts +310 -310
- package/src/process-blueprint/index.ts +31 -31
- package/src/process-blueprint/layout.ts +535 -531
- package/src/process-blueprint/types.ts +263 -242
- package/src/process-blueprint/validate.ts +181 -181
- package/src/process-map/__tests__/validate.test.ts +223 -223
- package/src/process-map/index.ts +10 -10
- package/src/process-map/types.ts +36 -36
- package/src/process-map/validate.ts +135 -135
- package/src/products/__tests__/validate.test.ts +239 -239
- package/src/products/index.ts +9 -9
- package/src/products/types.ts +31 -31
- package/src/products/validate.ts +112 -112
- package/src/repo-validate/__tests__/validate-repo.test.ts +149 -149
- package/src/repo-validate/index.ts +2 -2
- package/src/repo-validate/types.ts +50 -50
- package/src/repo-validate/validate-repo.ts +196 -196
- package/src/requirement/__tests__/example.test.ts +24 -24
- package/src/requirement/__tests__/validate.test.ts +96 -96
- package/src/requirement/index.ts +4 -4
- package/src/requirement/types.ts +35 -35
- package/src/requirement/validate.ts +92 -92
- package/src/scenarios/__tests__/validate.test.ts +209 -209
- package/src/scenarios/index.ts +15 -15
- package/src/scenarios/types.ts +37 -37
- package/src/scenarios/validate.ts +122 -122
- package/src/schema-version.ts +16 -16
- package/src/scope.ts +46 -46
- package/src/stakeholder/__tests__/validate.test.ts +51 -47
- package/src/stakeholder/index.ts +1 -0
- package/src/stakeholder/types.ts +7 -7
- package/src/stakeholder/validate.ts +79 -52
- package/src/target-state/__tests__/validate.test.ts +48 -27
- package/src/target-state/index.ts +2 -0
- package/src/target-state/types.ts +11 -7
- package/src/target-state/validate.ts +76 -46
- package/src/theme/index.ts +4 -4
- package/src/theme/themes.ts +237 -237
- package/src/theme/tokens.ts +133 -133
- package/src/typed-id.ts +56 -56
- package/src/validation-types.ts +36 -36
- package/src/webview/__tests__/entry-notations.test.ts +77 -77
- package/src/webview/__tests__/entry.test.ts +64 -64
- package/src/webview/__tests__/render-goals.test.ts +72 -72
- package/src/webview/entry.ts +292 -292
- package/src/webview/render-activities.ts +5 -3
- package/src/webview/render-activity-card.ts +266 -234
- package/src/webview/render-fgca.ts +6 -5
- package/src/webview/render-goals.ts +111 -110
- package/src/webview/render-process-blueprint.ts +207 -207
- package/src/webview/styles.css +322 -323
- package/src/yaml-normalize.ts +56 -56
- package/dist/element-envelope.d.ts +0 -15
- package/dist/element-envelope.js +0 -55
- package/dist/issues/__tests__/layout.test.js +0 -62
- package/dist/issues/__tests__/validate.test.d.ts +0 -1
- package/dist/issues/__tests__/validate.test.js +0 -82
- package/dist/issues/index.d.ts +0 -4
- package/dist/issues/index.js +0 -2
- package/dist/issues/layout.d.ts +0 -10
- package/dist/issues/layout.js +0 -82
- package/dist/issues/types.d.ts +0 -61
- package/dist/issues/types.js +0 -6
- package/dist/issues/validate.d.ts +0 -3
- package/dist/issues/validate.js +0 -143
- package/dist/webview/transitrix-render.css +0 -323
- package/dist/webview/transitrix-render.js +0 -6452
- package/dist/webview/transitrix-render.js.map +0 -7
- package/src/element-envelope.ts +0 -76
- /package/dist/{issues/__tests__/layout.test.d.ts → fgca/__tests__/resolver.test.d.ts} +0 -0
package/src/products/validate.ts
CHANGED
|
@@ -1,112 +1,112 @@
|
|
|
1
|
-
import type { ProductsCatalogueFile, ProductType, ProductStatus } from './types.js';
|
|
2
|
-
import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
|
|
3
|
-
|
|
4
|
-
export type { ValidationError, ValidationWarning, ValidationResult };
|
|
5
|
-
|
|
6
|
-
const VALID_TYPES = new Set<ProductType>(['digital_product', 'service', 'platform', 'bundle']);
|
|
7
|
-
const VALID_STATUSES = new Set<ProductStatus>(['Draft', 'Active', 'Deprecated']);
|
|
8
|
-
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
9
|
-
|
|
10
|
-
export function validateProductsCatalogue(input: unknown): ValidationResult {
|
|
11
|
-
const errors: ValidationError[] = [];
|
|
12
|
-
const warnings: ValidationWarning[] = [];
|
|
13
|
-
|
|
14
|
-
if (!input || typeof input !== 'object') {
|
|
15
|
-
return { valid: false, errors: [{ code: 'PROD-001', message: 'Input must be an object' }], warnings };
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const raw = input as Record<string, unknown>;
|
|
19
|
-
|
|
20
|
-
// PROD-001: notation header
|
|
21
|
-
if (!('notation' in raw)) {
|
|
22
|
-
errors.push({ code: 'PROD-001', message: 'Missing required field: notation' });
|
|
23
|
-
} else if (raw['notation'] !== 'products') {
|
|
24
|
-
errors.push({ code: 'PROD-001', message: `notation must be "products", got "${raw['notation']}"` });
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
28
|
-
|
|
29
|
-
// PROD-002: catalogue header fields
|
|
30
|
-
const cat = (raw['products_catalogue'] ?? {}) as Record<string, unknown>;
|
|
31
|
-
if (!raw['products_catalogue'] || typeof raw['products_catalogue'] !== 'object') {
|
|
32
|
-
errors.push({ code: 'PROD-002', message: 'Missing required field: products_catalogue' });
|
|
33
|
-
return { valid: false, errors, warnings };
|
|
34
|
-
}
|
|
35
|
-
if (!cat['id'] || typeof cat['id'] !== 'string' || !(cat['id'] as string).trim()) {
|
|
36
|
-
errors.push({ code: 'PROD-002', message: 'products_catalogue.id is required' });
|
|
37
|
-
}
|
|
38
|
-
if (!cat['name'] || typeof cat['name'] !== 'string' || !(cat['name'] as string).trim()) {
|
|
39
|
-
errors.push({ code: 'PROD-002', message: 'products_catalogue.name is required' });
|
|
40
|
-
}
|
|
41
|
-
if (!cat['updated_at'] || typeof cat['updated_at'] !== 'string') {
|
|
42
|
-
errors.push({ code: 'PROD-002', message: 'products_catalogue.updated_at is required' });
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
46
|
-
|
|
47
|
-
// PROD-007: updated_at format
|
|
48
|
-
if (!DATE_RE.test(cat['updated_at'] as string)) {
|
|
49
|
-
errors.push({ code: 'PROD-007', message: `products_catalogue.updated_at must be YYYY-MM-DD, got "${cat['updated_at']}"` });
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const products = cat['products'];
|
|
53
|
-
if (!Array.isArray(products)) {
|
|
54
|
-
errors.push({ code: 'PROD-002', message: 'products_catalogue.products must be an array' });
|
|
55
|
-
return { valid: false, errors, warnings };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// PROD-008: unique product_id
|
|
59
|
-
const seenIds = new Set<string>();
|
|
60
|
-
|
|
61
|
-
for (let i = 0; i < products.length; i++) {
|
|
62
|
-
const rawProduct = products[i];
|
|
63
|
-
const idx = `products[${i}]`;
|
|
64
|
-
|
|
65
|
-
if (!rawProduct || typeof rawProduct !== 'object') {
|
|
66
|
-
errors.push({ code: 'PROD-003', message: `${idx} must be an object` });
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
const p = rawProduct as Record<string, unknown>;
|
|
70
|
-
|
|
71
|
-
// PROD-003: required per-product fields
|
|
72
|
-
if (!p['product_id'] || typeof p['product_id'] !== 'string' || !(p['product_id'] as string).trim()) {
|
|
73
|
-
errors.push({ code: 'PROD-003', message: `${idx}: product_id is required` });
|
|
74
|
-
} else {
|
|
75
|
-
const pid = p['product_id'] as string;
|
|
76
|
-
if (seenIds.has(pid)) {
|
|
77
|
-
errors.push({ code: 'PROD-008', message: `Duplicate product_id: "${pid}"` });
|
|
78
|
-
}
|
|
79
|
-
seenIds.add(pid);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (!p['name'] || typeof p['name'] !== 'string' || !(p['name'] as string).trim()) {
|
|
83
|
-
errors.push({ code: 'PROD-003', message: `${idx}: name is required` });
|
|
84
|
-
}
|
|
85
|
-
if (!p['type']) {
|
|
86
|
-
errors.push({ code: 'PROD-003', message: `${idx}: type is required` });
|
|
87
|
-
}
|
|
88
|
-
if (!p['status']) {
|
|
89
|
-
errors.push({ code: 'PROD-003', message: `${idx}: status is required` });
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// PROD-004: type enum
|
|
93
|
-
if (p['type'] && !VALID_TYPES.has(p['type'] as ProductType)) {
|
|
94
|
-
errors.push({ code: 'PROD-004', message: `${idx}: type "${p['type']}" must be one of: digital_product, service, platform, bundle` });
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// PROD-005: status enum
|
|
98
|
-
if (p['status'] && !VALID_STATUSES.has(p['status'] as ProductStatus)) {
|
|
99
|
-
errors.push({ code: 'PROD-005', message: `${idx}: status "${p['status']}" must be one of: Draft, Active, Deprecated` });
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// PROD-006: maturity range
|
|
103
|
-
if (p['maturity'] !== undefined) {
|
|
104
|
-
const m = p['maturity'];
|
|
105
|
-
if (typeof m !== 'number' || !Number.isInteger(m) || m < 1 || m > 5) {
|
|
106
|
-
errors.push({ code: 'PROD-006', message: `${idx}: maturity must be an integer 1–5, got "${m}"` });
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
112
|
-
}
|
|
1
|
+
import type { ProductsCatalogueFile, ProductType, ProductStatus } from './types.js';
|
|
2
|
+
import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
|
|
3
|
+
|
|
4
|
+
export type { ValidationError, ValidationWarning, ValidationResult };
|
|
5
|
+
|
|
6
|
+
const VALID_TYPES = new Set<ProductType>(['digital_product', 'service', 'platform', 'bundle']);
|
|
7
|
+
const VALID_STATUSES = new Set<ProductStatus>(['Draft', 'Active', 'Deprecated']);
|
|
8
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
9
|
+
|
|
10
|
+
export function validateProductsCatalogue(input: unknown): ValidationResult {
|
|
11
|
+
const errors: ValidationError[] = [];
|
|
12
|
+
const warnings: ValidationWarning[] = [];
|
|
13
|
+
|
|
14
|
+
if (!input || typeof input !== 'object') {
|
|
15
|
+
return { valid: false, errors: [{ code: 'PROD-001', message: 'Input must be an object' }], warnings };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const raw = input as Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
// PROD-001: notation header
|
|
21
|
+
if (!('notation' in raw)) {
|
|
22
|
+
errors.push({ code: 'PROD-001', message: 'Missing required field: notation' });
|
|
23
|
+
} else if (raw['notation'] !== 'products') {
|
|
24
|
+
errors.push({ code: 'PROD-001', message: `notation must be "products", got "${raw['notation']}"` });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
28
|
+
|
|
29
|
+
// PROD-002: catalogue header fields
|
|
30
|
+
const cat = (raw['products_catalogue'] ?? {}) as Record<string, unknown>;
|
|
31
|
+
if (!raw['products_catalogue'] || typeof raw['products_catalogue'] !== 'object') {
|
|
32
|
+
errors.push({ code: 'PROD-002', message: 'Missing required field: products_catalogue' });
|
|
33
|
+
return { valid: false, errors, warnings };
|
|
34
|
+
}
|
|
35
|
+
if (!cat['id'] || typeof cat['id'] !== 'string' || !(cat['id'] as string).trim()) {
|
|
36
|
+
errors.push({ code: 'PROD-002', message: 'products_catalogue.id is required' });
|
|
37
|
+
}
|
|
38
|
+
if (!cat['name'] || typeof cat['name'] !== 'string' || !(cat['name'] as string).trim()) {
|
|
39
|
+
errors.push({ code: 'PROD-002', message: 'products_catalogue.name is required' });
|
|
40
|
+
}
|
|
41
|
+
if (!cat['updated_at'] || typeof cat['updated_at'] !== 'string') {
|
|
42
|
+
errors.push({ code: 'PROD-002', message: 'products_catalogue.updated_at is required' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
46
|
+
|
|
47
|
+
// PROD-007: updated_at format
|
|
48
|
+
if (!DATE_RE.test(cat['updated_at'] as string)) {
|
|
49
|
+
errors.push({ code: 'PROD-007', message: `products_catalogue.updated_at must be YYYY-MM-DD, got "${cat['updated_at']}"` });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const products = cat['products'];
|
|
53
|
+
if (!Array.isArray(products)) {
|
|
54
|
+
errors.push({ code: 'PROD-002', message: 'products_catalogue.products must be an array' });
|
|
55
|
+
return { valid: false, errors, warnings };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// PROD-008: unique product_id
|
|
59
|
+
const seenIds = new Set<string>();
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < products.length; i++) {
|
|
62
|
+
const rawProduct = products[i];
|
|
63
|
+
const idx = `products[${i}]`;
|
|
64
|
+
|
|
65
|
+
if (!rawProduct || typeof rawProduct !== 'object') {
|
|
66
|
+
errors.push({ code: 'PROD-003', message: `${idx} must be an object` });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const p = rawProduct as Record<string, unknown>;
|
|
70
|
+
|
|
71
|
+
// PROD-003: required per-product fields
|
|
72
|
+
if (!p['product_id'] || typeof p['product_id'] !== 'string' || !(p['product_id'] as string).trim()) {
|
|
73
|
+
errors.push({ code: 'PROD-003', message: `${idx}: product_id is required` });
|
|
74
|
+
} else {
|
|
75
|
+
const pid = p['product_id'] as string;
|
|
76
|
+
if (seenIds.has(pid)) {
|
|
77
|
+
errors.push({ code: 'PROD-008', message: `Duplicate product_id: "${pid}"` });
|
|
78
|
+
}
|
|
79
|
+
seenIds.add(pid);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!p['name'] || typeof p['name'] !== 'string' || !(p['name'] as string).trim()) {
|
|
83
|
+
errors.push({ code: 'PROD-003', message: `${idx}: name is required` });
|
|
84
|
+
}
|
|
85
|
+
if (!p['type']) {
|
|
86
|
+
errors.push({ code: 'PROD-003', message: `${idx}: type is required` });
|
|
87
|
+
}
|
|
88
|
+
if (!p['status']) {
|
|
89
|
+
errors.push({ code: 'PROD-003', message: `${idx}: status is required` });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// PROD-004: type enum
|
|
93
|
+
if (p['type'] && !VALID_TYPES.has(p['type'] as ProductType)) {
|
|
94
|
+
errors.push({ code: 'PROD-004', message: `${idx}: type "${p['type']}" must be one of: digital_product, service, platform, bundle` });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// PROD-005: status enum
|
|
98
|
+
if (p['status'] && !VALID_STATUSES.has(p['status'] as ProductStatus)) {
|
|
99
|
+
errors.push({ code: 'PROD-005', message: `${idx}: status "${p['status']}" must be one of: Draft, Active, Deprecated` });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// PROD-006: maturity range
|
|
103
|
+
if (p['maturity'] !== undefined) {
|
|
104
|
+
const m = p['maturity'];
|
|
105
|
+
if (typeof m !== 'number' || !Number.isInteger(m) || m < 1 || m > 5) {
|
|
106
|
+
errors.push({ code: 'PROD-006', message: `${idx}: maturity must be an integer 1–5, got "${m}"` });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
112
|
+
}
|
|
@@ -1,149 +1,149 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { validateRepoModel } from '../validate-repo.js';
|
|
3
|
-
import type { RepoDoc, RepoModelInput } from '../types.js';
|
|
4
|
-
|
|
5
|
-
function el(path: string, data: Record<string, unknown> | null, parseError?: string): RepoDoc {
|
|
6
|
-
return { path, data, parseError };
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/** A small clean model mirroring the acme_corp shape: relations use `from`/`to`
|
|
10
|
-
* and every endpoint resolves to an element. Should produce zero findings —
|
|
11
|
-
* the parity bar against lint.py on the worked example. */
|
|
12
|
-
function cleanModel(): RepoModelInput {
|
|
13
|
-
return {
|
|
14
|
-
elements: [
|
|
15
|
-
el('canon/elements/01_motivation/goals/GOAL-OPS-1.yaml', { notation: 'goal', id: 'GOAL-OPS-1' }),
|
|
16
|
-
el('canon/elements/01_motivation/assessments/ASSESSMENT-1.yaml', { notation: 'assessment', id: 'ASSESSMENT-1' }),
|
|
17
|
-
el('canon/elements/02_business/capabilities/CAPABILITY-V1.yaml', { notation: 'capability', id: 'CAPABILITY-V1' }),
|
|
18
|
-
// sidecar with no `id` — must be ignored (no duplicate, not an element)
|
|
19
|
-
el('canon/elements/02_business/capabilities/CAPABILITY-V1.history.yaml', { target: 'CAPABILITY-V1', attribute_versions: [] }),
|
|
20
|
-
],
|
|
21
|
-
relations: [
|
|
22
|
-
el('canon/relations/REL-1.yaml', { notation: 'relation', id: 'REL-1', from: 'ASSESSMENT-1', to: 'GOAL-OPS-1' }),
|
|
23
|
-
],
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe('validateRepoModel — parity / clean tree', () => {
|
|
28
|
-
it('produces zero findings on a clean model (acme_corp parity)', () => {
|
|
29
|
-
expect(validateRepoModel(cleanModel())).toEqual([]);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('ignores sidecar docs without an id (no false duplicate)', () => {
|
|
33
|
-
const findings = validateRepoModel(cleanModel());
|
|
34
|
-
expect(findings.filter((f) => f.message.includes('Duplicate'))).toEqual([]);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('every finding carries the frozen { scope, id, message } shape', () => {
|
|
38
|
-
const model = cleanModel();
|
|
39
|
-
model.relations.push(el('canon/relations/REL-BAD.yaml', { notation: 'relation', id: 'REL-BAD', from: 'NOPE', to: 'GOAL-OPS-1' }));
|
|
40
|
-
const findings = validateRepoModel(model);
|
|
41
|
-
expect(findings.length).toBeGreaterThan(0);
|
|
42
|
-
for (const f of findings) {
|
|
43
|
-
expect(Object.keys(f).sort()).toEqual(['id', 'message', 'scope']);
|
|
44
|
-
expect(f.scope).toBe('repo');
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe('validateRepoModel — referential integrity', () => {
|
|
50
|
-
it('flags a relation whose `from` endpoint is unknown', () => {
|
|
51
|
-
const model = cleanModel();
|
|
52
|
-
model.relations = [el('canon/relations/REL-X.yaml', { notation: 'relation', id: 'REL-X', from: 'MISSING-1', to: 'GOAL-OPS-1' })];
|
|
53
|
-
const findings = validateRepoModel(model);
|
|
54
|
-
expect(findings).toHaveLength(1);
|
|
55
|
-
expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-X' });
|
|
56
|
-
expect(findings[0].message).toContain('MISSING-1');
|
|
57
|
-
expect(findings[0].message).toContain('(from)');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('flags a relation whose `to` endpoint is unknown', () => {
|
|
61
|
-
const model = cleanModel();
|
|
62
|
-
model.relations = [el('canon/relations/REL-Y.yaml', { notation: 'relation', id: 'REL-Y', from: 'GOAL-OPS-1', to: 'MISSING-2' })];
|
|
63
|
-
const findings = validateRepoModel(model);
|
|
64
|
-
expect(findings).toHaveLength(1);
|
|
65
|
-
expect(findings[0].message).toContain('(to)');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('accepts lint.py-style `source`/`target` endpoint keys', () => {
|
|
69
|
-
const model = cleanModel();
|
|
70
|
-
model.relations = [el('canon/relations/REL-ST.yaml', { id: 'REL-ST', source: 'ASSESSMENT-1', target: 'GOAL-OPS-1' })];
|
|
71
|
-
expect(validateRepoModel(model)).toEqual([]);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('resolves `{ id }` object endpoints', () => {
|
|
75
|
-
const model = cleanModel();
|
|
76
|
-
model.relations = [el('canon/relations/REL-OBJ.yaml', { id: 'REL-OBJ', from: { id: 'ASSESSMENT-1' }, to: { id: 'GOAL-OPS-1' } })];
|
|
77
|
-
expect(validateRepoModel(model)).toEqual([]);
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
describe('validateRepoModel — atomicity', () => {
|
|
82
|
-
it('flags an element file that contains an inline `relations` section', () => {
|
|
83
|
-
const model = cleanModel();
|
|
84
|
-
model.elements.push(el('canon/elements/02_business/E.yaml', { id: 'ELEMENT-1', relations: [{ to: 'GOAL-OPS-1' }] }));
|
|
85
|
-
const findings = validateRepoModel(model);
|
|
86
|
-
expect(findings).toHaveLength(1);
|
|
87
|
-
expect(findings[0]).toMatchObject({ scope: 'repo', id: 'ELEMENT-1' });
|
|
88
|
-
expect(findings[0].message).toContain('Atomicity');
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe('validateRepoModel — id uniqueness', () => {
|
|
93
|
-
it('flags the same id defined in two files', () => {
|
|
94
|
-
const model = cleanModel();
|
|
95
|
-
model.elements.push(el('canon/elements/dupe-a.yaml', { id: 'DUP-1' }));
|
|
96
|
-
model.elements.push(el('canon/elements/dupe-b.yaml', { id: 'DUP-1' }));
|
|
97
|
-
const findings = validateRepoModel(model);
|
|
98
|
-
expect(findings).toHaveLength(1);
|
|
99
|
-
expect(findings[0]).toMatchObject({ scope: 'repo', id: 'DUP-1' });
|
|
100
|
-
expect(findings[0].message).toContain('dupe-a.yaml');
|
|
101
|
-
expect(findings[0].message).toContain('dupe-b.yaml');
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('flags an element/relation id collision across zones', () => {
|
|
105
|
-
const model = cleanModel();
|
|
106
|
-
model.elements.push(el('canon/elements/clash.yaml', { id: 'CLASH-1' }));
|
|
107
|
-
model.relations.push(el('canon/relations/clash.yaml', { id: 'CLASH-1', from: 'GOAL-OPS-1', to: 'ASSESSMENT-1' }));
|
|
108
|
-
const findings = validateRepoModel(model);
|
|
109
|
-
expect(findings.filter((f) => f.id === 'CLASH-1')).toHaveLength(1);
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
describe('validateRepoModel — policy', () => {
|
|
114
|
-
it('flags an Active element with no owner', () => {
|
|
115
|
-
const model = cleanModel();
|
|
116
|
-
model.elements.push(el('canon/elements/svc.yaml', { id: 'SVC-1', metadata: { status: 'Active' } }));
|
|
117
|
-
const findings = validateRepoModel(model);
|
|
118
|
-
expect(findings).toHaveLength(1);
|
|
119
|
-
expect(findings[0]).toMatchObject({ scope: 'repo', id: 'SVC-1' });
|
|
120
|
-
expect(findings[0].message).toContain('Policy');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('does not flag an Active element that has an owner', () => {
|
|
124
|
-
const model = cleanModel();
|
|
125
|
-
model.elements.push(el('canon/elements/svc.yaml', { id: 'SVC-2', metadata: { status: 'Active', owner: 'team-a' } }));
|
|
126
|
-
expect(validateRepoModel(model)).toEqual([]);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('does not flag elements using the canon admission record (no metadata.status)', () => {
|
|
130
|
-
// acme_corp elements use `zone`/`admitted_by`, not `metadata.status` — must not fire.
|
|
131
|
-
const model = cleanModel();
|
|
132
|
-
model.elements.push(el('canon/elements/g.yaml', { id: 'GOAL-X', zone: 'canon', admitted_by: 'v.korobeinikov' }));
|
|
133
|
-
expect(validateRepoModel(model)).toEqual([]);
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
describe('validateRepoModel — syntax', () => {
|
|
138
|
-
it('reports a parse error and skips graph checks', () => {
|
|
139
|
-
const model = cleanModel();
|
|
140
|
-
model.elements.push(el('canon/elements/broken.yaml', null, 'bad indentation'));
|
|
141
|
-
// also add a dangling relation that would otherwise fire — it must be suppressed
|
|
142
|
-
model.relations.push(el('canon/relations/REL-Z.yaml', { id: 'REL-Z', from: 'MISSING', to: 'GOAL-OPS-1' }));
|
|
143
|
-
const findings = validateRepoModel(model);
|
|
144
|
-
expect(findings).toHaveLength(1);
|
|
145
|
-
expect(findings[0]).toMatchObject({ scope: 'repo', id: '' });
|
|
146
|
-
expect(findings[0].message).toContain('YAML syntax error');
|
|
147
|
-
expect(findings[0].message).toContain('broken.yaml');
|
|
148
|
-
});
|
|
149
|
-
});
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateRepoModel } from '../validate-repo.js';
|
|
3
|
+
import type { RepoDoc, RepoModelInput } from '../types.js';
|
|
4
|
+
|
|
5
|
+
function el(path: string, data: Record<string, unknown> | null, parseError?: string): RepoDoc {
|
|
6
|
+
return { path, data, parseError };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A small clean model mirroring the acme_corp shape: relations use `from`/`to`
|
|
10
|
+
* and every endpoint resolves to an element. Should produce zero findings —
|
|
11
|
+
* the parity bar against lint.py on the worked example. */
|
|
12
|
+
function cleanModel(): RepoModelInput {
|
|
13
|
+
return {
|
|
14
|
+
elements: [
|
|
15
|
+
el('canon/elements/01_motivation/goals/GOAL-OPS-1.yaml', { notation: 'goal', id: 'GOAL-OPS-1' }),
|
|
16
|
+
el('canon/elements/01_motivation/assessments/ASSESSMENT-1.yaml', { notation: 'assessment', id: 'ASSESSMENT-1' }),
|
|
17
|
+
el('canon/elements/02_business/capabilities/CAPABILITY-V1.yaml', { notation: 'capability', id: 'CAPABILITY-V1' }),
|
|
18
|
+
// sidecar with no `id` — must be ignored (no duplicate, not an element)
|
|
19
|
+
el('canon/elements/02_business/capabilities/CAPABILITY-V1.history.yaml', { target: 'CAPABILITY-V1', attribute_versions: [] }),
|
|
20
|
+
],
|
|
21
|
+
relations: [
|
|
22
|
+
el('canon/relations/REL-1.yaml', { notation: 'relation', id: 'REL-1', from: 'ASSESSMENT-1', to: 'GOAL-OPS-1' }),
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('validateRepoModel — parity / clean tree', () => {
|
|
28
|
+
it('produces zero findings on a clean model (acme_corp parity)', () => {
|
|
29
|
+
expect(validateRepoModel(cleanModel())).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('ignores sidecar docs without an id (no false duplicate)', () => {
|
|
33
|
+
const findings = validateRepoModel(cleanModel());
|
|
34
|
+
expect(findings.filter((f) => f.message.includes('Duplicate'))).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('every finding carries the frozen { scope, id, message } shape', () => {
|
|
38
|
+
const model = cleanModel();
|
|
39
|
+
model.relations.push(el('canon/relations/REL-BAD.yaml', { notation: 'relation', id: 'REL-BAD', from: 'NOPE', to: 'GOAL-OPS-1' }));
|
|
40
|
+
const findings = validateRepoModel(model);
|
|
41
|
+
expect(findings.length).toBeGreaterThan(0);
|
|
42
|
+
for (const f of findings) {
|
|
43
|
+
expect(Object.keys(f).sort()).toEqual(['id', 'message', 'scope']);
|
|
44
|
+
expect(f.scope).toBe('repo');
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('validateRepoModel — referential integrity', () => {
|
|
50
|
+
it('flags a relation whose `from` endpoint is unknown', () => {
|
|
51
|
+
const model = cleanModel();
|
|
52
|
+
model.relations = [el('canon/relations/REL-X.yaml', { notation: 'relation', id: 'REL-X', from: 'MISSING-1', to: 'GOAL-OPS-1' })];
|
|
53
|
+
const findings = validateRepoModel(model);
|
|
54
|
+
expect(findings).toHaveLength(1);
|
|
55
|
+
expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-X' });
|
|
56
|
+
expect(findings[0].message).toContain('MISSING-1');
|
|
57
|
+
expect(findings[0].message).toContain('(from)');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('flags a relation whose `to` endpoint is unknown', () => {
|
|
61
|
+
const model = cleanModel();
|
|
62
|
+
model.relations = [el('canon/relations/REL-Y.yaml', { notation: 'relation', id: 'REL-Y', from: 'GOAL-OPS-1', to: 'MISSING-2' })];
|
|
63
|
+
const findings = validateRepoModel(model);
|
|
64
|
+
expect(findings).toHaveLength(1);
|
|
65
|
+
expect(findings[0].message).toContain('(to)');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('accepts lint.py-style `source`/`target` endpoint keys', () => {
|
|
69
|
+
const model = cleanModel();
|
|
70
|
+
model.relations = [el('canon/relations/REL-ST.yaml', { id: 'REL-ST', source: 'ASSESSMENT-1', target: 'GOAL-OPS-1' })];
|
|
71
|
+
expect(validateRepoModel(model)).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('resolves `{ id }` object endpoints', () => {
|
|
75
|
+
const model = cleanModel();
|
|
76
|
+
model.relations = [el('canon/relations/REL-OBJ.yaml', { id: 'REL-OBJ', from: { id: 'ASSESSMENT-1' }, to: { id: 'GOAL-OPS-1' } })];
|
|
77
|
+
expect(validateRepoModel(model)).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('validateRepoModel — atomicity', () => {
|
|
82
|
+
it('flags an element file that contains an inline `relations` section', () => {
|
|
83
|
+
const model = cleanModel();
|
|
84
|
+
model.elements.push(el('canon/elements/02_business/E.yaml', { id: 'ELEMENT-1', relations: [{ to: 'GOAL-OPS-1' }] }));
|
|
85
|
+
const findings = validateRepoModel(model);
|
|
86
|
+
expect(findings).toHaveLength(1);
|
|
87
|
+
expect(findings[0]).toMatchObject({ scope: 'repo', id: 'ELEMENT-1' });
|
|
88
|
+
expect(findings[0].message).toContain('Atomicity');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('validateRepoModel — id uniqueness', () => {
|
|
93
|
+
it('flags the same id defined in two files', () => {
|
|
94
|
+
const model = cleanModel();
|
|
95
|
+
model.elements.push(el('canon/elements/dupe-a.yaml', { id: 'DUP-1' }));
|
|
96
|
+
model.elements.push(el('canon/elements/dupe-b.yaml', { id: 'DUP-1' }));
|
|
97
|
+
const findings = validateRepoModel(model);
|
|
98
|
+
expect(findings).toHaveLength(1);
|
|
99
|
+
expect(findings[0]).toMatchObject({ scope: 'repo', id: 'DUP-1' });
|
|
100
|
+
expect(findings[0].message).toContain('dupe-a.yaml');
|
|
101
|
+
expect(findings[0].message).toContain('dupe-b.yaml');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('flags an element/relation id collision across zones', () => {
|
|
105
|
+
const model = cleanModel();
|
|
106
|
+
model.elements.push(el('canon/elements/clash.yaml', { id: 'CLASH-1' }));
|
|
107
|
+
model.relations.push(el('canon/relations/clash.yaml', { id: 'CLASH-1', from: 'GOAL-OPS-1', to: 'ASSESSMENT-1' }));
|
|
108
|
+
const findings = validateRepoModel(model);
|
|
109
|
+
expect(findings.filter((f) => f.id === 'CLASH-1')).toHaveLength(1);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('validateRepoModel — policy', () => {
|
|
114
|
+
it('flags an Active element with no owner', () => {
|
|
115
|
+
const model = cleanModel();
|
|
116
|
+
model.elements.push(el('canon/elements/svc.yaml', { id: 'SVC-1', metadata: { status: 'Active' } }));
|
|
117
|
+
const findings = validateRepoModel(model);
|
|
118
|
+
expect(findings).toHaveLength(1);
|
|
119
|
+
expect(findings[0]).toMatchObject({ scope: 'repo', id: 'SVC-1' });
|
|
120
|
+
expect(findings[0].message).toContain('Policy');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('does not flag an Active element that has an owner', () => {
|
|
124
|
+
const model = cleanModel();
|
|
125
|
+
model.elements.push(el('canon/elements/svc.yaml', { id: 'SVC-2', metadata: { status: 'Active', owner: 'team-a' } }));
|
|
126
|
+
expect(validateRepoModel(model)).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('does not flag elements using the canon admission record (no metadata.status)', () => {
|
|
130
|
+
// acme_corp elements use `zone`/`admitted_by`, not `metadata.status` — must not fire.
|
|
131
|
+
const model = cleanModel();
|
|
132
|
+
model.elements.push(el('canon/elements/g.yaml', { id: 'GOAL-X', zone: 'canon', admitted_by: 'v.korobeinikov' }));
|
|
133
|
+
expect(validateRepoModel(model)).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('validateRepoModel — syntax', () => {
|
|
138
|
+
it('reports a parse error and skips graph checks', () => {
|
|
139
|
+
const model = cleanModel();
|
|
140
|
+
model.elements.push(el('canon/elements/broken.yaml', null, 'bad indentation'));
|
|
141
|
+
// also add a dangling relation that would otherwise fire — it must be suppressed
|
|
142
|
+
model.relations.push(el('canon/relations/REL-Z.yaml', { id: 'REL-Z', from: 'MISSING', to: 'GOAL-OPS-1' }));
|
|
143
|
+
const findings = validateRepoModel(model);
|
|
144
|
+
expect(findings).toHaveLength(1);
|
|
145
|
+
expect(findings[0]).toMatchObject({ scope: 'repo', id: '' });
|
|
146
|
+
expect(findings[0].message).toContain('YAML syntax error');
|
|
147
|
+
expect(findings[0].message).toContain('broken.yaml');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { validateRepoModel } from './validate-repo.js';
|
|
2
|
-
export type { RepoDoc, RepoFinding, RepoModelInput } from './types.js';
|
|
1
|
+
export { validateRepoModel } from './validate-repo.js';
|
|
2
|
+
export type { RepoDoc, RepoFinding, RepoModelInput } from './types.js';
|
|
@@ -1,50 +1,50 @@
|
|
|
1
|
-
// Repo-scope validation types (vkgeorgia/transitrix-studio#141).
|
|
2
|
-
//
|
|
3
|
-
// `validate --scope=repo` runs whole-model checks over a loaded `canon/` tree —
|
|
4
|
-
// referential integrity, atomicity, ID uniqueness, policy — the checks that the
|
|
5
|
-
// methodology's Python `.validators/lint.py` owns today. This converges those
|
|
6
|
-
// checks onto the shared TypeScript model so there is a single validation
|
|
7
|
-
// runtime (methodology ADR
|
|
8
|
-
// `2026-06-11-validation-two-axis-model.md`; Studio referencing ADR
|
|
9
|
-
// `docs/decisions/2026-06-11-validation-runtime-convergence.md`).
|
|
10
|
-
//
|
|
11
|
-
// The finding shape is deliberately minimal — `{ scope, id, message }`. The
|
|
12
|
-
// richer `target` / `category` reporting taxonomy is explicitly DEFERRED per the
|
|
13
|
-
// ADR until a real consumer needs it; do not add it here.
|
|
14
|
-
|
|
15
|
-
/** A single repo-scope validation finding. Shape is frozen at `{ scope, id, message }`. */
|
|
16
|
-
export interface RepoFinding {
|
|
17
|
-
/** Always `'repo'` — the execution axis this finding came from. */
|
|
18
|
-
scope: 'repo';
|
|
19
|
-
/**
|
|
20
|
-
* The offending element/relation id, or `''` when the finding is not tied to
|
|
21
|
-
* a single id (e.g. a YAML syntax error in a file with no parseable id).
|
|
22
|
-
*/
|
|
23
|
-
id: string;
|
|
24
|
-
/** Human-readable description of the violation. */
|
|
25
|
-
message: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** A parsed canon document fed to the repo validator. IO (the filesystem walk)
|
|
29
|
-
* lives in the CLI; the validator itself stays pure and testable. */
|
|
30
|
-
export interface RepoDoc {
|
|
31
|
-
/** Source file path, relative to the scanned root — used in messages. */
|
|
32
|
-
path: string;
|
|
33
|
-
/**
|
|
34
|
-
* The parsed top-level YAML mapping, or `null` when the file failed to parse.
|
|
35
|
-
* A `null` data with a non-empty `parseError` produces a syntax finding.
|
|
36
|
-
*/
|
|
37
|
-
data: Record<string, unknown> | null;
|
|
38
|
-
/** Set when the YAML failed to parse; surfaced as a syntax finding. */
|
|
39
|
-
parseError?: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** The loaded repository model: element docs and relation docs, partitioned by
|
|
43
|
-
* canon zone exactly as `lint.py` partitions them (`canon/elements/**` vs
|
|
44
|
-
* `canon/relations/**`). */
|
|
45
|
-
export interface RepoModelInput {
|
|
46
|
-
/** Documents found under `canon/elements/**`. */
|
|
47
|
-
elements: RepoDoc[];
|
|
48
|
-
/** Documents found under `canon/relations/**`. */
|
|
49
|
-
relations: RepoDoc[];
|
|
50
|
-
}
|
|
1
|
+
// Repo-scope validation types (vkgeorgia/transitrix-studio#141).
|
|
2
|
+
//
|
|
3
|
+
// `validate --scope=repo` runs whole-model checks over a loaded `canon/` tree —
|
|
4
|
+
// referential integrity, atomicity, ID uniqueness, policy — the checks that the
|
|
5
|
+
// methodology's Python `.validators/lint.py` owns today. This converges those
|
|
6
|
+
// checks onto the shared TypeScript model so there is a single validation
|
|
7
|
+
// runtime (methodology ADR
|
|
8
|
+
// `2026-06-11-validation-two-axis-model.md`; Studio referencing ADR
|
|
9
|
+
// `docs/decisions/2026-06-11-validation-runtime-convergence.md`).
|
|
10
|
+
//
|
|
11
|
+
// The finding shape is deliberately minimal — `{ scope, id, message }`. The
|
|
12
|
+
// richer `target` / `category` reporting taxonomy is explicitly DEFERRED per the
|
|
13
|
+
// ADR until a real consumer needs it; do not add it here.
|
|
14
|
+
|
|
15
|
+
/** A single repo-scope validation finding. Shape is frozen at `{ scope, id, message }`. */
|
|
16
|
+
export interface RepoFinding {
|
|
17
|
+
/** Always `'repo'` — the execution axis this finding came from. */
|
|
18
|
+
scope: 'repo';
|
|
19
|
+
/**
|
|
20
|
+
* The offending element/relation id, or `''` when the finding is not tied to
|
|
21
|
+
* a single id (e.g. a YAML syntax error in a file with no parseable id).
|
|
22
|
+
*/
|
|
23
|
+
id: string;
|
|
24
|
+
/** Human-readable description of the violation. */
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A parsed canon document fed to the repo validator. IO (the filesystem walk)
|
|
29
|
+
* lives in the CLI; the validator itself stays pure and testable. */
|
|
30
|
+
export interface RepoDoc {
|
|
31
|
+
/** Source file path, relative to the scanned root — used in messages. */
|
|
32
|
+
path: string;
|
|
33
|
+
/**
|
|
34
|
+
* The parsed top-level YAML mapping, or `null` when the file failed to parse.
|
|
35
|
+
* A `null` data with a non-empty `parseError` produces a syntax finding.
|
|
36
|
+
*/
|
|
37
|
+
data: Record<string, unknown> | null;
|
|
38
|
+
/** Set when the YAML failed to parse; surfaced as a syntax finding. */
|
|
39
|
+
parseError?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The loaded repository model: element docs and relation docs, partitioned by
|
|
43
|
+
* canon zone exactly as `lint.py` partitions them (`canon/elements/**` vs
|
|
44
|
+
* `canon/relations/**`). */
|
|
45
|
+
export interface RepoModelInput {
|
|
46
|
+
/** Documents found under `canon/elements/**`. */
|
|
47
|
+
elements: RepoDoc[];
|
|
48
|
+
/** Documents found under `canon/relations/**`. */
|
|
49
|
+
relations: RepoDoc[];
|
|
50
|
+
}
|