@transitrix/diagrams 1.8.11 → 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 (45) 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/index.d.ts +2 -1
  20. package/dist/repo-validate/index.js +1 -0
  21. package/dist/repo-validate/resolve-model.d.ts +5 -0
  22. package/dist/repo-validate/resolve-model.js +71 -0
  23. package/dist/repo-validate/types.d.ts +70 -1
  24. package/dist/repo-validate/types.js +22 -3
  25. package/dist/repo-validate/validate-repo.d.ts +14 -3
  26. package/dist/repo-validate/validate-repo.js +21 -8
  27. package/dist/webview/render-blocks.d.ts +23 -1
  28. package/dist/webview/render-blocks.js +100 -2
  29. package/package.json +1 -1
  30. package/src/activities/validate.ts +9 -1
  31. package/src/blocks/index.ts +16 -2
  32. package/src/blocks/layout.ts +70 -0
  33. package/src/blocks/templates/index.ts +13 -0
  34. package/src/blocks/templates/raci.ts +28 -0
  35. package/src/blocks/types.ts +67 -0
  36. package/src/blocks/validate.ts +157 -1
  37. package/src/capability-map/validate.ts +40 -0
  38. package/src/fgca/parse-canonical.ts +8 -0
  39. package/src/repo-validate/check-element-hygiene.ts +236 -0
  40. package/src/repo-validate/check-strategy-chain.ts +528 -0
  41. package/src/repo-validate/index.ts +2 -1
  42. package/src/repo-validate/resolve-model.ts +79 -0
  43. package/src/repo-validate/types.ts +95 -4
  44. package/src/repo-validate/validate-repo.ts +21 -8
  45. 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
+ }
@@ -1,2 +1,3 @@
1
1
  export { validateRepoModel } from './validate-repo.js';
2
- export type { RepoDoc, RepoFinding, RepoModelInput } from './types.js';
2
+ export { resolveRepoModel } from './resolve-model.js';
3
+ export type { RepoDoc, RepoFinding, RepoModelInput, ResolvedElementRecord, ResolvedRelationRecord, ResolvedRepoModel } from './types.js';
@@ -0,0 +1,79 @@
1
+ // Resolved element/relation records for `validate --scope=repo --json --include-model`.
2
+ // A non-JS consumer (DSM's Go backend) needs the canon model this CLI already
3
+ // parses without re-implementing the notation schema — this is a pure
4
+ // projection over the same `RepoModelInput` the repo-scope validator
5
+ // consumes, not a second parse pass, so the two never drift out of sync.
6
+
7
+ import { docId, endpointId } from './validate-repo.js';
8
+ import type {
9
+ RepoDoc,
10
+ RepoModelInput,
11
+ ResolvedElementRecord,
12
+ ResolvedRelationRecord,
13
+ ResolvedRepoModel,
14
+ } from './types.js';
15
+
16
+ /** Matches the `<NN>_<layer>` folder segment under `canon/elements/`, e.g.
17
+ * `01_motivation` -> `motivation` (ELEMENT_PRIMITIVES.md §2/§6). */
18
+ const LAYER_FOLDER_RE = /^\d+_(.+)$/;
19
+
20
+ function readString(data: Record<string, unknown>, key: string): string | undefined {
21
+ const v = data[key];
22
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
23
+ }
24
+
25
+ /** Derive `layer` from the `canon/elements/<NN>_<layer>/…` folder segment when
26
+ * the doc has no explicit `layer` field — the folder is authoritative
27
+ * (ELEMENT_PRIMITIVES.md §3, `ELEM-003`). */
28
+ function layerFromPath(path: string): string | undefined {
29
+ const segs = path.split('/');
30
+ const layerSeg = segs[2]; // canon / elements / <NN>_<layer> / …
31
+ if (!layerSeg) return undefined;
32
+ const m = LAYER_FOLDER_RE.exec(layerSeg);
33
+ return m ? m[1] : undefined;
34
+ }
35
+
36
+ function resolveElement(doc: RepoDoc): ResolvedElementRecord | null {
37
+ const id = docId(doc);
38
+ if (!id || !doc.data) return null;
39
+ return {
40
+ id,
41
+ name: readString(doc.data, 'name') ?? '',
42
+ notation: readString(doc.data, 'notation') ?? '',
43
+ type: readString(doc.data, 'type'),
44
+ layer: readString(doc.data, 'layer') ?? layerFromPath(doc.path),
45
+ sourceFile: doc.path,
46
+ data: doc.data,
47
+ };
48
+ }
49
+
50
+ function resolveRelation(doc: RepoDoc): ResolvedRelationRecord | null {
51
+ if (!doc.data) return null;
52
+ const source = endpointId(doc.data['from']) ?? endpointId(doc.data['source']);
53
+ const target = endpointId(doc.data['to']) ?? endpointId(doc.data['target']);
54
+ if (!source || !target) return null;
55
+ return {
56
+ id: docId(doc) ?? '',
57
+ kind: readString(doc.data, 'type'),
58
+ source,
59
+ target,
60
+ sourceFile: doc.path,
61
+ };
62
+ }
63
+
64
+ /** Project a loaded canon model into the resolved element/relation records a
65
+ * non-JS consumer can read directly, without depending on this repo's YAML
66
+ * parsing internals. Pure: no IO. */
67
+ export function resolveRepoModel(input: RepoModelInput): ResolvedRepoModel {
68
+ const elements: ResolvedElementRecord[] = [];
69
+ for (const doc of input.elements) {
70
+ const rec = resolveElement(doc);
71
+ if (rec) elements.push(rec);
72
+ }
73
+ const relations: ResolvedRelationRecord[] = [];
74
+ for (const doc of input.relations) {
75
+ const rec = resolveRelation(doc);
76
+ if (rec) relations.push(rec);
77
+ }
78
+ return { elements, relations };
79
+ }