lean-spec 0.2.7 → 0.2.9-dev.20251205030455
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backfill-446GBTBC.js +5 -0
- package/dist/backfill-446GBTBC.js.map +1 -0
- package/dist/{chunk-Q6B3LVO7.js → chunk-BJHJ6IUO.js} +3095 -6151
- package/dist/chunk-BJHJ6IUO.js.map +1 -0
- package/dist/chunk-CJMVV46H.js +2990 -0
- package/dist/chunk-CJMVV46H.js.map +1 -0
- package/dist/chunk-H5MCUMBK.js +741 -0
- package/dist/chunk-H5MCUMBK.js.map +1 -0
- package/dist/chunk-RF5PKL6L.js +298 -0
- package/dist/chunk-RF5PKL6L.js.map +1 -0
- package/dist/{chunk-LVD7ZAVZ.js → chunk-VN5BUHTV.js} +5 -3
- package/dist/chunk-VN5BUHTV.js.map +1 -0
- package/dist/cli.js +5 -3
- package/dist/cli.js.map +1 -1
- package/dist/{frontmatter-R2DANL5X.js → frontmatter-6ZBAGOEU.js} +3 -3
- package/dist/{frontmatter-R2DANL5X.js.map → frontmatter-6ZBAGOEU.js.map} +1 -1
- package/dist/mcp-server.js +5 -2
- package/dist/validate-DIWYTDEF.js +5 -0
- package/dist/validate-DIWYTDEF.js.map +1 -0
- package/package.json +2 -2
- package/templates/detailed/AGENTS.md +54 -115
- package/templates/detailed/README.md +3 -3
- package/templates/examples/api-refactor/README.md +1 -1
- package/templates/examples/dark-theme/README.md +1 -1
- package/templates/examples/dashboard-widgets/README.md +1 -1
- package/templates/standard/AGENTS.md +54 -115
- package/templates/standard/README.md +1 -2
- package/dist/chunk-LVD7ZAVZ.js.map +0 -1
- package/dist/chunk-Q6B3LVO7.js.map +0 -1
|
@@ -0,0 +1,2990 @@
|
|
|
1
|
+
import { loadConfig, loadAllSpecs, loadSubFiles } from './chunk-RF5PKL6L.js';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import chalk3 from 'chalk';
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
import stripAnsi from 'strip-ansi';
|
|
8
|
+
import matter3 from 'gray-matter';
|
|
9
|
+
import yaml2 from 'js-yaml';
|
|
10
|
+
import 'dayjs';
|
|
11
|
+
|
|
12
|
+
function sanitizeUserInput(input) {
|
|
13
|
+
if (typeof input !== "string") {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
if (!input) {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
let sanitized = stripAnsi(input);
|
|
20
|
+
sanitized = sanitized.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, "");
|
|
21
|
+
return sanitized;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/utils/ui.ts
|
|
25
|
+
async function withSpinner(text, fn, options) {
|
|
26
|
+
const spinner = ora(text).start();
|
|
27
|
+
try {
|
|
28
|
+
const result = await fn();
|
|
29
|
+
spinner.succeed(options?.successText || text);
|
|
30
|
+
return result;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
spinner.fail(options?.failText || `${text} failed`);
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
var FrontmatterValidator = class {
|
|
37
|
+
name = "frontmatter";
|
|
38
|
+
description = "Validate spec frontmatter for required fields and valid values";
|
|
39
|
+
validStatuses;
|
|
40
|
+
validPriorities;
|
|
41
|
+
constructor(options = {}) {
|
|
42
|
+
this.validStatuses = options.validStatuses ?? ["planned", "in-progress", "complete", "archived"];
|
|
43
|
+
this.validPriorities = options.validPriorities ?? ["low", "medium", "high", "critical"];
|
|
44
|
+
}
|
|
45
|
+
validate(spec, content) {
|
|
46
|
+
const errors = [];
|
|
47
|
+
const warnings = [];
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = matter3(content, {
|
|
51
|
+
engines: {
|
|
52
|
+
yaml: (str) => yaml2.load(str, { schema: yaml2.FAILSAFE_SCHEMA })
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
errors.push({
|
|
57
|
+
message: "Failed to parse frontmatter YAML",
|
|
58
|
+
suggestion: "Check for YAML syntax errors in frontmatter"
|
|
59
|
+
});
|
|
60
|
+
return { passed: false, errors, warnings };
|
|
61
|
+
}
|
|
62
|
+
const frontmatter = parsed.data;
|
|
63
|
+
if (!frontmatter || Object.keys(frontmatter).length === 0) {
|
|
64
|
+
errors.push({
|
|
65
|
+
message: "No frontmatter found",
|
|
66
|
+
suggestion: "Add YAML frontmatter at the top of the file between --- delimiters"
|
|
67
|
+
});
|
|
68
|
+
return { passed: false, errors, warnings };
|
|
69
|
+
}
|
|
70
|
+
if (!frontmatter.status) {
|
|
71
|
+
errors.push({
|
|
72
|
+
message: "Missing required field: status",
|
|
73
|
+
suggestion: "Add status field (valid values: planned, in-progress, complete, archived)"
|
|
74
|
+
});
|
|
75
|
+
} else {
|
|
76
|
+
const statusStr = String(frontmatter.status);
|
|
77
|
+
if (!this.validStatuses.includes(statusStr)) {
|
|
78
|
+
errors.push({
|
|
79
|
+
message: `Invalid status: "${statusStr}"`,
|
|
80
|
+
suggestion: `Valid values: ${this.validStatuses.join(", ")}`
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!frontmatter.created) {
|
|
85
|
+
errors.push({
|
|
86
|
+
message: "Missing required field: created",
|
|
87
|
+
suggestion: "Add created field with date in YYYY-MM-DD format"
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
const dateValidation = this.validateDateField(frontmatter.created, "created");
|
|
91
|
+
if (!dateValidation.valid) {
|
|
92
|
+
errors.push({
|
|
93
|
+
message: dateValidation.message,
|
|
94
|
+
suggestion: dateValidation.suggestion
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (frontmatter.priority) {
|
|
99
|
+
const priorityStr = String(frontmatter.priority);
|
|
100
|
+
if (!this.validPriorities.includes(priorityStr)) {
|
|
101
|
+
errors.push({
|
|
102
|
+
message: `Invalid priority: "${priorityStr}"`,
|
|
103
|
+
suggestion: `Valid values: ${this.validPriorities.join(", ")}`
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (frontmatter.tags !== void 0 && frontmatter.tags !== null) {
|
|
108
|
+
if (!Array.isArray(frontmatter.tags)) {
|
|
109
|
+
errors.push({
|
|
110
|
+
message: 'Field "tags" must be an array',
|
|
111
|
+
suggestion: "Use array format: tags: [tag1, tag2]"
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const dateFields = ["updated", "completed", "due"];
|
|
116
|
+
for (const field of dateFields) {
|
|
117
|
+
if (frontmatter[field]) {
|
|
118
|
+
const dateValidation = this.validateDateField(frontmatter[field], field);
|
|
119
|
+
if (!dateValidation.valid) {
|
|
120
|
+
warnings.push({
|
|
121
|
+
message: dateValidation.message,
|
|
122
|
+
suggestion: dateValidation.suggestion
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
passed: errors.length === 0,
|
|
129
|
+
errors,
|
|
130
|
+
warnings
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Validate date field format (ISO 8601: YYYY-MM-DD or full timestamp)
|
|
135
|
+
*/
|
|
136
|
+
validateDateField(value, fieldName) {
|
|
137
|
+
if (value instanceof Date) {
|
|
138
|
+
return { valid: true };
|
|
139
|
+
}
|
|
140
|
+
if (typeof value !== "string") {
|
|
141
|
+
return {
|
|
142
|
+
valid: false,
|
|
143
|
+
message: `Field "${fieldName}" must be a string or date`,
|
|
144
|
+
suggestion: "Use YYYY-MM-DD format (e.g., 2025-11-05)"
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const isoDateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{1,3})?(Z|[+-]\d{2}:\d{2})?)?$/;
|
|
148
|
+
if (!isoDateRegex.test(value)) {
|
|
149
|
+
return {
|
|
150
|
+
valid: false,
|
|
151
|
+
message: `Field "${fieldName}" has invalid date format: "${value}"`,
|
|
152
|
+
suggestion: "Use ISO 8601 format: YYYY-MM-DD (e.g., 2025-11-05)"
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const date = new Date(value);
|
|
156
|
+
if (isNaN(date.getTime())) {
|
|
157
|
+
return {
|
|
158
|
+
valid: false,
|
|
159
|
+
message: `Field "${fieldName}" has invalid date: "${value}"`,
|
|
160
|
+
suggestion: "Ensure date is valid (e.g., month 01-12, day 01-31)"
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { valid: true };
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
var StructureValidator = class {
|
|
167
|
+
name = "structure";
|
|
168
|
+
description = "Validate spec structure and required sections";
|
|
169
|
+
requiredSections;
|
|
170
|
+
strict;
|
|
171
|
+
constructor(options = {}) {
|
|
172
|
+
this.requiredSections = options.requiredSections ?? ["Overview", "Design"];
|
|
173
|
+
this.strict = options.strict ?? false;
|
|
174
|
+
}
|
|
175
|
+
async validate(spec, content) {
|
|
176
|
+
const errors = [];
|
|
177
|
+
const warnings = [];
|
|
178
|
+
let parsed;
|
|
179
|
+
try {
|
|
180
|
+
parsed = matter3(content);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
errors.push({
|
|
183
|
+
message: "Failed to parse frontmatter",
|
|
184
|
+
suggestion: "Check YAML frontmatter syntax"
|
|
185
|
+
});
|
|
186
|
+
return { passed: false, errors, warnings };
|
|
187
|
+
}
|
|
188
|
+
const body = parsed.content;
|
|
189
|
+
const h1Match = body.match(/^#\s+(.+)$/m);
|
|
190
|
+
if (!h1Match) {
|
|
191
|
+
errors.push({
|
|
192
|
+
message: "Missing H1 title (# Heading)",
|
|
193
|
+
suggestion: "Add a title as the first heading in the spec"
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const headings = this.extractHeadings(body);
|
|
197
|
+
const duplicates = this.findDuplicateHeaders(headings);
|
|
198
|
+
for (const dup of duplicates) {
|
|
199
|
+
errors.push({
|
|
200
|
+
message: `Duplicate section header: ${"#".repeat(dup.level)} ${dup.text}`,
|
|
201
|
+
suggestion: "Remove or rename duplicate section headers"
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
passed: errors.length === 0,
|
|
206
|
+
errors,
|
|
207
|
+
warnings
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Extract all headings from markdown content (excluding code blocks)
|
|
212
|
+
*/
|
|
213
|
+
extractHeadings(content) {
|
|
214
|
+
const headings = [];
|
|
215
|
+
const lines = content.split("\n");
|
|
216
|
+
let inCodeBlock = false;
|
|
217
|
+
for (let i = 0; i < lines.length; i++) {
|
|
218
|
+
const line = lines[i];
|
|
219
|
+
if (line.trim().startsWith("```")) {
|
|
220
|
+
inCodeBlock = !inCodeBlock;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (inCodeBlock) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
|
227
|
+
if (match) {
|
|
228
|
+
headings.push({
|
|
229
|
+
level: match[1].length,
|
|
230
|
+
text: match[2].trim(),
|
|
231
|
+
line: i + 1
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return headings;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Find empty sections (sections with no content until next heading)
|
|
239
|
+
*/
|
|
240
|
+
findEmptySections(content, headings) {
|
|
241
|
+
const emptySections = [];
|
|
242
|
+
const lines = content.split("\n");
|
|
243
|
+
for (let i = 0; i < headings.length; i++) {
|
|
244
|
+
const heading = headings[i];
|
|
245
|
+
if (heading.level !== 2) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
let nextSameLevelIndex = i + 1;
|
|
249
|
+
while (nextSameLevelIndex < headings.length && headings[nextSameLevelIndex].level > heading.level) {
|
|
250
|
+
nextSameLevelIndex++;
|
|
251
|
+
}
|
|
252
|
+
const nextHeading = headings[nextSameLevelIndex];
|
|
253
|
+
const startLine = heading.line;
|
|
254
|
+
const endLine = nextHeading ? nextHeading.line - 1 : lines.length;
|
|
255
|
+
const sectionLines = lines.slice(startLine, endLine);
|
|
256
|
+
const hasSubsections = headings.some(
|
|
257
|
+
(h, idx) => idx > i && idx < nextSameLevelIndex && h.level > heading.level
|
|
258
|
+
);
|
|
259
|
+
if (hasSubsections) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const hasContent = sectionLines.some((line) => {
|
|
263
|
+
const trimmed = line.trim();
|
|
264
|
+
return trimmed.length > 0 && !trimmed.startsWith("<!--") && !trimmed.startsWith("//");
|
|
265
|
+
});
|
|
266
|
+
if (!hasContent) {
|
|
267
|
+
emptySections.push(heading.text);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return emptySections;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Find duplicate headers at the same level
|
|
274
|
+
*/
|
|
275
|
+
findDuplicateHeaders(headings) {
|
|
276
|
+
const seen = /* @__PURE__ */ new Map();
|
|
277
|
+
const duplicates = [];
|
|
278
|
+
for (const heading of headings) {
|
|
279
|
+
const key = `${heading.level}:${heading.text.toLowerCase()}`;
|
|
280
|
+
const count = seen.get(key) ?? 0;
|
|
281
|
+
seen.set(key, count + 1);
|
|
282
|
+
if (count === 1) {
|
|
283
|
+
duplicates.push({ level: heading.level, text: heading.text });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return duplicates;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// src/validators/corruption.ts
|
|
291
|
+
var CorruptionValidator = class {
|
|
292
|
+
name = "corruption";
|
|
293
|
+
description = "Detect file corruption from failed edits";
|
|
294
|
+
options;
|
|
295
|
+
constructor(options = {}) {
|
|
296
|
+
this.options = {
|
|
297
|
+
checkCodeBlocks: options.checkCodeBlocks ?? true,
|
|
298
|
+
checkMarkdownStructure: options.checkMarkdownStructure ?? true,
|
|
299
|
+
checkDuplicateContent: options.checkDuplicateContent ?? true,
|
|
300
|
+
duplicateBlockSize: options.duplicateBlockSize ?? 8,
|
|
301
|
+
duplicateMinLength: options.duplicateMinLength ?? 200
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
validate(_spec, content) {
|
|
305
|
+
const errors = [];
|
|
306
|
+
const warnings = [];
|
|
307
|
+
const codeBlockRanges = this.parseCodeBlockRanges(content);
|
|
308
|
+
if (this.options.checkCodeBlocks) {
|
|
309
|
+
const codeBlockErrors = this.validateCodeBlocks(content);
|
|
310
|
+
errors.push(...codeBlockErrors);
|
|
311
|
+
}
|
|
312
|
+
if (this.options.checkMarkdownStructure) {
|
|
313
|
+
const markdownErrors = this.validateMarkdownStructure(content, codeBlockRanges);
|
|
314
|
+
errors.push(...markdownErrors);
|
|
315
|
+
}
|
|
316
|
+
if (this.options.checkDuplicateContent) {
|
|
317
|
+
const duplicateWarnings = this.detectDuplicateContent(content);
|
|
318
|
+
warnings.push(...duplicateWarnings);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
passed: errors.length === 0,
|
|
322
|
+
errors,
|
|
323
|
+
warnings
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Parse all code block ranges in the document
|
|
328
|
+
* Returns array of {start, end} line numbers (1-indexed)
|
|
329
|
+
*/
|
|
330
|
+
parseCodeBlockRanges(content) {
|
|
331
|
+
const ranges = [];
|
|
332
|
+
const lines = content.split("\n");
|
|
333
|
+
let inCodeBlock = false;
|
|
334
|
+
let blockStart = -1;
|
|
335
|
+
for (let i = 0; i < lines.length; i++) {
|
|
336
|
+
const line = lines[i];
|
|
337
|
+
if (line.trim().startsWith("```")) {
|
|
338
|
+
if (!inCodeBlock) {
|
|
339
|
+
inCodeBlock = true;
|
|
340
|
+
blockStart = i + 1;
|
|
341
|
+
} else {
|
|
342
|
+
ranges.push({
|
|
343
|
+
start: blockStart,
|
|
344
|
+
end: i + 1
|
|
345
|
+
// 1-indexed, inclusive
|
|
346
|
+
});
|
|
347
|
+
inCodeBlock = false;
|
|
348
|
+
blockStart = -1;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return ranges;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Check if a line number is inside a code block
|
|
356
|
+
*/
|
|
357
|
+
isInCodeBlock(lineNumber, codeBlockRanges) {
|
|
358
|
+
return codeBlockRanges.some(
|
|
359
|
+
(range) => lineNumber >= range.start && lineNumber <= range.end
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Get content outside code blocks for analysis
|
|
364
|
+
*/
|
|
365
|
+
getContentOutsideCodeBlocks(content, codeBlockRanges) {
|
|
366
|
+
const lines = content.split("\n");
|
|
367
|
+
const filteredLines = lines.filter((_, index) => {
|
|
368
|
+
const lineNumber = index + 1;
|
|
369
|
+
return !this.isInCodeBlock(lineNumber, codeBlockRanges);
|
|
370
|
+
});
|
|
371
|
+
return filteredLines.join("\n");
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Validate code blocks are properly closed
|
|
375
|
+
* This is the #1 indicator of corruption - causes visible syntax highlighting issues
|
|
376
|
+
*/
|
|
377
|
+
validateCodeBlocks(content) {
|
|
378
|
+
const errors = [];
|
|
379
|
+
const lines = content.split("\n");
|
|
380
|
+
let inCodeBlock = false;
|
|
381
|
+
let codeBlockStartLine = -1;
|
|
382
|
+
for (let i = 0; i < lines.length; i++) {
|
|
383
|
+
const line = lines[i];
|
|
384
|
+
if (line.trim().startsWith("```")) {
|
|
385
|
+
if (!inCodeBlock) {
|
|
386
|
+
inCodeBlock = true;
|
|
387
|
+
codeBlockStartLine = i + 1;
|
|
388
|
+
} else {
|
|
389
|
+
inCodeBlock = false;
|
|
390
|
+
codeBlockStartLine = -1;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (inCodeBlock) {
|
|
395
|
+
errors.push({
|
|
396
|
+
message: `Unclosed code block starting at line ${codeBlockStartLine}`,
|
|
397
|
+
suggestion: "Add closing ``` to complete the code block"
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
return errors;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Detect duplicate content blocks
|
|
404
|
+
* Improved tuning to reduce false positives
|
|
405
|
+
*
|
|
406
|
+
* Thresholds:
|
|
407
|
+
* - Block size: 8 lines - requires substantial duplication
|
|
408
|
+
* - Min length: 200 chars - ignores short similar sections
|
|
409
|
+
* - Filters overlapping windows - prevents adjacent line false positives
|
|
410
|
+
*/
|
|
411
|
+
detectDuplicateContent(content) {
|
|
412
|
+
const warnings = [];
|
|
413
|
+
const lines = content.split("\n");
|
|
414
|
+
const blockSize = this.options.duplicateBlockSize;
|
|
415
|
+
const minLength = this.options.duplicateMinLength;
|
|
416
|
+
const blocks = /* @__PURE__ */ new Map();
|
|
417
|
+
for (let i = 0; i <= lines.length - blockSize; i++) {
|
|
418
|
+
const block = lines.slice(i, i + blockSize).map((l) => l.trim()).filter((l) => l.length > 0).join("\n");
|
|
419
|
+
if (block.length >= minLength) {
|
|
420
|
+
if (!blocks.has(block)) {
|
|
421
|
+
blocks.set(block, []);
|
|
422
|
+
}
|
|
423
|
+
blocks.get(block).push(i + 1);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
for (const [block, lineNumbers] of blocks.entries()) {
|
|
427
|
+
if (lineNumbers.length > 1) {
|
|
428
|
+
const nonOverlapping = [];
|
|
429
|
+
for (const lineNum of lineNumbers) {
|
|
430
|
+
const isOverlapping = nonOverlapping.some(
|
|
431
|
+
(existing) => Math.abs(existing - lineNum) < blockSize
|
|
432
|
+
);
|
|
433
|
+
if (!isOverlapping) {
|
|
434
|
+
nonOverlapping.push(lineNum);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (nonOverlapping.length > 1) {
|
|
438
|
+
warnings.push({
|
|
439
|
+
message: `Duplicate content block found at lines: ${nonOverlapping.join(", ")}`,
|
|
440
|
+
suggestion: "Check for merge artifacts or failed edits"
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return warnings;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Validate markdown structure (excluding code blocks)
|
|
449
|
+
* Only checks actual content for formatting issues
|
|
450
|
+
*/
|
|
451
|
+
validateMarkdownStructure(content, codeBlockRanges) {
|
|
452
|
+
const errors = [];
|
|
453
|
+
const contentOutsideCodeBlocks = this.getContentOutsideCodeBlocks(content, codeBlockRanges);
|
|
454
|
+
const lines = contentOutsideCodeBlocks.split("\n");
|
|
455
|
+
const linesWithoutListMarkers = lines.map((line) => {
|
|
456
|
+
const trimmed = line.trim();
|
|
457
|
+
if (trimmed.match(/^[-*+]\s/)) {
|
|
458
|
+
return line.replace(/^(\s*)([-*+]\s)/, "$1 ");
|
|
459
|
+
}
|
|
460
|
+
return line;
|
|
461
|
+
});
|
|
462
|
+
let contentWithoutListMarkers = linesWithoutListMarkers.join("\n");
|
|
463
|
+
contentWithoutListMarkers = contentWithoutListMarkers.replace(/`[^`]*`/g, "");
|
|
464
|
+
const boldMatches = contentWithoutListMarkers.match(/\*\*/g) || [];
|
|
465
|
+
const withoutBold = contentWithoutListMarkers.split("**").join("");
|
|
466
|
+
const italicMatches = withoutBold.match(/\*/g) || [];
|
|
467
|
+
if (boldMatches.length % 2 !== 0) {
|
|
468
|
+
errors.push({
|
|
469
|
+
message: "Unclosed bold formatting (**)",
|
|
470
|
+
suggestion: "Check for missing closing ** in markdown content (not code blocks)"
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
if (italicMatches.length % 2 !== 0) {
|
|
474
|
+
errors.push({
|
|
475
|
+
message: "Unclosed italic formatting (*)",
|
|
476
|
+
suggestion: "Check for missing closing * in markdown content (not code blocks)"
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
return errors;
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
function normalizeDateFields(data) {
|
|
483
|
+
const dateFields = ["created", "completed", "updated", "due"];
|
|
484
|
+
for (const field of dateFields) {
|
|
485
|
+
if (data[field] instanceof Date) {
|
|
486
|
+
data[field] = data[field].toISOString().split("T")[0];
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function enrichWithTimestamps(data, previousData) {
|
|
491
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
492
|
+
if (!data.created_at) {
|
|
493
|
+
data.created_at = now;
|
|
494
|
+
}
|
|
495
|
+
if (previousData) {
|
|
496
|
+
data.updated_at = now;
|
|
497
|
+
}
|
|
498
|
+
if (data.status === "complete" && previousData?.status !== "complete" && !data.completed_at) {
|
|
499
|
+
data.completed_at = now;
|
|
500
|
+
if (!data.completed) {
|
|
501
|
+
data.completed = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (previousData && data.status !== previousData.status) {
|
|
505
|
+
if (!Array.isArray(data.transitions)) {
|
|
506
|
+
data.transitions = [];
|
|
507
|
+
}
|
|
508
|
+
data.transitions.push({
|
|
509
|
+
status: data.status,
|
|
510
|
+
at: now
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function normalizeTagsField(data) {
|
|
515
|
+
if (data.tags && typeof data.tags === "string") {
|
|
516
|
+
try {
|
|
517
|
+
const parsed = JSON.parse(data.tags);
|
|
518
|
+
if (Array.isArray(parsed)) {
|
|
519
|
+
data.tags = parsed;
|
|
520
|
+
}
|
|
521
|
+
} catch {
|
|
522
|
+
data.tags = data.tags.split(",").map((t) => t.trim());
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function validateCustomField(value, expectedType) {
|
|
527
|
+
switch (expectedType) {
|
|
528
|
+
case "string":
|
|
529
|
+
if (typeof value === "string") {
|
|
530
|
+
return { valid: true, coerced: value };
|
|
531
|
+
}
|
|
532
|
+
return { valid: true, coerced: String(value) };
|
|
533
|
+
case "number":
|
|
534
|
+
if (typeof value === "number") {
|
|
535
|
+
return { valid: true, coerced: value };
|
|
536
|
+
}
|
|
537
|
+
const num = Number(value);
|
|
538
|
+
if (!isNaN(num)) {
|
|
539
|
+
return { valid: true, coerced: num };
|
|
540
|
+
}
|
|
541
|
+
return { valid: false, error: `Cannot convert '${value}' to number` };
|
|
542
|
+
case "boolean":
|
|
543
|
+
if (typeof value === "boolean") {
|
|
544
|
+
return { valid: true, coerced: value };
|
|
545
|
+
}
|
|
546
|
+
if (value === "true" || value === "yes" || value === "1") {
|
|
547
|
+
return { valid: true, coerced: true };
|
|
548
|
+
}
|
|
549
|
+
if (value === "false" || value === "no" || value === "0") {
|
|
550
|
+
return { valid: true, coerced: false };
|
|
551
|
+
}
|
|
552
|
+
return { valid: false, error: `Cannot convert '${value}' to boolean` };
|
|
553
|
+
case "array":
|
|
554
|
+
if (Array.isArray(value)) {
|
|
555
|
+
return { valid: true, coerced: value };
|
|
556
|
+
}
|
|
557
|
+
return { valid: false, error: `Expected array but got ${typeof value}` };
|
|
558
|
+
default:
|
|
559
|
+
return { valid: false, error: `Unknown type: ${expectedType}` };
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function validateCustomFields(frontmatter, config) {
|
|
563
|
+
if (!config?.frontmatter?.custom) {
|
|
564
|
+
return frontmatter;
|
|
565
|
+
}
|
|
566
|
+
const result = { ...frontmatter };
|
|
567
|
+
for (const [fieldName, expectedType] of Object.entries(config.frontmatter.custom)) {
|
|
568
|
+
if (fieldName in result) {
|
|
569
|
+
const validation = validateCustomField(result[fieldName], expectedType);
|
|
570
|
+
if (validation.valid) {
|
|
571
|
+
result[fieldName] = validation.coerced;
|
|
572
|
+
} else {
|
|
573
|
+
console.warn(`Warning: Invalid custom field '${fieldName}': ${validation.error}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return result;
|
|
578
|
+
}
|
|
579
|
+
function parseFrontmatterFromString(content, filePath, config) {
|
|
580
|
+
try {
|
|
581
|
+
const parsed = matter3(content, {
|
|
582
|
+
engines: {
|
|
583
|
+
yaml: (str) => yaml2.load(str, { schema: yaml2.FAILSAFE_SCHEMA })
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
if (!parsed.data || Object.keys(parsed.data).length === 0) {
|
|
587
|
+
return parseFallbackFields(content);
|
|
588
|
+
}
|
|
589
|
+
if (!parsed.data.status) {
|
|
590
|
+
if (filePath) console.warn(`Warning: Missing required field 'status' in ${filePath}`);
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
if (!parsed.data.created) {
|
|
594
|
+
if (filePath) console.warn(`Warning: Missing required field 'created' in ${filePath}`);
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
const validStatuses = ["planned", "in-progress", "complete", "archived"];
|
|
598
|
+
if (!validStatuses.includes(parsed.data.status)) {
|
|
599
|
+
if (filePath) {
|
|
600
|
+
console.warn(`Warning: Invalid status '${parsed.data.status}' in ${filePath}. Valid values: ${validStatuses.join(", ")}`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (parsed.data.priority) {
|
|
604
|
+
const validPriorities = ["low", "medium", "high", "critical"];
|
|
605
|
+
if (!validPriorities.includes(parsed.data.priority)) {
|
|
606
|
+
if (filePath) {
|
|
607
|
+
console.warn(`Warning: Invalid priority '${parsed.data.priority}' in ${filePath}. Valid values: ${validPriorities.join(", ")}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
normalizeTagsField(parsed.data);
|
|
612
|
+
const knownFields = [
|
|
613
|
+
"status",
|
|
614
|
+
"created",
|
|
615
|
+
"tags",
|
|
616
|
+
"priority",
|
|
617
|
+
"related",
|
|
618
|
+
"depends_on",
|
|
619
|
+
"updated",
|
|
620
|
+
"completed",
|
|
621
|
+
"assignee",
|
|
622
|
+
"reviewer",
|
|
623
|
+
"issue",
|
|
624
|
+
"pr",
|
|
625
|
+
"epic",
|
|
626
|
+
"breaking",
|
|
627
|
+
"due",
|
|
628
|
+
"created_at",
|
|
629
|
+
"updated_at",
|
|
630
|
+
"completed_at",
|
|
631
|
+
"transitions"
|
|
632
|
+
];
|
|
633
|
+
const customFields = config?.frontmatter?.custom ? Object.keys(config.frontmatter.custom) : [];
|
|
634
|
+
const allKnownFields = [...knownFields, ...customFields];
|
|
635
|
+
const unknownFields = Object.keys(parsed.data).filter((k) => !allKnownFields.includes(k));
|
|
636
|
+
if (unknownFields.length > 0 && filePath) {
|
|
637
|
+
console.warn(`Info: Unknown fields in ${filePath}: ${unknownFields.join(", ")}`);
|
|
638
|
+
}
|
|
639
|
+
const validatedData = validateCustomFields(parsed.data, config);
|
|
640
|
+
return validatedData;
|
|
641
|
+
} catch (error) {
|
|
642
|
+
console.error(`Error parsing frontmatter${filePath ? ` from ${filePath}` : ""}:`, error);
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function parseFallbackFields(content) {
|
|
647
|
+
const statusMatch = content.match(/\*\*Status\*\*:\s*(?:📅\s*)?(\w+(?:-\w+)?)/i);
|
|
648
|
+
const createdMatch = content.match(/\*\*Created\*\*:\s*(\d{4}-\d{2}-\d{2})/);
|
|
649
|
+
if (statusMatch && createdMatch) {
|
|
650
|
+
const status = statusMatch[1].toLowerCase().replace(/\s+/g, "-");
|
|
651
|
+
const created = createdMatch[1];
|
|
652
|
+
return {
|
|
653
|
+
status,
|
|
654
|
+
created
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
function createUpdatedFrontmatter(existingContent, updates) {
|
|
660
|
+
const parsed = matter3(existingContent, {
|
|
661
|
+
engines: {
|
|
662
|
+
yaml: (str) => yaml2.load(str, { schema: yaml2.FAILSAFE_SCHEMA })
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
const previousData = { ...parsed.data };
|
|
666
|
+
const newData = { ...parsed.data, ...updates };
|
|
667
|
+
normalizeDateFields(newData);
|
|
668
|
+
enrichWithTimestamps(newData, previousData);
|
|
669
|
+
if (updates.status === "complete" && !newData.completed) {
|
|
670
|
+
newData.completed = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
671
|
+
}
|
|
672
|
+
if ("updated" in parsed.data) {
|
|
673
|
+
newData.updated = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
674
|
+
}
|
|
675
|
+
let updatedContent = parsed.content;
|
|
676
|
+
updatedContent = updateVisualMetadata(updatedContent, newData);
|
|
677
|
+
const newContent = matter3.stringify(updatedContent, newData);
|
|
678
|
+
return {
|
|
679
|
+
content: newContent,
|
|
680
|
+
frontmatter: newData
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
function updateVisualMetadata(content, frontmatter) {
|
|
684
|
+
const statusEmoji = getStatusEmojiPlain(frontmatter.status);
|
|
685
|
+
const statusLabel = frontmatter.status.charAt(0).toUpperCase() + frontmatter.status.slice(1).replace("-", " ");
|
|
686
|
+
const created = frontmatter.created;
|
|
687
|
+
let metadataLine = `> **Status**: ${statusEmoji} ${statusLabel}`;
|
|
688
|
+
if (frontmatter.priority) {
|
|
689
|
+
const priorityLabel = frontmatter.priority.charAt(0).toUpperCase() + frontmatter.priority.slice(1);
|
|
690
|
+
metadataLine += ` \xB7 **Priority**: ${priorityLabel}`;
|
|
691
|
+
}
|
|
692
|
+
metadataLine += ` \xB7 **Created**: ${created}`;
|
|
693
|
+
if (frontmatter.tags && frontmatter.tags.length > 0) {
|
|
694
|
+
metadataLine += ` \xB7 **Tags**: ${frontmatter.tags.join(", ")}`;
|
|
695
|
+
}
|
|
696
|
+
let secondLine = "";
|
|
697
|
+
if (frontmatter.assignee || frontmatter.reviewer) {
|
|
698
|
+
const assignee = frontmatter.assignee || "TBD";
|
|
699
|
+
const reviewer = frontmatter.reviewer || "TBD";
|
|
700
|
+
secondLine = `
|
|
701
|
+
> **Assignee**: ${assignee} \xB7 **Reviewer**: ${reviewer}`;
|
|
702
|
+
}
|
|
703
|
+
const metadataPattern = /^>\s+\*\*Status\*\*:.*(?:\n>\s+\*\*Assignee\*\*:.*)?/m;
|
|
704
|
+
if (metadataPattern.test(content)) {
|
|
705
|
+
return content.replace(metadataPattern, metadataLine + secondLine);
|
|
706
|
+
} else {
|
|
707
|
+
const titleMatch = content.match(/^#\s+.+$/m);
|
|
708
|
+
if (titleMatch) {
|
|
709
|
+
const insertPos = titleMatch.index + titleMatch[0].length;
|
|
710
|
+
return content.slice(0, insertPos) + "\n\n" + metadataLine + secondLine + "\n" + content.slice(insertPos);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return content;
|
|
714
|
+
}
|
|
715
|
+
function getStatusEmojiPlain(status) {
|
|
716
|
+
switch (status) {
|
|
717
|
+
case "planned":
|
|
718
|
+
return "\u{1F4C5}";
|
|
719
|
+
case "in-progress":
|
|
720
|
+
return "\u23F3";
|
|
721
|
+
case "complete":
|
|
722
|
+
return "\u2705";
|
|
723
|
+
case "archived":
|
|
724
|
+
return "\u{1F4E6}";
|
|
725
|
+
default:
|
|
726
|
+
return "\u{1F4C4}";
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
function parseMarkdownSections(content) {
|
|
730
|
+
const lines = content.split("\n");
|
|
731
|
+
const sections = [];
|
|
732
|
+
const sectionStack = [];
|
|
733
|
+
let inCodeBlock = false;
|
|
734
|
+
let currentLineNum = 1;
|
|
735
|
+
for (let i = 0; i < lines.length; i++) {
|
|
736
|
+
const line = lines[i];
|
|
737
|
+
currentLineNum = i + 1;
|
|
738
|
+
if (line.trimStart().startsWith("```")) {
|
|
739
|
+
inCodeBlock = !inCodeBlock;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (inCodeBlock) {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
746
|
+
if (headingMatch) {
|
|
747
|
+
const level = headingMatch[1].length;
|
|
748
|
+
const title = headingMatch[2].trim();
|
|
749
|
+
while (sectionStack.length > 0 && sectionStack[sectionStack.length - 1].level >= level) {
|
|
750
|
+
const closedSection = sectionStack.pop();
|
|
751
|
+
closedSection.endLine = currentLineNum - 1;
|
|
752
|
+
closedSection.lineCount = closedSection.endLine - closedSection.startLine + 1;
|
|
753
|
+
}
|
|
754
|
+
const newSection = {
|
|
755
|
+
title,
|
|
756
|
+
level,
|
|
757
|
+
startLine: currentLineNum,
|
|
758
|
+
endLine: lines.length,
|
|
759
|
+
// Will be updated when section closes
|
|
760
|
+
lineCount: 0,
|
|
761
|
+
// Will be calculated when section closes
|
|
762
|
+
subsections: []
|
|
763
|
+
};
|
|
764
|
+
if (sectionStack.length > 0) {
|
|
765
|
+
sectionStack[sectionStack.length - 1].subsections.push(newSection);
|
|
766
|
+
} else {
|
|
767
|
+
sections.push(newSection);
|
|
768
|
+
}
|
|
769
|
+
sectionStack.push(newSection);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
while (sectionStack.length > 0) {
|
|
773
|
+
const closedSection = sectionStack.pop();
|
|
774
|
+
closedSection.endLine = lines.length;
|
|
775
|
+
closedSection.lineCount = closedSection.endLine - closedSection.startLine + 1;
|
|
776
|
+
}
|
|
777
|
+
return sections;
|
|
778
|
+
}
|
|
779
|
+
function flattenSections(sections) {
|
|
780
|
+
const result = [];
|
|
781
|
+
for (const section of sections) {
|
|
782
|
+
result.push(section);
|
|
783
|
+
result.push(...flattenSections(section.subsections));
|
|
784
|
+
}
|
|
785
|
+
return result;
|
|
786
|
+
}
|
|
787
|
+
function extractLines(content, startLine, endLine) {
|
|
788
|
+
const lines = content.split("\n");
|
|
789
|
+
if (startLine < 1 || endLine < startLine || startLine > lines.length || endLine > lines.length) {
|
|
790
|
+
throw new Error(`Invalid line range: ${startLine}-${endLine}`);
|
|
791
|
+
}
|
|
792
|
+
const extracted = lines.slice(startLine - 1, endLine);
|
|
793
|
+
return extracted.join("\n");
|
|
794
|
+
}
|
|
795
|
+
function removeLines(content, startLine, endLine) {
|
|
796
|
+
const lines = content.split("\n");
|
|
797
|
+
if (startLine < 1 || endLine < startLine || startLine > lines.length) {
|
|
798
|
+
throw new Error(`Invalid line range: ${startLine}-${endLine}`);
|
|
799
|
+
}
|
|
800
|
+
lines.splice(startLine - 1, endLine - startLine + 1);
|
|
801
|
+
return lines.join("\n");
|
|
802
|
+
}
|
|
803
|
+
function countLines(content) {
|
|
804
|
+
return content.split("\n").length;
|
|
805
|
+
}
|
|
806
|
+
function analyzeMarkdownStructure(content) {
|
|
807
|
+
const lines = content.split("\n");
|
|
808
|
+
const sections = parseMarkdownSections(content);
|
|
809
|
+
const allSections = flattenSections(sections);
|
|
810
|
+
const levelCounts = { h1: 0, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0, total: 0 };
|
|
811
|
+
for (const section of allSections) {
|
|
812
|
+
levelCounts[`h${section.level}`]++;
|
|
813
|
+
levelCounts.total++;
|
|
814
|
+
}
|
|
815
|
+
let codeBlocks = 0;
|
|
816
|
+
let inCodeBlock = false;
|
|
817
|
+
for (const line of lines) {
|
|
818
|
+
if (line.trimStart().startsWith("```")) {
|
|
819
|
+
if (!inCodeBlock) {
|
|
820
|
+
codeBlocks++;
|
|
821
|
+
}
|
|
822
|
+
inCodeBlock = !inCodeBlock;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
let maxNesting = 0;
|
|
826
|
+
function calculateNesting(secs, depth) {
|
|
827
|
+
for (const section of secs) {
|
|
828
|
+
maxNesting = Math.max(maxNesting, depth);
|
|
829
|
+
calculateNesting(section.subsections, depth + 1);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
calculateNesting(sections, 1);
|
|
833
|
+
return {
|
|
834
|
+
lines: lines.length,
|
|
835
|
+
sections,
|
|
836
|
+
allSections,
|
|
837
|
+
sectionsByLevel: levelCounts,
|
|
838
|
+
codeBlocks,
|
|
839
|
+
maxNesting
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
var TokenCounter = class {
|
|
843
|
+
encoding = null;
|
|
844
|
+
async getEncoding() {
|
|
845
|
+
if (!this.encoding) {
|
|
846
|
+
const { encoding_for_model } = await import('tiktoken');
|
|
847
|
+
this.encoding = encoding_for_model("gpt-4");
|
|
848
|
+
}
|
|
849
|
+
return this.encoding;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Clean up resources (important to prevent memory leaks)
|
|
853
|
+
*/
|
|
854
|
+
dispose() {
|
|
855
|
+
if (this.encoding) {
|
|
856
|
+
this.encoding.free();
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Count tokens in a string
|
|
861
|
+
*/
|
|
862
|
+
async countString(text) {
|
|
863
|
+
const encoding = await this.getEncoding();
|
|
864
|
+
const tokens = encoding.encode(text);
|
|
865
|
+
return tokens.length;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Count tokens in content (convenience method for analyze command)
|
|
869
|
+
* Alias for countString - provided for clarity in command usage
|
|
870
|
+
*/
|
|
871
|
+
async countTokensInContent(content) {
|
|
872
|
+
return this.countString(content);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Count tokens in a single file
|
|
876
|
+
*/
|
|
877
|
+
async countFile(filePath, options = {}) {
|
|
878
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
879
|
+
const tokens = await this.countString(content);
|
|
880
|
+
const lines = content.split("\n").length;
|
|
881
|
+
const result = {
|
|
882
|
+
total: tokens,
|
|
883
|
+
files: [{
|
|
884
|
+
path: filePath,
|
|
885
|
+
tokens,
|
|
886
|
+
lines
|
|
887
|
+
}]
|
|
888
|
+
};
|
|
889
|
+
if (options.detailed) {
|
|
890
|
+
result.breakdown = await this.analyzeBreakdown(content);
|
|
891
|
+
}
|
|
892
|
+
return result;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Count tokens in a spec (including sub-specs if requested)
|
|
896
|
+
*/
|
|
897
|
+
async countSpec(specPath, options = {}) {
|
|
898
|
+
const stats = await fs.stat(specPath);
|
|
899
|
+
if (stats.isFile()) {
|
|
900
|
+
return this.countFile(specPath, options);
|
|
901
|
+
}
|
|
902
|
+
const files = await fs.readdir(specPath);
|
|
903
|
+
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
904
|
+
const filesToCount = [];
|
|
905
|
+
if (mdFiles.includes("README.md")) {
|
|
906
|
+
filesToCount.push("README.md");
|
|
907
|
+
}
|
|
908
|
+
if (options.includeSubSpecs) {
|
|
909
|
+
mdFiles.forEach((f) => {
|
|
910
|
+
if (f !== "README.md") {
|
|
911
|
+
filesToCount.push(f);
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
const fileCounts = [];
|
|
916
|
+
let totalTokens = 0;
|
|
917
|
+
let totalBreakdown;
|
|
918
|
+
if (options.detailed) {
|
|
919
|
+
totalBreakdown = {
|
|
920
|
+
code: 0,
|
|
921
|
+
prose: 0,
|
|
922
|
+
tables: 0,
|
|
923
|
+
frontmatter: 0
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
for (const file of filesToCount) {
|
|
927
|
+
const filePath = path.join(specPath, file);
|
|
928
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
929
|
+
const tokens = await this.countString(content);
|
|
930
|
+
const lines = content.split("\n").length;
|
|
931
|
+
fileCounts.push({
|
|
932
|
+
path: file,
|
|
933
|
+
tokens,
|
|
934
|
+
lines
|
|
935
|
+
});
|
|
936
|
+
totalTokens += tokens;
|
|
937
|
+
if (options.detailed && totalBreakdown) {
|
|
938
|
+
const breakdown = await this.analyzeBreakdown(content);
|
|
939
|
+
totalBreakdown.code += breakdown.code;
|
|
940
|
+
totalBreakdown.prose += breakdown.prose;
|
|
941
|
+
totalBreakdown.tables += breakdown.tables;
|
|
942
|
+
totalBreakdown.frontmatter += breakdown.frontmatter;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return {
|
|
946
|
+
total: totalTokens,
|
|
947
|
+
files: fileCounts,
|
|
948
|
+
breakdown: totalBreakdown
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Analyze token breakdown by content type
|
|
953
|
+
*/
|
|
954
|
+
async analyzeBreakdown(content) {
|
|
955
|
+
const breakdown = {
|
|
956
|
+
code: 0,
|
|
957
|
+
prose: 0,
|
|
958
|
+
tables: 0,
|
|
959
|
+
frontmatter: 0
|
|
960
|
+
};
|
|
961
|
+
let body = content;
|
|
962
|
+
let frontmatterContent = "";
|
|
963
|
+
try {
|
|
964
|
+
const parsed = matter3(content);
|
|
965
|
+
body = parsed.content;
|
|
966
|
+
frontmatterContent = parsed.matter;
|
|
967
|
+
breakdown.frontmatter = await this.countString(frontmatterContent);
|
|
968
|
+
} catch {
|
|
969
|
+
}
|
|
970
|
+
let inCodeBlock = false;
|
|
971
|
+
let inTable = false;
|
|
972
|
+
const lines = body.split("\n");
|
|
973
|
+
for (let i = 0; i < lines.length; i++) {
|
|
974
|
+
const line = lines[i];
|
|
975
|
+
const trimmed = line.trim();
|
|
976
|
+
if (trimmed.startsWith("```")) {
|
|
977
|
+
inCodeBlock = !inCodeBlock;
|
|
978
|
+
breakdown.code += await this.countString(line + "\n");
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
if (inCodeBlock) {
|
|
982
|
+
breakdown.code += await this.countString(line + "\n");
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
const isTableSeparator = trimmed.includes("|") && /[-:]{3,}/.test(trimmed);
|
|
986
|
+
const isTableRow = trimmed.includes("|") && trimmed.startsWith("|");
|
|
987
|
+
if (isTableSeparator || inTable && isTableRow) {
|
|
988
|
+
inTable = true;
|
|
989
|
+
breakdown.tables += await this.countString(line + "\n");
|
|
990
|
+
continue;
|
|
991
|
+
} else if (inTable && !isTableRow) {
|
|
992
|
+
inTable = false;
|
|
993
|
+
}
|
|
994
|
+
breakdown.prose += await this.countString(line + "\n");
|
|
995
|
+
}
|
|
996
|
+
return breakdown;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Check if content fits within token limit
|
|
1000
|
+
*/
|
|
1001
|
+
isWithinLimit(count, limit) {
|
|
1002
|
+
return count.total <= limit;
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Format token count for display
|
|
1006
|
+
*/
|
|
1007
|
+
formatCount(count, verbose = false) {
|
|
1008
|
+
if (!verbose) {
|
|
1009
|
+
return `${count.total.toLocaleString()} tokens`;
|
|
1010
|
+
}
|
|
1011
|
+
const lines = [
|
|
1012
|
+
`Total: ${count.total.toLocaleString()} tokens`,
|
|
1013
|
+
"",
|
|
1014
|
+
"Files:"
|
|
1015
|
+
];
|
|
1016
|
+
for (const file of count.files) {
|
|
1017
|
+
const lineInfo = file.lines ? ` (${file.lines} lines)` : "";
|
|
1018
|
+
lines.push(` ${file.path}: ${file.tokens.toLocaleString()} tokens${lineInfo}`);
|
|
1019
|
+
}
|
|
1020
|
+
if (count.breakdown) {
|
|
1021
|
+
const b = count.breakdown;
|
|
1022
|
+
const total = b.code + b.prose + b.tables + b.frontmatter;
|
|
1023
|
+
lines.push("");
|
|
1024
|
+
lines.push("Content Breakdown:");
|
|
1025
|
+
lines.push(` Prose: ${b.prose.toLocaleString()} tokens (${Math.round(b.prose / total * 100)}%)`);
|
|
1026
|
+
lines.push(` Code: ${b.code.toLocaleString()} tokens (${Math.round(b.code / total * 100)}%)`);
|
|
1027
|
+
lines.push(` Tables: ${b.tables.toLocaleString()} tokens (${Math.round(b.tables / total * 100)}%)`);
|
|
1028
|
+
lines.push(` Frontmatter: ${b.frontmatter.toLocaleString()} tokens (${Math.round(b.frontmatter / total * 100)}%)`);
|
|
1029
|
+
}
|
|
1030
|
+
return lines.join("\n");
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Get performance indicators based on token count
|
|
1034
|
+
* Based on research from spec 066
|
|
1035
|
+
*/
|
|
1036
|
+
getPerformanceIndicators(tokenCount) {
|
|
1037
|
+
const baselineTokens = 1200;
|
|
1038
|
+
const costMultiplier = Math.round(tokenCount / baselineTokens * 10) / 10;
|
|
1039
|
+
if (tokenCount < 2e3) {
|
|
1040
|
+
return {
|
|
1041
|
+
level: "excellent",
|
|
1042
|
+
costMultiplier,
|
|
1043
|
+
effectiveness: 100,
|
|
1044
|
+
recommendation: "Optimal size for Context Economy"
|
|
1045
|
+
};
|
|
1046
|
+
} else if (tokenCount < 3500) {
|
|
1047
|
+
return {
|
|
1048
|
+
level: "good",
|
|
1049
|
+
costMultiplier,
|
|
1050
|
+
effectiveness: 95,
|
|
1051
|
+
recommendation: "Good size, no action needed"
|
|
1052
|
+
};
|
|
1053
|
+
} else if (tokenCount < 5e3) {
|
|
1054
|
+
return {
|
|
1055
|
+
level: "warning",
|
|
1056
|
+
costMultiplier,
|
|
1057
|
+
effectiveness: 85,
|
|
1058
|
+
recommendation: "Consider simplification or sub-specs"
|
|
1059
|
+
};
|
|
1060
|
+
} else {
|
|
1061
|
+
return {
|
|
1062
|
+
level: "problem",
|
|
1063
|
+
costMultiplier,
|
|
1064
|
+
effectiveness: 70,
|
|
1065
|
+
recommendation: "Should split - elevated token count"
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
async function countTokens(input, options) {
|
|
1071
|
+
const counter = new TokenCounter();
|
|
1072
|
+
try {
|
|
1073
|
+
if (typeof input === "string") {
|
|
1074
|
+
return {
|
|
1075
|
+
total: await counter.countString(input),
|
|
1076
|
+
files: []
|
|
1077
|
+
};
|
|
1078
|
+
} else if ("content" in input) {
|
|
1079
|
+
return {
|
|
1080
|
+
total: await counter.countString(input.content),
|
|
1081
|
+
files: []
|
|
1082
|
+
};
|
|
1083
|
+
} else if ("filePath" in input) {
|
|
1084
|
+
return await counter.countFile(input.filePath, options);
|
|
1085
|
+
} else if ("specPath" in input) {
|
|
1086
|
+
return await counter.countSpec(input.specPath, options);
|
|
1087
|
+
}
|
|
1088
|
+
throw new Error("Invalid input type");
|
|
1089
|
+
} finally {
|
|
1090
|
+
counter.dispose();
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
var ComplexityValidator = class {
|
|
1094
|
+
name = "complexity";
|
|
1095
|
+
description = "Direct token threshold validation with independent structure checks";
|
|
1096
|
+
excellentThreshold;
|
|
1097
|
+
goodThreshold;
|
|
1098
|
+
warningThreshold;
|
|
1099
|
+
maxLines;
|
|
1100
|
+
warningLines;
|
|
1101
|
+
constructor(options = {}) {
|
|
1102
|
+
this.excellentThreshold = options.excellentThreshold ?? 2e3;
|
|
1103
|
+
this.goodThreshold = options.goodThreshold ?? 3500;
|
|
1104
|
+
this.warningThreshold = options.warningThreshold ?? 5e3;
|
|
1105
|
+
this.maxLines = options.maxLines ?? 500;
|
|
1106
|
+
this.warningLines = options.warningLines ?? 400;
|
|
1107
|
+
}
|
|
1108
|
+
async validate(spec, content) {
|
|
1109
|
+
const errors = [];
|
|
1110
|
+
const warnings = [];
|
|
1111
|
+
const metrics = await this.analyzeComplexity(content, spec);
|
|
1112
|
+
const tokenValidation = this.validateTokens(metrics.tokenCount);
|
|
1113
|
+
if (tokenValidation.level === "error") {
|
|
1114
|
+
errors.push({
|
|
1115
|
+
message: tokenValidation.message,
|
|
1116
|
+
suggestion: "Consider splitting for Context Economy (attention and cognitive load)"
|
|
1117
|
+
});
|
|
1118
|
+
} else if (tokenValidation.level === "warning") {
|
|
1119
|
+
warnings.push({
|
|
1120
|
+
message: tokenValidation.message,
|
|
1121
|
+
suggestion: "Consider simplification or splitting into sub-specs"
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
const structureChecks = this.checkStructure(metrics);
|
|
1125
|
+
for (const check of structureChecks) {
|
|
1126
|
+
if (!check.passed && check.message) {
|
|
1127
|
+
warnings.push({
|
|
1128
|
+
message: check.message,
|
|
1129
|
+
suggestion: check.suggestion
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
const lineWarning = this.checkLineCounts(metrics.lineCount);
|
|
1134
|
+
if (lineWarning) {
|
|
1135
|
+
warnings.push(lineWarning);
|
|
1136
|
+
}
|
|
1137
|
+
return {
|
|
1138
|
+
passed: errors.length === 0,
|
|
1139
|
+
errors,
|
|
1140
|
+
warnings
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Validate token count with direct thresholds
|
|
1145
|
+
*/
|
|
1146
|
+
validateTokens(tokens) {
|
|
1147
|
+
if (tokens > this.warningThreshold) {
|
|
1148
|
+
return {
|
|
1149
|
+
level: "error",
|
|
1150
|
+
message: `Spec has ${tokens.toLocaleString()} tokens (threshold: ${this.warningThreshold.toLocaleString()}) - should split`
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
if (tokens > this.goodThreshold) {
|
|
1154
|
+
return {
|
|
1155
|
+
level: "warning",
|
|
1156
|
+
message: `Spec has ${tokens.toLocaleString()} tokens (threshold: ${this.goodThreshold.toLocaleString()})`
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
if (tokens > this.excellentThreshold) {
|
|
1160
|
+
return {
|
|
1161
|
+
level: "info",
|
|
1162
|
+
message: `Spec has ${tokens.toLocaleString()} tokens - acceptable, watch for growth`
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
return {
|
|
1166
|
+
level: "excellent",
|
|
1167
|
+
message: `Spec has ${tokens.toLocaleString()} tokens - excellent`
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Check structure quality independently
|
|
1172
|
+
*/
|
|
1173
|
+
checkStructure(metrics) {
|
|
1174
|
+
const checks = [];
|
|
1175
|
+
if (metrics.hasSubSpecs) {
|
|
1176
|
+
if (metrics.tokenCount > this.excellentThreshold) {
|
|
1177
|
+
checks.push({
|
|
1178
|
+
passed: true,
|
|
1179
|
+
message: `Uses ${metrics.subSpecCount} sub-spec file${metrics.subSpecCount > 1 ? "s" : ""} for progressive disclosure`
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
} else if (metrics.tokenCount > this.goodThreshold) {
|
|
1183
|
+
checks.push({
|
|
1184
|
+
passed: false,
|
|
1185
|
+
message: "Consider using sub-spec files (DESIGN.md, IMPLEMENTATION.md, etc.)",
|
|
1186
|
+
suggestion: "Progressive disclosure reduces cognitive load for large specs"
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
if (metrics.sectionCount >= 15 && metrics.sectionCount <= 35) {
|
|
1190
|
+
if (metrics.tokenCount > this.excellentThreshold) {
|
|
1191
|
+
checks.push({
|
|
1192
|
+
passed: true,
|
|
1193
|
+
message: `Good sectioning (${metrics.sectionCount} sections) enables cognitive chunking`
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
} else if (metrics.sectionCount < 8 && metrics.lineCount > 200) {
|
|
1197
|
+
checks.push({
|
|
1198
|
+
passed: false,
|
|
1199
|
+
message: `Only ${metrics.sectionCount} sections - too monolithic`,
|
|
1200
|
+
suggestion: "Break into 15-35 sections for better readability (7\xB12 cognitive chunks)"
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
if (metrics.codeBlockCount > 20) {
|
|
1204
|
+
checks.push({
|
|
1205
|
+
passed: false,
|
|
1206
|
+
message: `High code block density (${metrics.codeBlockCount} blocks)`,
|
|
1207
|
+
suggestion: "Consider moving examples to separate files or sub-specs"
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
return checks;
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Provide warnings when line counts exceed backstop thresholds
|
|
1214
|
+
*/
|
|
1215
|
+
checkLineCounts(lineCount) {
|
|
1216
|
+
if (lineCount > this.maxLines) {
|
|
1217
|
+
return {
|
|
1218
|
+
message: `Spec is very long at ${lineCount.toLocaleString()} lines (limit ${this.maxLines.toLocaleString()})`,
|
|
1219
|
+
suggestion: "Split the document or move details to sub-spec files to keep context manageable"
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
if (lineCount > this.warningLines) {
|
|
1223
|
+
return {
|
|
1224
|
+
message: `Spec is ${lineCount.toLocaleString()} lines \u2014 approaching the ${this.warningLines.toLocaleString()} line backstop`,
|
|
1225
|
+
suggestion: "Watch size growth and consider progressive disclosure before hitting hard limits"
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
return null;
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Analyze complexity metrics from spec content
|
|
1232
|
+
*/
|
|
1233
|
+
async analyzeComplexity(content, spec) {
|
|
1234
|
+
let body;
|
|
1235
|
+
try {
|
|
1236
|
+
const parsed = matter3(content);
|
|
1237
|
+
body = parsed.content;
|
|
1238
|
+
} catch {
|
|
1239
|
+
body = content;
|
|
1240
|
+
}
|
|
1241
|
+
const lines = content.split("\n");
|
|
1242
|
+
const lineCount = lines.length;
|
|
1243
|
+
let sectionCount = 0;
|
|
1244
|
+
let inCodeBlock = false;
|
|
1245
|
+
for (const line of lines) {
|
|
1246
|
+
if (line.trim().startsWith("```")) {
|
|
1247
|
+
inCodeBlock = !inCodeBlock;
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
if (!inCodeBlock && line.match(/^#{2,4}\s/)) {
|
|
1251
|
+
sectionCount++;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
const codeBlockCount = Math.floor((content.match(/```/g) || []).length / 2);
|
|
1255
|
+
const listItemCount = lines.filter((line) => line.match(/^[\s]*[-*]\s/) || line.match(/^[\s]*\d+\.\s/)).length;
|
|
1256
|
+
const tableCount = lines.filter((line) => line.includes("|") && line.match(/[-:]{3,}/)).length;
|
|
1257
|
+
const counter = new TokenCounter();
|
|
1258
|
+
const tokenCount = await counter.countString(content);
|
|
1259
|
+
counter.dispose();
|
|
1260
|
+
let hasSubSpecs = false;
|
|
1261
|
+
let subSpecCount = 0;
|
|
1262
|
+
try {
|
|
1263
|
+
const specDir = path.dirname(spec.filePath);
|
|
1264
|
+
const files = await fs.readdir(specDir);
|
|
1265
|
+
const mdFiles = files.filter(
|
|
1266
|
+
(f) => f.endsWith(".md") && f !== "README.md"
|
|
1267
|
+
);
|
|
1268
|
+
hasSubSpecs = mdFiles.length > 0;
|
|
1269
|
+
subSpecCount = mdFiles.length;
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
hasSubSpecs = /\b(DESIGN|IMPLEMENTATION|TESTING|CONFIGURATION|API|MIGRATION)\.md\b/.test(content);
|
|
1272
|
+
const subSpecMatches = content.match(/\b[A-Z-]+\.md\b/g) || [];
|
|
1273
|
+
const uniqueSubSpecs = new Set(subSpecMatches.filter((m) => m !== "README.md"));
|
|
1274
|
+
subSpecCount = uniqueSubSpecs.size;
|
|
1275
|
+
}
|
|
1276
|
+
const averageSectionLength = sectionCount > 0 ? Math.round(lineCount / sectionCount) : 0;
|
|
1277
|
+
return {
|
|
1278
|
+
lineCount,
|
|
1279
|
+
tokenCount,
|
|
1280
|
+
sectionCount,
|
|
1281
|
+
codeBlockCount,
|
|
1282
|
+
listItemCount,
|
|
1283
|
+
tableCount,
|
|
1284
|
+
hasSubSpecs,
|
|
1285
|
+
subSpecCount,
|
|
1286
|
+
averageSectionLength
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
var SpecDependencyGraph = class {
|
|
1291
|
+
graph;
|
|
1292
|
+
specs;
|
|
1293
|
+
constructor(allSpecs) {
|
|
1294
|
+
this.graph = /* @__PURE__ */ new Map();
|
|
1295
|
+
this.specs = /* @__PURE__ */ new Map();
|
|
1296
|
+
this.buildGraph(allSpecs);
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Build the complete dependency graph from all specs
|
|
1300
|
+
*/
|
|
1301
|
+
buildGraph(specs) {
|
|
1302
|
+
for (const spec of specs) {
|
|
1303
|
+
this.specs.set(spec.path, spec);
|
|
1304
|
+
this.graph.set(spec.path, {
|
|
1305
|
+
dependsOn: new Set(spec.frontmatter.depends_on || []),
|
|
1306
|
+
requiredBy: /* @__PURE__ */ new Set()
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
for (const [specPath, node] of this.graph.entries()) {
|
|
1310
|
+
for (const dep of node.dependsOn) {
|
|
1311
|
+
const depNode = this.graph.get(dep);
|
|
1312
|
+
if (depNode) {
|
|
1313
|
+
depNode.requiredBy.add(specPath);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Get complete dependency graph for a spec
|
|
1320
|
+
*/
|
|
1321
|
+
getCompleteGraph(specPath) {
|
|
1322
|
+
const spec = this.specs.get(specPath);
|
|
1323
|
+
if (!spec) {
|
|
1324
|
+
throw new Error(`Spec not found: ${specPath}`);
|
|
1325
|
+
}
|
|
1326
|
+
const node = this.graph.get(specPath);
|
|
1327
|
+
if (!node) {
|
|
1328
|
+
throw new Error(`Graph node not found: ${specPath}`);
|
|
1329
|
+
}
|
|
1330
|
+
return {
|
|
1331
|
+
current: spec,
|
|
1332
|
+
dependsOn: this.getSpecsByPaths(Array.from(node.dependsOn)),
|
|
1333
|
+
requiredBy: this.getSpecsByPaths(Array.from(node.requiredBy))
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Get upstream dependencies (specs this one depends on)
|
|
1338
|
+
* Recursively traverses the dependsOn chain up to maxDepth
|
|
1339
|
+
*/
|
|
1340
|
+
getUpstream(specPath, maxDepth = 3) {
|
|
1341
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1342
|
+
const result = [];
|
|
1343
|
+
const traverse = (path32, depth) => {
|
|
1344
|
+
if (visited.has(path32)) {
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
visited.add(path32);
|
|
1348
|
+
const node = this.graph.get(path32);
|
|
1349
|
+
if (!node) return;
|
|
1350
|
+
for (const dep of node.dependsOn) {
|
|
1351
|
+
if (!visited.has(dep)) {
|
|
1352
|
+
if (depth < maxDepth) {
|
|
1353
|
+
const spec = this.specs.get(dep);
|
|
1354
|
+
if (spec) {
|
|
1355
|
+
result.push(spec);
|
|
1356
|
+
traverse(dep, depth + 1);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
traverse(specPath, 0);
|
|
1363
|
+
return result;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Get downstream dependents (specs that depend on this one)
|
|
1367
|
+
* Recursively traverses the requiredBy chain up to maxDepth
|
|
1368
|
+
*/
|
|
1369
|
+
getDownstream(specPath, maxDepth = 3) {
|
|
1370
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1371
|
+
const result = [];
|
|
1372
|
+
const traverse = (path32, depth) => {
|
|
1373
|
+
if (visited.has(path32)) {
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
visited.add(path32);
|
|
1377
|
+
const node = this.graph.get(path32);
|
|
1378
|
+
if (!node) return;
|
|
1379
|
+
for (const dep of node.requiredBy) {
|
|
1380
|
+
if (!visited.has(dep)) {
|
|
1381
|
+
if (depth < maxDepth) {
|
|
1382
|
+
const spec = this.specs.get(dep);
|
|
1383
|
+
if (spec) {
|
|
1384
|
+
result.push(spec);
|
|
1385
|
+
traverse(dep, depth + 1);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
traverse(specPath, 0);
|
|
1392
|
+
return result;
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Get impact radius - all specs affected by changes to this spec
|
|
1396
|
+
* Includes upstream dependencies and downstream dependents
|
|
1397
|
+
*/
|
|
1398
|
+
getImpactRadius(specPath, maxDepth = 3) {
|
|
1399
|
+
const spec = this.specs.get(specPath);
|
|
1400
|
+
if (!spec) {
|
|
1401
|
+
throw new Error(`Spec not found: ${specPath}`);
|
|
1402
|
+
}
|
|
1403
|
+
return {
|
|
1404
|
+
current: spec,
|
|
1405
|
+
upstream: this.getUpstream(specPath, maxDepth),
|
|
1406
|
+
downstream: this.getDownstream(specPath, maxDepth)
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Check if a circular dependency exists
|
|
1411
|
+
*/
|
|
1412
|
+
hasCircularDependency(specPath) {
|
|
1413
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1414
|
+
const recursionStack = /* @__PURE__ */ new Set();
|
|
1415
|
+
const hasCycle = (path32) => {
|
|
1416
|
+
if (recursionStack.has(path32)) {
|
|
1417
|
+
return true;
|
|
1418
|
+
}
|
|
1419
|
+
if (visited.has(path32)) {
|
|
1420
|
+
return false;
|
|
1421
|
+
}
|
|
1422
|
+
visited.add(path32);
|
|
1423
|
+
recursionStack.add(path32);
|
|
1424
|
+
const node = this.graph.get(path32);
|
|
1425
|
+
if (node) {
|
|
1426
|
+
for (const dep of node.dependsOn) {
|
|
1427
|
+
if (hasCycle(dep)) {
|
|
1428
|
+
return true;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
recursionStack.delete(path32);
|
|
1433
|
+
return false;
|
|
1434
|
+
};
|
|
1435
|
+
return hasCycle(specPath);
|
|
1436
|
+
}
|
|
1437
|
+
/**
|
|
1438
|
+
* Get all specs in the graph
|
|
1439
|
+
*/
|
|
1440
|
+
getAllSpecs() {
|
|
1441
|
+
return Array.from(this.specs.values());
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Get specs by their paths
|
|
1445
|
+
*/
|
|
1446
|
+
getSpecsByPaths(paths) {
|
|
1447
|
+
return paths.map((path32) => this.specs.get(path32)).filter((spec) => spec !== void 0);
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
var FIELD_WEIGHTS = {
|
|
1451
|
+
title: 100,
|
|
1452
|
+
name: 70,
|
|
1453
|
+
tags: 70,
|
|
1454
|
+
description: 50,
|
|
1455
|
+
content: 10
|
|
1456
|
+
};
|
|
1457
|
+
function calculateMatchScore(match, queryTerms, totalMatches, matchPosition) {
|
|
1458
|
+
let score = FIELD_WEIGHTS[match.field];
|
|
1459
|
+
match.text.toLowerCase();
|
|
1460
|
+
const hasExactMatch = queryTerms.some((term) => {
|
|
1461
|
+
const regex = new RegExp(`\\b${escapeRegex(term)}\\b`, "i");
|
|
1462
|
+
return regex.test(match.text);
|
|
1463
|
+
});
|
|
1464
|
+
if (hasExactMatch) {
|
|
1465
|
+
score *= 2;
|
|
1466
|
+
}
|
|
1467
|
+
const positionBonus = Math.max(1, 1.5 - matchPosition * 0.1);
|
|
1468
|
+
score *= positionBonus;
|
|
1469
|
+
const frequencyFactor = Math.min(1, 3 / totalMatches);
|
|
1470
|
+
score *= frequencyFactor;
|
|
1471
|
+
return Math.min(100, score * 10);
|
|
1472
|
+
}
|
|
1473
|
+
function calculateSpecScore(matches) {
|
|
1474
|
+
if (matches.length === 0) return 0;
|
|
1475
|
+
const fieldScores = {};
|
|
1476
|
+
for (const match of matches) {
|
|
1477
|
+
const field = match.field;
|
|
1478
|
+
const currentScore = fieldScores[field] || 0;
|
|
1479
|
+
fieldScores[field] = Math.max(currentScore, match.score);
|
|
1480
|
+
}
|
|
1481
|
+
let totalScore = 0;
|
|
1482
|
+
let totalWeight = 0;
|
|
1483
|
+
for (const [field, score] of Object.entries(fieldScores)) {
|
|
1484
|
+
const weight = FIELD_WEIGHTS[field] || 1;
|
|
1485
|
+
totalScore += score * weight;
|
|
1486
|
+
totalWeight += weight;
|
|
1487
|
+
}
|
|
1488
|
+
return totalWeight > 0 ? Math.round(totalScore / totalWeight) : 0;
|
|
1489
|
+
}
|
|
1490
|
+
function containsAnyTerm(text, queryTerms) {
|
|
1491
|
+
const textLower = text.toLowerCase();
|
|
1492
|
+
return queryTerms.some((term) => textLower.includes(term));
|
|
1493
|
+
}
|
|
1494
|
+
function countOccurrences(text, queryTerms) {
|
|
1495
|
+
const textLower = text.toLowerCase();
|
|
1496
|
+
let count = 0;
|
|
1497
|
+
for (const term of queryTerms) {
|
|
1498
|
+
const regex = new RegExp(escapeRegex(term), "gi");
|
|
1499
|
+
const matches = textLower.match(regex);
|
|
1500
|
+
count += matches ? matches.length : 0;
|
|
1501
|
+
}
|
|
1502
|
+
return count;
|
|
1503
|
+
}
|
|
1504
|
+
function escapeRegex(str) {
|
|
1505
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1506
|
+
}
|
|
1507
|
+
function findMatchPositions(text, queryTerms) {
|
|
1508
|
+
const positions = [];
|
|
1509
|
+
const textLower = text.toLowerCase();
|
|
1510
|
+
for (const term of queryTerms) {
|
|
1511
|
+
const termLower = term.toLowerCase();
|
|
1512
|
+
let index = 0;
|
|
1513
|
+
while ((index = textLower.indexOf(termLower, index)) !== -1) {
|
|
1514
|
+
positions.push([index, index + term.length]);
|
|
1515
|
+
index += term.length;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
positions.sort((a, b) => a[0] - b[0]);
|
|
1519
|
+
const merged = [];
|
|
1520
|
+
for (const pos of positions) {
|
|
1521
|
+
if (merged.length === 0) {
|
|
1522
|
+
merged.push(pos);
|
|
1523
|
+
} else {
|
|
1524
|
+
const last = merged[merged.length - 1];
|
|
1525
|
+
if (pos[0] <= last[1]) {
|
|
1526
|
+
last[1] = Math.max(last[1], pos[1]);
|
|
1527
|
+
} else {
|
|
1528
|
+
merged.push(pos);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
return merged;
|
|
1533
|
+
}
|
|
1534
|
+
function extractContext(text, matchIndex, queryTerms, contextLength = 80) {
|
|
1535
|
+
const lines = text.split("\n");
|
|
1536
|
+
const matchLine = lines[matchIndex] || "";
|
|
1537
|
+
if (matchLine.length <= contextLength * 2) {
|
|
1538
|
+
const highlights2 = findMatchPositions(matchLine, queryTerms);
|
|
1539
|
+
return { text: matchLine, highlights: highlights2 };
|
|
1540
|
+
}
|
|
1541
|
+
const matchLineLower = matchLine.toLowerCase();
|
|
1542
|
+
let firstMatchPos = matchLine.length;
|
|
1543
|
+
for (const term of queryTerms) {
|
|
1544
|
+
const pos = matchLineLower.indexOf(term.toLowerCase());
|
|
1545
|
+
if (pos !== -1 && pos < firstMatchPos) {
|
|
1546
|
+
firstMatchPos = pos;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
const start = Math.max(0, firstMatchPos - contextLength);
|
|
1550
|
+
const end = Math.min(matchLine.length, firstMatchPos + contextLength);
|
|
1551
|
+
let contextText = matchLine.substring(start, end);
|
|
1552
|
+
if (start > 0) contextText = "..." + contextText;
|
|
1553
|
+
if (end < matchLine.length) contextText = contextText + "...";
|
|
1554
|
+
const highlights = findMatchPositions(contextText, queryTerms);
|
|
1555
|
+
return { text: contextText, highlights };
|
|
1556
|
+
}
|
|
1557
|
+
function extractSmartContext(text, matchIndex, queryTerms, contextLength = 80) {
|
|
1558
|
+
const lines = text.split("\n");
|
|
1559
|
+
const matchLine = lines[matchIndex] || "";
|
|
1560
|
+
if (matchLine.length <= contextLength * 2) {
|
|
1561
|
+
return extractContext(text, matchIndex, queryTerms, contextLength);
|
|
1562
|
+
}
|
|
1563
|
+
const matchLineLower = matchLine.toLowerCase();
|
|
1564
|
+
let firstMatchPos = matchLine.length;
|
|
1565
|
+
for (const term of queryTerms) {
|
|
1566
|
+
const pos = matchLineLower.indexOf(term.toLowerCase());
|
|
1567
|
+
if (pos !== -1 && pos < firstMatchPos) {
|
|
1568
|
+
firstMatchPos = pos;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
let start = Math.max(0, firstMatchPos - contextLength);
|
|
1572
|
+
let end = Math.min(matchLine.length, firstMatchPos + contextLength);
|
|
1573
|
+
const beforeText = matchLine.substring(0, start);
|
|
1574
|
+
const lastSentence = beforeText.lastIndexOf(". ");
|
|
1575
|
+
if (lastSentence !== -1 && start - lastSentence < 20) {
|
|
1576
|
+
start = lastSentence + 2;
|
|
1577
|
+
}
|
|
1578
|
+
const afterText = matchLine.substring(end);
|
|
1579
|
+
const nextSentence = afterText.indexOf(". ");
|
|
1580
|
+
if (nextSentence !== -1 && nextSentence < 20) {
|
|
1581
|
+
end = end + nextSentence + 1;
|
|
1582
|
+
}
|
|
1583
|
+
let contextText = matchLine.substring(start, end);
|
|
1584
|
+
if (start > 0) contextText = "..." + contextText;
|
|
1585
|
+
if (end < matchLine.length) contextText = contextText + "...";
|
|
1586
|
+
const highlights = findMatchPositions(contextText, queryTerms);
|
|
1587
|
+
return { text: contextText, highlights };
|
|
1588
|
+
}
|
|
1589
|
+
function deduplicateMatches(matches, minDistance = 3) {
|
|
1590
|
+
if (matches.length === 0) return matches;
|
|
1591
|
+
const sorted = [...matches].sort((a, b) => {
|
|
1592
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
1593
|
+
return (a.lineNumber || 0) - (b.lineNumber || 0);
|
|
1594
|
+
});
|
|
1595
|
+
const deduplicated = [];
|
|
1596
|
+
const usedLines = /* @__PURE__ */ new Set();
|
|
1597
|
+
for (const match of sorted) {
|
|
1598
|
+
if (match.field !== "content") {
|
|
1599
|
+
deduplicated.push(match);
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
const lineNum = match.lineNumber || 0;
|
|
1603
|
+
let tooClose = false;
|
|
1604
|
+
for (let i = lineNum - minDistance; i <= lineNum + minDistance; i++) {
|
|
1605
|
+
if (usedLines.has(i)) {
|
|
1606
|
+
tooClose = true;
|
|
1607
|
+
break;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
if (!tooClose) {
|
|
1611
|
+
deduplicated.push(match);
|
|
1612
|
+
usedLines.add(lineNum);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return deduplicated.sort((a, b) => {
|
|
1616
|
+
const fieldOrder = { title: 0, name: 1, tags: 2, description: 3, content: 4 };
|
|
1617
|
+
const orderA = fieldOrder[a.field];
|
|
1618
|
+
const orderB = fieldOrder[b.field];
|
|
1619
|
+
if (orderA !== orderB) return orderA - orderB;
|
|
1620
|
+
return b.score - a.score;
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
function limitMatches(matches, maxMatches = 5) {
|
|
1624
|
+
if (matches.length <= maxMatches) return matches;
|
|
1625
|
+
const fieldMatches = {
|
|
1626
|
+
title: [],
|
|
1627
|
+
name: [],
|
|
1628
|
+
tags: [],
|
|
1629
|
+
description: [],
|
|
1630
|
+
content: []
|
|
1631
|
+
};
|
|
1632
|
+
for (const match of matches) {
|
|
1633
|
+
fieldMatches[match.field].push(match);
|
|
1634
|
+
}
|
|
1635
|
+
const nonContent = [
|
|
1636
|
+
...fieldMatches.title,
|
|
1637
|
+
...fieldMatches.name,
|
|
1638
|
+
...fieldMatches.tags,
|
|
1639
|
+
...fieldMatches.description
|
|
1640
|
+
];
|
|
1641
|
+
const contentMatches = fieldMatches.content.sort((a, b) => b.score - a.score).slice(0, Math.max(0, maxMatches - nonContent.length));
|
|
1642
|
+
return [...nonContent, ...contentMatches];
|
|
1643
|
+
}
|
|
1644
|
+
var SUPPORTED_FIELDS = [
|
|
1645
|
+
"status",
|
|
1646
|
+
"tag",
|
|
1647
|
+
"tags",
|
|
1648
|
+
"priority",
|
|
1649
|
+
"assignee",
|
|
1650
|
+
"title",
|
|
1651
|
+
"name",
|
|
1652
|
+
"created",
|
|
1653
|
+
"updated"
|
|
1654
|
+
];
|
|
1655
|
+
function tokenize(query) {
|
|
1656
|
+
const tokens = [];
|
|
1657
|
+
let position = 0;
|
|
1658
|
+
while (position < query.length) {
|
|
1659
|
+
if (/\s/.test(query[position])) {
|
|
1660
|
+
position++;
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
if (query[position] === '"') {
|
|
1664
|
+
const start2 = position;
|
|
1665
|
+
position++;
|
|
1666
|
+
let phrase = "";
|
|
1667
|
+
while (position < query.length && query[position] !== '"') {
|
|
1668
|
+
phrase += query[position];
|
|
1669
|
+
position++;
|
|
1670
|
+
}
|
|
1671
|
+
position++;
|
|
1672
|
+
tokens.push({ type: "PHRASE", value: phrase, position: start2 });
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
if (query[position] === "(") {
|
|
1676
|
+
tokens.push({ type: "LPAREN", value: "(", position });
|
|
1677
|
+
position++;
|
|
1678
|
+
continue;
|
|
1679
|
+
}
|
|
1680
|
+
if (query[position] === ")") {
|
|
1681
|
+
tokens.push({ type: "RPAREN", value: ")", position });
|
|
1682
|
+
position++;
|
|
1683
|
+
continue;
|
|
1684
|
+
}
|
|
1685
|
+
const start = position;
|
|
1686
|
+
let word = "";
|
|
1687
|
+
while (position < query.length && !/[\s()"]/.test(query[position])) {
|
|
1688
|
+
word += query[position];
|
|
1689
|
+
position++;
|
|
1690
|
+
}
|
|
1691
|
+
if (word.length === 0) continue;
|
|
1692
|
+
const upperWord = word.toUpperCase();
|
|
1693
|
+
if (upperWord === "AND") {
|
|
1694
|
+
tokens.push({ type: "AND", value: word, position: start });
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
if (upperWord === "OR") {
|
|
1698
|
+
tokens.push({ type: "OR", value: word, position: start });
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
if (upperWord === "NOT") {
|
|
1702
|
+
tokens.push({ type: "NOT", value: word, position: start });
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
1705
|
+
const colonIndex = word.indexOf(":");
|
|
1706
|
+
if (colonIndex > 0) {
|
|
1707
|
+
const fieldName = word.substring(0, colonIndex).toLowerCase();
|
|
1708
|
+
if (SUPPORTED_FIELDS.includes(fieldName)) {
|
|
1709
|
+
tokens.push({ type: "FIELD", value: word, position: start });
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
if (word.endsWith("~")) {
|
|
1714
|
+
tokens.push({ type: "FUZZY", value: word.slice(0, -1), position: start });
|
|
1715
|
+
continue;
|
|
1716
|
+
}
|
|
1717
|
+
tokens.push({ type: "TERM", value: word, position: start });
|
|
1718
|
+
}
|
|
1719
|
+
tokens.push({ type: "EOF", value: "", position: query.length });
|
|
1720
|
+
return tokens;
|
|
1721
|
+
}
|
|
1722
|
+
var QueryParser = class {
|
|
1723
|
+
tokens;
|
|
1724
|
+
current = 0;
|
|
1725
|
+
errors = [];
|
|
1726
|
+
constructor(tokens) {
|
|
1727
|
+
this.tokens = tokens;
|
|
1728
|
+
}
|
|
1729
|
+
parse() {
|
|
1730
|
+
if (this.tokens.length <= 1) {
|
|
1731
|
+
return { ast: null, errors: [] };
|
|
1732
|
+
}
|
|
1733
|
+
try {
|
|
1734
|
+
const ast = this.parseExpression();
|
|
1735
|
+
return { ast, errors: this.errors };
|
|
1736
|
+
} catch {
|
|
1737
|
+
return { ast: null, errors: this.errors };
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
parseExpression() {
|
|
1741
|
+
return this.parseOr();
|
|
1742
|
+
}
|
|
1743
|
+
parseOr() {
|
|
1744
|
+
let left = this.parseAnd();
|
|
1745
|
+
if (!left) return null;
|
|
1746
|
+
while (this.check("OR")) {
|
|
1747
|
+
this.advance();
|
|
1748
|
+
const right = this.parseAnd();
|
|
1749
|
+
if (!right) {
|
|
1750
|
+
this.errors.push("Expected term after OR");
|
|
1751
|
+
break;
|
|
1752
|
+
}
|
|
1753
|
+
left = { type: "OR", left, right };
|
|
1754
|
+
}
|
|
1755
|
+
return left;
|
|
1756
|
+
}
|
|
1757
|
+
parseAnd() {
|
|
1758
|
+
let left = this.parseNot();
|
|
1759
|
+
if (!left) return null;
|
|
1760
|
+
while (this.check("AND") || this.isTermStart()) {
|
|
1761
|
+
if (this.check("AND")) {
|
|
1762
|
+
this.advance();
|
|
1763
|
+
}
|
|
1764
|
+
const right = this.parseNot();
|
|
1765
|
+
if (!right) break;
|
|
1766
|
+
left = { type: "AND", left, right };
|
|
1767
|
+
}
|
|
1768
|
+
return left;
|
|
1769
|
+
}
|
|
1770
|
+
parseNot() {
|
|
1771
|
+
if (this.check("NOT")) {
|
|
1772
|
+
this.advance();
|
|
1773
|
+
const operand = this.parsePrimary();
|
|
1774
|
+
if (!operand) {
|
|
1775
|
+
this.errors.push("Expected term after NOT");
|
|
1776
|
+
return null;
|
|
1777
|
+
}
|
|
1778
|
+
return { type: "NOT", left: operand };
|
|
1779
|
+
}
|
|
1780
|
+
return this.parsePrimary();
|
|
1781
|
+
}
|
|
1782
|
+
parsePrimary() {
|
|
1783
|
+
const token = this.peek();
|
|
1784
|
+
if (token.type === "LPAREN") {
|
|
1785
|
+
this.advance();
|
|
1786
|
+
const expr = this.parseExpression();
|
|
1787
|
+
if (!this.check("RPAREN")) {
|
|
1788
|
+
this.errors.push("Expected closing parenthesis");
|
|
1789
|
+
} else {
|
|
1790
|
+
this.advance();
|
|
1791
|
+
}
|
|
1792
|
+
return expr;
|
|
1793
|
+
}
|
|
1794
|
+
if (token.type === "TERM") {
|
|
1795
|
+
this.advance();
|
|
1796
|
+
return { type: "TERM", value: token.value };
|
|
1797
|
+
}
|
|
1798
|
+
if (token.type === "PHRASE") {
|
|
1799
|
+
this.advance();
|
|
1800
|
+
return { type: "PHRASE", value: token.value };
|
|
1801
|
+
}
|
|
1802
|
+
if (token.type === "FIELD") {
|
|
1803
|
+
this.advance();
|
|
1804
|
+
const colonIndex = token.value.indexOf(":");
|
|
1805
|
+
const field = token.value.substring(0, colonIndex).toLowerCase();
|
|
1806
|
+
const value = token.value.substring(colonIndex + 1);
|
|
1807
|
+
return { type: "FIELD", field, value };
|
|
1808
|
+
}
|
|
1809
|
+
if (token.type === "FUZZY") {
|
|
1810
|
+
this.advance();
|
|
1811
|
+
return { type: "FUZZY", value: token.value };
|
|
1812
|
+
}
|
|
1813
|
+
return null;
|
|
1814
|
+
}
|
|
1815
|
+
isTermStart() {
|
|
1816
|
+
const type = this.peek().type;
|
|
1817
|
+
return type === "TERM" || type === "PHRASE" || type === "FIELD" || type === "FUZZY" || type === "LPAREN" || type === "NOT";
|
|
1818
|
+
}
|
|
1819
|
+
peek() {
|
|
1820
|
+
return this.tokens[this.current] || { type: "EOF", value: "", position: 0 };
|
|
1821
|
+
}
|
|
1822
|
+
check(type) {
|
|
1823
|
+
return this.peek().type === type;
|
|
1824
|
+
}
|
|
1825
|
+
advance() {
|
|
1826
|
+
if (!this.isAtEnd()) {
|
|
1827
|
+
this.current++;
|
|
1828
|
+
}
|
|
1829
|
+
return this.tokens[this.current - 1];
|
|
1830
|
+
}
|
|
1831
|
+
isAtEnd() {
|
|
1832
|
+
return this.peek().type === "EOF";
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
1835
|
+
function extractFieldFilters(ast) {
|
|
1836
|
+
if (!ast) return [];
|
|
1837
|
+
const filters = [];
|
|
1838
|
+
function traverse(node) {
|
|
1839
|
+
if (node.type === "FIELD" && node.field && node.value !== void 0) {
|
|
1840
|
+
filters.push({
|
|
1841
|
+
field: node.field,
|
|
1842
|
+
value: node.value,
|
|
1843
|
+
exact: true
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
if (node.left) traverse(node.left);
|
|
1847
|
+
if (node.right) traverse(node.right);
|
|
1848
|
+
if (node.children) {
|
|
1849
|
+
for (const child of node.children) {
|
|
1850
|
+
traverse(child);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
traverse(ast);
|
|
1855
|
+
return filters;
|
|
1856
|
+
}
|
|
1857
|
+
function extractDateFilters(fieldFilters) {
|
|
1858
|
+
const dateFields = ["created", "updated"];
|
|
1859
|
+
const dateFilters = [];
|
|
1860
|
+
for (const filter of fieldFilters) {
|
|
1861
|
+
if (!dateFields.includes(filter.field)) continue;
|
|
1862
|
+
const value = filter.value;
|
|
1863
|
+
if (value.includes("..")) {
|
|
1864
|
+
const [start, end] = value.split("..");
|
|
1865
|
+
dateFilters.push({
|
|
1866
|
+
field: filter.field,
|
|
1867
|
+
operator: "range",
|
|
1868
|
+
value: start,
|
|
1869
|
+
endValue: end
|
|
1870
|
+
});
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
if (value.startsWith(">=")) {
|
|
1874
|
+
dateFilters.push({
|
|
1875
|
+
field: filter.field,
|
|
1876
|
+
operator: ">=",
|
|
1877
|
+
value: value.substring(2)
|
|
1878
|
+
});
|
|
1879
|
+
} else if (value.startsWith("<=")) {
|
|
1880
|
+
dateFilters.push({
|
|
1881
|
+
field: filter.field,
|
|
1882
|
+
operator: "<=",
|
|
1883
|
+
value: value.substring(2)
|
|
1884
|
+
});
|
|
1885
|
+
} else if (value.startsWith(">")) {
|
|
1886
|
+
dateFilters.push({
|
|
1887
|
+
field: filter.field,
|
|
1888
|
+
operator: ">",
|
|
1889
|
+
value: value.substring(1)
|
|
1890
|
+
});
|
|
1891
|
+
} else if (value.startsWith("<")) {
|
|
1892
|
+
dateFilters.push({
|
|
1893
|
+
field: filter.field,
|
|
1894
|
+
operator: "<",
|
|
1895
|
+
value: value.substring(1)
|
|
1896
|
+
});
|
|
1897
|
+
} else {
|
|
1898
|
+
dateFilters.push({
|
|
1899
|
+
field: filter.field,
|
|
1900
|
+
operator: "=",
|
|
1901
|
+
value
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
return dateFilters;
|
|
1906
|
+
}
|
|
1907
|
+
function extractTerms(ast) {
|
|
1908
|
+
if (!ast) return [];
|
|
1909
|
+
const terms = [];
|
|
1910
|
+
function traverse(node) {
|
|
1911
|
+
if (node.type === "TERM" && node.value) {
|
|
1912
|
+
terms.push(node.value.toLowerCase());
|
|
1913
|
+
}
|
|
1914
|
+
if (node.type === "PHRASE" && node.value) {
|
|
1915
|
+
terms.push(node.value.toLowerCase());
|
|
1916
|
+
}
|
|
1917
|
+
if (node.left) traverse(node.left);
|
|
1918
|
+
if (node.right) traverse(node.right);
|
|
1919
|
+
if (node.children) {
|
|
1920
|
+
for (const child of node.children) {
|
|
1921
|
+
traverse(child);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
traverse(ast);
|
|
1926
|
+
return terms;
|
|
1927
|
+
}
|
|
1928
|
+
function extractFuzzyTerms(ast) {
|
|
1929
|
+
if (!ast) return [];
|
|
1930
|
+
const fuzzyTerms = [];
|
|
1931
|
+
function traverse(node) {
|
|
1932
|
+
if (node.type === "FUZZY" && node.value) {
|
|
1933
|
+
fuzzyTerms.push(node.value.toLowerCase());
|
|
1934
|
+
}
|
|
1935
|
+
if (node.left) traverse(node.left);
|
|
1936
|
+
if (node.right) traverse(node.right);
|
|
1937
|
+
if (node.children) {
|
|
1938
|
+
for (const child of node.children) {
|
|
1939
|
+
traverse(child);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
traverse(ast);
|
|
1944
|
+
return fuzzyTerms;
|
|
1945
|
+
}
|
|
1946
|
+
function hasAdvancedSyntax(tokens) {
|
|
1947
|
+
for (const token of tokens) {
|
|
1948
|
+
if (token.type === "AND" || token.type === "OR" || token.type === "NOT" || token.type === "FIELD" || token.type === "FUZZY" || token.type === "PHRASE" || token.type === "LPAREN") {
|
|
1949
|
+
return true;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
return false;
|
|
1953
|
+
}
|
|
1954
|
+
function parseQuery(query) {
|
|
1955
|
+
const trimmed = query.trim();
|
|
1956
|
+
if (!trimmed) {
|
|
1957
|
+
return {
|
|
1958
|
+
ast: null,
|
|
1959
|
+
terms: [],
|
|
1960
|
+
fields: [],
|
|
1961
|
+
dateFilters: [],
|
|
1962
|
+
fuzzyTerms: [],
|
|
1963
|
+
hasAdvancedSyntax: false,
|
|
1964
|
+
originalQuery: query,
|
|
1965
|
+
errors: []
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
const tokens = tokenize(trimmed);
|
|
1969
|
+
const parser = new QueryParser(tokens);
|
|
1970
|
+
const { ast, errors } = parser.parse();
|
|
1971
|
+
const fields = extractFieldFilters(ast);
|
|
1972
|
+
const dateFilters = extractDateFilters(fields);
|
|
1973
|
+
const terms = extractTerms(ast);
|
|
1974
|
+
const fuzzyTerms = extractFuzzyTerms(ast);
|
|
1975
|
+
return {
|
|
1976
|
+
ast,
|
|
1977
|
+
terms,
|
|
1978
|
+
fields,
|
|
1979
|
+
dateFilters,
|
|
1980
|
+
fuzzyTerms,
|
|
1981
|
+
hasAdvancedSyntax: hasAdvancedSyntax(tokens),
|
|
1982
|
+
originalQuery: query,
|
|
1983
|
+
errors
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
function levenshteinDistance(a, b) {
|
|
1987
|
+
if (a.length === 0) return b.length;
|
|
1988
|
+
if (b.length === 0) return a.length;
|
|
1989
|
+
const matrix = [];
|
|
1990
|
+
for (let i = 0; i <= b.length; i++) {
|
|
1991
|
+
matrix[i] = [i];
|
|
1992
|
+
}
|
|
1993
|
+
for (let j = 0; j <= a.length; j++) {
|
|
1994
|
+
matrix[0][j] = j;
|
|
1995
|
+
}
|
|
1996
|
+
for (let i = 1; i <= b.length; i++) {
|
|
1997
|
+
for (let j = 1; j <= a.length; j++) {
|
|
1998
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
1999
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
2000
|
+
} else {
|
|
2001
|
+
matrix[i][j] = Math.min(
|
|
2002
|
+
matrix[i - 1][j - 1] + 1,
|
|
2003
|
+
// substitution
|
|
2004
|
+
matrix[i][j - 1] + 1,
|
|
2005
|
+
// insertion
|
|
2006
|
+
matrix[i - 1][j] + 1
|
|
2007
|
+
// deletion
|
|
2008
|
+
);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
return matrix[b.length][a.length];
|
|
2013
|
+
}
|
|
2014
|
+
function fuzzyMatch(term, text, maxDistance) {
|
|
2015
|
+
const termLower = term.toLowerCase();
|
|
2016
|
+
const textLower = text.toLowerCase();
|
|
2017
|
+
const autoDistance = termLower.length <= 4 ? 1 : 2;
|
|
2018
|
+
const threshold = autoDistance;
|
|
2019
|
+
const words = textLower.split(/\s+/);
|
|
2020
|
+
for (const word of words) {
|
|
2021
|
+
if (levenshteinDistance(termLower, word) <= threshold) {
|
|
2022
|
+
return true;
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
return false;
|
|
2026
|
+
}
|
|
2027
|
+
function getSearchSyntaxHelp() {
|
|
2028
|
+
return `
|
|
2029
|
+
Search Syntax:
|
|
2030
|
+
term Simple term search
|
|
2031
|
+
"exact phrase" Match exact phrase
|
|
2032
|
+
term1 AND term2 Both terms must match (AND is optional)
|
|
2033
|
+
term1 OR term2 Either term matches
|
|
2034
|
+
NOT term Exclude specs with term
|
|
2035
|
+
|
|
2036
|
+
Field Filters:
|
|
2037
|
+
status:in-progress Filter by status (planned, in-progress, complete, archived)
|
|
2038
|
+
tag:api Filter by tag
|
|
2039
|
+
priority:high Filter by priority (low, medium, high, critical)
|
|
2040
|
+
assignee:marvin Filter by assignee
|
|
2041
|
+
title:dashboard Search in title only
|
|
2042
|
+
name:oauth Search in spec name
|
|
2043
|
+
|
|
2044
|
+
Date Filters:
|
|
2045
|
+
created:>2025-11-01 Created after date
|
|
2046
|
+
created:<2025-11-15 Created before date
|
|
2047
|
+
created:2025-11-01..2025-11-15 Created in date range
|
|
2048
|
+
updated:>=2025-11-01 Updated on or after date
|
|
2049
|
+
|
|
2050
|
+
Fuzzy Matching:
|
|
2051
|
+
authetication~ Matches "authentication" (typo-tolerant)
|
|
2052
|
+
|
|
2053
|
+
Examples:
|
|
2054
|
+
api authentication Find specs with both terms
|
|
2055
|
+
tag:api status:planned API specs that are planned
|
|
2056
|
+
"user session" OR "token refresh" Either phrase
|
|
2057
|
+
dashboard NOT deprecated Dashboard specs, exclude deprecated
|
|
2058
|
+
authetication~ Find despite typo
|
|
2059
|
+
`.trim();
|
|
2060
|
+
}
|
|
2061
|
+
function specContainsAllTerms(spec, queryTerms) {
|
|
2062
|
+
if (queryTerms.length === 0) {
|
|
2063
|
+
return false;
|
|
2064
|
+
}
|
|
2065
|
+
const allText = [
|
|
2066
|
+
spec.title || "",
|
|
2067
|
+
spec.name || "",
|
|
2068
|
+
spec.tags?.join(" ") || "",
|
|
2069
|
+
spec.description || "",
|
|
2070
|
+
spec.content || ""
|
|
2071
|
+
].join(" ").toLowerCase();
|
|
2072
|
+
return queryTerms.every((term) => allText.includes(term));
|
|
2073
|
+
}
|
|
2074
|
+
function searchSpecs(query, specs, options = {}) {
|
|
2075
|
+
const startTime = Date.now();
|
|
2076
|
+
const queryTerms = query.trim().toLowerCase().split(/\s+/).filter((term) => term.length > 0);
|
|
2077
|
+
if (queryTerms.length === 0) {
|
|
2078
|
+
return {
|
|
2079
|
+
results: [],
|
|
2080
|
+
metadata: {
|
|
2081
|
+
totalResults: 0,
|
|
2082
|
+
searchTime: Date.now() - startTime,
|
|
2083
|
+
query,
|
|
2084
|
+
specsSearched: specs.length
|
|
2085
|
+
}
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
const maxMatchesPerSpec = options.maxMatchesPerSpec || 5;
|
|
2089
|
+
const contextLength = options.contextLength || 80;
|
|
2090
|
+
const results = [];
|
|
2091
|
+
for (const spec of specs) {
|
|
2092
|
+
if (!specContainsAllTerms(spec, queryTerms)) {
|
|
2093
|
+
continue;
|
|
2094
|
+
}
|
|
2095
|
+
const matches = searchSpec(spec, queryTerms, contextLength);
|
|
2096
|
+
if (matches.length > 0) {
|
|
2097
|
+
let processedMatches = deduplicateMatches(matches, 3);
|
|
2098
|
+
processedMatches = limitMatches(processedMatches, maxMatchesPerSpec);
|
|
2099
|
+
const score = calculateSpecScore(processedMatches);
|
|
2100
|
+
results.push({
|
|
2101
|
+
spec: specToSearchResult(spec),
|
|
2102
|
+
score,
|
|
2103
|
+
totalMatches: matches.length,
|
|
2104
|
+
matches: processedMatches
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
results.sort((a, b) => b.score - a.score);
|
|
2109
|
+
return {
|
|
2110
|
+
results,
|
|
2111
|
+
metadata: {
|
|
2112
|
+
totalResults: results.length,
|
|
2113
|
+
searchTime: Date.now() - startTime,
|
|
2114
|
+
query,
|
|
2115
|
+
specsSearched: specs.length
|
|
2116
|
+
}
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
function searchSpec(spec, queryTerms, contextLength) {
|
|
2120
|
+
const matches = [];
|
|
2121
|
+
if (spec.title && containsAnyTerm(spec.title, queryTerms)) {
|
|
2122
|
+
const occurrences = countOccurrences(spec.title, queryTerms);
|
|
2123
|
+
const highlights = findMatchPositions(spec.title, queryTerms);
|
|
2124
|
+
const score = calculateMatchScore(
|
|
2125
|
+
{ field: "title", text: spec.title },
|
|
2126
|
+
queryTerms,
|
|
2127
|
+
1,
|
|
2128
|
+
0
|
|
2129
|
+
);
|
|
2130
|
+
matches.push({
|
|
2131
|
+
field: "title",
|
|
2132
|
+
text: spec.title,
|
|
2133
|
+
score,
|
|
2134
|
+
highlights,
|
|
2135
|
+
occurrences
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
if (spec.name && containsAnyTerm(spec.name, queryTerms)) {
|
|
2139
|
+
const occurrences = countOccurrences(spec.name, queryTerms);
|
|
2140
|
+
const highlights = findMatchPositions(spec.name, queryTerms);
|
|
2141
|
+
const score = calculateMatchScore(
|
|
2142
|
+
{ field: "name", text: spec.name },
|
|
2143
|
+
queryTerms,
|
|
2144
|
+
1,
|
|
2145
|
+
0
|
|
2146
|
+
);
|
|
2147
|
+
matches.push({
|
|
2148
|
+
field: "name",
|
|
2149
|
+
text: spec.name,
|
|
2150
|
+
score,
|
|
2151
|
+
highlights,
|
|
2152
|
+
occurrences
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
2155
|
+
if (spec.tags && spec.tags.length > 0) {
|
|
2156
|
+
for (const tag of spec.tags) {
|
|
2157
|
+
if (containsAnyTerm(tag, queryTerms)) {
|
|
2158
|
+
const occurrences = countOccurrences(tag, queryTerms);
|
|
2159
|
+
const highlights = findMatchPositions(tag, queryTerms);
|
|
2160
|
+
const score = calculateMatchScore(
|
|
2161
|
+
{ field: "tags", text: tag },
|
|
2162
|
+
queryTerms,
|
|
2163
|
+
spec.tags.length,
|
|
2164
|
+
spec.tags.indexOf(tag)
|
|
2165
|
+
);
|
|
2166
|
+
matches.push({
|
|
2167
|
+
field: "tags",
|
|
2168
|
+
text: tag,
|
|
2169
|
+
score,
|
|
2170
|
+
highlights,
|
|
2171
|
+
occurrences
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
if (spec.description && containsAnyTerm(spec.description, queryTerms)) {
|
|
2177
|
+
const occurrences = countOccurrences(spec.description, queryTerms);
|
|
2178
|
+
const highlights = findMatchPositions(spec.description, queryTerms);
|
|
2179
|
+
const score = calculateMatchScore(
|
|
2180
|
+
{ field: "description", text: spec.description },
|
|
2181
|
+
queryTerms,
|
|
2182
|
+
1,
|
|
2183
|
+
0
|
|
2184
|
+
);
|
|
2185
|
+
matches.push({
|
|
2186
|
+
field: "description",
|
|
2187
|
+
text: spec.description,
|
|
2188
|
+
score,
|
|
2189
|
+
highlights,
|
|
2190
|
+
occurrences
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
if (spec.content) {
|
|
2194
|
+
const contentMatches = searchContent(
|
|
2195
|
+
spec.content,
|
|
2196
|
+
queryTerms,
|
|
2197
|
+
contextLength
|
|
2198
|
+
);
|
|
2199
|
+
matches.push(...contentMatches);
|
|
2200
|
+
}
|
|
2201
|
+
return matches;
|
|
2202
|
+
}
|
|
2203
|
+
function searchContent(content, queryTerms, contextLength) {
|
|
2204
|
+
const matches = [];
|
|
2205
|
+
const lines = content.split("\n");
|
|
2206
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2207
|
+
const line = lines[i];
|
|
2208
|
+
if (containsAnyTerm(line, queryTerms)) {
|
|
2209
|
+
const occurrences = countOccurrences(line, queryTerms);
|
|
2210
|
+
const { text, highlights } = extractSmartContext(
|
|
2211
|
+
content,
|
|
2212
|
+
i,
|
|
2213
|
+
queryTerms,
|
|
2214
|
+
contextLength
|
|
2215
|
+
);
|
|
2216
|
+
const score = calculateMatchScore(
|
|
2217
|
+
{ field: "content", text: line },
|
|
2218
|
+
queryTerms,
|
|
2219
|
+
lines.length,
|
|
2220
|
+
i
|
|
2221
|
+
);
|
|
2222
|
+
matches.push({
|
|
2223
|
+
field: "content",
|
|
2224
|
+
text,
|
|
2225
|
+
lineNumber: i + 1,
|
|
2226
|
+
// 1-based line numbers
|
|
2227
|
+
score,
|
|
2228
|
+
highlights,
|
|
2229
|
+
occurrences
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
return matches;
|
|
2234
|
+
}
|
|
2235
|
+
function specToSearchResult(spec) {
|
|
2236
|
+
return {
|
|
2237
|
+
name: spec.name,
|
|
2238
|
+
path: spec.path,
|
|
2239
|
+
status: spec.status,
|
|
2240
|
+
priority: spec.priority,
|
|
2241
|
+
tags: spec.tags,
|
|
2242
|
+
title: spec.title,
|
|
2243
|
+
description: spec.description
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
function specMatchesFieldFilters(spec, filters) {
|
|
2247
|
+
for (const filter of filters) {
|
|
2248
|
+
const field = filter.field;
|
|
2249
|
+
const filterValue = filter.value.toLowerCase();
|
|
2250
|
+
switch (field) {
|
|
2251
|
+
case "status":
|
|
2252
|
+
if (spec.status.toLowerCase() !== filterValue) return false;
|
|
2253
|
+
break;
|
|
2254
|
+
case "tag":
|
|
2255
|
+
case "tags":
|
|
2256
|
+
if (!spec.tags?.some((tag) => tag.toLowerCase() === filterValue)) return false;
|
|
2257
|
+
break;
|
|
2258
|
+
case "priority":
|
|
2259
|
+
if (spec.priority?.toLowerCase() !== filterValue) return false;
|
|
2260
|
+
break;
|
|
2261
|
+
case "assignee":
|
|
2262
|
+
if (spec.assignee?.toLowerCase() !== filterValue) return false;
|
|
2263
|
+
break;
|
|
2264
|
+
case "title":
|
|
2265
|
+
if (!spec.title?.toLowerCase().includes(filterValue)) return false;
|
|
2266
|
+
break;
|
|
2267
|
+
case "name":
|
|
2268
|
+
if (!spec.name.toLowerCase().includes(filterValue)) return false;
|
|
2269
|
+
break;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
return true;
|
|
2273
|
+
}
|
|
2274
|
+
function specMatchesDateFilters(spec, filters) {
|
|
2275
|
+
for (const filter of filters) {
|
|
2276
|
+
const field = filter.field;
|
|
2277
|
+
const specDate = field === "created" ? spec.created : spec.updated;
|
|
2278
|
+
if (!specDate) return false;
|
|
2279
|
+
const specDateStr = specDate.substring(0, 10);
|
|
2280
|
+
const filterDateStr = filter.value.substring(0, 10);
|
|
2281
|
+
switch (filter.operator) {
|
|
2282
|
+
case ">":
|
|
2283
|
+
if (specDateStr <= filterDateStr) return false;
|
|
2284
|
+
break;
|
|
2285
|
+
case ">=":
|
|
2286
|
+
if (specDateStr < filterDateStr) return false;
|
|
2287
|
+
break;
|
|
2288
|
+
case "<":
|
|
2289
|
+
if (specDateStr >= filterDateStr) return false;
|
|
2290
|
+
break;
|
|
2291
|
+
case "<=":
|
|
2292
|
+
if (specDateStr > filterDateStr) return false;
|
|
2293
|
+
break;
|
|
2294
|
+
case "=":
|
|
2295
|
+
if (!specDateStr.startsWith(filterDateStr)) return false;
|
|
2296
|
+
break;
|
|
2297
|
+
case "range":
|
|
2298
|
+
if (filter.endValue) {
|
|
2299
|
+
const endDateStr = filter.endValue.substring(0, 10);
|
|
2300
|
+
if (specDateStr < filterDateStr || specDateStr > endDateStr) return false;
|
|
2301
|
+
}
|
|
2302
|
+
break;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
return true;
|
|
2306
|
+
}
|
|
2307
|
+
function evaluateAST(node, spec) {
|
|
2308
|
+
if (!node) return true;
|
|
2309
|
+
switch (node.type) {
|
|
2310
|
+
case "AND":
|
|
2311
|
+
return evaluateAST(node.left, spec) && evaluateAST(node.right, spec);
|
|
2312
|
+
case "OR":
|
|
2313
|
+
return evaluateAST(node.left, spec) || evaluateAST(node.right, spec);
|
|
2314
|
+
case "NOT":
|
|
2315
|
+
return !evaluateAST(node.left, spec);
|
|
2316
|
+
case "TERM":
|
|
2317
|
+
case "PHRASE":
|
|
2318
|
+
return specContainsAllTerms(spec, [node.value.toLowerCase()]);
|
|
2319
|
+
case "FUZZY":
|
|
2320
|
+
return specContainsFuzzyTerm(spec, node.value);
|
|
2321
|
+
case "FIELD":
|
|
2322
|
+
return specMatchesFieldFilters(spec, [
|
|
2323
|
+
{ field: node.field, value: node.value, exact: true }
|
|
2324
|
+
]);
|
|
2325
|
+
default:
|
|
2326
|
+
return true;
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
function specContainsFuzzyTerm(spec, term) {
|
|
2330
|
+
const allText = [
|
|
2331
|
+
spec.title || "",
|
|
2332
|
+
spec.name || "",
|
|
2333
|
+
spec.tags?.join(" ") || "",
|
|
2334
|
+
spec.description || "",
|
|
2335
|
+
spec.content || ""
|
|
2336
|
+
].join(" ");
|
|
2337
|
+
return fuzzyMatch(term, allText);
|
|
2338
|
+
}
|
|
2339
|
+
function advancedSearchSpecs(query, specs, options = {}) {
|
|
2340
|
+
const startTime = Date.now();
|
|
2341
|
+
const parsedQuery = parseQuery(query);
|
|
2342
|
+
if (!parsedQuery.hasAdvancedSyntax && parsedQuery.errors.length === 0) {
|
|
2343
|
+
return searchSpecs(query, specs, options);
|
|
2344
|
+
}
|
|
2345
|
+
const maxMatchesPerSpec = options.maxMatchesPerSpec || 5;
|
|
2346
|
+
const contextLength = options.contextLength || 80;
|
|
2347
|
+
const queryTerms = [
|
|
2348
|
+
...parsedQuery.terms,
|
|
2349
|
+
...parsedQuery.fuzzyTerms
|
|
2350
|
+
];
|
|
2351
|
+
const nonDateFieldFilters = parsedQuery.fields.filter(
|
|
2352
|
+
(f) => f.field !== "created" && f.field !== "updated"
|
|
2353
|
+
);
|
|
2354
|
+
const results = [];
|
|
2355
|
+
for (const spec of specs) {
|
|
2356
|
+
if (!specMatchesFieldFilters(spec, nonDateFieldFilters)) {
|
|
2357
|
+
continue;
|
|
2358
|
+
}
|
|
2359
|
+
if (!specMatchesDateFilters(spec, parsedQuery.dateFilters)) {
|
|
2360
|
+
continue;
|
|
2361
|
+
}
|
|
2362
|
+
if (parsedQuery.ast && !evaluateAST(parsedQuery.ast, spec)) {
|
|
2363
|
+
continue;
|
|
2364
|
+
}
|
|
2365
|
+
const matches = queryTerms.length > 0 ? searchSpec(spec, queryTerms, contextLength) : [];
|
|
2366
|
+
let processedMatches = deduplicateMatches(matches, 3);
|
|
2367
|
+
processedMatches = limitMatches(processedMatches, maxMatchesPerSpec);
|
|
2368
|
+
const score = matches.length > 0 ? calculateSpecScore(processedMatches) : 50;
|
|
2369
|
+
results.push({
|
|
2370
|
+
spec: specToSearchResult(spec),
|
|
2371
|
+
score,
|
|
2372
|
+
totalMatches: matches.length || 1,
|
|
2373
|
+
matches: processedMatches
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
results.sort((a, b) => b.score - a.score);
|
|
2377
|
+
return {
|
|
2378
|
+
results,
|
|
2379
|
+
metadata: {
|
|
2380
|
+
totalResults: results.length,
|
|
2381
|
+
searchTime: Date.now() - startTime,
|
|
2382
|
+
query,
|
|
2383
|
+
specsSearched: specs.length
|
|
2384
|
+
}
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// src/validators/sub-spec.ts
|
|
2389
|
+
var SubSpecValidator = class {
|
|
2390
|
+
name = "sub-specs";
|
|
2391
|
+
description = "Validate sub-spec files using direct token thresholds (spec 071)";
|
|
2392
|
+
excellentThreshold;
|
|
2393
|
+
goodThreshold;
|
|
2394
|
+
warningThreshold;
|
|
2395
|
+
maxLines;
|
|
2396
|
+
checkCrossRefs;
|
|
2397
|
+
constructor(options = {}) {
|
|
2398
|
+
this.excellentThreshold = options.excellentThreshold ?? 2e3;
|
|
2399
|
+
this.goodThreshold = options.goodThreshold ?? 3500;
|
|
2400
|
+
this.warningThreshold = options.warningThreshold ?? 5e3;
|
|
2401
|
+
this.maxLines = options.maxLines ?? 500;
|
|
2402
|
+
this.checkCrossRefs = options.checkCrossReferences ?? true;
|
|
2403
|
+
}
|
|
2404
|
+
async validate(spec, content) {
|
|
2405
|
+
const errors = [];
|
|
2406
|
+
const warnings = [];
|
|
2407
|
+
const subFiles = await loadSubFiles(spec.fullPath, { includeContent: true });
|
|
2408
|
+
const subSpecs = subFiles.filter((f) => f.type === "document");
|
|
2409
|
+
if (subSpecs.length === 0) {
|
|
2410
|
+
return { passed: true, errors, warnings };
|
|
2411
|
+
}
|
|
2412
|
+
this.validateNamingConventions(subSpecs, warnings);
|
|
2413
|
+
await this.validateComplexity(subSpecs, errors, warnings);
|
|
2414
|
+
this.checkOrphanedSubSpecs(subSpecs, content, warnings);
|
|
2415
|
+
if (this.checkCrossRefs) {
|
|
2416
|
+
this.validateCrossReferences(subSpecs, warnings);
|
|
2417
|
+
}
|
|
2418
|
+
return {
|
|
2419
|
+
passed: errors.length === 0,
|
|
2420
|
+
errors,
|
|
2421
|
+
warnings
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
/**
|
|
2425
|
+
* Validate sub-spec naming conventions
|
|
2426
|
+
* Convention: Uppercase filenames (e.g., DESIGN.md, TESTING.md, IMPLEMENTATION.md)
|
|
2427
|
+
*/
|
|
2428
|
+
validateNamingConventions(subSpecs, warnings) {
|
|
2429
|
+
for (const subSpec of subSpecs) {
|
|
2430
|
+
const baseName = path.basename(subSpec.name, ".md");
|
|
2431
|
+
if (baseName !== baseName.toUpperCase()) {
|
|
2432
|
+
warnings.push({
|
|
2433
|
+
message: `Sub-spec filename should be uppercase: ${subSpec.name}`,
|
|
2434
|
+
suggestion: `Consider renaming to ${baseName.toUpperCase()}.md`
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* Validate complexity for each sub-spec file using direct token thresholds
|
|
2441
|
+
* Same approach as ComplexityValidator (spec 071)
|
|
2442
|
+
*/
|
|
2443
|
+
async validateComplexity(subSpecs, errors, warnings) {
|
|
2444
|
+
for (const subSpec of subSpecs) {
|
|
2445
|
+
if (!subSpec.content) {
|
|
2446
|
+
continue;
|
|
2447
|
+
}
|
|
2448
|
+
const lines = subSpec.content.split("\n");
|
|
2449
|
+
const lineCount = lines.length;
|
|
2450
|
+
let sectionCount = 0;
|
|
2451
|
+
let inCodeBlock = false;
|
|
2452
|
+
for (const line of lines) {
|
|
2453
|
+
if (line.trim().startsWith("```")) {
|
|
2454
|
+
inCodeBlock = !inCodeBlock;
|
|
2455
|
+
continue;
|
|
2456
|
+
}
|
|
2457
|
+
if (!inCodeBlock && line.match(/^#{2,4}\s/)) {
|
|
2458
|
+
sectionCount++;
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
const tokenResult = await countTokens(subSpec.content);
|
|
2462
|
+
const tokenCount = tokenResult.total;
|
|
2463
|
+
if (tokenCount > this.warningThreshold) {
|
|
2464
|
+
errors.push({
|
|
2465
|
+
message: `Sub-spec ${subSpec.name} has ${tokenCount.toLocaleString()} tokens (threshold: ${this.warningThreshold.toLocaleString()}) - should split`,
|
|
2466
|
+
suggestion: "Consider splitting for Context Economy (attention and cognitive load)"
|
|
2467
|
+
});
|
|
2468
|
+
} else if (tokenCount > this.goodThreshold) {
|
|
2469
|
+
warnings.push({
|
|
2470
|
+
message: `Sub-spec ${subSpec.name} has ${tokenCount.toLocaleString()} tokens (threshold: ${this.goodThreshold.toLocaleString()})`,
|
|
2471
|
+
suggestion: "Consider simplification or further splitting"
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
if (sectionCount < 8 && lineCount > 200) {
|
|
2475
|
+
warnings.push({
|
|
2476
|
+
message: `Sub-spec ${subSpec.name} has only ${sectionCount} sections - too monolithic`,
|
|
2477
|
+
suggestion: "Break into 15-35 sections for better readability (7\xB12 cognitive chunks)"
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Check for orphaned sub-specs not referenced in README.md
|
|
2484
|
+
*/
|
|
2485
|
+
checkOrphanedSubSpecs(subSpecs, readmeContent, warnings) {
|
|
2486
|
+
for (const subSpec of subSpecs) {
|
|
2487
|
+
const fileName = subSpec.name;
|
|
2488
|
+
const escapedFileName = fileName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2489
|
+
const linkPattern = new RegExp(`\\[([^\\]]+)\\]\\((?:\\.\\/)?${escapedFileName}\\)`, "gi");
|
|
2490
|
+
const isReferenced = linkPattern.test(readmeContent);
|
|
2491
|
+
if (!isReferenced) {
|
|
2492
|
+
warnings.push({
|
|
2493
|
+
message: `Orphaned sub-spec: ${fileName} (not linked from README.md)`,
|
|
2494
|
+
suggestion: `Add a link to ${fileName} in README.md to document its purpose`
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Detect cross-document references that point to missing files
|
|
2501
|
+
*/
|
|
2502
|
+
validateCrossReferences(subSpecs, warnings) {
|
|
2503
|
+
const availableFiles = new Set(
|
|
2504
|
+
subSpecs.map((subSpec) => subSpec.name.toLowerCase())
|
|
2505
|
+
);
|
|
2506
|
+
availableFiles.add("readme.md");
|
|
2507
|
+
const linkPattern = /\[[^\]]+\]\(([^)]+)\)/gi;
|
|
2508
|
+
for (const subSpec of subSpecs) {
|
|
2509
|
+
if (!subSpec.content) continue;
|
|
2510
|
+
for (const match of subSpec.content.matchAll(linkPattern)) {
|
|
2511
|
+
const rawTarget = match[1].split("#")[0]?.trim();
|
|
2512
|
+
if (!rawTarget || !rawTarget.toLowerCase().endsWith(".md")) {
|
|
2513
|
+
continue;
|
|
2514
|
+
}
|
|
2515
|
+
const normalized = rawTarget.replace(/^\.\//, "");
|
|
2516
|
+
const normalizedLower = normalized.toLowerCase();
|
|
2517
|
+
if (availableFiles.has(normalizedLower)) {
|
|
2518
|
+
continue;
|
|
2519
|
+
}
|
|
2520
|
+
warnings.push({
|
|
2521
|
+
message: `Broken reference in ${subSpec.name}: ${normalized}`,
|
|
2522
|
+
suggestion: `Ensure ${normalized} exists or update the link`
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
};
|
|
2528
|
+
var DEPENDS_ON_PATTERNS = [
|
|
2529
|
+
/depends on[:\s]+.*?\b(\d{3})\b/gi,
|
|
2530
|
+
/blocked by[:\s]+.*?\b(\d{3})\b/gi,
|
|
2531
|
+
/requires[:\s]+.*?spec[:\s]*(\d{3})\b/gi,
|
|
2532
|
+
/prerequisite[:\s]+.*?\b(\d{3})\b/gi,
|
|
2533
|
+
/after[:\s]+.*?spec[:\s]*(\d{3})\b/gi,
|
|
2534
|
+
/builds on[:\s]+.*?\b(\d{3})\b/gi,
|
|
2535
|
+
/extends[:\s]+.*?\b(\d{3})\b/gi
|
|
2536
|
+
];
|
|
2537
|
+
var DependencyAlignmentValidator = class {
|
|
2538
|
+
name = "dependency-alignment";
|
|
2539
|
+
description = "Detect content references to specs not linked in frontmatter";
|
|
2540
|
+
strict;
|
|
2541
|
+
existingSpecNumbers;
|
|
2542
|
+
constructor(options = {}) {
|
|
2543
|
+
this.strict = options.strict ?? false;
|
|
2544
|
+
this.existingSpecNumbers = options.existingSpecNumbers ?? null;
|
|
2545
|
+
}
|
|
2546
|
+
/**
|
|
2547
|
+
* Set the existing spec numbers to validate against
|
|
2548
|
+
*/
|
|
2549
|
+
setExistingSpecNumbers(numbers) {
|
|
2550
|
+
this.existingSpecNumbers = numbers;
|
|
2551
|
+
}
|
|
2552
|
+
validate(spec, content) {
|
|
2553
|
+
const errors = [];
|
|
2554
|
+
const warnings = [];
|
|
2555
|
+
let parsed;
|
|
2556
|
+
try {
|
|
2557
|
+
parsed = matter3(content, {
|
|
2558
|
+
engines: {
|
|
2559
|
+
yaml: (str) => yaml2.load(str, { schema: yaml2.FAILSAFE_SCHEMA })
|
|
2560
|
+
}
|
|
2561
|
+
});
|
|
2562
|
+
} catch {
|
|
2563
|
+
return { passed: true, errors: [], warnings: [] };
|
|
2564
|
+
}
|
|
2565
|
+
const frontmatter = parsed.data;
|
|
2566
|
+
const bodyContent = parsed.content;
|
|
2567
|
+
const currentDependsOn = this.normalizeDeps(frontmatter.depends_on);
|
|
2568
|
+
const selfNumber = this.extractSpecNumber(spec.name);
|
|
2569
|
+
const detectedRefs = this.detectReferences(bodyContent, selfNumber);
|
|
2570
|
+
const missingDependsOn = [];
|
|
2571
|
+
for (const ref of detectedRefs) {
|
|
2572
|
+
if (currentDependsOn.includes(ref.specNumber)) continue;
|
|
2573
|
+
if (this.existingSpecNumbers && !this.existingSpecNumbers.has(ref.specNumber)) continue;
|
|
2574
|
+
missingDependsOn.push(ref);
|
|
2575
|
+
}
|
|
2576
|
+
if (missingDependsOn.length > 0) {
|
|
2577
|
+
const specNumbers = [...new Set(missingDependsOn.map((r) => r.specNumber))];
|
|
2578
|
+
const issue = {
|
|
2579
|
+
message: `Content references dependencies not in frontmatter: ${specNumbers.join(", ")}`,
|
|
2580
|
+
suggestion: `Run: lean-spec link ${spec.name} --depends-on ${specNumbers.join(",")}`
|
|
2581
|
+
};
|
|
2582
|
+
if (this.strict) {
|
|
2583
|
+
errors.push(issue);
|
|
2584
|
+
} else {
|
|
2585
|
+
warnings.push(issue);
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
return {
|
|
2589
|
+
passed: errors.length === 0,
|
|
2590
|
+
errors,
|
|
2591
|
+
warnings
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
/**
|
|
2595
|
+
* Normalize dependency field to array of spec numbers
|
|
2596
|
+
*/
|
|
2597
|
+
normalizeDeps(deps) {
|
|
2598
|
+
if (!deps) return [];
|
|
2599
|
+
const depArray = Array.isArray(deps) ? deps : [deps];
|
|
2600
|
+
return depArray.map((d) => {
|
|
2601
|
+
const str = String(d);
|
|
2602
|
+
const match = str.match(/(\d{3})/);
|
|
2603
|
+
return match ? match[1] : str;
|
|
2604
|
+
}).filter(Boolean);
|
|
2605
|
+
}
|
|
2606
|
+
/**
|
|
2607
|
+
* Extract spec number from spec name (e.g., "045-unified-dashboard" -> "045")
|
|
2608
|
+
*/
|
|
2609
|
+
extractSpecNumber(specName) {
|
|
2610
|
+
const match = specName.match(/^(\d{3})/);
|
|
2611
|
+
return match ? match[1] : null;
|
|
2612
|
+
}
|
|
2613
|
+
/**
|
|
2614
|
+
* Detect spec references in content that indicate dependencies
|
|
2615
|
+
*/
|
|
2616
|
+
detectReferences(content, selfNumber) {
|
|
2617
|
+
const refs = [];
|
|
2618
|
+
const seenNumbers = /* @__PURE__ */ new Set();
|
|
2619
|
+
for (const pattern of DEPENDS_ON_PATTERNS) {
|
|
2620
|
+
const matches = content.matchAll(new RegExp(pattern));
|
|
2621
|
+
for (const match of matches) {
|
|
2622
|
+
const specNumber = match[1];
|
|
2623
|
+
if (specNumber && specNumber !== selfNumber && !seenNumbers.has(specNumber)) {
|
|
2624
|
+
seenNumbers.add(specNumber);
|
|
2625
|
+
refs.push({
|
|
2626
|
+
specNumber,
|
|
2627
|
+
type: "depends_on",
|
|
2628
|
+
context: match[0].substring(0, 50)
|
|
2629
|
+
});
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
return refs;
|
|
2634
|
+
}
|
|
2635
|
+
};
|
|
2636
|
+
var STATUS_CONFIG = {
|
|
2637
|
+
planned: {
|
|
2638
|
+
emoji: "\u{1F4C5}",
|
|
2639
|
+
label: "Planned",
|
|
2640
|
+
colorFn: chalk3.blue,
|
|
2641
|
+
badge: (s = "planned") => chalk3.blue(`[${s}]`)
|
|
2642
|
+
},
|
|
2643
|
+
"in-progress": {
|
|
2644
|
+
emoji: "\u23F3",
|
|
2645
|
+
label: "In Progress",
|
|
2646
|
+
colorFn: chalk3.yellow,
|
|
2647
|
+
badge: (s = "in-progress") => chalk3.yellow(`[${s}]`)
|
|
2648
|
+
},
|
|
2649
|
+
complete: {
|
|
2650
|
+
emoji: "\u2705",
|
|
2651
|
+
label: "Complete",
|
|
2652
|
+
colorFn: chalk3.green,
|
|
2653
|
+
badge: (s = "complete") => chalk3.green(`[${s}]`)
|
|
2654
|
+
},
|
|
2655
|
+
archived: {
|
|
2656
|
+
emoji: "\u{1F4E6}",
|
|
2657
|
+
label: "Archived",
|
|
2658
|
+
colorFn: chalk3.gray,
|
|
2659
|
+
badge: (s = "archived") => chalk3.gray(`[${s}]`)
|
|
2660
|
+
}
|
|
2661
|
+
};
|
|
2662
|
+
var PRIORITY_CONFIG = {
|
|
2663
|
+
critical: {
|
|
2664
|
+
emoji: "\u{1F534}",
|
|
2665
|
+
colorFn: chalk3.red.bold,
|
|
2666
|
+
badge: (s = "critical") => chalk3.red.bold(`[${s}]`)
|
|
2667
|
+
},
|
|
2668
|
+
high: {
|
|
2669
|
+
emoji: "\u{1F7E0}",
|
|
2670
|
+
colorFn: chalk3.hex("#FFA500"),
|
|
2671
|
+
badge: (s = "high") => chalk3.hex("#FFA500")(`[${s}]`)
|
|
2672
|
+
},
|
|
2673
|
+
medium: {
|
|
2674
|
+
emoji: "\u{1F7E1}",
|
|
2675
|
+
colorFn: chalk3.yellow,
|
|
2676
|
+
badge: (s = "medium") => chalk3.yellow(`[${s}]`)
|
|
2677
|
+
},
|
|
2678
|
+
low: {
|
|
2679
|
+
emoji: "\u{1F7E2}",
|
|
2680
|
+
colorFn: chalk3.gray,
|
|
2681
|
+
badge: (s = "low") => chalk3.gray(`[${s}]`)
|
|
2682
|
+
}
|
|
2683
|
+
};
|
|
2684
|
+
function formatStatusBadge(status) {
|
|
2685
|
+
return STATUS_CONFIG[status]?.badge() || chalk3.white(`[${status}]`);
|
|
2686
|
+
}
|
|
2687
|
+
function formatPriorityBadge(priority) {
|
|
2688
|
+
return PRIORITY_CONFIG[priority]?.badge() || chalk3.white(`[${priority}]`);
|
|
2689
|
+
}
|
|
2690
|
+
function getStatusIndicator(status) {
|
|
2691
|
+
const config = STATUS_CONFIG[status];
|
|
2692
|
+
if (!config) return chalk3.gray("[unknown]");
|
|
2693
|
+
return config.colorFn(`[${status}]`);
|
|
2694
|
+
}
|
|
2695
|
+
function getStatusEmoji(status) {
|
|
2696
|
+
return STATUS_CONFIG[status]?.emoji || "\u{1F4C4}";
|
|
2697
|
+
}
|
|
2698
|
+
function getPriorityEmoji(priority) {
|
|
2699
|
+
return priority ? PRIORITY_CONFIG[priority]?.emoji || "" : "";
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
// src/utils/validate-formatter.ts
|
|
2703
|
+
function groupIssuesByFile(results) {
|
|
2704
|
+
const fileMap = /* @__PURE__ */ new Map();
|
|
2705
|
+
const addIssue = (filePath, issue, spec) => {
|
|
2706
|
+
if (!fileMap.has(filePath)) {
|
|
2707
|
+
fileMap.set(filePath, { issues: [], spec });
|
|
2708
|
+
}
|
|
2709
|
+
fileMap.get(filePath).issues.push(issue);
|
|
2710
|
+
};
|
|
2711
|
+
for (const { spec, validatorName, result } of results) {
|
|
2712
|
+
for (const error of result.errors) {
|
|
2713
|
+
addIssue(spec.filePath, {
|
|
2714
|
+
severity: "error",
|
|
2715
|
+
message: error.message,
|
|
2716
|
+
suggestion: error.suggestion,
|
|
2717
|
+
ruleName: validatorName,
|
|
2718
|
+
filePath: spec.filePath,
|
|
2719
|
+
spec
|
|
2720
|
+
}, spec);
|
|
2721
|
+
}
|
|
2722
|
+
for (const warning of result.warnings) {
|
|
2723
|
+
addIssue(spec.filePath, {
|
|
2724
|
+
severity: "warning",
|
|
2725
|
+
message: warning.message,
|
|
2726
|
+
suggestion: warning.suggestion,
|
|
2727
|
+
ruleName: validatorName,
|
|
2728
|
+
filePath: spec.filePath,
|
|
2729
|
+
spec
|
|
2730
|
+
}, spec);
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
const fileResults = [];
|
|
2734
|
+
for (const [filePath, data] of fileMap.entries()) {
|
|
2735
|
+
data.issues.sort((a, b) => {
|
|
2736
|
+
if (a.severity === b.severity) return 0;
|
|
2737
|
+
return a.severity === "error" ? -1 : 1;
|
|
2738
|
+
});
|
|
2739
|
+
fileResults.push({ filePath, issues: data.issues, spec: data.spec });
|
|
2740
|
+
}
|
|
2741
|
+
fileResults.sort((a, b) => {
|
|
2742
|
+
if (a.spec?.name && b.spec?.name) {
|
|
2743
|
+
return a.spec.name.localeCompare(b.spec.name);
|
|
2744
|
+
}
|
|
2745
|
+
return a.filePath.localeCompare(b.filePath);
|
|
2746
|
+
});
|
|
2747
|
+
return fileResults;
|
|
2748
|
+
}
|
|
2749
|
+
function normalizeFilePath(filePath) {
|
|
2750
|
+
const cwd = process.cwd();
|
|
2751
|
+
if (filePath.startsWith(cwd)) {
|
|
2752
|
+
return filePath.substring(cwd.length + 1);
|
|
2753
|
+
} else if (filePath.includes("/specs/")) {
|
|
2754
|
+
const specsIndex = filePath.indexOf("/specs/");
|
|
2755
|
+
return filePath.substring(specsIndex + 1);
|
|
2756
|
+
}
|
|
2757
|
+
return filePath;
|
|
2758
|
+
}
|
|
2759
|
+
function formatFileIssues(fileResult, specsDir) {
|
|
2760
|
+
const lines = [];
|
|
2761
|
+
const relativePath = normalizeFilePath(fileResult.filePath);
|
|
2762
|
+
const isMainSpec = relativePath.endsWith("README.md");
|
|
2763
|
+
if (isMainSpec && fileResult.spec) {
|
|
2764
|
+
const specName = fileResult.spec.name;
|
|
2765
|
+
const status = fileResult.spec.frontmatter.status;
|
|
2766
|
+
const priority = fileResult.spec.frontmatter.priority || "medium";
|
|
2767
|
+
const statusBadge = formatStatusBadge(status);
|
|
2768
|
+
const priorityBadge = formatPriorityBadge(priority);
|
|
2769
|
+
lines.push(chalk3.bold.cyan(`${specName} ${statusBadge} ${priorityBadge}`));
|
|
2770
|
+
} else {
|
|
2771
|
+
lines.push(chalk3.cyan.underline(relativePath));
|
|
2772
|
+
}
|
|
2773
|
+
for (const issue of fileResult.issues) {
|
|
2774
|
+
const severityColor = issue.severity === "error" ? chalk3.red : chalk3.yellow;
|
|
2775
|
+
const severityText = severityColor(issue.severity.padEnd(9));
|
|
2776
|
+
const ruleText = chalk3.gray(issue.ruleName);
|
|
2777
|
+
lines.push(` ${severityText}${issue.message.padEnd(60)} ${ruleText}`);
|
|
2778
|
+
if (issue.suggestion) {
|
|
2779
|
+
lines.push(chalk3.gray(` \u2192 ${issue.suggestion}`));
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
lines.push("");
|
|
2783
|
+
return lines.join("\n");
|
|
2784
|
+
}
|
|
2785
|
+
function formatSummary(totalSpecs, errorCount, warningCount, cleanCount) {
|
|
2786
|
+
if (errorCount > 0) {
|
|
2787
|
+
const errorText = errorCount === 1 ? "error" : "errors";
|
|
2788
|
+
const warningText = warningCount === 1 ? "warning" : "warnings";
|
|
2789
|
+
return chalk3.red.bold(
|
|
2790
|
+
`\u2716 ${errorCount} ${errorText}, ${warningCount} ${warningText} (${totalSpecs} specs checked, ${cleanCount} clean)`
|
|
2791
|
+
);
|
|
2792
|
+
} else if (warningCount > 0) {
|
|
2793
|
+
const warningText = warningCount === 1 ? "warning" : "warnings";
|
|
2794
|
+
return chalk3.yellow.bold(
|
|
2795
|
+
`\u26A0 ${warningCount} ${warningText} (${totalSpecs} specs checked, ${cleanCount} clean)`
|
|
2796
|
+
);
|
|
2797
|
+
} else {
|
|
2798
|
+
return chalk3.green.bold(`\u2713 All ${totalSpecs} specs passed`);
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
function formatPassingSpecs(specs, specsDir) {
|
|
2802
|
+
const lines = [];
|
|
2803
|
+
lines.push(chalk3.green.bold(`
|
|
2804
|
+
\u2713 ${specs.length} specs passed:`));
|
|
2805
|
+
for (const spec of specs) {
|
|
2806
|
+
const relativePath = normalizeFilePath(spec.filePath);
|
|
2807
|
+
lines.push(chalk3.gray(` ${relativePath}`));
|
|
2808
|
+
}
|
|
2809
|
+
return lines.join("\n");
|
|
2810
|
+
}
|
|
2811
|
+
function formatJson(fileResults, totalSpecs, errorCount, warningCount) {
|
|
2812
|
+
const output = {
|
|
2813
|
+
summary: {
|
|
2814
|
+
totalSpecs,
|
|
2815
|
+
errorCount,
|
|
2816
|
+
warningCount,
|
|
2817
|
+
cleanCount: totalSpecs - fileResults.length
|
|
2818
|
+
},
|
|
2819
|
+
files: fileResults.map((fr) => ({
|
|
2820
|
+
filePath: fr.filePath,
|
|
2821
|
+
issues: fr.issues.map((issue) => ({
|
|
2822
|
+
severity: issue.severity,
|
|
2823
|
+
message: issue.message,
|
|
2824
|
+
suggestion: issue.suggestion,
|
|
2825
|
+
rule: issue.ruleName
|
|
2826
|
+
}))
|
|
2827
|
+
}))
|
|
2828
|
+
};
|
|
2829
|
+
return JSON.stringify(output, null, 2);
|
|
2830
|
+
}
|
|
2831
|
+
function formatValidationResults(results, specs, specsDir, options = {}) {
|
|
2832
|
+
const fileResults = groupIssuesByFile(results);
|
|
2833
|
+
const filteredResults = options.rule ? fileResults.map((fr) => ({
|
|
2834
|
+
...fr,
|
|
2835
|
+
issues: fr.issues.filter((issue) => issue.ruleName === options.rule)
|
|
2836
|
+
})).filter((fr) => fr.issues.length > 0) : fileResults;
|
|
2837
|
+
const displayResults = options.quiet ? filteredResults.map((fr) => ({
|
|
2838
|
+
...fr,
|
|
2839
|
+
issues: fr.issues.filter((issue) => issue.severity === "error")
|
|
2840
|
+
})).filter((fr) => fr.issues.length > 0) : filteredResults;
|
|
2841
|
+
if (options.format === "json") {
|
|
2842
|
+
const errorCount2 = displayResults.reduce(
|
|
2843
|
+
(sum, fr) => sum + fr.issues.filter((i) => i.severity === "error").length,
|
|
2844
|
+
0
|
|
2845
|
+
);
|
|
2846
|
+
const warningCount2 = displayResults.reduce(
|
|
2847
|
+
(sum, fr) => sum + fr.issues.filter((i) => i.severity === "warning").length,
|
|
2848
|
+
0
|
|
2849
|
+
);
|
|
2850
|
+
return formatJson(displayResults, specs.length, errorCount2, warningCount2);
|
|
2851
|
+
}
|
|
2852
|
+
const lines = [];
|
|
2853
|
+
lines.push(chalk3.bold(`
|
|
2854
|
+
Validating ${specs.length} specs...
|
|
2855
|
+
`));
|
|
2856
|
+
let previousSpecName;
|
|
2857
|
+
for (const fileResult of displayResults) {
|
|
2858
|
+
if (fileResult.spec && previousSpecName && fileResult.spec.name !== previousSpecName) {
|
|
2859
|
+
lines.push(chalk3.gray("\u2500".repeat(80)));
|
|
2860
|
+
lines.push("");
|
|
2861
|
+
}
|
|
2862
|
+
lines.push(formatFileIssues(fileResult));
|
|
2863
|
+
if (fileResult.spec) {
|
|
2864
|
+
previousSpecName = fileResult.spec.name;
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
const errorCount = displayResults.reduce(
|
|
2868
|
+
(sum, fr) => sum + fr.issues.filter((i) => i.severity === "error").length,
|
|
2869
|
+
0
|
|
2870
|
+
);
|
|
2871
|
+
const warningCount = displayResults.reduce(
|
|
2872
|
+
(sum, fr) => sum + fr.issues.filter((i) => i.severity === "warning").length,
|
|
2873
|
+
0
|
|
2874
|
+
);
|
|
2875
|
+
const cleanCount = specs.length - fileResults.length;
|
|
2876
|
+
lines.push(formatSummary(specs.length, errorCount, warningCount, cleanCount));
|
|
2877
|
+
if (options.verbose && cleanCount > 0) {
|
|
2878
|
+
const specsWithIssues = new Set(fileResults.map((fr) => fr.filePath));
|
|
2879
|
+
const passingSpecs = specs.filter((spec) => !specsWithIssues.has(spec.filePath));
|
|
2880
|
+
lines.push(formatPassingSpecs(passingSpecs));
|
|
2881
|
+
}
|
|
2882
|
+
if (!options.verbose && cleanCount > 0 && displayResults.length > 0) {
|
|
2883
|
+
lines.push(chalk3.gray("\nRun with --verbose to see passing specs."));
|
|
2884
|
+
}
|
|
2885
|
+
return lines.join("\n");
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
// src/commands/validate.ts
|
|
2889
|
+
function validateCommand() {
|
|
2890
|
+
return new Command("validate").description("Validate specs for quality issues").argument("[specs...]", "Specific specs to validate (optional)").option("--max-lines <number>", "Custom line limit (default: 400)", parseInt).option("--verbose", "Show passing specs").option("--quiet", "Suppress warnings, only show errors").option("--format <format>", "Output format: default, json, compact", "default").option("--json", "Output as JSON (shorthand for --format json)").option("--rule <rule>", "Filter by specific rule name (e.g., max-lines, frontmatter)").option("--warnings-only", "Treat all issues as warnings, never fail (useful for CI pre-release checks)").option("--check-deps", "Check for content/frontmatter dependency alignment").action(async (specs, options) => {
|
|
2891
|
+
const passed = await validateSpecs({
|
|
2892
|
+
maxLines: options.maxLines,
|
|
2893
|
+
specs: specs && specs.length > 0 ? specs : void 0,
|
|
2894
|
+
verbose: options.verbose,
|
|
2895
|
+
quiet: options.quiet,
|
|
2896
|
+
format: options.json ? "json" : options.format,
|
|
2897
|
+
rule: options.rule,
|
|
2898
|
+
warningsOnly: options.warningsOnly,
|
|
2899
|
+
checkDeps: options.checkDeps
|
|
2900
|
+
});
|
|
2901
|
+
process.exit(passed ? 0 : 1);
|
|
2902
|
+
});
|
|
2903
|
+
}
|
|
2904
|
+
async function validateSpecs(options = {}) {
|
|
2905
|
+
const config = await loadConfig();
|
|
2906
|
+
let specs;
|
|
2907
|
+
if (options.specs && options.specs.length > 0) {
|
|
2908
|
+
const allSpecs = await loadAllSpecs();
|
|
2909
|
+
specs = [];
|
|
2910
|
+
for (const specPath of options.specs) {
|
|
2911
|
+
const spec = allSpecs.find(
|
|
2912
|
+
(s) => s.path.includes(specPath) || path.basename(s.path).includes(specPath)
|
|
2913
|
+
);
|
|
2914
|
+
if (spec) {
|
|
2915
|
+
specs.push(spec);
|
|
2916
|
+
} else {
|
|
2917
|
+
console.error(chalk3.red(`Error: Spec not found: ${specPath}`));
|
|
2918
|
+
return false;
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
} else {
|
|
2922
|
+
specs = await withSpinner(
|
|
2923
|
+
"Loading specs...",
|
|
2924
|
+
() => loadAllSpecs({ includeArchived: false })
|
|
2925
|
+
);
|
|
2926
|
+
}
|
|
2927
|
+
if (specs.length === 0) {
|
|
2928
|
+
console.log("No specs found to validate.");
|
|
2929
|
+
return true;
|
|
2930
|
+
}
|
|
2931
|
+
const validators = [
|
|
2932
|
+
new ComplexityValidator({ maxLines: options.maxLines }),
|
|
2933
|
+
// Token-based complexity (primary), line count (backstop)
|
|
2934
|
+
new FrontmatterValidator(),
|
|
2935
|
+
new StructureValidator(),
|
|
2936
|
+
new CorruptionValidator(),
|
|
2937
|
+
new SubSpecValidator({ maxLines: options.maxLines })
|
|
2938
|
+
];
|
|
2939
|
+
if (options.checkDeps) {
|
|
2940
|
+
const activeSpecs = await loadAllSpecs({ includeArchived: false });
|
|
2941
|
+
const existingSpecNumbers = /* @__PURE__ */ new Set();
|
|
2942
|
+
for (const s of activeSpecs) {
|
|
2943
|
+
const match = s.name.match(/^(\d{3})/);
|
|
2944
|
+
if (match) {
|
|
2945
|
+
existingSpecNumbers.add(match[1]);
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
validators.push(new DependencyAlignmentValidator({ existingSpecNumbers }));
|
|
2949
|
+
}
|
|
2950
|
+
const results = [];
|
|
2951
|
+
for (const spec of specs) {
|
|
2952
|
+
let content;
|
|
2953
|
+
try {
|
|
2954
|
+
content = await fs.readFile(spec.filePath, "utf-8");
|
|
2955
|
+
} catch (error) {
|
|
2956
|
+
console.error(chalk3.red(`Error reading ${spec.filePath}:`), error);
|
|
2957
|
+
continue;
|
|
2958
|
+
}
|
|
2959
|
+
for (const validator of validators) {
|
|
2960
|
+
try {
|
|
2961
|
+
const result = await validator.validate(spec, content);
|
|
2962
|
+
results.push({
|
|
2963
|
+
spec,
|
|
2964
|
+
validatorName: validator.name,
|
|
2965
|
+
result,
|
|
2966
|
+
content
|
|
2967
|
+
});
|
|
2968
|
+
} catch (error) {
|
|
2969
|
+
console.error(chalk3.yellow(`Warning: Validator ${validator.name} failed:`), error instanceof Error ? error.message : error);
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
const formatOptions = {
|
|
2974
|
+
verbose: options.verbose,
|
|
2975
|
+
quiet: options.quiet,
|
|
2976
|
+
format: options.format,
|
|
2977
|
+
rule: options.rule
|
|
2978
|
+
};
|
|
2979
|
+
const output = formatValidationResults(results, specs, config.specsDir, formatOptions);
|
|
2980
|
+
console.log(output);
|
|
2981
|
+
if (options.warningsOnly) {
|
|
2982
|
+
return true;
|
|
2983
|
+
}
|
|
2984
|
+
const hasErrors = results.some((r) => !r.result.passed);
|
|
2985
|
+
return !hasErrors;
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
export { PRIORITY_CONFIG, STATUS_CONFIG, SpecDependencyGraph, TokenCounter, advancedSearchSpecs, analyzeMarkdownStructure, countLines, countTokens, createUpdatedFrontmatter, extractLines, getPriorityEmoji, getSearchSyntaxHelp, getStatusEmoji, getStatusIndicator, parseFrontmatterFromString, removeLines, sanitizeUserInput, validateCommand, validateSpecs, withSpinner };
|
|
2989
|
+
//# sourceMappingURL=chunk-CJMVV46H.js.map
|
|
2990
|
+
//# sourceMappingURL=chunk-CJMVV46H.js.map
|