@millstone/synapse-cli 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.
Files changed (103) hide show
  1. package/README.md +135 -0
  2. package/bin/synapse.js +3 -0
  3. package/dist/commands/eject.d.ts +19 -0
  4. package/dist/commands/eject.d.ts.map +1 -0
  5. package/dist/commands/eject.js +146 -0
  6. package/dist/commands/eject.js.map +1 -0
  7. package/dist/commands/fetch-reference.d.ts +19 -0
  8. package/dist/commands/fetch-reference.d.ts.map +1 -0
  9. package/dist/commands/fetch-reference.js +93 -0
  10. package/dist/commands/fetch-reference.js.map +1 -0
  11. package/dist/commands/format.d.ts +26 -0
  12. package/dist/commands/format.d.ts.map +1 -0
  13. package/dist/commands/format.js +126 -0
  14. package/dist/commands/format.js.map +1 -0
  15. package/dist/commands/generate-pdf.d.ts +19 -0
  16. package/dist/commands/generate-pdf.d.ts.map +1 -0
  17. package/dist/commands/generate-pdf.js +140 -0
  18. package/dist/commands/generate-pdf.js.map +1 -0
  19. package/dist/commands/index.d.ts +17 -0
  20. package/dist/commands/index.d.ts.map +1 -0
  21. package/dist/commands/index.js +26 -0
  22. package/dist/commands/index.js.map +1 -0
  23. package/dist/commands/init.d.ts +58 -0
  24. package/dist/commands/init.d.ts.map +1 -0
  25. package/dist/commands/init.js +234 -0
  26. package/dist/commands/init.js.map +1 -0
  27. package/dist/commands/migrate.d.ts +29 -0
  28. package/dist/commands/migrate.d.ts.map +1 -0
  29. package/dist/commands/migrate.js +297 -0
  30. package/dist/commands/migrate.js.map +1 -0
  31. package/dist/commands/scaffold.d.ts +24 -0
  32. package/dist/commands/scaffold.d.ts.map +1 -0
  33. package/dist/commands/scaffold.js +244 -0
  34. package/dist/commands/scaffold.js.map +1 -0
  35. package/dist/commands/update.d.ts +25 -0
  36. package/dist/commands/update.d.ts.map +1 -0
  37. package/dist/commands/update.js +253 -0
  38. package/dist/commands/update.js.map +1 -0
  39. package/dist/commands/validate.d.ts +37 -0
  40. package/dist/commands/validate.d.ts.map +1 -0
  41. package/dist/commands/validate.js +526 -0
  42. package/dist/commands/validate.js.map +1 -0
  43. package/dist/index.d.ts +3 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +277 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/lib/bodyRules.d.ts +70 -0
  48. package/dist/lib/bodyRules.d.ts.map +1 -0
  49. package/dist/lib/bodyRules.js +711 -0
  50. package/dist/lib/bodyRules.js.map +1 -0
  51. package/dist/lib/config.d.ts +49 -0
  52. package/dist/lib/config.d.ts.map +1 -0
  53. package/dist/lib/config.js +91 -0
  54. package/dist/lib/config.js.map +1 -0
  55. package/dist/lib/git.d.ts +99 -0
  56. package/dist/lib/git.d.ts.map +1 -0
  57. package/dist/lib/git.js +266 -0
  58. package/dist/lib/git.js.map +1 -0
  59. package/dist/lib/graph.d.ts +6 -0
  60. package/dist/lib/graph.d.ts.map +1 -0
  61. package/dist/lib/graph.js +6 -0
  62. package/dist/lib/graph.js.map +1 -0
  63. package/dist/lib/homepage.d.ts +10 -0
  64. package/dist/lib/homepage.d.ts.map +1 -0
  65. package/dist/lib/homepage.js +172 -0
  66. package/dist/lib/homepage.js.map +1 -0
  67. package/dist/lib/markdown.d.ts +107 -0
  68. package/dist/lib/markdown.d.ts.map +1 -0
  69. package/dist/lib/markdown.js +318 -0
  70. package/dist/lib/markdown.js.map +1 -0
  71. package/dist/lib/mode-detection.d.ts +10 -0
  72. package/dist/lib/mode-detection.d.ts.map +1 -0
  73. package/dist/lib/mode-detection.js +29 -0
  74. package/dist/lib/mode-detection.js.map +1 -0
  75. package/dist/lib/naming.d.ts +47 -0
  76. package/dist/lib/naming.d.ts.map +1 -0
  77. package/dist/lib/naming.js +403 -0
  78. package/dist/lib/naming.js.map +1 -0
  79. package/dist/lib/schemas.d.ts +38 -0
  80. package/dist/lib/schemas.d.ts.map +1 -0
  81. package/dist/lib/schemas.js +248 -0
  82. package/dist/lib/schemas.js.map +1 -0
  83. package/dist/lib/templateLint.d.ts +21 -0
  84. package/dist/lib/templateLint.d.ts.map +1 -0
  85. package/dist/lib/templateLint.js +243 -0
  86. package/dist/lib/templateLint.js.map +1 -0
  87. package/dist/lib/templates.d.ts +53 -0
  88. package/dist/lib/templates.d.ts.map +1 -0
  89. package/dist/lib/templates.js +128 -0
  90. package/dist/lib/templates.js.map +1 -0
  91. package/dist/lib/tracking.d.ts +52 -0
  92. package/dist/lib/tracking.d.ts.map +1 -0
  93. package/dist/lib/tracking.js +135 -0
  94. package/dist/lib/tracking.js.map +1 -0
  95. package/dist/lib/types.generated.d.ts +54 -0
  96. package/dist/lib/types.generated.d.ts.map +1 -0
  97. package/dist/lib/types.generated.js +144 -0
  98. package/dist/lib/types.generated.js.map +1 -0
  99. package/dist/lib/validate-plugins.d.ts +22 -0
  100. package/dist/lib/validate-plugins.d.ts.map +1 -0
  101. package/dist/lib/validate-plugins.js +851 -0
  102. package/dist/lib/validate-plugins.js.map +1 -0
  103. package/package.json +85 -0
