@transitrix/diagrams 1.0.0 → 1.1.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/LICENSE +21 -21
- package/README.md +47 -47
- package/dist/__tests__/edge-path.test.js +8 -0
- package/dist/activities/cpm.js +11 -2
- package/dist/activities/gantt.js +6 -3
- package/dist/activities/types.d.ts +1 -0
- package/dist/activities/validate.js +3 -3
- package/dist/activity-card/__tests__/example.test.js +20 -1
- package/dist/activity-card/__tests__/layout.test.js +102 -11
- package/dist/activity-card/__tests__/resolver.test.js +161 -7
- package/dist/activity-card/index.d.ts +1 -1
- package/dist/activity-card/layout.d.ts +1 -1
- package/dist/activity-card/layout.js +220 -103
- package/dist/activity-card/resolver.js +115 -28
- package/dist/activity-card/types.d.ts +150 -27
- package/dist/activity-card/validate.js +3 -0
- package/dist/actor/__tests__/validate.test.js +63 -31
- package/dist/actor/index.d.ts +1 -1
- package/dist/actor/index.js +1 -1
- package/dist/actor/types.d.ts +7 -3
- package/dist/actor/types.js +17 -3
- package/dist/actor/validate.js +55 -26
- package/dist/assertion/types.d.ts +3 -1
- package/dist/assertion/types.js +1 -1
- package/dist/change/__tests__/validate.test.js +40 -34
- package/dist/change/index.d.ts +1 -0
- package/dist/change/types.d.ts +1 -1
- package/dist/change/types.js +2 -2
- package/dist/change/validate.d.ts +6 -1
- package/dist/change/validate.js +68 -41
- package/dist/compliance/__tests__/classify.test.js +34 -0
- package/dist/compliance/__tests__/coverage-metric.test.js +146 -46
- package/dist/compliance/__tests__/html.test.js +2 -2
- package/dist/compliance/__tests__/impact.test.js +509 -6
- package/dist/compliance/classify.d.ts +6 -0
- package/dist/compliance/classify.js +9 -1
- package/dist/compliance/coverage-metric.d.ts +31 -11
- package/dist/compliance/coverage-metric.js +149 -39
- package/dist/compliance/html.js +78 -81
- package/dist/compliance/impact.d.ts +100 -16
- package/dist/compliance/impact.js +272 -28
- package/dist/compliance/index.d.ts +2 -2
- package/dist/compliance/index.js +1 -1
- package/dist/compliance/markdown.js +1 -0
- package/dist/compliance/types.d.ts +25 -6
- package/dist/edge-path.d.ts +8 -4
- package/dist/edge-path.js +11 -6
- package/dist/factor/__tests__/validate.test.js +75 -46
- package/dist/factor/index.d.ts +3 -2
- package/dist/factor/index.js +1 -1
- package/dist/factor/types.d.ts +5 -8
- package/dist/factor/types.js +10 -5
- package/dist/factor/validate.d.ts +6 -1
- package/dist/factor/validate.js +68 -50
- package/dist/fgca/__tests__/chain-table.test.js +2 -2
- package/dist/fgca/__tests__/layout.test.js +11 -11
- package/dist/fgca/__tests__/preview-layout.test.js +7 -7
- package/dist/fgca/__tests__/resolver.test.js +185 -0
- package/dist/fgca/chain-table.d.ts +1 -1
- package/dist/fgca/chain-table.js +4 -4
- package/dist/fgca/index.d.ts +4 -2
- package/dist/fgca/index.js +2 -1
- package/dist/fgca/layout.d.ts +2 -2
- package/dist/fgca/layout.js +9 -9
- package/dist/fgca/nodeTypes.d.ts +1 -1
- package/dist/fgca/nodeTypes.js +2 -2
- package/dist/fgca/nodes/FGCAFactorNode.d.ts +3 -3
- package/dist/fgca/nodes/FGCAFactorNode.js +5 -5
- package/dist/fgca/parse-canonical.js +15 -10
- package/dist/fgca/preview-layout.d.ts +1 -1
- package/dist/fgca/preview-layout.js +4 -4
- package/dist/fgca/resolver.d.ts +49 -0
- package/dist/fgca/resolver.js +126 -0
- package/dist/fgca/types.d.ts +11 -11
- package/dist/fgca/types.js +2 -2
- package/dist/fgca/validate.d.ts +2 -2
- package/dist/fgca/validate.js +17 -14
- package/dist/goals/index.d.ts +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/process-blueprint/__tests__/layout.test.js +37 -0
- package/dist/process-blueprint/index.d.ts +1 -1
- package/dist/process-blueprint/layout.js +66 -35
- package/dist/process-blueprint/types.d.ts +36 -1
- package/dist/scenarios/index.d.ts +1 -1
- package/dist/scenarios/types.d.ts +4 -4
- package/dist/stakeholder/__tests__/validate.test.js +49 -47
- package/dist/stakeholder/index.d.ts +1 -0
- package/dist/stakeholder/types.d.ts +3 -4
- package/dist/stakeholder/types.js +2 -3
- package/dist/stakeholder/validate.d.ts +6 -1
- package/dist/stakeholder/validate.js +71 -50
- package/dist/target-state/__tests__/validate.test.js +43 -27
- package/dist/target-state/index.d.ts +2 -0
- package/dist/target-state/index.js +1 -0
- package/dist/target-state/types.d.ts +6 -1
- package/dist/target-state/types.js +8 -7
- package/dist/target-state/validate.d.ts +6 -1
- package/dist/target-state/validate.js +69 -45
- package/dist/theme/themes.js +71 -69
- package/dist/theme/tokens.d.ts +7 -1
- package/dist/theme/tokens.js +3 -1
- package/dist/webview/__tests__/entry-notations.test.js +2 -0
- package/dist/webview/__tests__/entry.test.js +11 -11
- package/dist/webview/__tests__/render-process.test.js +463 -0
- package/dist/webview/__tests__/render-util.test.d.ts +1 -0
- package/dist/webview/__tests__/render-util.test.js +54 -0
- package/dist/webview/entry.d.ts +1 -1
- package/dist/webview/entry.js +9 -5
- package/dist/webview/render-activities.d.ts +24 -2
- package/dist/webview/render-activities.js +92 -57
- package/dist/webview/render-activity-card.d.ts +11 -1
- package/dist/webview/render-activity-card.js +115 -79
- package/dist/webview/render-blocks.d.ts +24 -1
- package/dist/webview/render-blocks.js +58 -24
- package/dist/webview/render-fgca.d.ts +31 -0
- package/dist/webview/render-fgca.js +30 -19
- package/dist/webview/render-goals.d.ts +27 -1
- package/dist/webview/render-goals.js +42 -32
- package/dist/webview/render-process-blueprint.d.ts +11 -1
- package/dist/webview/render-process-blueprint.js +118 -53
- package/dist/webview/render-process.d.ts +95 -0
- package/dist/webview/render-process.js +392 -0
- package/dist/webview/render-scenarios.js +2 -2
- package/dist/webview/render-util.d.ts +19 -6
- package/dist/webview/render-util.js +23 -15
- package/package.json +54 -40
- package/src/__tests__/edge-path.test.ts +58 -46
- package/src/__tests__/scope.test.ts +27 -27
- package/src/__tests__/yaml-normalize.test.ts +158 -158
- package/src/activities/__tests__/cpm.test.ts +164 -164
- package/src/activities/__tests__/gantt.test.ts +245 -245
- package/src/activities/__tests__/layout.test.ts +112 -112
- package/src/activities/__tests__/validate.test.ts +409 -409
- package/src/activities/cpm.ts +107 -95
- package/src/activities/gantt.ts +277 -276
- package/src/activities/index.ts +28 -28
- package/src/activities/layout.ts +112 -112
- package/src/activities/types.ts +149 -148
- package/src/activities/validate.ts +357 -357
- package/src/activity-card/__tests__/example.test.ts +85 -66
- package/src/activity-card/__tests__/layout.test.ts +169 -63
- package/src/activity-card/__tests__/resolver.test.ts +330 -161
- package/src/activity-card/__tests__/validate.test.ts +131 -131
- package/src/activity-card/index.ts +38 -32
- package/src/activity-card/layout.ts +324 -194
- package/src/activity-card/resolver.ts +422 -337
- package/src/activity-card/types.ts +376 -237
- package/src/activity-card/validate.ts +162 -158
- package/src/actor/__tests__/validate.test.ts +70 -31
- package/src/actor/index.ts +1 -1
- package/src/actor/types.ts +21 -7
- package/src/actor/validate.ts +59 -27
- package/src/applications/__tests__/validate.test.ts +256 -256
- package/src/applications/index.ts +11 -11
- package/src/applications/types.ts +43 -43
- package/src/applications/validate.ts +131 -131
- package/src/assertion/__tests__/example.test.ts +37 -37
- package/src/assertion/__tests__/validate.test.ts +142 -142
- package/src/assertion/index.ts +12 -12
- package/src/assertion/types.ts +60 -58
- package/src/assertion/validate.ts +145 -145
- package/src/blocks/__tests__/layout.test.ts +169 -169
- package/src/blocks/__tests__/validate.test.ts +260 -260
- package/src/blocks/index.ts +17 -17
- package/src/blocks/layout.ts +186 -186
- package/src/blocks/types.ts +62 -62
- package/src/blocks/validate.ts +206 -206
- package/src/capability-map/__tests__/validate.test.ts +238 -238
- package/src/capability-map/index.ts +8 -8
- package/src/capability-map/types.ts +29 -29
- package/src/capability-map/validate.ts +127 -127
- package/src/change/__tests__/validate.test.ts +43 -34
- package/src/change/index.ts +1 -0
- package/src/change/types.ts +5 -3
- package/src/change/validate.ts +75 -43
- package/src/compliance/__tests__/classify.test.ts +64 -27
- package/src/compliance/__tests__/confidence.test.ts +98 -98
- package/src/compliance/__tests__/coverage-metric.test.ts +300 -188
- package/src/compliance/__tests__/demo-scenario.test.ts +273 -273
- package/src/compliance/__tests__/example.test.ts +57 -57
- package/src/compliance/__tests__/gap-report.test.ts +128 -128
- package/src/compliance/__tests__/html.test.ts +141 -141
- package/src/compliance/__tests__/impact.test.ts +1059 -481
- package/src/compliance/__tests__/markdown.test.ts +94 -94
- package/src/compliance/__tests__/views.test.ts +64 -64
- package/src/compliance/classify.ts +100 -84
- package/src/compliance/confidence.ts +69 -69
- package/src/compliance/coverage-metric.ts +404 -263
- package/src/compliance/gap-report.ts +94 -94
- package/src/compliance/html.ts +291 -295
- package/src/compliance/impact.ts +979 -677
- package/src/compliance/index.ts +57 -56
- package/src/compliance/markdown.ts +136 -135
- package/src/compliance/reverse-index.ts +33 -33
- package/src/compliance/types.ts +136 -120
- package/src/compliance/views.ts +46 -46
- package/src/compliance-matrix/__tests__/build.test.ts +200 -200
- package/src/compliance-matrix/__tests__/example.test.ts +57 -57
- package/src/compliance-matrix/index.ts +11 -11
- package/src/compliance-matrix/types.ts +102 -102
- package/src/edge-path.ts +50 -44
- package/src/factor/__tests__/validate.test.ts +81 -46
- package/src/factor/index.ts +3 -7
- package/src/factor/types.ts +15 -19
- package/src/factor/validate.ts +77 -51
- package/src/fgca/__tests__/chain-table.test.ts +172 -172
- package/src/fgca/__tests__/layout.test.ts +179 -179
- package/src/fgca/__tests__/parse-canonical.test.ts +248 -248
- package/src/fgca/__tests__/preview-layout.test.ts +124 -124
- package/src/fgca/__tests__/resolver.test.ts +209 -0
- package/src/fgca/__tests__/validate.test.ts +142 -142
- package/src/fgca/chain-table.ts +225 -225
- package/src/fgca/index.ts +47 -45
- package/src/fgca/layout.ts +204 -204
- package/src/fgca/nodeTypes.ts +11 -11
- package/src/fgca/nodes/FGCAActivityNode.tsx +65 -65
- package/src/fgca/nodes/FGCAChangeNode.tsx +64 -64
- package/src/fgca/nodes/FGCAFactorNode.tsx +64 -64
- package/src/fgca/nodes/FGCAGoalNode.tsx +72 -72
- package/src/fgca/parse-canonical.ts +405 -400
- package/src/fgca/preview-layout.ts +217 -217
- package/src/fgca/resolver.ts +159 -0
- package/src/fgca/types.ts +50 -50
- package/src/fgca/validate.ts +129 -126
- package/src/geometry.ts +9 -9
- package/src/goals/__tests__/layout.test.ts +175 -175
- package/src/goals/__tests__/mutations.test.ts +111 -111
- package/src/goals/__tests__/parse-canonical.test.ts +145 -145
- package/src/goals/__tests__/validate.test.ts +202 -202
- package/src/goals/index.ts +20 -21
- package/src/goals/layout.ts +172 -172
- package/src/goals/mutations.ts +123 -123
- package/src/goals/parse-canonical.ts +286 -286
- package/src/goals/types.ts +85 -85
- package/src/goals/validate.ts +136 -136
- package/src/index.ts +28 -23
- package/src/process-blueprint/__tests__/compliance-lane.test.ts +320 -320
- package/src/process-blueprint/__tests__/layout.test.ts +240 -195
- package/src/process-blueprint/__tests__/validate.test.ts +310 -310
- package/src/process-blueprint/index.ts +32 -31
- package/src/process-blueprint/layout.ts +564 -531
- package/src/process-blueprint/types.ts +278 -242
- package/src/process-blueprint/validate.ts +181 -181
- package/src/process-map/__tests__/validate.test.ts +223 -223
- package/src/process-map/index.ts +10 -10
- package/src/process-map/types.ts +36 -36
- package/src/process-map/validate.ts +135 -135
- package/src/products/__tests__/validate.test.ts +239 -239
- package/src/products/index.ts +9 -9
- package/src/products/types.ts +31 -31
- package/src/products/validate.ts +112 -112
- package/src/repo-validate/__tests__/validate-repo.test.ts +149 -149
- package/src/repo-validate/index.ts +2 -2
- package/src/repo-validate/types.ts +50 -50
- package/src/repo-validate/validate-repo.ts +196 -196
- package/src/requirement/__tests__/example.test.ts +24 -24
- package/src/requirement/__tests__/validate.test.ts +96 -96
- package/src/requirement/index.ts +4 -4
- package/src/requirement/types.ts +35 -35
- package/src/requirement/validate.ts +92 -92
- package/src/scenarios/__tests__/validate.test.ts +209 -209
- package/src/scenarios/index.ts +15 -15
- package/src/scenarios/types.ts +37 -37
- package/src/scenarios/validate.ts +122 -122
- package/src/schema-version.ts +16 -16
- package/src/scope.ts +46 -46
- package/src/stakeholder/__tests__/validate.test.ts +51 -47
- package/src/stakeholder/index.ts +1 -0
- package/src/stakeholder/types.ts +7 -7
- package/src/stakeholder/validate.ts +79 -52
- package/src/target-state/__tests__/validate.test.ts +48 -27
- package/src/target-state/index.ts +2 -0
- package/src/target-state/types.ts +11 -7
- package/src/target-state/validate.ts +76 -46
- package/src/theme/index.ts +4 -4
- package/src/theme/themes.ts +239 -237
- package/src/theme/tokens.ts +135 -133
- package/src/typed-id.ts +56 -56
- package/src/validation-types.ts +36 -36
- package/src/webview/__tests__/entry-notations.test.ts +79 -77
- package/src/webview/__tests__/entry.test.ts +64 -64
- package/src/webview/__tests__/render-goals.test.ts +72 -72
- package/src/webview/__tests__/render-process.test.ts +538 -0
- package/src/webview/__tests__/render-util.test.ts +66 -0
- package/src/webview/entry.ts +298 -292
- package/src/webview/render-activities.ts +111 -69
- package/src/webview/render-activity-card.ts +275 -234
- package/src/webview/render-blocks.ts +78 -28
- package/src/webview/render-fgca.ts +53 -28
- package/src/webview/render-goals.ts +133 -110
- package/src/webview/render-process-blueprint.ts +281 -207
- package/src/webview/render-process.ts +507 -0
- package/src/webview/render-scenarios.ts +3 -3
- package/src/webview/render-util.ts +27 -14
- package/src/webview/styles.css +322 -323
- package/src/yaml-normalize.ts +56 -56
- package/dist/element-envelope.d.ts +0 -15
- package/dist/element-envelope.js +0 -55
- package/dist/issues/__tests__/layout.test.js +0 -62
- package/dist/issues/__tests__/validate.test.js +0 -82
- package/dist/issues/index.d.ts +0 -4
- package/dist/issues/index.js +0 -2
- package/dist/issues/layout.d.ts +0 -10
- package/dist/issues/layout.js +0 -82
- package/dist/issues/types.d.ts +0 -61
- package/dist/issues/types.js +0 -6
- package/dist/issues/validate.d.ts +0 -3
- package/dist/issues/validate.js +0 -143
- package/dist/webview/transitrix-render.css +0 -323
- package/dist/webview/transitrix-render.js +0 -6452
- package/dist/webview/transitrix-render.js.map +0 -7
- package/src/element-envelope.ts +0 -76
- /package/dist/{issues/__tests__/layout.test.d.ts → fgca/__tests__/resolver.test.d.ts} +0 -0
- /package/dist/{issues/__tests__/validate.test.d.ts → webview/__tests__/render-process.test.d.ts} +0 -0
|
@@ -1,357 +1,357 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ActivityDoc,
|
|
3
|
-
Activity,
|
|
4
|
-
ActivityValidationResult,
|
|
5
|
-
ActivityValidationError,
|
|
6
|
-
ActivityValidationWarning,
|
|
7
|
-
} from './types.js';
|
|
8
|
-
|
|
9
|
-
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
10
|
-
const VALID_WEEKDAYS = new Set(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
|
11
|
-
|
|
12
|
-
export function validateActivities(input: unknown): ActivityValidationResult {
|
|
13
|
-
const errors: ActivityValidationError[] = [];
|
|
14
|
-
const warnings: ActivityValidationWarning[] = [];
|
|
15
|
-
|
|
16
|
-
if (!input || typeof input !== 'object') {
|
|
17
|
-
return { valid: false, errors: [{ code: 'ACT-001', message: 'Input must be an object' }], warnings };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const raw = input as Record<string, unknown>;
|
|
21
|
-
|
|
22
|
-
// ACT-001: notation must equal "activities"
|
|
23
|
-
if (raw.notation === undefined) {
|
|
24
|
-
errors.push({ code: 'ACT-001', message: 'notation field is required' });
|
|
25
|
-
return { valid: false, errors, warnings };
|
|
26
|
-
}
|
|
27
|
-
if (raw.notation !== 'activities') {
|
|
28
|
-
errors.push({ code: 'ACT-001', message: `notation must be "activities", got "${String(raw.notation)}"` });
|
|
29
|
-
return { valid: false, errors, warnings };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (!Array.isArray(raw.activities)) {
|
|
33
|
-
errors.push({ code: 'SCHEMA_INVALID', message: 'activities must be an array', path: 'activities' });
|
|
34
|
-
return { valid: false, errors, warnings };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// ACT-014 / ACT-015: project.calendar validation (run before per-activity loop;
|
|
38
|
-
// surfaces clearly even when activities also have issues).
|
|
39
|
-
if (raw.project && typeof raw.project === 'object') {
|
|
40
|
-
const project = raw.project as Record<string, unknown>;
|
|
41
|
-
const cal = project.calendar;
|
|
42
|
-
if (cal && typeof cal === 'object') {
|
|
43
|
-
const calendar = cal as Record<string, unknown>;
|
|
44
|
-
if (calendar.working_days !== undefined) {
|
|
45
|
-
if (!Array.isArray(calendar.working_days)) {
|
|
46
|
-
errors.push({ code: 'ACT-014', message: 'project.calendar.working_days must be an array of weekday names', path: 'project.calendar.working_days' });
|
|
47
|
-
} else {
|
|
48
|
-
const seen = new Set<string>();
|
|
49
|
-
for (let i = 0; i < calendar.working_days.length; i++) {
|
|
50
|
-
const raw = calendar.working_days[i];
|
|
51
|
-
const day = typeof raw === 'string' ? raw.toLowerCase() : '';
|
|
52
|
-
if (!VALID_WEEKDAYS.has(day)) {
|
|
53
|
-
errors.push({
|
|
54
|
-
code: 'ACT-014',
|
|
55
|
-
message: `project.calendar.working_days[${i}] "${String(raw)}" must be one of: mon, tue, wed, thu, fri, sat, sun`,
|
|
56
|
-
path: `project.calendar.working_days[${i}]`,
|
|
57
|
-
});
|
|
58
|
-
} else if (seen.has(day)) {
|
|
59
|
-
errors.push({
|
|
60
|
-
code: 'ACT-014',
|
|
61
|
-
message: `project.calendar.working_days has duplicate entry "${day}"`,
|
|
62
|
-
path: `project.calendar.working_days[${i}]`,
|
|
63
|
-
});
|
|
64
|
-
} else {
|
|
65
|
-
seen.add(day);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
if (calendar.holidays !== undefined) {
|
|
71
|
-
if (!Array.isArray(calendar.holidays)) {
|
|
72
|
-
errors.push({ code: 'ACT-015', message: 'project.calendar.holidays must be an array of ISO 8601 dates', path: 'project.calendar.holidays' });
|
|
73
|
-
} else {
|
|
74
|
-
for (let i = 0; i < calendar.holidays.length; i++) {
|
|
75
|
-
const h = calendar.holidays[i];
|
|
76
|
-
if (typeof h !== 'string' || !DATE_RE.test(h)) {
|
|
77
|
-
errors.push({
|
|
78
|
-
code: 'ACT-015',
|
|
79
|
-
message: `project.calendar.holidays[${i}] "${String(h)}" must be an ISO 8601 date (YYYY-MM-DD)`,
|
|
80
|
-
path: `project.calendar.holidays[${i}]`,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const doc = raw as unknown as ActivityDoc;
|
|
90
|
-
const idSet = new Set<string>();
|
|
91
|
-
|
|
92
|
-
for (let i = 0; i < doc.activities.length; i++) {
|
|
93
|
-
const a = doc.activities[i] as unknown;
|
|
94
|
-
const path = `activities[${i}]`;
|
|
95
|
-
|
|
96
|
-
if (!a || typeof a !== 'object') {
|
|
97
|
-
errors.push({ code: 'SCHEMA_INVALID', message: 'activity entry must be an object', path });
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
const act = a as Record<string, unknown>;
|
|
101
|
-
|
|
102
|
-
// ACT-010: reject single-value forms
|
|
103
|
-
if ('goal' in act) {
|
|
104
|
-
errors.push({ code: 'ACT-010', message: `Use "goals: []" array form instead of "goal:" (activity at index ${i})`, path });
|
|
105
|
-
}
|
|
106
|
-
if ('predecessor' in act) {
|
|
107
|
-
errors.push({ code: 'ACT-010', message: `Use "predecessors: []" array form instead of "predecessor:" (activity at index ${i})`, path });
|
|
108
|
-
}
|
|
109
|
-
if ('tag' in act) {
|
|
110
|
-
errors.push({ code: 'ACT-010', message: `Use "tags: []" array form instead of "tag:" (activity at index ${i})`, path });
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ACT-002: non-empty id
|
|
114
|
-
if (!act.id || typeof act.id !== 'string' || (act.id as string).trim() === '') {
|
|
115
|
-
errors.push({ code: 'ACT-002', message: `Activity at index ${i} is missing a non-empty id`, path });
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
const id = act.id as string;
|
|
119
|
-
|
|
120
|
-
// ACT-003: non-empty name
|
|
121
|
-
if (!act.name || typeof act.name !== 'string' || (act.name as string).trim() === '') {
|
|
122
|
-
errors.push({ code: 'ACT-003', message: `Activity "${id}" is missing a non-empty name`, path });
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ACT-004: unique ids
|
|
126
|
-
if (idSet.has(id)) {
|
|
127
|
-
errors.push({ code: 'ACT-004', message: `Duplicate activity id: "${id}"`, path });
|
|
128
|
-
} else {
|
|
129
|
-
idSet.add(id);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ACT-009: non-negative numeric fields
|
|
133
|
-
const numericFields = ['duration', 'labor_cost', 'resources_cost', 'effort', 'score', 'sort'] as const;
|
|
134
|
-
for (const field of numericFields) {
|
|
135
|
-
const val = act[field];
|
|
136
|
-
if (val !== undefined && val !== null) {
|
|
137
|
-
if (typeof val !== 'number' || val < 0) {
|
|
138
|
-
errors.push({ code: 'ACT-009', message: `Activity "${id}" field "${field}" must be a non-negative number, got ${String(val)}`, path });
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ACT-011: warn if no duration
|
|
144
|
-
if (act.duration === undefined || act.duration === null) {
|
|
145
|
-
warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — cannot participate in CPM analysis`, path });
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
150
|
-
|
|
151
|
-
// Second pass — checks that need the full id set
|
|
152
|
-
for (let i = 0; i < doc.activities.length; i++) {
|
|
153
|
-
const a = doc.activities[i];
|
|
154
|
-
const path = `activities[${i}]`;
|
|
155
|
-
|
|
156
|
-
// ACT-007: self-loop
|
|
157
|
-
if (Array.isArray(a.predecessors) && a.predecessors.includes(a.id)) {
|
|
158
|
-
errors.push({ code: 'ACT-007', message: `Activity "${a.id}" lists itself as a predecessor`, path });
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ACT-005: predecessor existence (intra-document)
|
|
162
|
-
for (const predId of (a.predecessors ?? [])) {
|
|
163
|
-
if (!idSet.has(predId)) {
|
|
164
|
-
errors.push({ code: 'ACT-005', message: `Activity "${a.id}" references unknown predecessor "${predId}"`, path });
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ACT-008: date format validation + end_date >= start_date.
|
|
169
|
-
// The orchestrator's pre-release review flagged that the raw lexicographic
|
|
170
|
-
// string compare is only correct for strict YYYY-MM-DD, with no format
|
|
171
|
-
// check applied. Now we validate the format first; the order compare runs
|
|
172
|
-
// only when both dates are valid (otherwise the comparison message would
|
|
173
|
-
// be misleading).
|
|
174
|
-
let startValid = true;
|
|
175
|
-
let endValid = true;
|
|
176
|
-
if (a.start_date !== undefined) {
|
|
177
|
-
if (typeof a.start_date !== 'string' || !DATE_RE.test(a.start_date)) {
|
|
178
|
-
errors.push({ code: 'ACT-008', message: `Activity "${a.id}" start_date "${String(a.start_date)}" must be ISO 8601 YYYY-MM-DD`, path });
|
|
179
|
-
startValid = false;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
if (a.end_date !== undefined) {
|
|
183
|
-
if (typeof a.end_date !== 'string' || !DATE_RE.test(a.end_date)) {
|
|
184
|
-
errors.push({ code: 'ACT-008', message: `Activity "${a.id}" end_date "${String(a.end_date)}" must be ISO 8601 YYYY-MM-DD`, path });
|
|
185
|
-
endValid = false;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
if (a.start_date && a.end_date && startValid && endValid) {
|
|
189
|
-
if (a.end_date < a.start_date) {
|
|
190
|
-
errors.push({ code: 'ACT-008', message: `Activity "${a.id}" end_date "${a.end_date}" is before start_date "${a.start_date}"`, path });
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
196
|
-
|
|
197
|
-
// ACT-006: no cycles
|
|
198
|
-
const cycleError = detectCycle(doc.activities);
|
|
199
|
-
if (cycleError) errors.push(cycleError);
|
|
200
|
-
|
|
201
|
-
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
202
|
-
|
|
203
|
-
// ACT-012: date/duration consistency (warning only)
|
|
204
|
-
for (let i = 0; i < doc.activities.length; i++) {
|
|
205
|
-
const a = doc.activities[i];
|
|
206
|
-
if (a.start_date && a.end_date && a.duration !== undefined) {
|
|
207
|
-
const start = new Date(a.start_date).getTime();
|
|
208
|
-
const end = new Date(a.end_date).getTime();
|
|
209
|
-
const diffDays = Math.round((end - start) / 86400000);
|
|
210
|
-
if (Math.abs(diffDays - a.duration) > 1) {
|
|
211
|
-
warnings.push({
|
|
212
|
-
code: 'ACT-012',
|
|
213
|
-
message: `Activity "${a.id}" duration ${a.duration} is inconsistent with date range of ~${diffDays} days`,
|
|
214
|
-
path: `activities[${i}]`,
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// ACT-013: orphan activities (warning only)
|
|
221
|
-
const successorIds = new Set<string>();
|
|
222
|
-
for (const a of doc.activities) {
|
|
223
|
-
for (const pred of (a.predecessors ?? [])) successorIds.add(pred);
|
|
224
|
-
}
|
|
225
|
-
for (let i = 0; i < doc.activities.length; i++) {
|
|
226
|
-
const a = doc.activities[i];
|
|
227
|
-
const hasSuccessors = successorIds.has(a.id);
|
|
228
|
-
const hasGoals = Array.isArray(a.goals) && a.goals.length > 0;
|
|
229
|
-
const hasPredecessors = Array.isArray(a.predecessors) && a.predecessors.length > 0;
|
|
230
|
-
if (doc.activities.length > 1 && !hasSuccessors && !hasGoals && !hasPredecessors) {
|
|
231
|
-
warnings.push({
|
|
232
|
-
code: 'ACT-013',
|
|
233
|
-
message: `Activity "${a.id}" appears to be structurally orphan (no predecessors, successors, or goals)`,
|
|
234
|
-
path: `activities[${i}]`,
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// ACT-016: milestone (duration=0) with both pinned dates MUST have start == end.
|
|
240
|
-
for (let i = 0; i < doc.activities.length; i++) {
|
|
241
|
-
const a = doc.activities[i];
|
|
242
|
-
if (a.duration === 0 && a.start_date && a.end_date && a.start_date !== a.end_date) {
|
|
243
|
-
errors.push({
|
|
244
|
-
code: 'ACT-016',
|
|
245
|
-
message: `Milestone "${a.id}" has duration 0 but start_date "${a.start_date}" ≠ end_date "${a.end_date}"`,
|
|
246
|
-
path: `activities[${i}]`,
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
252
|
-
|
|
253
|
-
// ACT-017 / ACT-018: phase warnings. A "phase" is an activity that is referenced
|
|
254
|
-
// by another activity's `parent` field. ACT-017 warns when a phase carries its
|
|
255
|
-
// own duration/dates (those roll up from children). ACT-018 warns when an
|
|
256
|
-
// activity that looks like a phase (no duration, no pinned dates) has no
|
|
257
|
-
// children — possibly an unused placeholder.
|
|
258
|
-
const childCount = new Map<string, number>();
|
|
259
|
-
for (const a of doc.activities) {
|
|
260
|
-
if (typeof a.parent === 'string' && a.parent.length > 0) {
|
|
261
|
-
childCount.set(a.parent, (childCount.get(a.parent) ?? 0) + 1);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
for (let i = 0; i < doc.activities.length; i++) {
|
|
265
|
-
const a = doc.activities[i];
|
|
266
|
-
const isReferencedAsParent = (childCount.get(a.id) ?? 0) > 0;
|
|
267
|
-
if (isReferencedAsParent) {
|
|
268
|
-
const carriesOwnTiming =
|
|
269
|
-
(a.duration !== undefined && a.duration !== null) ||
|
|
270
|
-
typeof a.start_date === 'string' ||
|
|
271
|
-
typeof a.end_date === 'string';
|
|
272
|
-
if (carriesOwnTiming) {
|
|
273
|
-
warnings.push({
|
|
274
|
-
code: 'ACT-017',
|
|
275
|
-
message: `Phase "${a.id}" carries its own duration/dates — those should roll up from children, not be authored directly`,
|
|
276
|
-
path: `activities[${i}]`,
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
} else {
|
|
280
|
-
// Not referenced as parent. ACT-018 only fires when the activity LOOKS
|
|
281
|
-
// like a phase: no duration declared and no pinned dates. A leaf without
|
|
282
|
-
// duration is already covered by ACT-011 — the same activity may surface
|
|
283
|
-
// both warnings, which is correct (separate concerns: CPM-unfit vs
|
|
284
|
-
// empty-phase).
|
|
285
|
-
const looksLikePhase =
|
|
286
|
-
(a.duration === undefined || a.duration === null) &&
|
|
287
|
-
!a.start_date &&
|
|
288
|
-
!a.end_date;
|
|
289
|
-
if (looksLikePhase) {
|
|
290
|
-
warnings.push({
|
|
291
|
-
code: 'ACT-018',
|
|
292
|
-
message: `Activity "${a.id}" has no duration and no children — may be an empty phase`,
|
|
293
|
-
path: `activities[${i}]`,
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// ACT-019: Gantt view will not render when neither project.start_date nor
|
|
300
|
-
// any per-activity pinned date pair is present. Network view is unaffected.
|
|
301
|
-
const hasProjectStart = typeof doc.project?.start_date === 'string' && doc.project.start_date.length > 0;
|
|
302
|
-
const hasPinnedActivity = doc.activities.some(
|
|
303
|
-
(a) => typeof a.start_date === 'string' && typeof a.end_date === 'string',
|
|
304
|
-
);
|
|
305
|
-
if (!hasProjectStart && !hasPinnedActivity) {
|
|
306
|
-
warnings.push({
|
|
307
|
-
code: 'ACT-019',
|
|
308
|
-
message: 'Gantt view will not render: project.start_date is absent and no activity has both start_date and end_date pinned. Network view is unaffected.',
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function detectCycle(activities: Activity[]): ActivityValidationError | null {
|
|
316
|
-
const successors = new Map<string, string[]>();
|
|
317
|
-
for (const a of activities) {
|
|
318
|
-
successors.set(a.id, []);
|
|
319
|
-
}
|
|
320
|
-
for (const a of activities) {
|
|
321
|
-
for (const pred of (a.predecessors ?? [])) {
|
|
322
|
-
const list = successors.get(pred);
|
|
323
|
-
if (list) list.push(a.id);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Kahn's algorithm: if we can't process all nodes, there's a cycle
|
|
328
|
-
const inDegree = new Map<string, number>();
|
|
329
|
-
for (const a of activities) inDegree.set(a.id, 0);
|
|
330
|
-
for (const a of activities) {
|
|
331
|
-
for (const pred of (a.predecessors ?? [])) {
|
|
332
|
-
inDegree.set(a.id, (inDegree.get(a.id) ?? 0) + 1);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const queue: string[] = [];
|
|
337
|
-
for (const [id, deg] of inDegree) {
|
|
338
|
-
if (deg === 0) queue.push(id);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
let processed = 0;
|
|
342
|
-
while (queue.length > 0) {
|
|
343
|
-
const id = queue.shift()!;
|
|
344
|
-
processed++;
|
|
345
|
-
for (const succ of (successors.get(id) ?? [])) {
|
|
346
|
-
const newDeg = (inDegree.get(succ) ?? 1) - 1;
|
|
347
|
-
inDegree.set(succ, newDeg);
|
|
348
|
-
if (newDeg === 0) queue.push(succ);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
if (processed < activities.length) {
|
|
353
|
-
const cycleNodes = activities.filter(a => (inDegree.get(a.id) ?? 0) > 0).map(a => a.id);
|
|
354
|
-
return { code: 'ACT-006', message: `Cycle detected in activity dependency graph involving: ${cycleNodes.join(', ')}` };
|
|
355
|
-
}
|
|
356
|
-
return null;
|
|
357
|
-
}
|
|
1
|
+
import type {
|
|
2
|
+
ActivityDoc,
|
|
3
|
+
Activity,
|
|
4
|
+
ActivityValidationResult,
|
|
5
|
+
ActivityValidationError,
|
|
6
|
+
ActivityValidationWarning,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
|
|
9
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
10
|
+
const VALID_WEEKDAYS = new Set(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
|
11
|
+
|
|
12
|
+
export function validateActivities(input: unknown): ActivityValidationResult {
|
|
13
|
+
const errors: ActivityValidationError[] = [];
|
|
14
|
+
const warnings: ActivityValidationWarning[] = [];
|
|
15
|
+
|
|
16
|
+
if (!input || typeof input !== 'object') {
|
|
17
|
+
return { valid: false, errors: [{ code: 'ACT-001', message: 'Input must be an object' }], warnings };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const raw = input as Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
// ACT-001: notation must equal "activities"
|
|
23
|
+
if (raw.notation === undefined) {
|
|
24
|
+
errors.push({ code: 'ACT-001', message: 'notation field is required' });
|
|
25
|
+
return { valid: false, errors, warnings };
|
|
26
|
+
}
|
|
27
|
+
if (raw.notation !== 'activities') {
|
|
28
|
+
errors.push({ code: 'ACT-001', message: `notation must be "activities", got "${String(raw.notation)}"` });
|
|
29
|
+
return { valid: false, errors, warnings };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!Array.isArray(raw.activities)) {
|
|
33
|
+
errors.push({ code: 'SCHEMA_INVALID', message: 'activities must be an array', path: 'activities' });
|
|
34
|
+
return { valid: false, errors, warnings };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ACT-014 / ACT-015: project.calendar validation (run before per-activity loop;
|
|
38
|
+
// surfaces clearly even when activities also have issues).
|
|
39
|
+
if (raw.project && typeof raw.project === 'object') {
|
|
40
|
+
const project = raw.project as Record<string, unknown>;
|
|
41
|
+
const cal = project.calendar;
|
|
42
|
+
if (cal && typeof cal === 'object') {
|
|
43
|
+
const calendar = cal as Record<string, unknown>;
|
|
44
|
+
if (calendar.working_days !== undefined) {
|
|
45
|
+
if (!Array.isArray(calendar.working_days)) {
|
|
46
|
+
errors.push({ code: 'ACT-014', message: 'project.calendar.working_days must be an array of weekday names', path: 'project.calendar.working_days' });
|
|
47
|
+
} else {
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
for (let i = 0; i < calendar.working_days.length; i++) {
|
|
50
|
+
const raw = calendar.working_days[i];
|
|
51
|
+
const day = typeof raw === 'string' ? raw.toLowerCase() : '';
|
|
52
|
+
if (!VALID_WEEKDAYS.has(day)) {
|
|
53
|
+
errors.push({
|
|
54
|
+
code: 'ACT-014',
|
|
55
|
+
message: `project.calendar.working_days[${i}] "${String(raw)}" must be one of: mon, tue, wed, thu, fri, sat, sun`,
|
|
56
|
+
path: `project.calendar.working_days[${i}]`,
|
|
57
|
+
});
|
|
58
|
+
} else if (seen.has(day)) {
|
|
59
|
+
errors.push({
|
|
60
|
+
code: 'ACT-014',
|
|
61
|
+
message: `project.calendar.working_days has duplicate entry "${day}"`,
|
|
62
|
+
path: `project.calendar.working_days[${i}]`,
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
seen.add(day);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (calendar.holidays !== undefined) {
|
|
71
|
+
if (!Array.isArray(calendar.holidays)) {
|
|
72
|
+
errors.push({ code: 'ACT-015', message: 'project.calendar.holidays must be an array of ISO 8601 dates', path: 'project.calendar.holidays' });
|
|
73
|
+
} else {
|
|
74
|
+
for (let i = 0; i < calendar.holidays.length; i++) {
|
|
75
|
+
const h = calendar.holidays[i];
|
|
76
|
+
if (typeof h !== 'string' || !DATE_RE.test(h)) {
|
|
77
|
+
errors.push({
|
|
78
|
+
code: 'ACT-015',
|
|
79
|
+
message: `project.calendar.holidays[${i}] "${String(h)}" must be an ISO 8601 date (YYYY-MM-DD)`,
|
|
80
|
+
path: `project.calendar.holidays[${i}]`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const doc = raw as unknown as ActivityDoc;
|
|
90
|
+
const idSet = new Set<string>();
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < doc.activities.length; i++) {
|
|
93
|
+
const a = doc.activities[i] as unknown;
|
|
94
|
+
const path = `activities[${i}]`;
|
|
95
|
+
|
|
96
|
+
if (!a || typeof a !== 'object') {
|
|
97
|
+
errors.push({ code: 'SCHEMA_INVALID', message: 'activity entry must be an object', path });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const act = a as Record<string, unknown>;
|
|
101
|
+
|
|
102
|
+
// ACT-010: reject single-value forms
|
|
103
|
+
if ('goal' in act) {
|
|
104
|
+
errors.push({ code: 'ACT-010', message: `Use "goals: []" array form instead of "goal:" (activity at index ${i})`, path });
|
|
105
|
+
}
|
|
106
|
+
if ('predecessor' in act) {
|
|
107
|
+
errors.push({ code: 'ACT-010', message: `Use "predecessors: []" array form instead of "predecessor:" (activity at index ${i})`, path });
|
|
108
|
+
}
|
|
109
|
+
if ('tag' in act) {
|
|
110
|
+
errors.push({ code: 'ACT-010', message: `Use "tags: []" array form instead of "tag:" (activity at index ${i})`, path });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ACT-002: non-empty id
|
|
114
|
+
if (!act.id || typeof act.id !== 'string' || (act.id as string).trim() === '') {
|
|
115
|
+
errors.push({ code: 'ACT-002', message: `Activity at index ${i} is missing a non-empty id`, path });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const id = act.id as string;
|
|
119
|
+
|
|
120
|
+
// ACT-003: non-empty name
|
|
121
|
+
if (!act.name || typeof act.name !== 'string' || (act.name as string).trim() === '') {
|
|
122
|
+
errors.push({ code: 'ACT-003', message: `Activity "${id}" is missing a non-empty name`, path });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ACT-004: unique ids
|
|
126
|
+
if (idSet.has(id)) {
|
|
127
|
+
errors.push({ code: 'ACT-004', message: `Duplicate activity id: "${id}"`, path });
|
|
128
|
+
} else {
|
|
129
|
+
idSet.add(id);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ACT-009: non-negative numeric fields
|
|
133
|
+
const numericFields = ['duration', 'duration_days', 'labor_cost', 'resources_cost', 'effort', 'score', 'sort'] as const;
|
|
134
|
+
for (const field of numericFields) {
|
|
135
|
+
const val = act[field];
|
|
136
|
+
if (val !== undefined && val !== null) {
|
|
137
|
+
if (typeof val !== 'number' || val < 0) {
|
|
138
|
+
errors.push({ code: 'ACT-009', message: `Activity "${id}" field "${field}" must be a non-negative number, got ${String(val)}`, path });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ACT-011: warn if no duration (duration_days is accepted as an alias)
|
|
144
|
+
if ((act.duration === undefined || act.duration === null) && (act.duration_days === undefined || act.duration_days === null)) {
|
|
145
|
+
warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — cannot participate in CPM analysis`, path });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
150
|
+
|
|
151
|
+
// Second pass — checks that need the full id set
|
|
152
|
+
for (let i = 0; i < doc.activities.length; i++) {
|
|
153
|
+
const a = doc.activities[i];
|
|
154
|
+
const path = `activities[${i}]`;
|
|
155
|
+
|
|
156
|
+
// ACT-007: self-loop
|
|
157
|
+
if (Array.isArray(a.predecessors) && a.predecessors.includes(a.id)) {
|
|
158
|
+
errors.push({ code: 'ACT-007', message: `Activity "${a.id}" lists itself as a predecessor`, path });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ACT-005: predecessor existence (intra-document)
|
|
162
|
+
for (const predId of (a.predecessors ?? [])) {
|
|
163
|
+
if (!idSet.has(predId)) {
|
|
164
|
+
errors.push({ code: 'ACT-005', message: `Activity "${a.id}" references unknown predecessor "${predId}"`, path });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ACT-008: date format validation + end_date >= start_date.
|
|
169
|
+
// The orchestrator's pre-release review flagged that the raw lexicographic
|
|
170
|
+
// string compare is only correct for strict YYYY-MM-DD, with no format
|
|
171
|
+
// check applied. Now we validate the format first; the order compare runs
|
|
172
|
+
// only when both dates are valid (otherwise the comparison message would
|
|
173
|
+
// be misleading).
|
|
174
|
+
let startValid = true;
|
|
175
|
+
let endValid = true;
|
|
176
|
+
if (a.start_date !== undefined) {
|
|
177
|
+
if (typeof a.start_date !== 'string' || !DATE_RE.test(a.start_date)) {
|
|
178
|
+
errors.push({ code: 'ACT-008', message: `Activity "${a.id}" start_date "${String(a.start_date)}" must be ISO 8601 YYYY-MM-DD`, path });
|
|
179
|
+
startValid = false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (a.end_date !== undefined) {
|
|
183
|
+
if (typeof a.end_date !== 'string' || !DATE_RE.test(a.end_date)) {
|
|
184
|
+
errors.push({ code: 'ACT-008', message: `Activity "${a.id}" end_date "${String(a.end_date)}" must be ISO 8601 YYYY-MM-DD`, path });
|
|
185
|
+
endValid = false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (a.start_date && a.end_date && startValid && endValid) {
|
|
189
|
+
if (a.end_date < a.start_date) {
|
|
190
|
+
errors.push({ code: 'ACT-008', message: `Activity "${a.id}" end_date "${a.end_date}" is before start_date "${a.start_date}"`, path });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
196
|
+
|
|
197
|
+
// ACT-006: no cycles
|
|
198
|
+
const cycleError = detectCycle(doc.activities);
|
|
199
|
+
if (cycleError) errors.push(cycleError);
|
|
200
|
+
|
|
201
|
+
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
202
|
+
|
|
203
|
+
// ACT-012: date/duration consistency (warning only)
|
|
204
|
+
for (let i = 0; i < doc.activities.length; i++) {
|
|
205
|
+
const a = doc.activities[i];
|
|
206
|
+
if (a.start_date && a.end_date && a.duration !== undefined) {
|
|
207
|
+
const start = new Date(a.start_date).getTime();
|
|
208
|
+
const end = new Date(a.end_date).getTime();
|
|
209
|
+
const diffDays = Math.round((end - start) / 86400000);
|
|
210
|
+
if (Math.abs(diffDays - a.duration) > 1) {
|
|
211
|
+
warnings.push({
|
|
212
|
+
code: 'ACT-012',
|
|
213
|
+
message: `Activity "${a.id}" duration ${a.duration} is inconsistent with date range of ~${diffDays} days`,
|
|
214
|
+
path: `activities[${i}]`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ACT-013: orphan activities (warning only)
|
|
221
|
+
const successorIds = new Set<string>();
|
|
222
|
+
for (const a of doc.activities) {
|
|
223
|
+
for (const pred of (a.predecessors ?? [])) successorIds.add(pred);
|
|
224
|
+
}
|
|
225
|
+
for (let i = 0; i < doc.activities.length; i++) {
|
|
226
|
+
const a = doc.activities[i];
|
|
227
|
+
const hasSuccessors = successorIds.has(a.id);
|
|
228
|
+
const hasGoals = Array.isArray(a.goals) && a.goals.length > 0;
|
|
229
|
+
const hasPredecessors = Array.isArray(a.predecessors) && a.predecessors.length > 0;
|
|
230
|
+
if (doc.activities.length > 1 && !hasSuccessors && !hasGoals && !hasPredecessors) {
|
|
231
|
+
warnings.push({
|
|
232
|
+
code: 'ACT-013',
|
|
233
|
+
message: `Activity "${a.id}" appears to be structurally orphan (no predecessors, successors, or goals)`,
|
|
234
|
+
path: `activities[${i}]`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ACT-016: milestone (duration=0) with both pinned dates MUST have start == end.
|
|
240
|
+
for (let i = 0; i < doc.activities.length; i++) {
|
|
241
|
+
const a = doc.activities[i];
|
|
242
|
+
if (a.duration === 0 && a.start_date && a.end_date && a.start_date !== a.end_date) {
|
|
243
|
+
errors.push({
|
|
244
|
+
code: 'ACT-016',
|
|
245
|
+
message: `Milestone "${a.id}" has duration 0 but start_date "${a.start_date}" ≠ end_date "${a.end_date}"`,
|
|
246
|
+
path: `activities[${i}]`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
252
|
+
|
|
253
|
+
// ACT-017 / ACT-018: phase warnings. A "phase" is an activity that is referenced
|
|
254
|
+
// by another activity's `parent` field. ACT-017 warns when a phase carries its
|
|
255
|
+
// own duration/dates (those roll up from children). ACT-018 warns when an
|
|
256
|
+
// activity that looks like a phase (no duration, no pinned dates) has no
|
|
257
|
+
// children — possibly an unused placeholder.
|
|
258
|
+
const childCount = new Map<string, number>();
|
|
259
|
+
for (const a of doc.activities) {
|
|
260
|
+
if (typeof a.parent === 'string' && a.parent.length > 0) {
|
|
261
|
+
childCount.set(a.parent, (childCount.get(a.parent) ?? 0) + 1);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
for (let i = 0; i < doc.activities.length; i++) {
|
|
265
|
+
const a = doc.activities[i];
|
|
266
|
+
const isReferencedAsParent = (childCount.get(a.id) ?? 0) > 0;
|
|
267
|
+
if (isReferencedAsParent) {
|
|
268
|
+
const carriesOwnTiming =
|
|
269
|
+
(a.duration !== undefined && a.duration !== null) ||
|
|
270
|
+
typeof a.start_date === 'string' ||
|
|
271
|
+
typeof a.end_date === 'string';
|
|
272
|
+
if (carriesOwnTiming) {
|
|
273
|
+
warnings.push({
|
|
274
|
+
code: 'ACT-017',
|
|
275
|
+
message: `Phase "${a.id}" carries its own duration/dates — those should roll up from children, not be authored directly`,
|
|
276
|
+
path: `activities[${i}]`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
// Not referenced as parent. ACT-018 only fires when the activity LOOKS
|
|
281
|
+
// like a phase: no duration declared and no pinned dates. A leaf without
|
|
282
|
+
// duration is already covered by ACT-011 — the same activity may surface
|
|
283
|
+
// both warnings, which is correct (separate concerns: CPM-unfit vs
|
|
284
|
+
// empty-phase).
|
|
285
|
+
const looksLikePhase =
|
|
286
|
+
(a.duration === undefined || a.duration === null) &&
|
|
287
|
+
!a.start_date &&
|
|
288
|
+
!a.end_date;
|
|
289
|
+
if (looksLikePhase) {
|
|
290
|
+
warnings.push({
|
|
291
|
+
code: 'ACT-018',
|
|
292
|
+
message: `Activity "${a.id}" has no duration and no children — may be an empty phase`,
|
|
293
|
+
path: `activities[${i}]`,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ACT-019: Gantt view will not render when neither project.start_date nor
|
|
300
|
+
// any per-activity pinned date pair is present. Network view is unaffected.
|
|
301
|
+
const hasProjectStart = typeof doc.project?.start_date === 'string' && doc.project.start_date.length > 0;
|
|
302
|
+
const hasPinnedActivity = doc.activities.some(
|
|
303
|
+
(a) => typeof a.start_date === 'string' && typeof a.end_date === 'string',
|
|
304
|
+
);
|
|
305
|
+
if (!hasProjectStart && !hasPinnedActivity) {
|
|
306
|
+
warnings.push({
|
|
307
|
+
code: 'ACT-019',
|
|
308
|
+
message: 'Gantt view will not render: project.start_date is absent and no activity has both start_date and end_date pinned. Network view is unaffected.',
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function detectCycle(activities: Activity[]): ActivityValidationError | null {
|
|
316
|
+
const successors = new Map<string, string[]>();
|
|
317
|
+
for (const a of activities) {
|
|
318
|
+
successors.set(a.id, []);
|
|
319
|
+
}
|
|
320
|
+
for (const a of activities) {
|
|
321
|
+
for (const pred of (a.predecessors ?? [])) {
|
|
322
|
+
const list = successors.get(pred);
|
|
323
|
+
if (list) list.push(a.id);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Kahn's algorithm: if we can't process all nodes, there's a cycle
|
|
328
|
+
const inDegree = new Map<string, number>();
|
|
329
|
+
for (const a of activities) inDegree.set(a.id, 0);
|
|
330
|
+
for (const a of activities) {
|
|
331
|
+
for (const pred of (a.predecessors ?? [])) {
|
|
332
|
+
inDegree.set(a.id, (inDegree.get(a.id) ?? 0) + 1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const queue: string[] = [];
|
|
337
|
+
for (const [id, deg] of inDegree) {
|
|
338
|
+
if (deg === 0) queue.push(id);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let processed = 0;
|
|
342
|
+
while (queue.length > 0) {
|
|
343
|
+
const id = queue.shift()!;
|
|
344
|
+
processed++;
|
|
345
|
+
for (const succ of (successors.get(id) ?? [])) {
|
|
346
|
+
const newDeg = (inDegree.get(succ) ?? 1) - 1;
|
|
347
|
+
inDegree.set(succ, newDeg);
|
|
348
|
+
if (newDeg === 0) queue.push(succ);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (processed < activities.length) {
|
|
353
|
+
const cycleNodes = activities.filter(a => (inDegree.get(a.id) ?? 0) > 0).map(a => a.id);
|
|
354
|
+
return { code: 'ACT-006', message: `Cycle detected in activity dependency graph involving: ${cycleNodes.join(', ')}` };
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|