@transitrix/diagrams 1.8.13 → 1.8.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/activities/validate.js +9 -1
  2. package/dist/blocks/index.d.ts +5 -4
  3. package/dist/blocks/index.js +3 -2
  4. package/dist/blocks/layout.d.ts +8 -1
  5. package/dist/blocks/layout.js +57 -0
  6. package/dist/blocks/templates/index.d.ts +9 -0
  7. package/dist/blocks/templates/index.js +11 -0
  8. package/dist/blocks/templates/raci.d.ts +9 -0
  9. package/dist/blocks/templates/raci.js +24 -0
  10. package/dist/blocks/types.d.ts +58 -0
  11. package/dist/blocks/validate.d.ts +31 -1
  12. package/dist/blocks/validate.js +127 -0
  13. package/dist/capability-map/validate.js +41 -0
  14. package/dist/fgca/parse-canonical.js +8 -0
  15. package/dist/repo-validate/check-element-hygiene.d.ts +8 -0
  16. package/dist/repo-validate/check-element-hygiene.js +211 -0
  17. package/dist/repo-validate/check-strategy-chain.d.ts +8 -0
  18. package/dist/repo-validate/check-strategy-chain.js +491 -0
  19. package/dist/repo-validate/types.d.ts +14 -1
  20. package/dist/repo-validate/types.js +22 -3
  21. package/dist/repo-validate/validate-repo.d.ts +3 -2
  22. package/dist/repo-validate/validate-repo.js +16 -4
  23. package/dist/webview/render-blocks.d.ts +23 -1
  24. package/dist/webview/render-blocks.js +100 -2
  25. package/package.json +1 -1
  26. package/src/activities/validate.ts +9 -1
  27. package/src/blocks/index.ts +16 -2
  28. package/src/blocks/layout.ts +70 -0
  29. package/src/blocks/templates/index.ts +13 -0
  30. package/src/blocks/templates/raci.ts +28 -0
  31. package/src/blocks/types.ts +67 -0
  32. package/src/blocks/validate.ts +157 -1
  33. package/src/capability-map/validate.ts +40 -0
  34. package/src/fgca/parse-canonical.ts +8 -0
  35. package/src/repo-validate/check-element-hygiene.ts +236 -0
  36. package/src/repo-validate/check-strategy-chain.ts +528 -0
  37. package/src/repo-validate/types.ts +36 -4
  38. package/src/repo-validate/validate-repo.ts +16 -4
  39. package/src/webview/render-blocks.ts +159 -2