@@ -0,0 +1,711 @@
1
+ /**
2
+ * Body validation rules for Synapse document types
3
+ * Implements AST-based validation of Markdown body structure
4
+ */
5
+ import fsExtra from 'fs-extra';
6
+ import * as path from 'path';
7
+ import { createRequire } from 'module';
8
+ import { parseMarkdown, findSections, stringifyMarkdown } from './markdown.js';
9
+ const fs = fsExtra;
10
+ // ============================================================================
11
+ // Rule Loading
12
+ // ============================================================================
13
+ /**
14
+ * Loads body rules from the body-grammars directory
15
+ */
16
+ export async function loadBodyRules(root) {
17
+ const grammarsDir = root
18
+ ? path.resolve(root, 'schemas/body-grammars')
19
+ : getDefaultGrammarsDir();
20
+ if (!(await fs.pathExists(grammarsDir))) {
21
+ throw new Error(`Body grammars directory not found at ${grammarsDir}`);
22
+ }
23
+ // Read all .body-grammar.json files
24
+ const files = await fs.readdir(grammarsDir);
25
+ const grammarFiles = files.filter(f => f.endsWith('.body-grammar.json'));
26
+ if (grammarFiles.length === 0) {
27
+ throw new Error(`No body grammar files found in ${grammarsDir}`);
28
+ }
29
+ // Load each grammar file and construct BodyRules
30
+ const documentTypes = {};
31
+ for (const file of grammarFiles) {
32
+ const grammarPath = path.join(grammarsDir, file);
33
+ const grammarContent = await fs.readFile(grammarPath, 'utf-8');
34
+ const grammar = JSON.parse(grammarContent);
35
+ // Extract the type from the grammar
36
+ const type = grammar.type;
37
+ if (!type) {
38
+ throw new Error(`Body grammar file ${file} is missing 'type' field`);
39
+ }
40
+ // Convert sections to SectionRule format
41
+ const sections = grammar.sections.map((section) => ({
42
+ id: section.id,
43
+ title: section.title,
44
+ alternativeTitles: section.alternativeTitles,
45
+ required: section.required,
46
+ order: section.order,
47
+ shape: section.shape
48
+ }));
49
+ documentTypes[type] = {
50
+ displayName: grammar.displayName || type,
51
+ sections
52
+ };
53
+ }
54
+ return { documentTypes };
55
+ }
56
+ // ============================================================================
57
+ // Formatting
58
+ // ============================================================================
59
+ /**
60
+ * Formats a document body according to grammar rules
61
+ * - Inserts missing required sections in canonical order
62
+ * - Normalizes section titles to match grammar
63
+ * - Normalizes heading levels (H2 for sections)
64
+ * - Normalizes list markers
65
+ * - Preserves existing content
66
+ * @param body - The original markdown body
67
+ * @param rules - Body grammar rules
68
+ * @param type - Document type
69
+ * @returns Formatted markdown body
70
+ */
71
+ export function formatBody(body, rules, type) {
72
+ const typeRules = rules.documentTypes[type];
73
+ if (!typeRules) {
74
+ // No rules for this type, return body as-is
75
+ return body;
76
+ }
77
+ // Parse existing body
78
+ const ast = parseMarkdown(body);
79
+ const existingSections = findSections(ast);
80
+ // Build a map of existing sections by normalized ID
81
+ const sectionMap = new Map();
82
+ for (const section of existingSections) {
83
+ sectionMap.set(section.id, section);
84
+ }
85
+ // Build new AST with sections in canonical order
86
+ const newChildren = [];
87
+ for (const rule of typeRules.sections) {
88
+ const existingSection = sectionMap.get(rule.id);
89
+ if (existingSection) {
90
+ // Use existing section but normalize the title
91
+ const normalizedHeading = {
92
+ type: 'heading',
93
+ depth: 2, // Always use H2 for sections
94
+ children: [{ type: 'text', value: rule.title }],
95
+ };
96
+ // Add normalized heading
97
+ newChildren.push(normalizedHeading);
98
+ // Add existing content, normalizing lists
99
+ for (const node of existingSection.content) {
100
+ if (node.type === 'list') {
101
+ newChildren.push(normalizeList(node, rule.shape));
102
+ }
103
+ else if (node.type === 'heading' && node.depth > 2) {
104
+ // Preserve subsection headings (H3, H4, etc.)
105
+ newChildren.push(node);
106
+ }
107
+ else if (node.type !== 'heading') {
108
+ // Preserve non-heading content
109
+ newChildren.push(node);
110
+ }
111
+ }
112
+ }
113
+ else if (rule.required) {
114
+ // Insert missing required section with placeholder
115
+ const heading = {
116
+ type: 'heading',
117
+ depth: 2,
118
+ children: [{ type: 'text', value: rule.title }],
119
+ };
120
+ const placeholder = {
121
+ type: 'paragraph',
122
+ children: [{ type: 'text', value: '[TODO: Complete this section]' }],
123
+ };
124
+ newChildren.push(heading, placeholder);
125
+ }
126
+ // Add blank line after each section for readability
127
+ // (This is handled by the stringifier)
128
+ }
129
+ // Build new AST
130
+ const formattedAst = {
131
+ type: 'root',
132
+ children: newChildren,
133
+ };
134
+ return stringifyMarkdown(formattedAst);
135
+ }
136
+ /**
137
+ * Normalizes a list node according to shape rules
138
+ * - Converts to correct list type (ordered/unordered)
139
+ * - Uses canonical markers (- for unordered, 1. for ordered)
140
+ * - Flattens lists that exceed maxDepth
141
+ */
142
+ function normalizeList(list, shape) {
143
+ const normalizedList = {
144
+ type: 'list',
145
+ ordered: false,
146
+ spread: false,
147
+ children: list.children.map((item) => ({ ...item })),
148
+ };
149
+ // Determine target ordered state
150
+ if (shape.type === 'list' && shape.ordered !== undefined) {
151
+ normalizedList.ordered = shape.ordered;
152
+ }
153
+ else {
154
+ // Preserve original ordering if not specified
155
+ normalizedList.ordered = list.ordered;
156
+ }
157
+ // Flatten if maxDepth is specified and exceeded
158
+ if (shape.type === 'list' && shape.maxDepth !== undefined) {
159
+ const currentDepth = getListDepth(list);
160
+ if (currentDepth > shape.maxDepth) {
161
+ normalizedList.children = flattenList(list, shape.maxDepth);
162
+ }
163
+ }
164
+ return normalizedList;
165
+ }
166
+ /**
167
+ * Flattens a nested list to a maximum depth
168
+ */
169
+ function flattenList(list, maxDepth, currentDepth = 1) {
170
+ const flattened = [];
171
+ for (const item of list.children) {
172
+ if (currentDepth >= maxDepth) {
173
+ // At max depth, flatten any nested lists
174
+ const flatItemChildren = [];
175
+ for (const child of item.children) {
176
+ if (child.type === 'list') {
177
+ // Extract items from nested list and add as top-level items
178
+ for (const nestedItem of child.children) {
179
+ flattened.push(nestedItem);
180
+ }
181
+ }
182
+ else {
183
+ flatItemChildren.push(child);
184
+ }
185
+ }
186
+ if (flatItemChildren.length > 0) {
187
+ const flatItem = {
188
+ type: 'listItem',
189
+ children: flatItemChildren,
190
+ };
191
+ flattened.push(flatItem);
192
+ }
193
+ }
194
+ else {
195
+ // Below max depth, recursively process children
196
+ const newItemChildren = [];
197
+ for (const child of item.children) {
198
+ if (child.type === 'list') {
199
+ const flattenedChildren = flattenList(child, maxDepth, currentDepth + 1);
200
+ newItemChildren.push({
201
+ ...child,
202
+ children: flattenedChildren,
203
+ });
204
+ }
205
+ else {
206
+ newItemChildren.push(child);
207
+ }
208
+ }
209
+ const newItem = {
210
+ type: 'listItem',
211
+ children: newItemChildren,
212
+ };
213
+ flattened.push(newItem);
214
+ }
215
+ }
216
+ return flattened;
217
+ }
218
+ /**
219
+ * Resolve the body grammars directory using a cascade:
220
+ * 1. Local project override: {projectRoot}/schemas/body-grammars/
221
+ * 2. @synapse/schemas npm package
222
+ * 3. Error with helpful message
223
+ */
224
+ function getDefaultGrammarsDir() {
225
+ // Priority 1: Local project override
226
+ const localPaths = [
227
+ path.resolve(process.cwd(), 'schemas/body-grammars'),
228
+ path.resolve(process.cwd(), '../schemas/body-grammars'),
229
+ path.resolve(process.cwd(), '../../schemas/body-grammars'),
230
+ ];
231
+ for (const p of localPaths) {
232
+ if (fs.existsSync(p)) {
233
+ try {
234
+ const files = fs.readdirSync(p);
235
+ const hasGrammars = files.some(f => f.endsWith('.body-grammar.json'));
236
+ if (hasGrammars) {
237
+ return p;
238
+ }
239
+ }
240
+ catch {
241
+ continue;
242
+ }
243
+ }
244
+ }
245
+ // Priority 2: @synapse/schemas package
246
+ try {
247
+ const require = createRequire(import.meta.url);
248
+ const pkgPath = require.resolve('@synapse/schemas/package.json');
249
+ const pkgDir = path.dirname(pkgPath);
250
+ const grammarsDir = path.join(pkgDir, 'body-grammars');
251
+ if (fs.existsSync(grammarsDir)) {
252
+ return grammarsDir;
253
+ }
254
+ }
255
+ catch {
256
+ // Package not installed
257
+ }
258
+ // Priority 3: Error with helpful message
259
+ throw new Error('Could not find body grammar schemas. Either place schemas in {projectRoot}/schemas/body-grammars/ ' +
260
+ 'or install the @synapse/schemas package: npm install @synapse/schemas');
261
+ }
262
+ // ============================================================================
263
+ // Body Validation
264
+ // ============================================================================
265
+ /**
266
+ * Validates markdown body against rules for a given document type
267
+ */
268
+ export function validateBody(body, rules, type, notePath) {
269
+ const issues = [];
270
+ // Get rules for this document type
271
+ const typeRules = rules.documentTypes[type];
272
+ if (!typeRules) {
273
+ issues.push({
274
+ notePath,
275
+ type: 'error',
276
+ code: 'unknown-type',
277
+ message: `No body rules defined for document type '${type}'`,
278
+ });
279
+ return issues;
280
+ }
281
+ // Parse the markdown body
282
+ const ast = parseMarkdown(body);
283
+ const sections = findSections(ast);
284
+ // Build a map of found sections by ID
285
+ const foundSections = new Map();
286
+ for (const section of sections) {
287
+ if (!foundSections.has(section.id)) {
288
+ foundSections.set(section.id, []);
289
+ }
290
+ foundSections.get(section.id).push(section);
291
+ }
292
+ // Check each rule
293
+ for (const rule of typeRules.sections) {
294
+ const matchingSection = findMatchingSection(sections, rule);
295
+ // Check required sections
296
+ if (rule.required && !matchingSection) {
297
+ issues.push({
298
+ notePath,
299
+ type: 'error',
300
+ code: 'missing-required-section',
301
+ message: `Missing required section "${rule.title}"`,
302
+ sectionId: rule.id,
303
+ });
304
+ continue;
305
+ }
306
+ if (!matchingSection) {
307
+ continue; // Optional section not present
308
+ }
309
+ // Check uniqueness
310
+ const allMatches = findAllMatchingSections(sections, rule);
311
+ if (allMatches.length > 1) {
312
+ for (const duplicate of allMatches.slice(1)) {
313
+ issues.push({
314
+ notePath,
315
+ type: 'error',
316
+ code: 'duplicate-section',
317
+ message: `Duplicate section "${rule.title}" (appears ${allMatches.length} times)`,
318
+ sectionId: rule.id,
319
+ line: duplicate.line,
320
+ column: duplicate.column,
321
+ });
322
+ }
323
+ }
324
+ // Check shape
325
+ const shapeIssues = validateSectionShape(matchingSection, rule, notePath);
326
+ issues.push(...shapeIssues);
327
+ }
328
+ // Check section order
329
+ const orderIssues = validateSectionOrder(sections, typeRules.sections, notePath);
330
+ issues.push(...orderIssues);
331
+ return issues;
332
+ }
333
+ /**
334
+ * Finds a section that matches the rule (by ID or alternative titles)
335
+ */
336
+ function findMatchingSection(sections, rule) {
337
+ return sections.find((section) => {
338
+ // Match by normalized ID
339
+ if (section.id === rule.id) {
340
+ return true;
341
+ }
342
+ // Match by title (case-insensitive)
343
+ const normalizedTitle = section.title.toLowerCase().trim();
344
+ const ruleTitle = rule.title.toLowerCase().trim();
345
+ if (normalizedTitle === ruleTitle) {
346
+ return true;
347
+ }
348
+ // Match by alternative titles
349
+ if (rule.alternativeTitles) {
350
+ for (const altTitle of rule.alternativeTitles) {
351
+ if (normalizedTitle === altTitle.toLowerCase().trim()) {
352
+ return true;
353
+ }
354
+ }
355
+ }
356
+ return false;
357
+ });
358
+ }
359
+ /**
360
+ * Finds all sections that match the rule
361
+ */
362
+ function findAllMatchingSections(sections, rule) {
363
+ return sections.filter((section) => {
364
+ const normalizedTitle = section.title.toLowerCase().trim();
365
+ const ruleTitle = rule.title.toLowerCase().trim();
366
+ if (section.id === rule.id || normalizedTitle === ruleTitle) {
367
+ return true;
368
+ }
369
+ if (rule.alternativeTitles) {
370
+ for (const altTitle of rule.alternativeTitles) {
371
+ if (normalizedTitle === altTitle.toLowerCase().trim()) {
372
+ return true;
373
+ }
374
+ }
375
+ }
376
+ return false;
377
+ });
378
+ }
379
+ /**
380
+ * Validates that sections appear in the canonical order
381
+ */
382
+ function validateSectionOrder(sections, rules, notePath) {
383
+ const issues = [];
384
+ // Build a map of section IDs to their expected order
385
+ const expectedOrder = new Map();
386
+ for (const rule of rules) {
387
+ expectedOrder.set(rule.id, rule.order);
388
+ // Also map alternative titles
389
+ if (rule.alternativeTitles) {
390
+ for (const altTitle of rule.alternativeTitles) {
391
+ const altId = altTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
392
+ expectedOrder.set(altId, rule.order);
393
+ }
394
+ }
395
+ }
396
+ // Check the order of found sections
397
+ let lastOrder = 0;
398
+ for (const section of sections) {
399
+ const order = expectedOrder.get(section.id);
400
+ if (order !== undefined) {
401
+ if (order < lastOrder) {
402
+ issues.push({
403
+ notePath,
404
+ type: 'error',
405
+ code: 'section-out-of-order',
406
+ message: `Section "${section.title}" appears out of order (expected order: ${order}, follows: ${lastOrder})`,
407
+ sectionId: section.id,
408
+ line: section.line,
409
+ column: section.column,
410
+ });
411
+ }
412
+ lastOrder = order;
413
+ }
414
+ }
415
+ return issues;
416
+ }
417
+ /**
418
+ * Validates the shape of a section's content
419
+ */
420
+ function validateSectionShape(section, rule, notePath) {
421
+ const issues = [];
422
+ const shape = rule.shape;
423
+ switch (shape.type) {
424
+ case 'paragraphs':
425
+ return validateParagraphsShape(section, rule, notePath);
426
+ case 'list':
427
+ return validateListShape(section, rule, shape, notePath);
428
+ case 'flow':
429
+ return validateFlowShape(section, rule, shape, notePath);
430
+ case 'table':
431
+ return validateTableShape(section, rule, shape, notePath);
432
+ case 'milestoneList':
433
+ return validateMilestoneListShape(section, rule, shape, notePath);
434
+ default:
435
+ issues.push({
436
+ notePath,
437
+ type: 'error',
438
+ code: 'unknown-shape',
439
+ message: `Unknown shape type for section "${rule.title}"`,
440
+ sectionId: rule.id,
441
+ line: section.line,
442
+ });
443
+ }
444
+ return issues;
445
+ }
446
+ /**
447
+ * Validates paragraphs shape
448
+ */
449
+ function validateParagraphsShape(section, rule, notePath) {
450
+ const issues = [];
451
+ for (const node of section.content) {
452
+ if (node.type !== 'paragraph') {
453
+ issues.push({
454
+ notePath,
455
+ type: 'error',
456
+ code: 'invalid-section-content',
457
+ message: `Section "${rule.title}" should only contain paragraphs, found ${node.type}`,
458
+ sectionId: rule.id,
459
+ line: node.position?.start.line,
460
+ column: node.position?.start.column,
461
+ });
462
+ }
463
+ }
464
+ return issues;
465
+ }
466
+ /**
467
+ * Validates list shape
468
+ */
469
+ function validateListShape(section, rule, shape, notePath) {
470
+ const issues = [];
471
+ // Find lists in the content
472
+ const lists = section.content.filter((node) => node.type === 'list');
473
+ if (lists.length === 0 && shape.minItems && shape.minItems > 0) {
474
+ issues.push({
475
+ notePath,
476
+ type: 'error',
477
+ code: 'missing-list',
478
+ message: `Section "${rule.title}" should contain a list`,
479
+ sectionId: rule.id,
480
+ line: section.line,
481
+ });
482
+ return issues;
483
+ }
484
+ // Only check the first list for ordered/unordered requirement
485
+ // (subsequent lists may be nested details with different formatting)
486
+ const firstList = lists[0];
487
+ if (firstList && shape.ordered !== undefined && firstList.ordered !== shape.ordered) {
488
+ const expectedType = shape.ordered ? 'numbered (1. 2. 3.)' : 'bulleted (-)';
489
+ const actualType = firstList.ordered ? 'numbered' : 'bulleted';
490
+ const lineNum = firstList.position?.start.line || section.line;
491
+ issues.push({
492
+ notePath,
493
+ type: 'error',
494
+ code: 'wrong-list-type',
495
+ message: `Section "## ${rule.title}" at line ${lineNum}: expected ${expectedType} list but found ${actualType}. The first list in this section must be ${shape.ordered ? 'numbered' : 'bulleted'}.`,
496
+ sectionId: rule.id,
497
+ line: lineNum,
498
+ });
499
+ }
500
+ for (const list of lists) {
501
+ // Check minimum items
502
+ if (shape.minItems && list.children.length < shape.minItems) {
503
+ issues.push({
504
+ notePath,
505
+ type: 'error',
506
+ code: 'insufficient-list-items',
507
+ message: `Section "${rule.title}" list should have at least ${shape.minItems} items, found ${list.children.length}`,
508
+ sectionId: rule.id,
509
+ line: list.position?.start.line,
510
+ });
511
+ }
512
+ // Check max depth
513
+ if (shape.maxDepth !== undefined) {
514
+ const depth = getListDepth(list);
515
+ if (depth > shape.maxDepth) {
516
+ issues.push({
517
+ notePath,
518
+ type: 'error',
519
+ code: 'list-too-deep',
520
+ message: `Section "${rule.title}" list exceeds maximum depth of ${shape.maxDepth} (found depth: ${depth})`,
521
+ sectionId: rule.id,
522
+ line: list.position?.start.line,
523
+ });
524
+ }
525
+ }
526
+ }
527
+ return issues;
528
+ }
529
+ /**
530
+ * Gets the maximum nesting depth of a list
531
+ */
532
+ function getListDepth(list, currentDepth = 1) {
533
+ let maxDepth = currentDepth;
534
+ for (const item of list.children) {
535
+ for (const child of item.children) {
536
+ if (child.type === 'list') {
537
+ const childDepth = getListDepth(child, currentDepth + 1);
538
+ maxDepth = Math.max(maxDepth, childDepth);
539
+ }
540
+ }
541
+ }
542
+ return maxDepth;
543
+ }
544
+ /**
545
+ * Validates flow shape (mixed content)
546
+ */
547
+ function validateFlowShape(section, rule, shape, notePath) {
548
+ const issues = [];
549
+ for (const node of section.content) {
550
+ if (!shape.allowedNodes.includes(node.type)) {
551
+ issues.push({
552
+ notePath,
553
+ type: 'error',
554
+ code: 'disallowed-node-type',
555
+ message: `Section "${rule.title}" does not allow ${node.type} nodes (allowed: ${shape.allowedNodes.join(', ')})`,
556
+ sectionId: rule.id,
557
+ line: node.position?.start.line,
558
+ column: node.position?.start.column,
559
+ });
560
+ }
561
+ }
562
+ return issues;
563
+ }
564
+ /**
565
+ * Validates table shape
566
+ */
567
+ function validateTableShape(section, rule, shape, notePath) {
568
+ const issues = [];
569
+ // Filter for tables, ignoring blank lines (paragraph nodes with no content)
570
+ const tables = section.content.filter((node) => {
571
+ if (node.type === 'table')
572
+ return true;
573
+ // Allow blank paragraphs (they don't break table validation)
574
+ if (node.type === 'paragraph' && 'children' in node) {
575
+ const hasContent = node.children.some((child) => child.type === 'text' && child.value.trim().length > 0);
576
+ return false; // Don't count paragraphs as tables, even if blank
577
+ }
578
+ return false;
579
+ });
580
+ if (tables.length === 0) {
581
+ issues.push({
582
+ notePath,
583
+ type: 'error',
584
+ code: 'missing-table',
585
+ message: `Section "${rule.title}" should contain a table`,
586
+ sectionId: rule.id,
587
+ line: section.line,
588
+ });
589
+ return issues;
590
+ }
591
+ for (const table of tables) {
592
+ // Check required headers
593
+ if (shape.requiredHeaders && table.children.length > 0) {
594
+ const headerRow = table.children[0];
595
+ const headers = headerRow.children.map((cell) => {
596
+ // Extract text from cell
597
+ return cell.children
598
+ .map((child) => (child.type === 'text' ? child.value : ''))
599
+ .join('')
600
+ .trim();
601
+ });
602
+ for (const requiredHeader of shape.requiredHeaders) {
603
+ if (!headers.includes(requiredHeader)) {
604
+ issues.push({
605
+ notePath,
606
+ type: 'error',
607
+ code: 'missing-table-header',
608
+ message: `Table in "${rule.title}" missing required header: ${requiredHeader}`,
609
+ sectionId: rule.id,
610
+ line: table.position?.start.line,
611
+ });
612
+ }
613
+ }
614
+ }
615
+ // Check minimum rows (excluding header)
616
+ if (shape.minRows !== undefined) {
617
+ const dataRows = table.children.length - 1;
618
+ if (dataRows < shape.minRows) {
619
+ issues.push({
620
+ notePath,
621
+ type: 'error',
622
+ code: 'insufficient-table-rows',
623
+ message: `Table in "${rule.title}" should have at least ${shape.minRows} data rows, found ${dataRows}`,
624
+ sectionId: rule.id,
625
+ line: table.position?.start.line,
626
+ });
627
+ }
628
+ }
629
+ }
630
+ return issues;
631
+ }
632
+ /**
633
+ * Validates milestone list shape (SOW/PRD specific)
634
+ */
635
+ function validateMilestoneListShape(section, rule, shape, notePath) {
636
+ const issues = [];
637
+ // Look for H3 headings in the content
638
+ const milestones = section.content.filter((node) => node.type === 'heading' && node.depth === 3);
639
+ if (shape.requireH3 && milestones.length === 0) {
640
+ issues.push({
641
+ notePath,
642
+ type: 'error',
643
+ code: 'no-milestones',
644
+ message: `Section "${rule.title}" should contain milestone blocks (H3 headings)`,
645
+ sectionId: rule.id,
646
+ line: section.line,
647
+ });
648
+ }
649
+ // For each milestone, check for required subsections (H4)
650
+ if (shape.requiredSubsections && shape.requiredSubsections.length > 0 && milestones.length > 0) {
651
+ // Build a map of milestone positions to their content
652
+ const milestoneGroups = new Map();
653
+ // Group content by milestone
654
+ let currentMilestone = null;
655
+ let currentContent = [];
656
+ for (const node of section.content) {
657
+ if (node.type === 'heading' && node.depth === 3) {
658
+ // Save previous milestone's content if any
659
+ if (currentMilestone) {
660
+ milestoneGroups.set(currentMilestone, currentContent);
661
+ }
662
+ // Start new milestone
663
+ currentMilestone = node;
664
+ currentContent = [];
665
+ }
666
+ else if (currentMilestone) {
667
+ // Add to current milestone's content
668
+ currentContent.push(node);
669
+ }
670
+ }
671
+ // Save the last milestone's content
672
+ if (currentMilestone) {
673
+ milestoneGroups.set(currentMilestone, currentContent);
674
+ }
675
+ // Validate each milestone's subsections
676
+ for (const [milestone, content] of milestoneGroups.entries()) {
677
+ // Extract milestone title for error messages
678
+ const milestoneTitle = milestone.children
679
+ .filter((child) => child.type === 'text')
680
+ .map((child) => child.value)
681
+ .join('');
682
+ // Find all H4 headings in this milestone's content
683
+ const h4Headings = content
684
+ .filter((node) => node.type === 'heading' && node.depth === 4)
685
+ .map((heading) => {
686
+ return heading.children
687
+ .filter((child) => child.type === 'text')
688
+ .map((child) => child.value)
689
+ .join('')
690
+ .trim();
691
+ });
692
+ // Check for each required subsection
693
+ for (const requiredSubsection of shape.requiredSubsections) {
694
+ const found = h4Headings.some((h4Title) => h4Title.toLowerCase() === requiredSubsection.toLowerCase());
695
+ if (!found) {
696
+ issues.push({
697
+ notePath,
698
+ type: 'error',
699
+ code: 'missing-milestone-subsection',
700
+ message: `Milestone "${milestoneTitle}" is missing required subsection "${requiredSubsection}"`,
701
+ sectionId: rule.id,
702
+ line: milestone.position?.start.line,
703
+ column: milestone.position?.start.column,
704
+ });
705
+ }
706
+ }
707
+ }
708
+ }
709
+ return issues;
710
+ }
711
+ //# sourceMappingURL=bodyRules.js.map