@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.
- package/README.md +135 -0
- package/bin/synapse.js +3 -0
- package/dist/commands/eject.d.ts +19 -0
- package/dist/commands/eject.d.ts.map +1 -0
- package/dist/commands/eject.js +146 -0
- package/dist/commands/eject.js.map +1 -0
- package/dist/commands/fetch-reference.d.ts +19 -0
- package/dist/commands/fetch-reference.d.ts.map +1 -0
- package/dist/commands/fetch-reference.js +93 -0
- package/dist/commands/fetch-reference.js.map +1 -0
- package/dist/commands/format.d.ts +26 -0
- package/dist/commands/format.d.ts.map +1 -0
- package/dist/commands/format.js +126 -0
- package/dist/commands/format.js.map +1 -0
- package/dist/commands/generate-pdf.d.ts +19 -0
- package/dist/commands/generate-pdf.d.ts.map +1 -0
- package/dist/commands/generate-pdf.js +140 -0
- package/dist/commands/generate-pdf.js.map +1 -0
- package/dist/commands/index.d.ts +17 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +26 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/init.d.ts +58 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +234 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/migrate.d.ts +29 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +297 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/scaffold.d.ts +24 -0
- package/dist/commands/scaffold.d.ts.map +1 -0
- package/dist/commands/scaffold.js +244 -0
- package/dist/commands/scaffold.js.map +1 -0
- package/dist/commands/update.d.ts +25 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +253 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/validate.d.ts +37 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +526 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +277 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/bodyRules.d.ts +70 -0
- package/dist/lib/bodyRules.d.ts.map +1 -0
- package/dist/lib/bodyRules.js +711 -0
- package/dist/lib/bodyRules.js.map +1 -0
- package/dist/lib/config.d.ts +49 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +91 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/git.d.ts +99 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +266 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/graph.d.ts +6 -0
- package/dist/lib/graph.d.ts.map +1 -0
- package/dist/lib/graph.js +6 -0
- package/dist/lib/graph.js.map +1 -0
- package/dist/lib/homepage.d.ts +10 -0
- package/dist/lib/homepage.d.ts.map +1 -0
- package/dist/lib/homepage.js +172 -0
- package/dist/lib/homepage.js.map +1 -0
- package/dist/lib/markdown.d.ts +107 -0
- package/dist/lib/markdown.d.ts.map +1 -0
- package/dist/lib/markdown.js +318 -0
- package/dist/lib/markdown.js.map +1 -0
- package/dist/lib/mode-detection.d.ts +10 -0
- package/dist/lib/mode-detection.d.ts.map +1 -0
- package/dist/lib/mode-detection.js +29 -0
- package/dist/lib/mode-detection.js.map +1 -0
- package/dist/lib/naming.d.ts +47 -0
- package/dist/lib/naming.d.ts.map +1 -0
- package/dist/lib/naming.js +403 -0
- package/dist/lib/naming.js.map +1 -0
- package/dist/lib/schemas.d.ts +38 -0
- package/dist/lib/schemas.d.ts.map +1 -0
- package/dist/lib/schemas.js +248 -0
- package/dist/lib/schemas.js.map +1 -0
- package/dist/lib/templateLint.d.ts +21 -0
- package/dist/lib/templateLint.d.ts.map +1 -0
- package/dist/lib/templateLint.js +243 -0
- package/dist/lib/templateLint.js.map +1 -0
- package/dist/lib/templates.d.ts +53 -0
- package/dist/lib/templates.d.ts.map +1 -0
- package/dist/lib/templates.js +128 -0
- package/dist/lib/templates.js.map +1 -0
- package/dist/lib/tracking.d.ts +52 -0
- package/dist/lib/tracking.d.ts.map +1 -0
- package/dist/lib/tracking.js +135 -0
- package/dist/lib/tracking.js.map +1 -0
- package/dist/lib/types.generated.d.ts +54 -0
- package/dist/lib/types.generated.d.ts.map +1 -0
- package/dist/lib/types.generated.js +144 -0
- package/dist/lib/types.generated.js.map +1 -0
- package/dist/lib/validate-plugins.d.ts +22 -0
- package/dist/lib/validate-plugins.d.ts.map +1 -0
- package/dist/lib/validate-plugins.js +851 -0
- package/dist/lib/validate-plugins.js.map +1 -0
- 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
|