@@ -0,0 +1,528 @@
1
+ // Strategy-chain semantic checks — the error-severity subset of DSM's Go
2
+ // `Validate*` functions (`api02/internal/importer/{goals,activities,fgca}.go`
3
+ // in transitrix-dsm), ported onto the standalone-element repo shape
4
+ // (`canon/elements/**`) that `validate --scope=repo` already loads, so DSM
5
+ // can drop those Go functions without regressing the checks they enforce
6
+ // today.
7
+ //
8
+ // Rule codes are DSM's own (`GOALS-010`, `ACT-006`..`009`, `FGCA-008`..`011`)
9
+ // — not invented here — so DSM can map a CLI finding straight back onto its
10
+ // import-log taxonomy (`RepoFinding.ruleId`).
11
+ //
12
+ // Scope — DSM's full rule set is now ported except GOALS-008 (see below).
13
+ // `RepoFinding` grew a `severity` field precisely so DSM's warn-severity
14
+ // rules could land without becoming blocking: every finding from before that
15
+ // field existed stays implicitly `'error'` (`types.ts`), and the six rules
16
+ // below set `severity: 'warning'` explicitly, matching DSM's own
17
+ // classification (`Issue.Severity` in the Go source) and its "import
18
+ // anyway, record the warning" policy.
19
+ //
20
+ // GOALS-009/011 (orphan / missing parent) are ported at warning severity even
21
+ // though most standalone GOAL elements legitimately have no `parent` field —
22
+ // GOAL.parent is "v0.x transitional" (ELEMENT_PRIMITIVES.md §7.2; canonical
23
+ // home is a `goal_parent` REL file or the goals-tree view's inline `parent`).
24
+ // `organizations/acme_corp`'s GOAL-CUST-1/GOAL-OPS-1/GOAL-EU-1 are exactly
25
+ // this shape and do surface GOALS-011 warnings — that noise is the accepted
26
+ // cost of a *warning* level rule (non-blocking, advisory), unlike the ERROR
27
+ // tier this repo held the line on. This is a deliberate call, not an
28
+ // oversight: keep the coverage rather than drop it, now that it can be
29
+ // non-blocking.
30
+ //
31
+ // GOALS-008 (type/level mismatch) is still NOT ported, at either severity.
32
+ // Both of DSM's cases ("type not declared in goal_types" / "level doesn't
33
+ // match the type's declared level") need the `goal_types[]` catalogue, which
34
+ // lives on the goals-tree *view* (`canon/views/goals/**`,
35
+ // notations/views/04-goals.md §5.2) — a zone this validator's `RepoModelInput`
36
+ // does not load (only `canon/elements/**` and `canon/relations/**`, per
37
+ // `validate-repo.ts`). There is no standalone-element-shape data this rule
38
+ // could run against without the validator growing a third input zone — a
39
+ // bigger architectural change than adapting a predicate to data already in
40
+ // scope, unlike every other ported rule here. This is flagged for a
41
+ // per-rule call: skip it (status quo) or scope a follow-up that loads
42
+ // `canon/views/goals/**`'s `goal_types[]` into the repo-scope model.
43
+ //
44
+ // Ported (error-severity, blocking):
45
+ // GOALS-010 — GOAL `parent` chain contains a cycle.
46
+ // ACT-006 — ACTION `predecessors` graph contains a cycle.
47
+ // ACT-007 — ACTION lists itself as its own predecessor.
48
+ // ACT-008 — ACTION `start_date`/`end_date` unparseable, or end before start.
49
+ // ACT-009 — ACTION numeric field (`duration`/`duration_days`, `labor_cost`,
50
+ // `resources_cost`, `effort`, `score`) is negative.
51
+ // FGCA-008 — GOAL.factors references an undefined DRIVER.
52
+ // FGCA-009 — CHANGE.goals references an undefined GOAL.
53
+ // FGCA-010 — ACTION.delivers_changes references an undefined CHANGE.
54
+ // FGCA-011 — ACTION.goals references an undefined GOAL.
55
+ //
56
+ // Ported (warning-severity, advisory):
57
+ // GOALS-009 — GOAL.parent is set but does not resolve to a known GOAL (orphan).
58
+ // GOALS-011 — GOAL has no `parent` and `level` >= 1 (backlog).
59
+ // ACT-005 — ACTION.predecessors entry or ACTION.parent does not resolve
60
+ // to a known ACTION (orphan).
61
+ // FGCA-012 — a DRIVER is not referenced by any GOAL.factors (unreferenced).
62
+ // FGCA-013 — a GOAL is not referenced by any CHANGE.goals or ACTION.goals
63
+ // (unreferenced).
64
+ // FGCA-014 — a CHANGE is not referenced by any ACTION.delivers_changes
65
+ // (unreferenced).
66
+
67
+ import { docId } from './validate-repo.js';
68
+ import type { RepoDoc, RepoFinding, RepoModelInput } from './types.js';
69
+
70
+ const PScope: RepoFinding['scope'] = 'repo';
71
+
72
+ function isGoalNotation(n: unknown): boolean {
73
+ return n === 'goal';
74
+ }
75
+
76
+ function isActionNotation(n: unknown): boolean {
77
+ // 'activity' is the deprecated pre-2026-06-25 alias (elements/24-action.md §5).
78
+ return n === 'action' || n === 'activity';
79
+ }
80
+
81
+ function isDriverNotation(n: unknown): boolean {
82
+ // 'factor' is the pre-rename notation value (Factor -> Driver rename, in progress).
83
+ return n === 'driver' || n === 'factor';
84
+ }
85
+
86
+ function isChangeNotation(n: unknown): boolean {
87
+ return n === 'change';
88
+ }
89
+
90
+ function readStringArray(data: Record<string, unknown>, key: string): string[] {
91
+ const v = data[key];
92
+ if (!Array.isArray(v)) return [];
93
+ return v.filter((x): x is string => typeof x === 'string' && x.length > 0);
94
+ }
95
+
96
+ function readString(data: Record<string, unknown>, key: string): string | undefined {
97
+ const v = data[key];
98
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
99
+ }
100
+
101
+ function readFiniteNumber(data: Record<string, unknown>, key: string): number | undefined {
102
+ const v = data[key];
103
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
104
+ }
105
+
106
+ interface ChainElement {
107
+ id: string;
108
+ doc: RepoDoc;
109
+ data: Record<string, unknown>;
110
+ }
111
+
112
+ function collectByNotation(
113
+ elements: RepoDoc[],
114
+ matches: (notation: unknown) => boolean,
115
+ ): ChainElement[] {
116
+ const out: ChainElement[] = [];
117
+ for (const doc of elements) {
118
+ if (!doc.data) continue;
119
+ if (!matches(doc.data['notation'])) continue;
120
+ const id = docId(doc);
121
+ if (!id) continue;
122
+ out.push({ id, doc, data: doc.data });
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** DFS cycle detection over a single-parent edge (GOAL.parent), matching DSM's
128
+ * `findCycle` (goals.go): only walks an edge to a parent that itself resolves
129
+ * within the set — an edge into an unresolved (orphan) parent is not walked,
130
+ * so an orphan can never be mistaken for a cycle. Returns the id where a
131
+ * back-edge was hit, or `undefined` if the graph is acyclic. */
132
+ function findParentCycle(goals: ChainElement[]): string | undefined {
133
+ const parentOf = new Map<string, string | undefined>();
134
+ const known = new Set<string>();
135
+ for (const g of goals) {
136
+ known.add(g.id);
137
+ parentOf.set(g.id, readString(g.data, 'parent'));
138
+ }
139
+ const UNVISITED = 0;
140
+ const VISITING = 1;
141
+ const DONE = 2;
142
+ const state = new Map<string, number>();
143
+
144
+ function walk(id: string): string | undefined {
145
+ const s = state.get(id) ?? UNVISITED;
146
+ if (s === DONE) return undefined;
147
+ if (s === VISITING) return id;
148
+ state.set(id, VISITING);
149
+ const parent = parentOf.get(id);
150
+ if (parent && known.has(parent)) {
151
+ const hit = walk(parent);
152
+ if (hit) return hit;
153
+ }
154
+ state.set(id, DONE);
155
+ return undefined;
156
+ }
157
+
158
+ for (const g of goals) {
159
+ const hit = walk(g.id);
160
+ if (hit) return hit;
161
+ }
162
+ return undefined;
163
+ }
164
+
165
+ /** DFS cycle detection over a multi-predecessor edge list (ACTION.predecessors),
166
+ * matching DSM's `findActivityCycle` (activities.go). Unresolved predecessors
167
+ * are skipped, same rationale as `findParentCycle`. */
168
+ function findPredecessorCycle(actions: ChainElement[]): string | undefined {
169
+ const predsOf = new Map<string, string[]>();
170
+ const known = new Set<string>();
171
+ for (const a of actions) {
172
+ known.add(a.id);
173
+ predsOf.set(a.id, readStringArray(a.data, 'predecessors'));
174
+ }
175
+ const UNVISITED = 0;
176
+ const VISITING = 1;
177
+ const DONE = 2;
178
+ const state = new Map<string, number>();
179
+
180
+ function walk(id: string): string | undefined {
181
+ const s = state.get(id) ?? UNVISITED;
182
+ if (s === DONE) return undefined;
183
+ if (s === VISITING) return id;
184
+ state.set(id, VISITING);
185
+ for (const p of predsOf.get(id) ?? []) {
186
+ if (!known.has(p)) continue;
187
+ const hit = walk(p);
188
+ if (hit) return hit;
189
+ }
190
+ state.set(id, DONE);
191
+ return undefined;
192
+ }
193
+
194
+ for (const a of actions) {
195
+ const hit = walk(a.id);
196
+ if (hit) return hit;
197
+ }
198
+ return undefined;
199
+ }
200
+
201
+ /** GOALS-010 — the GOAL `parent` chain must not contain a cycle. */
202
+ function checkGoalParentCycle(goals: ChainElement[], findings: RepoFinding[]): void {
203
+ const cyc = findParentCycle(goals);
204
+ if (cyc) {
205
+ findings.push({
206
+ scope: PScope,
207
+ id: cyc,
208
+ ruleId: 'GOALS-010',
209
+ message: `GOALS-010: parent chain contains a cycle involving goal '${cyc}'.`,
210
+ });
211
+ }
212
+ }
213
+
214
+ /** GOALS-009 / GOALS-011 — GOAL `parent` resolution (warning). Mirrors DSM's
215
+ * goals.go: the two are mutually exclusive per goal — a goal with no
216
+ * `parent` is checked for GOALS-011 (backlog, level >= 1); a goal with a
217
+ * `parent` is checked for GOALS-009 (orphan, parent unresolved) instead. */
218
+ function checkGoalParentResolution(goals: ChainElement[], findings: RepoFinding[]): void {
219
+ const knownIds = new Set(goals.map((g) => g.id));
220
+ for (const g of goals) {
221
+ const parent = readString(g.data, 'parent');
222
+ if (!parent) {
223
+ const level = readFiniteNumber(g.data, 'level');
224
+ if (level !== undefined && level >= 1) {
225
+ findings.push({
226
+ scope: PScope,
227
+ id: g.id,
228
+ ruleId: 'GOALS-011',
229
+ severity: 'warning',
230
+ message: `GOALS-011: goal '${g.id}' (level ${level}) has no parent; treated as backlog until attached.`,
231
+ });
232
+ }
233
+ continue;
234
+ }
235
+ if (!knownIds.has(parent)) {
236
+ findings.push({
237
+ scope: PScope,
238
+ id: g.id,
239
+ ruleId: 'GOALS-009',
240
+ severity: 'warning',
241
+ message: `GOALS-009: goal '${g.id}' parent '${parent}' does not resolve to a known goal; treated as orphan.`,
242
+ });
243
+ }
244
+ }
245
+ }
246
+
247
+ /** ACT-007 — an ACTION cannot list itself as its own predecessor. */
248
+ function checkActionSelfPredecessor(actions: ChainElement[], findings: RepoFinding[]): void {
249
+ for (const a of actions) {
250
+ if (readStringArray(a.data, 'predecessors').includes(a.id)) {
251
+ findings.push({
252
+ scope: PScope,
253
+ id: a.id,
254
+ ruleId: 'ACT-007',
255
+ message: `ACT-007: action '${a.id}' lists itself as a predecessor.`,
256
+ });
257
+ }
258
+ }
259
+ }
260
+
261
+ /** ACT-006 — the ACTION `predecessors` graph must not contain a cycle. */
262
+ function checkActionPredecessorCycle(actions: ChainElement[], findings: RepoFinding[]): void {
263
+ const cyc = findPredecessorCycle(actions);
264
+ if (cyc) {
265
+ findings.push({
266
+ scope: PScope,
267
+ id: cyc,
268
+ ruleId: 'ACT-006',
269
+ message: `ACT-006: predecessor graph contains a cycle involving action '${cyc}'.`,
270
+ });
271
+ }
272
+ }
273
+
274
+ /** ACT-005 — an ACTION `predecessors` entry or `parent` that does not resolve
275
+ * to a known ACTION is an orphan reference (warning — advisory in DSM, same
276
+ * as the goal-parent orphan checks above). */
277
+ function checkActionOrphanReferences(actions: ChainElement[], findings: RepoFinding[]): void {
278
+ const knownIds = new Set(actions.map((a) => a.id));
279
+ for (const a of actions) {
280
+ for (const p of readStringArray(a.data, 'predecessors')) {
281
+ if (!knownIds.has(p)) {
282
+ findings.push({
283
+ scope: PScope,
284
+ id: a.id,
285
+ ruleId: 'ACT-005',
286
+ severity: 'warning',
287
+ message: `ACT-005: action '${a.id}' predecessor '${p}' does not resolve to a known action.`,
288
+ });
289
+ }
290
+ }
291
+ const parent = readString(a.data, 'parent');
292
+ if (parent && !knownIds.has(parent)) {
293
+ findings.push({
294
+ scope: PScope,
295
+ id: a.id,
296
+ ruleId: 'ACT-005',
297
+ severity: 'warning',
298
+ message: `ACT-005: action '${a.id}' parent '${parent}' does not resolve to a known action.`,
299
+ });
300
+ }
301
+ }
302
+ }
303
+
304
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
305
+
306
+ /** Parse a strict ISO `YYYY-MM-DD` date, rejecting calendar-invalid dates
307
+ * (e.g. `2026-02-30`) the way Go's `time.Parse(dateLayout, …)` does — a
308
+ * regex match alone accepts those. Returns `undefined` when invalid. */
309
+ function parseIsoDate(value: string): Date | undefined {
310
+ if (!ISO_DATE_RE.test(value)) return undefined;
311
+ const [y, m, d] = value.split('-').map(Number);
312
+ const dt = new Date(Date.UTC(y, m - 1, d));
313
+ if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) {
314
+ return undefined;
315
+ }
316
+ return dt;
317
+ }
318
+
319
+ /** ACT-008 — `start_date`/`end_date` must be valid ISO dates, and `end_date`
320
+ * must not be before `start_date` (equal is allowed — e.g. a milestone). */
321
+ function checkActionDates(actions: ChainElement[], findings: RepoFinding[]): void {
322
+ for (const a of actions) {
323
+ const startRaw = readString(a.data, 'start_date');
324
+ const endRaw = readString(a.data, 'end_date');
325
+ let start: Date | undefined;
326
+ let end: Date | undefined;
327
+
328
+ if (startRaw !== undefined) {
329
+ start = parseIsoDate(startRaw);
330
+ if (!start) {
331
+ findings.push({
332
+ scope: PScope,
333
+ id: a.id,
334
+ ruleId: 'ACT-008',
335
+ message: `ACT-008: action '${a.id}' start_date '${startRaw}' is not a valid YYYY-MM-DD date.`,
336
+ });
337
+ }
338
+ }
339
+ if (endRaw !== undefined) {
340
+ end = parseIsoDate(endRaw);
341
+ if (!end) {
342
+ findings.push({
343
+ scope: PScope,
344
+ id: a.id,
345
+ ruleId: 'ACT-008',
346
+ message: `ACT-008: action '${a.id}' end_date '${endRaw}' is not a valid YYYY-MM-DD date.`,
347
+ });
348
+ }
349
+ }
350
+ if (start && end && end.getTime() < start.getTime()) {
351
+ findings.push({
352
+ scope: PScope,
353
+ id: a.id,
354
+ ruleId: 'ACT-008',
355
+ message: `ACT-008: action '${a.id}' end_date '${endRaw}' is before start_date '${startRaw}'.`,
356
+ });
357
+ }
358
+ }
359
+ }
360
+
361
+ /** ACT-009 — numeric scheduling/cost fields must not be negative. `duration`
362
+ * and `duration_days` are both checked — the canonical field is `duration`
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;
368
+ for (const a of actions) {
369
+ for (const field of fields) {
370
+ const v = readFiniteNumber(a.data, field);
371
+ if (v !== undefined && v < 0) {
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
+ }
379
+ }
380
+ }
381
+ }
382
+
383
+ /** FGCA-008..011 — inline strategy-chain cross-references must resolve within
384
+ * the repo: GOAL.factors -> DRIVER, CHANGE.goals -> GOAL, ACTION.goals ->
385
+ * GOAL, ACTION.delivers_changes -> CHANGE (ELEMENT_PRIMITIVES.md §7.1-§7.4). */
386
+ function checkStrategyChainReferences(
387
+ goals: ChainElement[],
388
+ actions: ChainElement[],
389
+ drivers: ChainElement[],
390
+ changes: ChainElement[],
391
+ findings: RepoFinding[],
392
+ ): void {
393
+ const driverIds = new Set(drivers.map((d) => d.id));
394
+ const goalIds = new Set(goals.map((g) => g.id));
395
+ const changeIds = new Set(changes.map((c) => c.id));
396
+
397
+ for (const g of goals) {
398
+ for (const f of readStringArray(g.data, 'factors')) {
399
+ if (!driverIds.has(f)) {
400
+ findings.push({
401
+ scope: PScope,
402
+ id: g.id,
403
+ ruleId: 'FGCA-008',
404
+ message: `FGCA-008: goal '${g.id}' references undefined driver '${f}'.`,
405
+ });
406
+ }
407
+ }
408
+ }
409
+ for (const c of changes) {
410
+ for (const g of readStringArray(c.data, 'goals')) {
411
+ if (!goalIds.has(g)) {
412
+ findings.push({
413
+ scope: PScope,
414
+ id: c.id,
415
+ ruleId: 'FGCA-009',
416
+ message: `FGCA-009: change '${c.id}' references undefined goal '${g}'.`,
417
+ });
418
+ }
419
+ }
420
+ }
421
+ for (const a of actions) {
422
+ for (const c of readStringArray(a.data, 'delivers_changes')) {
423
+ if (!changeIds.has(c)) {
424
+ findings.push({
425
+ scope: PScope,
426
+ id: a.id,
427
+ ruleId: 'FGCA-010',
428
+ message: `FGCA-010: action '${a.id}' references undefined change '${c}'.`,
429
+ });
430
+ }
431
+ }
432
+ for (const g of readStringArray(a.data, 'goals')) {
433
+ if (!goalIds.has(g)) {
434
+ findings.push({
435
+ scope: PScope,
436
+ id: a.id,
437
+ ruleId: 'FGCA-011',
438
+ message: `FGCA-011: action '${a.id}' references undefined goal '${g}'.`,
439
+ });
440
+ }
441
+ }
442
+ }
443
+ }
444
+
445
+ /** FGCA-012..014 — a DRIVER/GOAL/CHANGE defined but never referenced
446
+ * downstream in the strategy chain is unreferenced (warning — advisory in
447
+ * DSM, mirroring the orphan-reference checks above). */
448
+ function checkStrategyChainOrphans(
449
+ goals: ChainElement[],
450
+ actions: ChainElement[],
451
+ drivers: ChainElement[],
452
+ changes: ChainElement[],
453
+ findings: RepoFinding[],
454
+ ): void {
455
+ const referencedDrivers = new Set<string>();
456
+ for (const g of goals) {
457
+ for (const f of readStringArray(g.data, 'factors')) referencedDrivers.add(f);
458
+ }
459
+ for (const d of drivers) {
460
+ if (!referencedDrivers.has(d.id)) {
461
+ findings.push({
462
+ scope: PScope,
463
+ id: d.id,
464
+ ruleId: 'FGCA-012',
465
+ severity: 'warning',
466
+ message: `FGCA-012: driver '${d.id}' is not referenced by any goal.`,
467
+ });
468
+ }
469
+ }
470
+
471
+ const referencedGoals = new Set<string>();
472
+ for (const c of changes) {
473
+ for (const g of readStringArray(c.data, 'goals')) referencedGoals.add(g);
474
+ }
475
+ for (const a of actions) {
476
+ for (const g of readStringArray(a.data, 'goals')) referencedGoals.add(g);
477
+ }
478
+ for (const g of goals) {
479
+ if (!referencedGoals.has(g.id)) {
480
+ findings.push({
481
+ scope: PScope,
482
+ id: g.id,
483
+ ruleId: 'FGCA-013',
484
+ severity: 'warning',
485
+ message: `FGCA-013: goal '${g.id}' is not referenced by any change or action.`,
486
+ });
487
+ }
488
+ }
489
+
490
+ const referencedChanges = new Set<string>();
491
+ for (const a of actions) {
492
+ for (const c of readStringArray(a.data, 'delivers_changes')) referencedChanges.add(c);
493
+ }
494
+ for (const c of changes) {
495
+ if (!referencedChanges.has(c.id)) {
496
+ findings.push({
497
+ scope: PScope,
498
+ id: c.id,
499
+ ruleId: 'FGCA-014',
500
+ severity: 'warning',
501
+ message: `FGCA-014: change '${c.id}' is not referenced by any action.`,
502
+ });
503
+ }
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Run the strategy-chain semantic checks (GOALS-009..011, ACT-005..009,
509
+ * FGCA-008..014 except GOALS-008 — see the module header) over the loaded
510
+ * element set and append findings. Called from `validateRepoModel` after the
511
+ * structural phases. Pure, deterministic order.
512
+ */
513
+ export function checkStrategyChainSemantics(input: RepoModelInput, findings: RepoFinding[]): void {
514
+ const goals = collectByNotation(input.elements, isGoalNotation);
515
+ const actions = collectByNotation(input.elements, isActionNotation);
516
+ const drivers = collectByNotation(input.elements, isDriverNotation);
517
+ const changes = collectByNotation(input.elements, isChangeNotation);
518
+
519
+ checkGoalParentCycle(goals, findings);
520
+ checkGoalParentResolution(goals, findings);
521
+ checkActionSelfPredecessor(actions, findings);
522
+ checkActionPredecessorCycle(actions, findings);
523
+ checkActionOrphanReferences(actions, findings);
524
+ checkActionDates(actions, findings);
525
+ checkActionNegativeNumbers(actions, findings);
526
+ checkStrategyChainReferences(goals, actions, drivers, changes, findings);
527
+ checkStrategyChainOrphans(goals, actions, drivers, changes, findings);
528
+ }
@@ -7,11 +7,30 @@
7
7
  // runtime (methodology ADR "Validation Two-Axis Model"; Studio decision
8
8
  // "Validation Runtime Convergence").
9
9
  //
10
- // The finding shape is deliberately minimal — `{ scope, id, message }`. The
11
- // richer `target` / `category` reporting taxonomy is explicitly DEFERRED per the
12
- // ADR until a real consumer needs it; do not add it here.
10
+ // The finding shape was originally frozen minimal — `{ scope, id, message }` —
11
+ // with the richer `target` / `category` reporting taxonomy explicitly DEFERRED
12
+ // per the ADR until a real consumer needed it. This un-freezes one field,
13
+ // `ruleId`: DSM (migrating off its own Go `Validate*` functions) needs a
14
+ // stable code per finding to map onto its existing import-log rule taxonomy
15
+ // (`api02/internal/importer/{goals,activities,fgca}.go`). The richer
16
+ // `target`/`category` taxonomy stays deferred — only `ruleId` is added.
17
+ // `ruleId` is optional: only checks with a stable, documented code set it
18
+ // (`docs/validation.md` § repo-scope rule list); the original structural
19
+ // checks (syntax/uniqueness/atomicity/referential-integrity/policy) stay
20
+ // uncoded rather than being retrofitted with invented codes.
21
+ //
22
+ // A second field, `severity`, is un-frozen for the same reason: DSM's
23
+ // warn-severity strategy-chain rules (GOALS-009/011, ACT-005, FGCA-012..014
24
+ // — see check-strategy-chain.ts) are non-fatal by
25
+ // DSM's own taxonomy ("import anyway, record the warning"); without a
26
+ // severity field, porting them here would silently make them blocking,
27
+ // since every existing repo-scope finding is implicitly blocking. `severity`
28
+ // is optional and defaults to `'error'` when omitted — every finding from
29
+ // before this field existed (syntax/uniqueness/atomicity/referential-
30
+ // integrity/policy, plus the error-tier strategy-chain rules and
31
+ // TSVC-003/INT-002) stays blocking with no code change at the call site.
13
32
 
14
- /** A single repo-scope validation finding. Shape is frozen at `{ scope, id, message }`. */
33
+ /** A single repo-scope validation finding. */
15
34
  export interface RepoFinding {
16
35
  /** Always `'repo'` — the execution axis this finding came from. */
17
36
  scope: 'repo';
@@ -22,6 +41,19 @@ export interface RepoFinding {
22
41
  id: string;
23
42
  /** Human-readable description of the violation. */
24
43
  message: string;
44
+ /**
45
+ * Stable rule code (e.g. `GOALS-010`, `ACT-006`, `FGCA-008`), for a consumer
46
+ * that maps findings onto its own rule taxonomy (`docs/validation.md`).
47
+ * Omitted for checks that don't yet have one.
48
+ */
49
+ ruleId?: string;
50
+ /**
51
+ * `'error'` (blocking) or `'warning'` (advisory). Omitted is equivalent to
52
+ * `'error'` — every check that predates this field never sets it and must
53
+ * stay blocking. Only the warn-severity strategy-chain rules set this to
54
+ * `'warning'` today (`docs/validation.md` § repo-scope rule list).
55
+ */
56
+ severity?: 'error' | 'warning';
25
57
  }
26
58
 
27
59
  /** A parsed canon document fed to the repo validator. IO (the filesystem walk)
@@ -23,9 +23,12 @@
23
23
  // 6. Policy (phase 6) — element marked Active/Production with no
24
24
  // owner.
25
25
  //
26
- // Findings stay `{ scope, id, message }` — no severity, no target/category
27
- // taxonomy (deferred per the ADR).
26
+ // Findings stay `{ scope, id, message }` plus the two un-frozen optional
27
+ // fields `ruleId` and `severity` — the richer target/category taxonomy stays
28
+ // deferred per the ADR.
28
29
 
30
+ import { checkStrategyChainSemantics } from './check-strategy-chain.js';
31
+ import { checkElementHygiene } from './check-element-hygiene.js';
29
32
  import type { RepoDoc, RepoFinding, RepoModelInput } from './types.js';
30
33
 
31
34
  const PScope: RepoFinding['scope'] = 'repo';
@@ -346,6 +349,7 @@ function checkLayerSemantics(input: RepoModelInput, findings: RepoFinding[]): vo
346
349
  findings.push({
347
350
  scope: PScope,
348
351
  id: svcId,
352
+ ruleId: 'TSVC-003',
349
353
  message:
350
354
  `TSVC-003: TECHNOLOGY_SERVICE '${svcId}' node '${nodeRef}' ` +
351
355
  `must resolve to a NODE element; got notation='${resolved['notation']}'.`,
@@ -369,6 +373,7 @@ function checkLayerSemantics(input: RepoModelInput, findings: RepoFinding[]): vo
369
373
  findings.push({
370
374
  scope: PScope,
371
375
  id: intId,
376
+ ruleId: 'INT-002',
372
377
  message:
373
378
  `INT-002: INTEGRATION '${intId}' interface_semantics endpoint '${endpointId}' ` +
374
379
  `(${field}) must resolve to an APPLICATION element; got notation='${resolved['notation']}'.`,
@@ -400,8 +405,9 @@ function checkPolicy(input: RepoModelInput, findings: RepoFinding[]): void {
400
405
  /**
401
406
  * Run all repo-scope checks over a loaded canon model and return the findings.
402
407
  * Pure: no IO, deterministic order (syntax, uniqueness, atomicity, referential,
403
- * semantics, policy). Reaches parity with `lint.py` on the `acme_corp` fixture
404
- * (zero findings on a clean tree).
408
+ * semantics, policy, strategy-chain). Reaches parity with `lint.py` on the
409
+ * `acme_corp` fixture (zero findings on a clean tree from the original
410
+ * lint.py-ported phases).
405
411
  */
406
412
  export function validateRepoModel(input: RepoModelInput): RepoFinding[] {
407
413
  const findings: RepoFinding[] = [];
@@ -416,5 +422,11 @@ export function validateRepoModel(input: RepoModelInput): RepoFinding[] {
416
422
  checkReferentialIntegrity(input, findings);
417
423
  checkLayerSemantics(input, findings);
418
424
  checkPolicy(input, findings);
425
+ // Phase 7 — strategy-chain semantic rules ported from DSM's Go Validate*
426
+ // functions (GOALS-010, ACT-006..009, FGCA-008..011).
427
+ checkStrategyChainSemantics(input, findings);
428
+ // Phase 8 — standalone-element envelope rules ported from DSM's Go
429
+ // Validate*Element functions (GOAL-ELEM-001..003, ACTION-001/002/005).
430
+ checkElementHygiene(input, findings);
419
431
  return findings;
420
432
  }