@transitrix/diagrams 1.8.13 → 1.8.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/activities/validate.js +9 -1
- package/dist/blocks/index.d.ts +5 -4
- package/dist/blocks/index.js +3 -2
- package/dist/blocks/layout.d.ts +8 -1
- package/dist/blocks/layout.js +57 -0
- package/dist/blocks/templates/index.d.ts +9 -0
- package/dist/blocks/templates/index.js +11 -0
- package/dist/blocks/templates/raci.d.ts +9 -0
- package/dist/blocks/templates/raci.js +24 -0
- package/dist/blocks/types.d.ts +58 -0
- package/dist/blocks/validate.d.ts +31 -1
- package/dist/blocks/validate.js +127 -0
- package/dist/capability-map/validate.js +41 -0
- package/dist/edge-path.d.ts +15 -0
- package/dist/edge-path.js +19 -0
- package/dist/fgca/parse-canonical.js +8 -0
- package/dist/repo-validate/check-element-hygiene.d.ts +8 -0
- package/dist/repo-validate/check-element-hygiene.js +211 -0
- package/dist/repo-validate/check-strategy-chain.d.ts +8 -0
- package/dist/repo-validate/check-strategy-chain.js +491 -0
- package/dist/repo-validate/types.d.ts +14 -1
- package/dist/repo-validate/types.js +22 -3
- package/dist/repo-validate/validate-repo.d.ts +3 -2
- package/dist/repo-validate/validate-repo.js +16 -4
- package/dist/webview/render-activities.d.ts +9 -0
- package/dist/webview/render-activities.js +72 -4
- package/dist/webview/render-blocks.d.ts +23 -1
- package/dist/webview/render-blocks.js +100 -2
- package/package.json +1 -1
- package/src/activities/validate.ts +9 -1
- package/src/blocks/index.ts +16 -2
- package/src/blocks/layout.ts +70 -0
- package/src/blocks/templates/index.ts +13 -0
- package/src/blocks/templates/raci.ts +28 -0
- package/src/blocks/types.ts +67 -0
- package/src/blocks/validate.ts +157 -1
- package/src/capability-map/validate.ts +40 -0
- package/src/edge-path.ts +27 -0
- package/src/fgca/parse-canonical.ts +8 -0
- package/src/repo-validate/check-element-hygiene.ts +236 -0
- package/src/repo-validate/check-strategy-chain.ts +528 -0
- package/src/repo-validate/types.ts +36 -4
- package/src/repo-validate/validate-repo.ts +16 -4
- package/src/webview/render-activities.ts +76 -4
- 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
|
|
11
|
-
// richer `target` / `category` reporting taxonomy
|
|
12
|
-
// ADR until a real consumer
|
|
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.
|
|
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 }`
|
|
27
|
-
//
|
|
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
|
|
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
|
}
|