@eddacraft/anvil-aps 0.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/AGENTS.md +155 -0
- package/LICENSE +14 -0
- package/README.md +57 -0
- package/TODO.md +40 -0
- package/dist/filter/context-bundle.d.ts +81 -0
- package/dist/filter/context-bundle.d.ts.map +1 -0
- package/dist/filter/context-bundle.js +230 -0
- package/dist/filter/index.d.ts +85 -0
- package/dist/filter/index.d.ts.map +1 -0
- package/dist/filter/index.js +169 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/loader/index.d.ts +80 -0
- package/dist/loader/index.d.ts.map +1 -0
- package/dist/loader/index.js +253 -0
- package/dist/parser/index.d.ts +24 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +22 -0
- package/dist/parser/parse-document.d.ts +17 -0
- package/dist/parser/parse-document.d.ts.map +1 -0
- package/dist/parser/parse-document.js +219 -0
- package/dist/parser/parse-index.d.ts +31 -0
- package/dist/parser/parse-index.d.ts.map +1 -0
- package/dist/parser/parse-index.js +251 -0
- package/dist/parser/parse-task.d.ts +30 -0
- package/dist/parser/parse-task.d.ts.map +1 -0
- package/dist/parser/parse-task.js +261 -0
- package/dist/state/index.d.ts +307 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +689 -0
- package/dist/templates/generator.d.ts +71 -0
- package/dist/templates/generator.d.ts.map +1 -0
- package/dist/templates/generator.js +723 -0
- package/dist/templates/index.d.ts +5 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +4 -0
- package/dist/types/index.d.ts +131 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +107 -0
- package/dist/validator/index.d.ts +83 -0
- package/dist/validator/index.d.ts.map +1 -0
- package/dist/validator/index.js +611 -0
- package/docs/APS-Anvil-Integration.md +750 -0
- package/docs/APS-Conventions.md +635 -0
- package/docs/APS-NonGoals.md +455 -0
- package/docs/APS-Planning-Spec-v0.1.md +362 -0
- package/examples/README.md +170 -0
- package/examples/feature-auth.aps.md +87 -0
- package/examples/refactor-error-handling.aps.md +119 -0
- package/examples/system-ecommerce/APS.md +57 -0
- package/examples/system-ecommerce/modules/auth.aps.md +38 -0
- package/examples/system-ecommerce/modules/cart.aps.md +53 -0
- package/examples/system-ecommerce/modules/payments.aps.md +68 -0
- package/examples/system-ecommerce/modules/products.aps.md +53 -0
- package/package.json +34 -0
- package/project.json +37 -0
- package/scripts/generate-templates.js +33 -0
- package/src/filter/context-bundle.ts +312 -0
- package/src/filter/filter.test.ts +317 -0
- package/src/filter/index.ts +249 -0
- package/src/index.ts +16 -0
- package/src/loader/index.ts +364 -0
- package/src/loader/loader.test.ts +224 -0
- package/src/parser/__fixtures__/invalid-task-id-not-padded.aps.md +7 -0
- package/src/parser/__fixtures__/invalid-task-id.aps.md +8 -0
- package/src/parser/__fixtures__/minimal-task.aps.md +7 -0
- package/src/parser/__fixtures__/non-scope-hyphenated.aps.md +10 -0
- package/src/parser/__fixtures__/simple-index.aps.md +35 -0
- package/src/parser/__fixtures__/simple-plan.aps.md +19 -0
- package/src/parser/index.ts +30 -0
- package/src/parser/parse-document.test.ts +603 -0
- package/src/parser/parse-document.ts +262 -0
- package/src/parser/parse-index.test.ts +316 -0
- package/src/parser/parse-index.ts +298 -0
- package/src/parser/parse-task.test.ts +476 -0
- package/src/parser/parse-task.ts +325 -0
- package/src/state/__fixtures__/invalid-plan.aps.md +9 -0
- package/src/state/__fixtures__/test-plan.aps.md +20 -0
- package/src/state/index.ts +879 -0
- package/src/state/state.test.ts +645 -0
- package/src/templates/generator.test.ts +378 -0
- package/src/templates/generator.ts +776 -0
- package/src/templates/index.ts +5 -0
- package/src/types/index.ts +168 -0
- package/src/validator/__fixtures__/broken-links.aps.md +10 -0
- package/src/validator/__fixtures__/circular-deps-index.aps.md +26 -0
- package/src/validator/__fixtures__/circular-modules/module-a.aps.md +9 -0
- package/src/validator/__fixtures__/circular-modules/module-b.aps.md +9 -0
- package/src/validator/__fixtures__/circular-modules/module-c.aps.md +9 -0
- package/src/validator/__fixtures__/dup-modules/module-a.aps.md +9 -0
- package/src/validator/__fixtures__/dup-modules/module-b.aps.md +9 -0
- package/src/validator/__fixtures__/duplicate-ids-index.aps.md +15 -0
- package/src/validator/__fixtures__/invalid-task-id.aps.md +17 -0
- package/src/validator/__fixtures__/missing-confidence.aps.md +9 -0
- package/src/validator/__fixtures__/missing-h1.aps.md +5 -0
- package/src/validator/__fixtures__/missing-intent.aps.md +9 -0
- package/src/validator/__fixtures__/missing-modules-section.aps.md +7 -0
- package/src/validator/__fixtures__/missing-tasks-section.aps.md +7 -0
- package/src/validator/__fixtures__/modules/auth.aps.md +17 -0
- package/src/validator/__fixtures__/modules/payments.aps.md +13 -0
- package/src/validator/__fixtures__/scope-mismatch.aps.md +14 -0
- package/src/validator/__fixtures__/valid-index.aps.md +24 -0
- package/src/validator/__fixtures__/valid-leaf.aps.md +22 -0
- package/src/validator/index.ts +776 -0
- package/src/validator/validator.test.ts +269 -0
- package/templates/index-full.md +94 -0
- package/templates/index-minimal.md +16 -0
- package/templates/index-template.md +63 -0
- package/templates/leaf-full.md +76 -0
- package/templates/leaf-minimal.md +14 -0
- package/templates/leaf-template.md +55 -0
- package/templates/simple-full.md +56 -0
- package/templates/simple-minimal.md +14 -0
- package/templates/simple-template.md +30 -0
- package/tsconfig.json +19 -0
- package/tsconfig.lib.json +14 -0
- package/tsconfig.lib.tsbuildinfo +1 -0
- package/tsconfig.spec.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validator module - Validation rules for APS planning documents
|
|
3
|
+
*
|
|
4
|
+
* Provides validation for:
|
|
5
|
+
* - Required sections (Index: ## Modules, Leaf: ## Tasks)
|
|
6
|
+
* - Task format (ID, Intent required)
|
|
7
|
+
* - Duplicate task IDs across plan graph
|
|
8
|
+
* - Broken module links
|
|
9
|
+
* - Scope mismatches (warning)
|
|
10
|
+
* - Missing Confidence (warning)
|
|
11
|
+
* - Missing Expected Outcome (warning)
|
|
12
|
+
* - Missing Validation/Test (warning)
|
|
13
|
+
* - Orphan leaf specs (warning)
|
|
14
|
+
* - Circular module dependencies
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { promises as fs, accessSync } from 'node:fs';
|
|
18
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
19
|
+
import { unified } from 'unified';
|
|
20
|
+
import remarkParse from 'remark-parse';
|
|
21
|
+
import { visit } from 'unist-util-visit';
|
|
22
|
+
import type { Root, Heading, Paragraph, Link } from 'mdast';
|
|
23
|
+
import { TASK_ID_REGEX } from '../types/index.js';
|
|
24
|
+
import { loadPlan, detectCycles, resolvePath, type LoadedPlan } from '../loader/index.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validation issue severity
|
|
28
|
+
*/
|
|
29
|
+
export type ValidationSeverity = 'error' | 'warning';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A single validation issue
|
|
33
|
+
*/
|
|
34
|
+
export interface ValidationIssue {
|
|
35
|
+
/** Severity level */
|
|
36
|
+
severity: ValidationSeverity;
|
|
37
|
+
|
|
38
|
+
/** Human-readable message */
|
|
39
|
+
message: string;
|
|
40
|
+
|
|
41
|
+
/** Rule that triggered this issue */
|
|
42
|
+
rule: string;
|
|
43
|
+
|
|
44
|
+
/** File path where the issue was found */
|
|
45
|
+
path?: string;
|
|
46
|
+
|
|
47
|
+
/** Line number in the file (1-based) */
|
|
48
|
+
lineNumber?: number;
|
|
49
|
+
|
|
50
|
+
/** Additional context */
|
|
51
|
+
context?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Result of validating a planning document
|
|
56
|
+
*/
|
|
57
|
+
export interface ValidationResult {
|
|
58
|
+
/** Whether the document is valid (no errors, warnings allowed) */
|
|
59
|
+
valid: boolean;
|
|
60
|
+
|
|
61
|
+
/** List of all issues found */
|
|
62
|
+
issues: ValidationIssue[];
|
|
63
|
+
|
|
64
|
+
/** Just the errors */
|
|
65
|
+
errors: ValidationIssue[];
|
|
66
|
+
|
|
67
|
+
/** Just the warnings */
|
|
68
|
+
warnings: ValidationIssue[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Options for validation
|
|
73
|
+
*/
|
|
74
|
+
export interface ValidateOptions {
|
|
75
|
+
/** Base directory for resolving relative paths */
|
|
76
|
+
baseDir?: string;
|
|
77
|
+
|
|
78
|
+
/** Whether to recursively validate linked modules (default: true) */
|
|
79
|
+
recursive?: boolean;
|
|
80
|
+
|
|
81
|
+
/** Rules to skip (by rule name) */
|
|
82
|
+
skipRules?: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validate an APS planning document
|
|
87
|
+
*
|
|
88
|
+
* @param filePath - Path to the planning document (index or leaf spec)
|
|
89
|
+
* @param options - Validation options
|
|
90
|
+
* @returns Validation result with errors and warnings
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```typescript
|
|
94
|
+
* const result = await validatePlanningDoc('docs/planning/APS.md');
|
|
95
|
+
* if (!result.valid) {
|
|
96
|
+
* for (const error of result.errors) {
|
|
97
|
+
* console.error(`${error.path}:${error.lineNumber}: ${error.message}`);
|
|
98
|
+
* }
|
|
99
|
+
* }
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export async function validatePlanningDoc(
|
|
103
|
+
filePath: string,
|
|
104
|
+
options: ValidateOptions = {}
|
|
105
|
+
): Promise<ValidationResult> {
|
|
106
|
+
const absolutePath = isAbsolute(filePath) ? filePath : resolve(filePath);
|
|
107
|
+
const baseDir = options.baseDir ?? dirname(absolutePath);
|
|
108
|
+
const recursive = options.recursive ?? true;
|
|
109
|
+
const skipRules = new Set(options.skipRules ?? []);
|
|
110
|
+
|
|
111
|
+
const issues: ValidationIssue[] = [];
|
|
112
|
+
|
|
113
|
+
// Read the file content
|
|
114
|
+
let content: string;
|
|
115
|
+
try {
|
|
116
|
+
content = await fs.readFile(absolutePath, 'utf-8');
|
|
117
|
+
} catch (error) {
|
|
118
|
+
issues.push({
|
|
119
|
+
severity: 'error',
|
|
120
|
+
message: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`,
|
|
121
|
+
rule: 'file-readable',
|
|
122
|
+
path: absolutePath,
|
|
123
|
+
});
|
|
124
|
+
return createResult(issues);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Determine document type and validate structure
|
|
128
|
+
const isIndex = detectIndexFile(content);
|
|
129
|
+
|
|
130
|
+
if (isIndex) {
|
|
131
|
+
// Validate index file structure
|
|
132
|
+
if (!skipRules.has('required-sections')) {
|
|
133
|
+
await validateIndexStructure(content, absolutePath, issues);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Validate module links
|
|
137
|
+
if (!skipRules.has('broken-links') && recursive) {
|
|
138
|
+
await validateModuleLinks(content, absolutePath, baseDir, issues);
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
// Validate leaf spec structure
|
|
142
|
+
if (!skipRules.has('required-sections')) {
|
|
143
|
+
validateLeafStructure(content, absolutePath, issues);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Validate tasks
|
|
147
|
+
if (!skipRules.has('task-format')) {
|
|
148
|
+
validateTaskFormat(content, absolutePath, issues, skipRules);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Load the full plan for cross-document validation
|
|
153
|
+
if (recursive) {
|
|
154
|
+
try {
|
|
155
|
+
const plan = await loadPlan(absolutePath, { baseDir, recursive: true });
|
|
156
|
+
|
|
157
|
+
// Check for duplicate task IDs
|
|
158
|
+
if (!skipRules.has('duplicate-ids')) {
|
|
159
|
+
validateDuplicateTaskIds(plan, issues);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check for circular dependencies
|
|
163
|
+
if (!skipRules.has('circular-dependencies')) {
|
|
164
|
+
validateCircularDependencies(plan, issues);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check scope mismatches
|
|
168
|
+
if (!skipRules.has('scope-mismatch')) {
|
|
169
|
+
validateScopeMismatches(plan, issues);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Check for orphan modules (only if index file)
|
|
173
|
+
if (isIndex && !skipRules.has('orphan-modules')) {
|
|
174
|
+
await validateOrphanModules(absolutePath, baseDir, plan, issues);
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
// If we can't load the plan, the earlier validation errors should explain why
|
|
178
|
+
if (issues.length === 0) {
|
|
179
|
+
issues.push({
|
|
180
|
+
severity: 'error',
|
|
181
|
+
message: `Failed to load plan: ${error instanceof Error ? error.message : String(error)}`,
|
|
182
|
+
rule: 'plan-loadable',
|
|
183
|
+
path: absolutePath,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return createResult(issues);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Detect if content is an index file (has ## Modules section)
|
|
194
|
+
*/
|
|
195
|
+
function detectIndexFile(content: string): boolean {
|
|
196
|
+
const processor = unified().use(remarkParse);
|
|
197
|
+
const ast = processor.parse(content) as Root;
|
|
198
|
+
|
|
199
|
+
let hasModulesSection = false;
|
|
200
|
+
|
|
201
|
+
visit(ast, 'heading', (node: Heading) => {
|
|
202
|
+
if (node.depth === 2) {
|
|
203
|
+
let text = '';
|
|
204
|
+
visit(node, 'text', (textNode: { value: string }) => {
|
|
205
|
+
text += textNode.value;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const normalizedText = text.trim().toLowerCase();
|
|
209
|
+
if (normalizedText === 'modules' || normalizedText.startsWith('modules')) {
|
|
210
|
+
hasModulesSection = true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return hasModulesSection;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Validate index file structure
|
|
220
|
+
*/
|
|
221
|
+
async function validateIndexStructure(
|
|
222
|
+
content: string,
|
|
223
|
+
filePath: string,
|
|
224
|
+
issues: ValidationIssue[]
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
const processor = unified().use(remarkParse);
|
|
227
|
+
const ast = processor.parse(content) as Root;
|
|
228
|
+
|
|
229
|
+
let hasH1 = false;
|
|
230
|
+
let hasModulesSection = false;
|
|
231
|
+
let modulesLineNumber = 0;
|
|
232
|
+
let hasModuleEntries = false;
|
|
233
|
+
|
|
234
|
+
visit(ast, 'heading', (node: Heading) => {
|
|
235
|
+
if (node.depth === 1) {
|
|
236
|
+
hasH1 = true;
|
|
237
|
+
}
|
|
238
|
+
if (node.depth === 2) {
|
|
239
|
+
let text = '';
|
|
240
|
+
visit(node, 'text', (textNode: { value: string }) => {
|
|
241
|
+
text += textNode.value;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const normalizedText = text.trim().toLowerCase();
|
|
245
|
+
if (normalizedText === 'modules' || normalizedText.startsWith('modules')) {
|
|
246
|
+
hasModulesSection = true;
|
|
247
|
+
modulesLineNumber = node.position?.start.line ?? 0;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (node.depth === 3 && hasModulesSection) {
|
|
251
|
+
hasModuleEntries = true;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (!hasH1) {
|
|
256
|
+
issues.push({
|
|
257
|
+
severity: 'error',
|
|
258
|
+
message: 'Index file must have an H1 title',
|
|
259
|
+
rule: 'required-sections',
|
|
260
|
+
path: filePath,
|
|
261
|
+
lineNumber: 1,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!hasModulesSection) {
|
|
266
|
+
issues.push({
|
|
267
|
+
severity: 'error',
|
|
268
|
+
message: 'Index file must have a "## Modules" section',
|
|
269
|
+
rule: 'required-sections',
|
|
270
|
+
path: filePath,
|
|
271
|
+
});
|
|
272
|
+
} else if (!hasModuleEntries) {
|
|
273
|
+
issues.push({
|
|
274
|
+
severity: 'warning',
|
|
275
|
+
message: '"## Modules" section has no module entries (H3 headings)',
|
|
276
|
+
rule: 'required-sections',
|
|
277
|
+
path: filePath,
|
|
278
|
+
lineNumber: modulesLineNumber,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Validate leaf spec structure
|
|
285
|
+
*/
|
|
286
|
+
function validateLeafStructure(content: string, filePath: string, issues: ValidationIssue[]): void {
|
|
287
|
+
const processor = unified().use(remarkParse);
|
|
288
|
+
const ast = processor.parse(content) as Root;
|
|
289
|
+
|
|
290
|
+
let hasH1 = false;
|
|
291
|
+
let hasTasksSection = false;
|
|
292
|
+
let tasksLineNumber = 0;
|
|
293
|
+
let hasTaskEntries = false;
|
|
294
|
+
|
|
295
|
+
visit(ast, 'heading', (node: Heading) => {
|
|
296
|
+
if (node.depth === 1) {
|
|
297
|
+
hasH1 = true;
|
|
298
|
+
}
|
|
299
|
+
if (node.depth === 2) {
|
|
300
|
+
let text = '';
|
|
301
|
+
visit(node, 'text', (textNode: { value: string }) => {
|
|
302
|
+
text += textNode.value;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
if (text.trim().toLowerCase() === 'tasks') {
|
|
306
|
+
hasTasksSection = true;
|
|
307
|
+
tasksLineNumber = node.position?.start.line ?? 0;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (node.depth === 3 && hasTasksSection) {
|
|
311
|
+
hasTaskEntries = true;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
if (!hasH1) {
|
|
316
|
+
issues.push({
|
|
317
|
+
severity: 'error',
|
|
318
|
+
message: 'Leaf spec must have an H1 title',
|
|
319
|
+
rule: 'required-sections',
|
|
320
|
+
path: filePath,
|
|
321
|
+
lineNumber: 1,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!hasTasksSection) {
|
|
326
|
+
issues.push({
|
|
327
|
+
severity: 'error',
|
|
328
|
+
message: 'Leaf spec must have a "## Tasks" section',
|
|
329
|
+
rule: 'required-sections',
|
|
330
|
+
path: filePath,
|
|
331
|
+
});
|
|
332
|
+
} else if (!hasTaskEntries) {
|
|
333
|
+
issues.push({
|
|
334
|
+
severity: 'warning',
|
|
335
|
+
message: '"## Tasks" section has no task entries (H3 headings)',
|
|
336
|
+
rule: 'required-sections',
|
|
337
|
+
path: filePath,
|
|
338
|
+
lineNumber: tasksLineNumber,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Validate module links in an index file
|
|
345
|
+
*/
|
|
346
|
+
async function validateModuleLinks(
|
|
347
|
+
content: string,
|
|
348
|
+
filePath: string,
|
|
349
|
+
baseDir: string,
|
|
350
|
+
issues: ValidationIssue[]
|
|
351
|
+
): Promise<void> {
|
|
352
|
+
const processor = unified().use(remarkParse);
|
|
353
|
+
const ast = processor.parse(content) as Root;
|
|
354
|
+
|
|
355
|
+
let inModulesSection = false;
|
|
356
|
+
let currentModuleId: string | null = null;
|
|
357
|
+
|
|
358
|
+
visit(ast, (node) => {
|
|
359
|
+
if (node.type === 'heading') {
|
|
360
|
+
const heading = node as Heading;
|
|
361
|
+
if (heading.depth === 2) {
|
|
362
|
+
let text = '';
|
|
363
|
+
visit(heading, 'text', (textNode: { value: string }) => {
|
|
364
|
+
text += textNode.value;
|
|
365
|
+
});
|
|
366
|
+
const normalizedText = text.trim().toLowerCase();
|
|
367
|
+
inModulesSection = normalizedText === 'modules' || normalizedText.startsWith('modules');
|
|
368
|
+
}
|
|
369
|
+
if (heading.depth === 3 && inModulesSection) {
|
|
370
|
+
let text = '';
|
|
371
|
+
visit(heading, 'text', (textNode: { value: string }) => {
|
|
372
|
+
text += textNode.value;
|
|
373
|
+
});
|
|
374
|
+
currentModuleId = text.trim();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Check for Path links in list items
|
|
379
|
+
if (node.type === 'paragraph' && inModulesSection && currentModuleId) {
|
|
380
|
+
const para = node as Paragraph;
|
|
381
|
+
let hasPathField = false;
|
|
382
|
+
let linkUrl: string | null = null;
|
|
383
|
+
const linkLine = para.position?.start.line ?? 0;
|
|
384
|
+
|
|
385
|
+
for (const child of para.children) {
|
|
386
|
+
if (child.type === 'strong') {
|
|
387
|
+
let strongText = '';
|
|
388
|
+
visit(child, 'text', (textNode: { value: string }) => {
|
|
389
|
+
strongText += textNode.value;
|
|
390
|
+
});
|
|
391
|
+
if (strongText === 'Path:') {
|
|
392
|
+
hasPathField = true;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (child.type === 'link' && hasPathField) {
|
|
396
|
+
linkUrl = (child as Link).url;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (hasPathField && linkUrl) {
|
|
401
|
+
// Validate the link exists
|
|
402
|
+
const resolvedPath = resolvePath(linkUrl, baseDir);
|
|
403
|
+
validateFileExists(resolvedPath, filePath, linkLine, currentModuleId, issues);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Validate that a file exists (async check queued for later)
|
|
411
|
+
*/
|
|
412
|
+
function validateFileExists(
|
|
413
|
+
targetPath: string,
|
|
414
|
+
sourcePath: string,
|
|
415
|
+
lineNumber: number,
|
|
416
|
+
moduleId: string,
|
|
417
|
+
issues: ValidationIssue[]
|
|
418
|
+
): void {
|
|
419
|
+
// Use sync check for simplicity (file system is fast for existence checks)
|
|
420
|
+
try {
|
|
421
|
+
accessSync(targetPath);
|
|
422
|
+
} catch {
|
|
423
|
+
issues.push({
|
|
424
|
+
severity: 'error',
|
|
425
|
+
message: `Broken link: module "${moduleId}" links to non-existent file "${targetPath}"`,
|
|
426
|
+
rule: 'broken-links',
|
|
427
|
+
path: sourcePath,
|
|
428
|
+
lineNumber,
|
|
429
|
+
context: `Module: ${moduleId}`,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Validate task format in a leaf spec
|
|
436
|
+
*/
|
|
437
|
+
function validateTaskFormat(
|
|
438
|
+
content: string,
|
|
439
|
+
filePath: string,
|
|
440
|
+
issues: ValidationIssue[],
|
|
441
|
+
skipRules: Set<string>
|
|
442
|
+
): void {
|
|
443
|
+
const processor = unified().use(remarkParse);
|
|
444
|
+
const ast = processor.parse(content) as Root;
|
|
445
|
+
|
|
446
|
+
let inTasksSection = false;
|
|
447
|
+
let currentTaskHeading: { id: string; title: string; line: number } | null = null;
|
|
448
|
+
let currentTaskContent: string[] = [];
|
|
449
|
+
|
|
450
|
+
visit(ast, (node) => {
|
|
451
|
+
if (node.type === 'heading') {
|
|
452
|
+
const heading = node as Heading;
|
|
453
|
+
|
|
454
|
+
if (heading.depth === 2) {
|
|
455
|
+
// Check for section change
|
|
456
|
+
if (currentTaskHeading) {
|
|
457
|
+
validateTaskContent(currentTaskHeading, currentTaskContent, filePath, issues, skipRules);
|
|
458
|
+
currentTaskHeading = null;
|
|
459
|
+
currentTaskContent = [];
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
let text = '';
|
|
463
|
+
visit(heading, 'text', (textNode: { value: string }) => {
|
|
464
|
+
text += textNode.value;
|
|
465
|
+
});
|
|
466
|
+
inTasksSection = text.trim().toLowerCase() === 'tasks';
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (heading.depth === 3 && inTasksSection) {
|
|
470
|
+
// Save previous task
|
|
471
|
+
if (currentTaskHeading) {
|
|
472
|
+
validateTaskContent(currentTaskHeading, currentTaskContent, filePath, issues, skipRules);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Parse task heading
|
|
476
|
+
let text = '';
|
|
477
|
+
visit(heading, 'text', (textNode: { value: string }) => {
|
|
478
|
+
text += textNode.value;
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const lineNumber = heading.position?.start.line ?? 0;
|
|
482
|
+
const match = text.match(/^([A-Z0-9]+-\d+):\s*(.*)$/);
|
|
483
|
+
|
|
484
|
+
if (!match) {
|
|
485
|
+
issues.push({
|
|
486
|
+
severity: 'error',
|
|
487
|
+
message: `Invalid task heading format: "${text}". Expected "ID: Title" (e.g., "AUTH-001: Implement login")`,
|
|
488
|
+
rule: 'task-format',
|
|
489
|
+
path: filePath,
|
|
490
|
+
lineNumber,
|
|
491
|
+
});
|
|
492
|
+
currentTaskHeading = null;
|
|
493
|
+
} else {
|
|
494
|
+
const [, id, title] = match;
|
|
495
|
+
|
|
496
|
+
// Validate task ID format
|
|
497
|
+
if (!TASK_ID_REGEX.test(id)) {
|
|
498
|
+
issues.push({
|
|
499
|
+
severity: 'error',
|
|
500
|
+
message: `Invalid task ID format: "${id}". Expected 1-10 alphanumeric scope + hyphen + 3-digit number (e.g., AUTH-001)`,
|
|
501
|
+
rule: 'task-format',
|
|
502
|
+
path: filePath,
|
|
503
|
+
lineNumber,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
currentTaskHeading = { id, title, line: lineNumber };
|
|
508
|
+
currentTaskContent = [];
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Collect task content
|
|
514
|
+
if (currentTaskHeading && node.type === 'paragraph') {
|
|
515
|
+
let text = '';
|
|
516
|
+
visit(node, 'text', (textNode: { value: string }) => {
|
|
517
|
+
text += textNode.value;
|
|
518
|
+
});
|
|
519
|
+
visit(node, 'strong', (strongNode) => {
|
|
520
|
+
visit(strongNode, 'text', (textNode: { value: string }) => {
|
|
521
|
+
text += textNode.value;
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
currentTaskContent.push(text);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Validate last task
|
|
529
|
+
if (currentTaskHeading) {
|
|
530
|
+
validateTaskContent(currentTaskHeading, currentTaskContent, filePath, issues, skipRules);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Validate task content (Intent required, Confidence/Validation/ExpectedOutcome warnings)
|
|
536
|
+
*/
|
|
537
|
+
function validateTaskContent(
|
|
538
|
+
task: { id: string; title: string; line: number },
|
|
539
|
+
content: string[],
|
|
540
|
+
filePath: string,
|
|
541
|
+
issues: ValidationIssue[],
|
|
542
|
+
skipRules: Set<string>
|
|
543
|
+
): void {
|
|
544
|
+
const fullContent = content.join(' ');
|
|
545
|
+
|
|
546
|
+
// Check for Intent (required)
|
|
547
|
+
if (!skipRules.has('task-intent') && !fullContent.includes('Intent:')) {
|
|
548
|
+
issues.push({
|
|
549
|
+
severity: 'error',
|
|
550
|
+
message: `Task "${task.id}" is missing required **Intent:** field`,
|
|
551
|
+
rule: 'task-intent',
|
|
552
|
+
path: filePath,
|
|
553
|
+
lineNumber: task.line,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Check for Expected Outcome (warning per APS spec)
|
|
558
|
+
if (
|
|
559
|
+
!skipRules.has('missing-expected-outcome') &&
|
|
560
|
+
!fullContent.includes('Expected Outcome:') &&
|
|
561
|
+
!fullContent.includes('ExpectedOutcome:')
|
|
562
|
+
) {
|
|
563
|
+
issues.push({
|
|
564
|
+
severity: 'warning',
|
|
565
|
+
message: `Task "${task.id}" is missing **Expected Outcome:** field (recommended for testability)`,
|
|
566
|
+
rule: 'missing-expected-outcome',
|
|
567
|
+
path: filePath,
|
|
568
|
+
lineNumber: task.line,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Check for Validation/Test (warning per APS spec)
|
|
573
|
+
if (
|
|
574
|
+
!skipRules.has('missing-validation') &&
|
|
575
|
+
!fullContent.includes('Validation:') &&
|
|
576
|
+
!fullContent.includes('Test:')
|
|
577
|
+
) {
|
|
578
|
+
issues.push({
|
|
579
|
+
severity: 'warning',
|
|
580
|
+
message: `Task "${task.id}" is missing **Validation:** or **Test:** field (recommended for verification)`,
|
|
581
|
+
rule: 'missing-validation',
|
|
582
|
+
path: filePath,
|
|
583
|
+
lineNumber: task.line,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Check for Confidence (warning)
|
|
588
|
+
if (!skipRules.has('missing-confidence') && !fullContent.includes('Confidence:')) {
|
|
589
|
+
issues.push({
|
|
590
|
+
severity: 'warning',
|
|
591
|
+
message: `Task "${task.id}" is missing **Confidence:** field (defaults to "medium")`,
|
|
592
|
+
rule: 'missing-confidence',
|
|
593
|
+
path: filePath,
|
|
594
|
+
lineNumber: task.line,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Validate duplicate task IDs across the plan
|
|
601
|
+
*/
|
|
602
|
+
function validateDuplicateTaskIds(plan: LoadedPlan, issues: ValidationIssue[]): void {
|
|
603
|
+
const taskLocations = new Map<string, Array<{ path: string; line?: number }>>();
|
|
604
|
+
|
|
605
|
+
for (const task of plan.allTasks) {
|
|
606
|
+
const locations = taskLocations.get(task.id) ?? [];
|
|
607
|
+
locations.push({
|
|
608
|
+
path: task.sourcePath ?? 'unknown',
|
|
609
|
+
line: task.sourceLineNumber,
|
|
610
|
+
});
|
|
611
|
+
taskLocations.set(task.id, locations);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
for (const [taskId, locations] of taskLocations) {
|
|
615
|
+
if (locations.length > 1) {
|
|
616
|
+
const locationStrings = locations
|
|
617
|
+
.map((loc) => `${loc.path}${loc.line ? `:${loc.line}` : ''}`)
|
|
618
|
+
.join(', ');
|
|
619
|
+
|
|
620
|
+
issues.push({
|
|
621
|
+
severity: 'error',
|
|
622
|
+
message: `Duplicate task ID "${taskId}" found in: ${locationStrings}`,
|
|
623
|
+
rule: 'duplicate-ids',
|
|
624
|
+
path: locations[0].path,
|
|
625
|
+
lineNumber: locations[0].line,
|
|
626
|
+
context: `Also found at: ${locations
|
|
627
|
+
.slice(1)
|
|
628
|
+
.map((l) => `${l.path}:${l.line}`)
|
|
629
|
+
.join(', ')}`,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Validate circular module dependencies
|
|
637
|
+
*/
|
|
638
|
+
function validateCircularDependencies(plan: LoadedPlan, issues: ValidationIssue[]): void {
|
|
639
|
+
const cycles = detectCycles(plan);
|
|
640
|
+
|
|
641
|
+
for (const cycle of cycles) {
|
|
642
|
+
const cycleStr = cycle.join(' -> ');
|
|
643
|
+
issues.push({
|
|
644
|
+
severity: 'error',
|
|
645
|
+
message: `Circular dependency detected: ${cycleStr}`,
|
|
646
|
+
rule: 'circular-dependencies',
|
|
647
|
+
path: plan.rootPath,
|
|
648
|
+
context: `Cycle: ${cycleStr}`,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Validate scope mismatches (task ID prefix vs module scope)
|
|
655
|
+
*/
|
|
656
|
+
function validateScopeMismatches(plan: LoadedPlan, issues: ValidationIssue[]): void {
|
|
657
|
+
for (const module of plan.modules.values()) {
|
|
658
|
+
const moduleScope = module.metadata.scope?.toUpperCase();
|
|
659
|
+
|
|
660
|
+
for (const task of module.tasks) {
|
|
661
|
+
const taskScope = task.id.split('-')[0];
|
|
662
|
+
|
|
663
|
+
if (moduleScope && taskScope !== moduleScope) {
|
|
664
|
+
issues.push({
|
|
665
|
+
severity: 'warning',
|
|
666
|
+
message: `Task "${task.id}" has scope prefix "${taskScope}" but belongs to module with scope "${moduleScope}"`,
|
|
667
|
+
rule: 'scope-mismatch',
|
|
668
|
+
path: task.sourcePath,
|
|
669
|
+
lineNumber: task.sourceLineNumber,
|
|
670
|
+
context: `Module scope: ${moduleScope}`,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Validate for orphan modules (leaf specs in directory not linked from index)
|
|
679
|
+
*/
|
|
680
|
+
async function validateOrphanModules(
|
|
681
|
+
indexPath: string,
|
|
682
|
+
baseDir: string,
|
|
683
|
+
plan: LoadedPlan,
|
|
684
|
+
issues: ValidationIssue[]
|
|
685
|
+
): Promise<void> {
|
|
686
|
+
// Get all linked module paths
|
|
687
|
+
const linkedPaths = new Set<string>();
|
|
688
|
+
for (const module of plan.modules.values()) {
|
|
689
|
+
linkedPaths.add(module.resolvedPath);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Scan the directory for .aps.md files
|
|
693
|
+
try {
|
|
694
|
+
const { readdir, stat } = await import('node:fs/promises');
|
|
695
|
+
const { join } = await import('node:path');
|
|
696
|
+
|
|
697
|
+
async function scanDir(dir: string): Promise<string[]> {
|
|
698
|
+
const files: string[] = [];
|
|
699
|
+
try {
|
|
700
|
+
const entries = await readdir(dir);
|
|
701
|
+
for (const entry of entries) {
|
|
702
|
+
const fullPath = join(dir, entry);
|
|
703
|
+
const stats = await stat(fullPath);
|
|
704
|
+
if (stats.isDirectory()) {
|
|
705
|
+
files.push(...(await scanDir(fullPath)));
|
|
706
|
+
} else if (entry.endsWith('.aps.md') && fullPath !== indexPath) {
|
|
707
|
+
files.push(fullPath);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
} catch {
|
|
711
|
+
// Ignore errors (permission denied, etc.)
|
|
712
|
+
}
|
|
713
|
+
return files;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const allApsFiles = await scanDir(baseDir);
|
|
717
|
+
|
|
718
|
+
for (const file of allApsFiles) {
|
|
719
|
+
if (!linkedPaths.has(file)) {
|
|
720
|
+
issues.push({
|
|
721
|
+
severity: 'warning',
|
|
722
|
+
message: `Orphan leaf spec found: "${file}" is not linked from the index file`,
|
|
723
|
+
rule: 'orphan-modules',
|
|
724
|
+
path: file,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
} catch {
|
|
729
|
+
// Ignore directory scanning errors
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Create a ValidationResult from issues
|
|
735
|
+
*/
|
|
736
|
+
function createResult(issues: ValidationIssue[]): ValidationResult {
|
|
737
|
+
const errors = issues.filter((i) => i.severity === 'error');
|
|
738
|
+
const warnings = issues.filter((i) => i.severity === 'warning');
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
valid: errors.length === 0,
|
|
742
|
+
issues,
|
|
743
|
+
errors,
|
|
744
|
+
warnings,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Format validation issues for display
|
|
750
|
+
*/
|
|
751
|
+
export function formatValidationIssues(result: ValidationResult): string {
|
|
752
|
+
if (result.issues.length === 0) {
|
|
753
|
+
return 'No issues found.';
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const lines: string[] = [];
|
|
757
|
+
|
|
758
|
+
for (const issue of result.issues) {
|
|
759
|
+
const severity = issue.severity === 'error' ? 'ERROR' : 'WARN';
|
|
760
|
+
const location = issue.path
|
|
761
|
+
? issue.lineNumber
|
|
762
|
+
? `${issue.path}:${issue.lineNumber}`
|
|
763
|
+
: issue.path
|
|
764
|
+
: '';
|
|
765
|
+
const prefix = location ? `${location}: ` : '';
|
|
766
|
+
lines.push(`[${severity}] ${prefix}${issue.message}`);
|
|
767
|
+
if (issue.context) {
|
|
768
|
+
lines.push(` ${issue.context}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const summary = `\n${result.errors.length} error(s), ${result.warnings.length} warning(s)`;
|
|
773
|
+
lines.push(summary);
|
|
774
|
+
|
|
775
|
+
return lines.join('\n');
|
|
776
|
+
}
|