@transitrix/diagrams 1.14.0-rc.0 → 1.15.0
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/action-fields.d.ts +6 -0
- package/dist/activities/action-fields.js +34 -0
- package/dist/activities/validate.d.ts +3 -1
- package/dist/activities/validate.js +36 -34
- package/dist/applications/validate.js +27 -26
- package/dist/capability-map/validate.d.ts +4 -1
- package/dist/capability-map/validate.js +32 -30
- package/dist/codex/validate.js +3 -3
- package/dist/compliance/coverage-metric.js +3 -3
- package/dist/compliance/impact.js +4 -4
- package/dist/fgca/parse-canonical.d.ts +0 -25
- package/dist/fgca/parse-canonical.js +16 -26
- package/dist/fgca/projection-origin.d.ts +2 -0
- package/dist/fgca/projection-origin.js +2 -0
- package/dist/fgca/resolver.d.ts +0 -8
- package/dist/fgca/resolver.js +2 -0
- package/dist/products/validate.js +30 -25
- package/dist/repo-validate/check-strategy-chain.d.ts +8 -2
- package/dist/repo-validate/check-strategy-chain.js +64 -64
- package/dist/repo-validate/types.d.ts +2 -0
- package/dist/repo-validate/validate-repo.js +2 -0
- package/dist/risk/index.d.ts +1 -1
- package/dist/risk/types.d.ts +8 -1
- package/dist/risk/validate.js +19 -4
- package/dist/scenarios/validate.js +4 -0
- package/dist/schema-finding.d.ts +3 -0
- package/dist/schema-finding.js +6 -0
- package/dist/standalone/history.d.ts +3 -0
- package/dist/standalone/history.js +67 -0
- package/dist/standalone/validate.d.ts +9 -0
- package/dist/standalone/validate.js +146 -0
- package/dist/webview/entry.js +7 -2
- package/dist/webview/render-goals.js +2 -1
- package/package.json +1 -1
- package/src/activities/action-fields.ts +31 -0
- package/src/activities/validate.ts +37 -34
- package/src/applications/validate.ts +27 -26
- package/src/capability-map/validate.ts +33 -29
- package/src/codex/validate.ts +3 -3
- package/src/compliance/coverage-metric.ts +3 -3
- package/src/compliance/impact.ts +4 -4
- package/src/fgca/parse-canonical.ts +14 -1
- package/src/fgca/projection-origin.ts +2 -0
- package/src/fgca/resolver.ts +2 -0
- package/src/products/validate.ts +31 -25
- package/src/repo-validate/check-strategy-chain.ts +69 -64
- package/src/repo-validate/types.ts +2 -0
- package/src/repo-validate/validate-repo.ts +2 -0
- package/src/risk/index.ts +1 -1
- package/src/risk/types.ts +9 -1
- package/src/risk/validate.ts +19 -5
- package/src/scenarios/validate.ts +5 -0
- package/src/schema-finding.ts +8 -0
- package/src/standalone/history.ts +45 -0
- package/src/standalone/validate.ts +93 -0
- package/src/webview/entry.ts +6 -2
- package/src/webview/render-goals.ts +2 -1
- package/src/webview/styles.css +8 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateActionFields } from '../activities/action-fields.js';
|
|
1
2
|
// Strategy-chain semantic checks — the error-severity subset of DSM's Go
|
|
2
3
|
// `Validate*` functions (`api02/internal/importer/{goals,activities,fgca}.go`
|
|
3
4
|
// in transitrix-dsm), ported onto the standalone-element repo shape
|
|
@@ -5,9 +6,8 @@
|
|
|
5
6
|
// can drop those Go functions without regressing the checks they enforce
|
|
6
7
|
// today.
|
|
7
8
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// import-log taxonomy (`RepoFinding.ruleId`).
|
|
9
|
+
// ACTION identities follow the versioned element contract; schedule ACT codes
|
|
10
|
+
// remain separate. Stored historical findings must retain their original codes.
|
|
11
11
|
//
|
|
12
12
|
// Scope — DSM's full rule set is now ported except GOALS-008 (see below).
|
|
13
13
|
// `RepoFinding` grew a `severity` field precisely so DSM's warn-severity
|
|
@@ -43,20 +43,20 @@
|
|
|
43
43
|
//
|
|
44
44
|
// Ported (error-severity, blocking):
|
|
45
45
|
// GOALS-010 — GOAL `parent` chain contains a cycle.
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
46
|
+
// ACTION-008 — ACTION `predecessors` graph contains a cycle.
|
|
47
|
+
// ACTION-009 — ACTION lists itself as its own predecessor.
|
|
48
|
+
// ACTION-010 — ACTION `start_date`/`end_date` unparseable, or end before start.
|
|
49
|
+
// ACTION-011 — ACTION numeric field (`duration`/`duration_days`, `labor_cost`,
|
|
50
50
|
// `resources_cost`, `effort`, `score`) is negative.
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
51
|
+
// DGCA-REPO-008 — GOAL.factors references an undefined DRIVER.
|
|
52
|
+
// DGCA-REPO-009 — CHANGE.goals references an undefined GOAL.
|
|
53
|
+
// DGCA-REPO-010 — ACTION.delivers_changes references an undefined CHANGE.
|
|
54
|
+
// DGCA-REPO-011 — ACTION.goals references an undefined GOAL.
|
|
55
55
|
//
|
|
56
56
|
// Ported (warning-severity, advisory):
|
|
57
57
|
// GOALS-009 — GOAL.parent is set but does not resolve to a known GOAL (orphan).
|
|
58
58
|
// GOALS-011 — GOAL has no `parent` and `level` >= 1 (backlog).
|
|
59
|
-
//
|
|
59
|
+
// ACTION-007 — ACTION.predecessors entry or ACTION.parent does not resolve
|
|
60
60
|
// to a known ACTION (orphan).
|
|
61
61
|
// FGCA-012 — a DRIVER is not referenced by any GOAL.factors (unreferenced).
|
|
62
62
|
// FGCA-013 — a GOAL is not referenced by any CHANGE.goals or ACTION.goals
|
|
@@ -244,34 +244,34 @@ function checkGoalParentResolution(goals: ChainElement[], findings: RepoFinding[
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
/**
|
|
247
|
+
/** ACTION-009 — an ACTION cannot list itself as its own predecessor. */
|
|
248
248
|
function checkActionSelfPredecessor(actions: ChainElement[], findings: RepoFinding[]): void {
|
|
249
249
|
for (const a of actions) {
|
|
250
250
|
if (readStringArray(a.data, 'predecessors').includes(a.id)) {
|
|
251
251
|
findings.push({
|
|
252
252
|
scope: PScope,
|
|
253
253
|
id: a.id,
|
|
254
|
-
ruleId: '
|
|
255
|
-
message: `
|
|
254
|
+
ruleId: 'ACTION-009',
|
|
255
|
+
message: `ACTION-009: action '${a.id}' lists itself as a predecessor.`,
|
|
256
256
|
});
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
/**
|
|
261
|
+
/** ACTION-008 — the ACTION `predecessors` graph must not contain a cycle. */
|
|
262
262
|
function checkActionPredecessorCycle(actions: ChainElement[], findings: RepoFinding[]): void {
|
|
263
263
|
const cyc = findPredecessorCycle(actions);
|
|
264
264
|
if (cyc) {
|
|
265
265
|
findings.push({
|
|
266
266
|
scope: PScope,
|
|
267
267
|
id: cyc,
|
|
268
|
-
ruleId: '
|
|
269
|
-
message: `
|
|
268
|
+
ruleId: 'ACTION-008',
|
|
269
|
+
message: `ACTION-008: predecessor graph contains a cycle involving action '${cyc}'.`,
|
|
270
270
|
});
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
/**
|
|
274
|
+
/** ACTION-007 — an ACTION `predecessors` entry or `parent` that does not resolve
|
|
275
275
|
* to a known ACTION is an orphan reference (warning — advisory in DSM, same
|
|
276
276
|
* as the goal-parent orphan checks above). */
|
|
277
277
|
function checkActionOrphanReferences(actions: ChainElement[], findings: RepoFinding[]): void {
|
|
@@ -282,9 +282,9 @@ function checkActionOrphanReferences(actions: ChainElement[], findings: RepoFind
|
|
|
282
282
|
findings.push({
|
|
283
283
|
scope: PScope,
|
|
284
284
|
id: a.id,
|
|
285
|
-
ruleId: '
|
|
285
|
+
ruleId: 'ACTION-007',
|
|
286
286
|
severity: 'warning',
|
|
287
|
-
message: `
|
|
287
|
+
message: `ACTION-007: action '${a.id}' predecessor '${p}' does not resolve to a known action.`,
|
|
288
288
|
});
|
|
289
289
|
}
|
|
290
290
|
}
|
|
@@ -293,9 +293,9 @@ function checkActionOrphanReferences(actions: ChainElement[], findings: RepoFind
|
|
|
293
293
|
findings.push({
|
|
294
294
|
scope: PScope,
|
|
295
295
|
id: a.id,
|
|
296
|
-
ruleId: '
|
|
296
|
+
ruleId: 'ACTION-007',
|
|
297
297
|
severity: 'warning',
|
|
298
|
-
message: `
|
|
298
|
+
message: `ACTION-007: action '${a.id}' parent '${parent}' does not resolve to a known action.`,
|
|
299
299
|
});
|
|
300
300
|
}
|
|
301
301
|
}
|
|
@@ -316,34 +316,34 @@ function parseIsoDate(value: string): Date | undefined {
|
|
|
316
316
|
return dt;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
-
/**
|
|
319
|
+
/** ACTION-010 — `start_date`/`end_date` must be valid ISO dates, and `end_date`
|
|
320
320
|
* must not be before `start_date` (equal is allowed — e.g. a milestone). */
|
|
321
321
|
function checkActionDates(actions: ChainElement[], findings: RepoFinding[]): void {
|
|
322
322
|
for (const a of actions) {
|
|
323
|
-
const startRaw =
|
|
324
|
-
const endRaw =
|
|
323
|
+
const startRaw = a.data.start_date == null ? undefined : a.data.start_date;
|
|
324
|
+
const endRaw = a.data.end_date == null ? undefined : a.data.end_date;
|
|
325
325
|
let start: Date | undefined;
|
|
326
326
|
let end: Date | undefined;
|
|
327
327
|
|
|
328
328
|
if (startRaw !== undefined) {
|
|
329
|
-
start = parseIsoDate(startRaw);
|
|
329
|
+
start = typeof startRaw === 'string' ? parseIsoDate(startRaw) : undefined;
|
|
330
330
|
if (!start) {
|
|
331
331
|
findings.push({
|
|
332
332
|
scope: PScope,
|
|
333
333
|
id: a.id,
|
|
334
|
-
ruleId: '
|
|
335
|
-
message: `
|
|
334
|
+
ruleId: 'ACTION-010',
|
|
335
|
+
message: `ACTION-010: action '${a.id}' start_date '${startRaw}' is not a valid YYYY-MM-DD date.`,
|
|
336
336
|
});
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
339
|
if (endRaw !== undefined) {
|
|
340
|
-
end = parseIsoDate(endRaw);
|
|
340
|
+
end = typeof endRaw === 'string' ? parseIsoDate(endRaw) : undefined;
|
|
341
341
|
if (!end) {
|
|
342
342
|
findings.push({
|
|
343
343
|
scope: PScope,
|
|
344
344
|
id: a.id,
|
|
345
|
-
ruleId: '
|
|
346
|
-
message: `
|
|
345
|
+
ruleId: 'ACTION-010',
|
|
346
|
+
message: `ACTION-010: action '${a.id}' end_date '${endRaw}' is not a valid YYYY-MM-DD date.`,
|
|
347
347
|
});
|
|
348
348
|
}
|
|
349
349
|
}
|
|
@@ -351,36 +351,24 @@ function checkActionDates(actions: ChainElement[], findings: RepoFinding[]): voi
|
|
|
351
351
|
findings.push({
|
|
352
352
|
scope: PScope,
|
|
353
353
|
id: a.id,
|
|
354
|
-
ruleId: '
|
|
355
|
-
message: `
|
|
354
|
+
ruleId: 'ACTION-010',
|
|
355
|
+
message: `ACTION-010: action '${a.id}' end_date '${endRaw}' is before start_date '${startRaw}'.`,
|
|
356
356
|
});
|
|
357
357
|
}
|
|
358
358
|
}
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
-
/**
|
|
362
|
-
|
|
363
|
-
* (elements/24-action.md §2), but `duration_days` is an accepted alias in
|
|
364
|
-
* this codebase's document-form validator (activities/validate.ts) and is
|
|
365
|
-
* the field acme_corp's own ACTION elements actually use. */
|
|
366
|
-
function checkActionNegativeNumbers(actions: ChainElement[], findings: RepoFinding[]): void {
|
|
367
|
-
const fields = ['duration', 'duration_days', 'labor_cost', 'resources_cost', 'effort', 'score'] as const;
|
|
361
|
+
/** Shared ACTION type and versioned numeric checks. */
|
|
362
|
+
function checkActionFields(actions: ChainElement[], findings: RepoFinding[], version?: string): void {
|
|
368
363
|
for (const a of actions) {
|
|
369
|
-
for (const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
findings.push({
|
|
373
|
-
scope: PScope,
|
|
374
|
-
id: a.id,
|
|
375
|
-
ruleId: 'ACT-009',
|
|
376
|
-
message: `ACT-009: action '${a.id}' field '${field}' is negative (${v}).`,
|
|
377
|
-
});
|
|
378
|
-
}
|
|
364
|
+
for (const error of validateActionFields(a.data, version)) {
|
|
365
|
+
findings.push({ scope: PScope, id: a.id, ruleId: error.code,
|
|
366
|
+
message: `${a.doc.path}: canonical action '${a.id}' ${error.message}` });
|
|
379
367
|
}
|
|
380
368
|
}
|
|
381
369
|
}
|
|
382
370
|
|
|
383
|
-
/**
|
|
371
|
+
/** DGCA-REPO-008..011 — inline strategy-chain cross-references must resolve within
|
|
384
372
|
* the repo: GOAL.factors -> DRIVER, CHANGE.goals -> GOAL, ACTION.goals ->
|
|
385
373
|
* GOAL, ACTION.delivers_changes -> CHANGE (ELEMENT_PRIMITIVES.md §7.1-§7.4). */
|
|
386
374
|
function checkStrategyChainReferences(
|
|
@@ -400,8 +388,8 @@ function checkStrategyChainReferences(
|
|
|
400
388
|
findings.push({
|
|
401
389
|
scope: PScope,
|
|
402
390
|
id: g.id,
|
|
403
|
-
ruleId: '
|
|
404
|
-
message: `
|
|
391
|
+
ruleId: 'DGCA-REPO-008',
|
|
392
|
+
message: `DGCA-REPO-008: goal '${g.id}' references undefined driver '${f}'.`,
|
|
405
393
|
});
|
|
406
394
|
}
|
|
407
395
|
}
|
|
@@ -412,8 +400,8 @@ function checkStrategyChainReferences(
|
|
|
412
400
|
findings.push({
|
|
413
401
|
scope: PScope,
|
|
414
402
|
id: c.id,
|
|
415
|
-
ruleId: '
|
|
416
|
-
message: `
|
|
403
|
+
ruleId: 'DGCA-REPO-009',
|
|
404
|
+
message: `DGCA-REPO-009: change '${c.id}' references undefined goal '${g}'.`,
|
|
417
405
|
});
|
|
418
406
|
}
|
|
419
407
|
}
|
|
@@ -424,8 +412,8 @@ function checkStrategyChainReferences(
|
|
|
424
412
|
findings.push({
|
|
425
413
|
scope: PScope,
|
|
426
414
|
id: a.id,
|
|
427
|
-
ruleId: '
|
|
428
|
-
message: `
|
|
415
|
+
ruleId: 'DGCA-REPO-010',
|
|
416
|
+
message: `DGCA-REPO-010: action '${a.id}' references undefined change '${c}'.`,
|
|
429
417
|
});
|
|
430
418
|
}
|
|
431
419
|
}
|
|
@@ -434,8 +422,8 @@ function checkStrategyChainReferences(
|
|
|
434
422
|
findings.push({
|
|
435
423
|
scope: PScope,
|
|
436
424
|
id: a.id,
|
|
437
|
-
ruleId: '
|
|
438
|
-
message: `
|
|
425
|
+
ruleId: 'DGCA-REPO-011',
|
|
426
|
+
message: `DGCA-REPO-011: action '${a.id}' references undefined goal '${g}'.`,
|
|
439
427
|
});
|
|
440
428
|
}
|
|
441
429
|
}
|
|
@@ -505,8 +493,8 @@ function checkStrategyChainOrphans(
|
|
|
505
493
|
}
|
|
506
494
|
|
|
507
495
|
/**
|
|
508
|
-
* Run the strategy-chain semantic checks (GOALS-009..011,
|
|
509
|
-
*
|
|
496
|
+
* Run the strategy-chain semantic checks (GOALS-009..011, ACTION-007..011,
|
|
497
|
+
* DGCA-REPO-008..014 except GOALS-008 — see the module header) over the loaded
|
|
510
498
|
* element set and append findings. Called from `validateRepoModel` after the
|
|
511
499
|
* structural phases. Pure, deterministic order.
|
|
512
500
|
*/
|
|
@@ -522,7 +510,24 @@ export function checkStrategyChainSemantics(input: RepoModelInput, findings: Rep
|
|
|
522
510
|
checkActionPredecessorCycle(actions, findings);
|
|
523
511
|
checkActionOrphanReferences(actions, findings);
|
|
524
512
|
checkActionDates(actions, findings);
|
|
525
|
-
|
|
513
|
+
checkActionFields(actions, findings, input.methodologyVersion);
|
|
526
514
|
checkStrategyChainReferences(goals, actions, drivers, changes, findings);
|
|
527
|
-
checkStrategyChainOrphans(goals, actions, drivers, changes, findings);
|
|
515
|
+
if (!usesCoverageObservations(input)) checkStrategyChainOrphans(goals, actions, drivers, changes, findings);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
function usesCoverageObservations(input: RepoModelInput): boolean {
|
|
520
|
+
return /^\d+\./.test(input.methodologyVersion ?? '') && Number(input.methodologyVersion!.split('.')[0]) >= 5;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Removed FGCA orphan rules become visible coverage observations, not new aliases. */
|
|
524
|
+
export function collectStrategyCoverageObservations(input: RepoModelInput): Array<{id: string; kind: 'unreferenced'; message: string}> {
|
|
525
|
+
if (!usesCoverageObservations(input)) return [];
|
|
526
|
+
const findings: RepoFinding[] = [];
|
|
527
|
+
checkStrategyChainOrphans(
|
|
528
|
+
collectByNotation(input.elements, isGoalNotation),
|
|
529
|
+
collectByNotation(input.elements, isActionNotation),
|
|
530
|
+
collectByNotation(input.elements, isDriverNotation),
|
|
531
|
+
collectByNotation(input.elements, isChangeNotation), findings);
|
|
532
|
+
return findings.map(f => ({ id: f.id, kind: 'unreferenced', message: f.message.replace(/^FGCA-01[234]: /, '') }));
|
|
528
533
|
}
|
|
@@ -74,6 +74,8 @@ export interface RepoDoc {
|
|
|
74
74
|
* canon zone exactly as `lint.py` partitions them (`canon/elements/**` vs
|
|
75
75
|
* `canon/relations/**`). */
|
|
76
76
|
export interface RepoModelInput {
|
|
77
|
+
/** Catalogue manifest version; document spec_version never selects compatibility. */
|
|
78
|
+
methodologyVersion?: string;
|
|
77
79
|
/** Documents found under `canon/elements/**`. */
|
|
78
80
|
elements: RepoDoc[];
|
|
79
81
|
/** Documents found under `canon/relations/**`. */
|
|
@@ -135,6 +135,7 @@ function checkReferentialIntegrity(input: RepoModelInput, findings: RepoFinding[
|
|
|
135
135
|
findings.push({
|
|
136
136
|
scope: PScope,
|
|
137
137
|
id: relId,
|
|
138
|
+
ruleId: 'REL-002',
|
|
138
139
|
message: `Referential integrity: relation '${relId || doc.path}' endpoint '${fromId}' (from) does not resolve to a known element.`,
|
|
139
140
|
});
|
|
140
141
|
}
|
|
@@ -142,6 +143,7 @@ function checkReferentialIntegrity(input: RepoModelInput, findings: RepoFinding[
|
|
|
142
143
|
findings.push({
|
|
143
144
|
scope: PScope,
|
|
144
145
|
id: relId,
|
|
146
|
+
ruleId: 'REL-002',
|
|
145
147
|
message: `Referential integrity: relation '${relId || doc.path}' endpoint '${toId}' (to) does not resolve to a known element.`,
|
|
146
148
|
});
|
|
147
149
|
}
|
package/src/risk/index.ts
CHANGED
package/src/risk/types.ts
CHANGED
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
import type { GateChecks } from '../requirement/types.js';
|
|
5
5
|
|
|
6
6
|
/** Likelihood / impact / residual severity vocabulary (§7.26). */
|
|
7
|
-
export type RiskLevel = 'low' | 'medium' | 'high';
|
|
7
|
+
export type RiskLevel = 'low' | 'medium' | 'high' | number;
|
|
8
|
+
|
|
9
|
+
export interface RiskScale {
|
|
10
|
+
id: string;
|
|
11
|
+
min: number;
|
|
12
|
+
max: number;
|
|
13
|
+
direction: 'higher' | 'lower';
|
|
14
|
+
}
|
|
8
15
|
|
|
9
16
|
export const RISK_LEVELS: readonly RiskLevel[] = ['low', 'medium', 'high'];
|
|
10
17
|
|
|
@@ -12,6 +19,7 @@ export interface Risk {
|
|
|
12
19
|
notation: 'risk';
|
|
13
20
|
id: string;
|
|
14
21
|
name: string;
|
|
22
|
+
risk_scale?: RiskScale;
|
|
15
23
|
likelihood: RiskLevel;
|
|
16
24
|
impact: RiskLevel;
|
|
17
25
|
residual: RiskLevel;
|
package/src/risk/validate.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Codes:
|
|
4
4
|
// RISK-001 — shape / id grammar / required envelope + per-type fields
|
|
5
5
|
// (likelihood, impact, residual, owner_role, threatens).
|
|
6
|
-
// RISK-002 —
|
|
6
|
+
// RISK-002 — invalid qualitative/numeric degree or adopter scale.
|
|
7
7
|
// RISK-003 — threatens is empty, or an entry does not resolve to an
|
|
8
8
|
// admitted element in canon (no TYPE restriction — any core
|
|
9
9
|
// element may be threatened).
|
|
@@ -62,13 +62,27 @@ export function validateRisk(input: unknown, options: RiskValidateOptions = {}):
|
|
|
62
62
|
errors.push({ code: 'RISK-001', message: 'valid_to is required (an ISO date string or null).', path: 'valid_to' });
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
//
|
|
66
|
-
|
|
65
|
+
// Authored numeric degrees use one explicitly declared adopter scale.
|
|
66
|
+
const fields = ['likelihood', 'impact', 'residual'] as const;
|
|
67
|
+
const scale = r.risk_scale as Record<string, unknown> | undefined;
|
|
68
|
+
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
|
69
|
+
const validScale = scale !== null && typeof scale === 'object' && !Array.isArray(scale)
|
|
70
|
+
&& typeof scale.id === 'string' && scale.id.trim().length > 0
|
|
71
|
+
&& finite(scale.min) && finite(scale.max) && scale.min < scale.max
|
|
72
|
+
&& (scale.direction === 'higher' || scale.direction === 'lower');
|
|
73
|
+
if ((scale !== undefined || fields.some(f => typeof r[f] === 'number')) && !validScale) {
|
|
74
|
+
errors.push({ code: 'RISK-002', message: 'risk_scale requires id, finite min < max, and direction higher or lower.', path: 'risk_scale' });
|
|
75
|
+
}
|
|
76
|
+
for (const f of fields) {
|
|
67
77
|
const v = r[f];
|
|
68
78
|
if (v === undefined || v === null || v === '') {
|
|
69
79
|
errors.push({ code: 'RISK-001', message: `${f} is required.`, path: f });
|
|
70
|
-
} else if (
|
|
71
|
-
|
|
80
|
+
} else if (typeof v === 'number') {
|
|
81
|
+
if (!finite(v) || (validScale && (v < (scale!.min as number) || v > (scale!.max as number)))) {
|
|
82
|
+
errors.push({ code: 'RISK-002', message: `${f} must be finite and within risk_scale bounds.`, path: f });
|
|
83
|
+
}
|
|
84
|
+
} else if (!(RISK_LEVELS as readonly unknown[]).includes(v)) {
|
|
85
|
+
errors.push({ code: 'RISK-002', message: `${f} must be low, medium, high, or a number with risk_scale.`, path: f });
|
|
72
86
|
}
|
|
73
87
|
}
|
|
74
88
|
|
|
@@ -38,6 +38,11 @@ export function validateScenario(input: unknown): ValidationResult {
|
|
|
38
38
|
}
|
|
39
39
|
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
40
40
|
|
|
41
|
+
if (('view_config' in raw || 'view' in raw || 'scenarios' in raw) && !('scenario' in raw)) {
|
|
42
|
+
return { valid: true, errors, warnings: [{ code: 'NOTATION-SKIP-001',
|
|
43
|
+
message: 'scenarios projection or scenario-set form is unsupported and remains unvalidated.' }] };
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
const scn = raw['scenario'];
|
|
42
47
|
if (!scn || typeof scn !== 'object') {
|
|
43
48
|
errors.push({ code: 'SCN-002', message: 'Missing required field: scenario' });
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ValidationError } from './validation-types.js';
|
|
2
|
+
|
|
3
|
+
/** Shared schema identity; callers supply the owning form and exact field. */
|
|
4
|
+
export function schemaFinding(form: string, path: string, expected: string, actual: unknown): ValidationError {
|
|
5
|
+
const type = actual === null ? 'null' : Array.isArray(actual) ? 'array' : typeof actual;
|
|
6
|
+
return { code: 'SCHEMA_INVALID', path,
|
|
7
|
+
message: `${form} field '${path}': expected ${expected}; actual ${type} (${String(actual)}).` };
|
|
8
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Strict raw history validation. Never filter malformed entries before checking them.
|
|
2
|
+
import type { ValidationResult } from '../validation-types.js';
|
|
3
|
+
import { mapping, isoDate, type StandaloneOptions } from './validate.js';
|
|
4
|
+
import { validateAttributeArray } from '../versioned-attribute/validate.js';
|
|
5
|
+
import { isCanonicalIdOfType } from '../typed-id.js';
|
|
6
|
+
|
|
7
|
+
export function validateHistory(input: unknown, options: StandaloneOptions = {}): ValidationResult {
|
|
8
|
+
const errors: ValidationResult['errors'] = [], warnings: ValidationResult['warnings'] = [];
|
|
9
|
+
const fail = (field: string, expected: string, actual: unknown, code = 'SCHEMA_INVALID') => errors.push({code, message: `history sidecar: ${field}: expected ${expected}; actual ${JSON.stringify(actual) ?? 'missing'}.`, path:field});
|
|
10
|
+
if (!mapping(input)) { fail('$', 'mapping', input); return {valid:false, errors, warnings}; }
|
|
11
|
+
const d = input;
|
|
12
|
+
if (typeof d.target !== 'string' || !d.target.trim()) fail('target', 'canonical target ID', d.target, 'VERSIONED-001');
|
|
13
|
+
const target = typeof d.target === 'string' ? options.documents?.get(d.target) : undefined;
|
|
14
|
+
if (options.documents && (!target || !options.catalog?.typeOf(d.target as string))) fail('target', 'admitted primitive', d.target, 'VERSIONED-001');
|
|
15
|
+
const notation = target?.notation;
|
|
16
|
+
const attributes: Record<string, string[]> = {capability: ['current_maturity','target_maturity','target_date','owner_role'], application: ['maturity','vendor','owner_role']};
|
|
17
|
+
const allowed = typeof notation === 'string' ? attributes[notation] : undefined;
|
|
18
|
+
if (!allowed) warnings.push({code:'NOTATION-SKIP-001', message: 'History target schema is unavailable; attribute coverage remains unvalidated.'});
|
|
19
|
+
if (!mapping(d.attribute_versions)) fail('attribute_versions', 'mapping', d.attribute_versions);
|
|
20
|
+
else for (const [attribute, entries] of Object.entries(d.attribute_versions)) {
|
|
21
|
+
const field = `attribute_versions.${attribute}`;
|
|
22
|
+
if (allowed && !allowed.includes(attribute)) fail(field, 'declared time_varying attribute', attribute);
|
|
23
|
+
if (!Array.isArray(entries)) { fail(field, 'array', entries); continue; }
|
|
24
|
+
const checked: {valid_from: string; value: string | number | boolean | null}[] = [];
|
|
25
|
+
for (const [i, entry] of entries.entries()) {
|
|
26
|
+
const key = `${field}[${i}]`;
|
|
27
|
+
if (!mapping(entry)) { fail(key, 'mapping', entry); continue; }
|
|
28
|
+
if (!isoDate(entry.valid_from)) fail(`${key}.valid_from`, 'ISO calendar date', entry.valid_from);
|
|
29
|
+
const value = entry.value;
|
|
30
|
+
const scalar = value === null || ['string','number','boolean'].includes(typeof value);
|
|
31
|
+
if (!('value' in entry) || !scalar) fail(`${key}.value`, 'scalar or null', value);
|
|
32
|
+
if (scalar && isoDate(entry.valid_from)) checked.push({valid_from: entry.valid_from, value: value as string | number | boolean | null});
|
|
33
|
+
if (value === null || !allowed?.includes(attribute)) continue;
|
|
34
|
+
if (['current_maturity','target_maturity','maturity'].includes(attribute) && !(Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 5)) fail(`${key}.value`, 'integer 1..5 or null', value);
|
|
35
|
+
if (attribute === 'target_date' && !isoDate(value)) fail(`${key}.value`, 'ISO calendar date or null', value);
|
|
36
|
+
if (attribute === 'vendor' && typeof value !== 'string') fail(`${key}.value`, 'string or null', value);
|
|
37
|
+
if (attribute === 'owner_role' && (!isCanonicalIdOfType(value, 'ROLE') || (options.catalog && options.catalog.typeOf(value as string) !== 'ROLE'))) fail(`${key}.value`, 'admitted ROLE reference or null', value);
|
|
38
|
+
}
|
|
39
|
+
for (const finding of validateAttributeArray(attribute, checked, typeof target?.valid_from === 'string' ? target.valid_from : undefined, target?.valid_to === null || typeof target?.valid_to === 'string' ? target.valid_to : undefined)) {
|
|
40
|
+
(finding.severity === 'error' ? errors : warnings).push({code:finding.code, message:finding.message});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const key of ['notation','id','zone','admitted_at','admitted_by','valid_from','valid_to']) if (key in d) fail(key, 'absent on headerless history sidecar', d[key]);
|
|
44
|
+
return {valid:errors.length === 0, errors, warnings};
|
|
45
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Standalone forms from ELEMENT_PRIMITIVES and CONTRACT (Methodology 7).
|
|
2
|
+
// Field payloads are opaque: only their published identity/admission contract is checked.
|
|
3
|
+
import { isCanonicalIdOfType, typeOfId, type CanonCatalog } from '../typed-id.js';
|
|
4
|
+
import type { ValidationResult } from '../validation-types.js';
|
|
5
|
+
|
|
6
|
+
export const mapping = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
7
|
+
const text = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0;
|
|
8
|
+
export const isoDate = (v: unknown): v is string => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v)) && new Date(v).toISOString().slice(0, 10) === v;
|
|
9
|
+
const capabilityId = (v: unknown) => typeof v === 'string' && /^CAPABILITY-[VH][1-9]\d*(?:\.[1-9]\d*)*$/.test(v);
|
|
10
|
+
export interface StandaloneOptions { catalog?: CanonCatalog; documents?: ReadonlyMap<string, Record<string, unknown>> }
|
|
11
|
+
|
|
12
|
+
export function validateStandalone(notation: string, input: unknown, options: StandaloneOptions = {}): ValidationResult {
|
|
13
|
+
const errors: ValidationResult['errors'] = [], warnings: ValidationResult['warnings'] = [];
|
|
14
|
+
const fail = (field: string, expected: string, actual: unknown, code = 'SCHEMA_INVALID') => errors.push({code, message: `${notation} standalone: ${field}: expected ${expected}; actual ${JSON.stringify(actual) ?? 'missing'}.`, path: field});
|
|
15
|
+
if (!mapping(input)) { fail('$', 'mapping', input); return {valid:false, errors, warnings}; }
|
|
16
|
+
const d = input, field = ['draft', 'observation'].includes(notation);
|
|
17
|
+
const requiredText = (key: string) => { if (!text(d[key])) fail(key, 'nonempty string', d[key]); };
|
|
18
|
+
const optionalText = (key: string) => { if (key in d && !text(d[key])) fail(key, 'nonempty string', d[key]); };
|
|
19
|
+
if (d.notation !== notation && !(field && d.notation === undefined && d.type === notation.toUpperCase())) fail('notation', notation, d.notation);
|
|
20
|
+
if (!(notation === 'capability' ? capabilityId(d.id) : isCanonicalIdOfType(d.id, notation.toUpperCase()))) fail('id', `${notation.toUpperCase()} canonical ID`, d.id);
|
|
21
|
+
if (!field) requiredText('name'); else optionalText('name');
|
|
22
|
+
if (d.zone !== (field ? 'field' : 'canon')) fail('zone', field ? 'field' : 'canon', d.zone);
|
|
23
|
+
requiredText('admitted_by');
|
|
24
|
+
if (!isoDate(d.admitted_at)) fail('admitted_at', 'ISO calendar date', d.admitted_at);
|
|
25
|
+
if (!mapping(d.gate_checks)) fail('gate_checks', 'mapping', d.gate_checks);
|
|
26
|
+
else for (const key of field ? ['provenance'] : ['uniqueness', 'consistency', 'completeness']) {
|
|
27
|
+
if (!text(d.gate_checks[key])) fail(`gate_checks.${key}`, 'nonempty check outcome', d.gate_checks[key]);
|
|
28
|
+
}
|
|
29
|
+
if (!field) {
|
|
30
|
+
if (!isoDate(d.valid_from)) fail('valid_from', 'ISO calendar date', d.valid_from, 'LIFECYCLE-001');
|
|
31
|
+
if (!(d.valid_to === null || isoDate(d.valid_to))) fail('valid_to', 'ISO calendar date or null', d.valid_to, 'LIFECYCLE-002');
|
|
32
|
+
if (isoDate(d.valid_from) && isoDate(d.valid_to) && d.valid_to < d.valid_from) fail('valid_to', 'date on or after valid_from', d.valid_to, 'LIFECYCLE-003');
|
|
33
|
+
}
|
|
34
|
+
const ref = (key: string, value: unknown, types: string[]) => {
|
|
35
|
+
const type = typeOfId(value);
|
|
36
|
+
if (!type || !types.includes(type) || !(type === 'CAPABILITY' ? capabilityId(value) : isCanonicalIdOfType(value, type))) { fail(key, types.join('|') + ' canonical reference', value); return; }
|
|
37
|
+
if (options.catalog && !types.includes(options.catalog.typeOf(value as string) ?? '')) fail(key, 'reference to admitted ' + types.join('|'), value);
|
|
38
|
+
};
|
|
39
|
+
const reference = (key: string, types: string[], required = false) => { if (required || key in d) ref(key, d[key], types); };
|
|
40
|
+
const references = (key: string, types: string[], required = false) => {
|
|
41
|
+
if (!required && !(key in d)) return;
|
|
42
|
+
const values = d[key];
|
|
43
|
+
if (!Array.isArray(values) || (required && values.length === 0)) { fail(key, required ? 'nonempty list of references' : 'list of references', values); return; }
|
|
44
|
+
values.forEach((v, i) => ref(`${key}[${i}]`, v, types));
|
|
45
|
+
};
|
|
46
|
+
optionalText('description');
|
|
47
|
+
if (notation === 'product') {
|
|
48
|
+
if (!['digital_product','service','platform','bundle'].includes(d.type as string)) fail('type', 'digital_product|service|platform|bundle', d.type);
|
|
49
|
+
optionalText('domain'); reference('owner_role', ['ROLE']);
|
|
50
|
+
if ('maturity' in d && !(Number.isInteger(d.maturity) && Number(d.maturity) >= 1 && Number(d.maturity) <= 5)) fail('maturity', 'integer 1..5', d.maturity);
|
|
51
|
+
references('capabilities', ['CAPABILITY']); references('processes', ['PROCESS']); references('supporting_apps', ['APPLICATION']);
|
|
52
|
+
} else if (notation === 'capability') {
|
|
53
|
+
if (!['domain','supporting'].includes(d.type as string)) fail('type', 'domain|supporting', d.type);
|
|
54
|
+
for (const key of ['current_maturity','target_maturity','owner_role','target_date']) if (key in d) fail(key, 'attribute in history sidecar, absent inline', d[key], 'VERSIONED-004');
|
|
55
|
+
reference('business_process', ['PROCESS']); references('applications', ['APPLICATION']);
|
|
56
|
+
} else if (notation === 'role') {
|
|
57
|
+
optionalText('responsibility_area'); reference('unit', ['ACTOR']);
|
|
58
|
+
const unit = typeof d.unit === 'string' ? options.documents?.get(d.unit) : undefined;
|
|
59
|
+
if (unit && unit.type !== 'business_unit') fail('unit', 'ACTOR of type business_unit', d.unit);
|
|
60
|
+
} else if (notation === 'assessment') {
|
|
61
|
+
requiredText('description'); reference('assesses', ['DRIVER','FACTOR'], true);
|
|
62
|
+
for (const key of ['method','source']) optionalText(key);
|
|
63
|
+
if ('observed_at' in d && !isoDate(d.observed_at)) fail('observed_at', 'ISO calendar date', d.observed_at);
|
|
64
|
+
for (const key of ['polarity','swot','type']) if (key in d) fail(key, 'absent on assessment', d[key]);
|
|
65
|
+
if ('geographic_scope' in d) {
|
|
66
|
+
const values = d.geographic_scope;
|
|
67
|
+
// Country vocabulary is checked by the shared jurisdiction set below.
|
|
68
|
+
if (!Array.isArray(values) || !values.length || values.some(v => typeof v !== 'string' || !COUNTRIES.has(v.toLowerCase())) || (values.some(v => typeof v === 'string' && v.toLowerCase() === 'global') && values.length !== 1)) fail('geographic_scope', 'nonempty ISO country/eu list, or [global]', values, 'ASSESS-001');
|
|
69
|
+
}
|
|
70
|
+
} else if (notation === 'scenario') {
|
|
71
|
+
references('pursues', ['GOAL'], true); reference('arrives_at', ['TARGET_STATE'], true); references('steps', ['ACTION','CHANGE'], true);
|
|
72
|
+
const target = typeof d.arrives_at === 'string' ? options.documents?.get(d.arrives_at) : undefined;
|
|
73
|
+
if (target?.type === 'base') fail('arrives_at', 'target plateau, not base', d.arrives_at);
|
|
74
|
+
for (const key of ['type','vision','factors_view','status','drivers','capabilities','processes','applications','products']) if (key in d) fail(key, 'absent on standalone scenario', d[key]);
|
|
75
|
+
optionalText('link');
|
|
76
|
+
} else if (field) {
|
|
77
|
+
const provenance = d.provenance;
|
|
78
|
+
if (provenance === undefined || provenance === null || (typeof provenance === 'string' && !provenance.trim()) || (mapping(provenance) && Object.keys(provenance).length === 0)) fail('provenance', 'recorded source evidence', provenance);
|
|
79
|
+
else if (!mapping(provenance)) warnings.push({code:'NOTATION-SKIP-001',message:'Field provenance form is unsupported; source evidence remains unvalidated.'});
|
|
80
|
+
else {
|
|
81
|
+
// Two supported evidence forms: captured source and versioned source import.
|
|
82
|
+
// Other forms remain visible as unsupported, not falsely validated or forbidden.
|
|
83
|
+
const captured = text(provenance.captured_by) && isoDate(provenance.captured_on) && text(provenance.setting);
|
|
84
|
+
const imported = text(provenance.source_revision) && text(provenance.original_path) && text(provenance.source_hash) && isoDate(provenance.captured_on);
|
|
85
|
+
if (!captured && !imported) warnings.push({code:'NOTATION-SKIP-001',message:'Field provenance form is unsupported: source, capture date and context cannot all be checked.'});
|
|
86
|
+
}
|
|
87
|
+
if ('source_quality' in d && !['authoritative','corroborated','single_source','unverified'].includes(d.source_quality as string)) fail('source_quality', 'authoritative|corroborated|single_source|unverified', d.source_quality);
|
|
88
|
+
}
|
|
89
|
+
return {valid: errors.length === 0, errors, warnings};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ISO 3166-1 alpha-2 vocabulary, plus the published eu/global scope tokens.
|
|
93
|
+
const COUNTRIES = new Set(('ad ae af ag ai al am ao aq ar as at au aw ax az ba bb bd be bf bg bh bi bj bl bm bn bo bq br bs bt bv bw by bz ca cc cd cf cg ch ci ck cl cm cn co cr cu cv cw cx cy cz de dj dk dm do dz ec ee eg eh er es et fi fj fk fm fo fr ga gb gd ge gf gg gh gi gl gm gn gp gq gr gs gt gu gw gy hk hm hn hr ht hu id ie il im in io iq ir is it je jm jo jp ke kg kh ki km kn kp kr kw ky kz la lb lc li lk lr ls lt lu lv ly ma mc md me mf mg mh mk ml mm mn mo mp mq mr ms mt mu mv mw mx my mz na nc ne nf ng ni nl no np nr nu nz om pa pe pf pg ph pk pl pm pn pr ps pt pw py qa re ro rs ru rw sa sb sc sd se sg sh si sj sk sl sm sn so sr ss st sv sx sy sz tc td tf tg th tj tk tl tm tn to tr tt tv tw tz ua ug um us uy uz va vc ve vg vi vn vu wf ws ye yt za zm zw eu global').split(' '));
|
package/src/webview/entry.ts
CHANGED
|
@@ -275,10 +275,14 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
|
|
|
275
275
|
return renderFromValidation('scenarios', validateScenario(doc), () =>
|
|
276
276
|
renderScenarioHtml((doc as ScenarioFile).scenario),
|
|
277
277
|
);
|
|
278
|
-
case 'capability-map':
|
|
279
|
-
|
|
278
|
+
case 'capability-map': {
|
|
279
|
+
const validation = validateCapabilityMap(doc);
|
|
280
|
+
const unresolved = validation.warnings.find(w => w.code === 'NOTATION-SKIP-001');
|
|
281
|
+
if (validation.valid && unresolved) return errorResult('capability-map', unresolved.code, unresolved.message);
|
|
282
|
+
return renderFromValidation('capability-map', validation, () =>
|
|
280
283
|
renderCapabilityMapHtml((doc as CapabilityMapFile).capability_map),
|
|
281
284
|
);
|
|
285
|
+
}
|
|
282
286
|
default:
|
|
283
287
|
return errorResult(
|
|
284
288
|
kind,
|
|
@@ -56,7 +56,8 @@ export function renderGoalsSvg(tree: GoalTree, options: RenderGoalsOptions = {})
|
|
|
56
56
|
? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Goal tree — ${treeName}`)}</text>`
|
|
57
57
|
: '';
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
// Reserve a separate header row; the theme centers text on its y coordinate.
|
|
60
|
+
return renderGoalsLayoutSvg(layout, { curvature, entryCurvature, edgeStyle, title, topInset: title ? PAD : 0, embedCssTheme: 'transitrix' });
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
export interface RenderGoalsLayoutOptions {
|
package/src/webview/styles.css
CHANGED
|
@@ -70,6 +70,14 @@ body {
|
|
|
70
70
|
margin-left: 6px;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/* The bundled SVG renderer uses its light palette independently of IDE chrome.
|
|
74
|
+
* Give transparent SVG titles and edges a matching canvas. */
|
|
75
|
+
.tx-svg-host {
|
|
76
|
+
background: #ffffff;
|
|
77
|
+
color: #0d2b35;
|
|
78
|
+
min-height: calc(100vh - 24px);
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
.tx-svg-host svg {
|
|
74
82
|
max-width: 100%;
|
|
75
83
|
height: auto;
|