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.
@@ -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