@joshski/dust 0.1.97 → 0.1.98

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.
@@ -1,27 +1,177 @@
1
1
  // lib/validation/index.ts
2
2
  import { relative } from "node:path";
3
3
 
4
- // lib/markdown/markdown-utilities.ts
5
- function extractTitle(content) {
6
- const match = content.match(/^#\s+(.+)$/m);
7
- return match ? match[1].trim() : null;
4
+ // lib/validation/overlay-filesystem.ts
5
+ function createOverlayFileSystem(base, patchFiles, deletedPaths = new Set) {
6
+ const patchDirs = new Set;
7
+ for (const path of patchFiles.keys()) {
8
+ let dir = path;
9
+ while (dir.includes("/")) {
10
+ dir = dir.substring(0, dir.lastIndexOf("/"));
11
+ if (dir)
12
+ patchDirs.add(dir);
13
+ }
14
+ }
15
+ function isDeleted(path) {
16
+ return deletedPaths.has(path);
17
+ }
18
+ return {
19
+ exists(path) {
20
+ if (isDeleted(path))
21
+ return false;
22
+ return patchFiles.has(path) || patchDirs.has(path) || base.exists(path);
23
+ },
24
+ async readFile(path) {
25
+ if (isDeleted(path)) {
26
+ const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
27
+ error.code = "ENOENT";
28
+ throw error;
29
+ }
30
+ const patchContent = patchFiles.get(path);
31
+ if (patchContent !== undefined) {
32
+ return patchContent;
33
+ }
34
+ return base.readFile(path);
35
+ },
36
+ async readdir(path) {
37
+ const prefix = `${path}/`;
38
+ const entries = new Set;
39
+ for (const patchPath of patchFiles.keys()) {
40
+ if (patchPath.startsWith(prefix)) {
41
+ const relative = patchPath.slice(prefix.length);
42
+ const firstSegment = relative.split("/")[0];
43
+ entries.add(firstSegment);
44
+ }
45
+ }
46
+ try {
47
+ const baseEntries = await base.readdir(path);
48
+ for (const entry of baseEntries) {
49
+ const entryPath = `${path}/${entry}`;
50
+ if (!isDeleted(entryPath)) {
51
+ entries.add(entry);
52
+ }
53
+ }
54
+ } catch (error) {
55
+ if (error.code !== "ENOENT") {
56
+ throw error;
57
+ }
58
+ }
59
+ return Array.from(entries);
60
+ },
61
+ isDirectory(path) {
62
+ if (isDeleted(path))
63
+ return false;
64
+ return patchDirs.has(path) || base.isDirectory(path);
65
+ }
66
+ };
8
67
  }
9
- var MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/;
10
- function findH1Index(lines) {
68
+
69
+ // lib/artifacts/parsed-artifact.ts
70
+ var MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
71
+ function parseArtifact(filePath, content) {
72
+ const lines = content.split(`
73
+ `);
74
+ let title = null;
75
+ let titleLine = null;
76
+ let openingSentence = null;
77
+ let openingSentenceLine = null;
78
+ const sections = [];
79
+ const allLinks = [];
80
+ let currentSection = null;
81
+ let inCodeFence = false;
82
+ let openingSentenceResolved = false;
11
83
  for (let i = 0;i < lines.length; i++) {
12
- if (lines[i].match(/^#\s+.+$/)) {
13
- return i;
84
+ const line = lines[i];
85
+ const lineNumber = i + 1;
86
+ if (line.startsWith("```")) {
87
+ inCodeFence = !inCodeFence;
88
+ continue;
89
+ }
90
+ if (inCodeFence) {
91
+ continue;
92
+ }
93
+ const h1Match = line.match(/^#\s+(.+)$/);
94
+ if (h1Match) {
95
+ if (title === null) {
96
+ title = h1Match[1].trim();
97
+ titleLine = lineNumber;
98
+ } else {
99
+ if (currentSection !== null) {
100
+ currentSection.endLine = findLastNonEmptyLine(lines, currentSection.startLine, lineNumber - 2);
101
+ sections.push(currentSection);
102
+ currentSection = null;
103
+ }
104
+ }
105
+ continue;
106
+ }
107
+ const headingMatch = line.match(/^(#{2,6})\s+(.+)$/);
108
+ if (headingMatch) {
109
+ openingSentenceResolved = true;
110
+ if (currentSection !== null) {
111
+ currentSection.endLine = findLastNonEmptyLine(lines, currentSection.startLine, lineNumber - 2);
112
+ sections.push(currentSection);
113
+ }
114
+ currentSection = {
115
+ heading: headingMatch[2].trim(),
116
+ level: headingMatch[1].length,
117
+ startLine: lineNumber,
118
+ endLine: -1,
119
+ links: []
120
+ };
121
+ continue;
122
+ }
123
+ if (shouldCheckForOpeningSentence(title, openingSentenceResolved, line)) {
124
+ const result = tryExtractOpeningSentence(lines, i);
125
+ openingSentence = result.sentence;
126
+ openingSentenceLine = result.sentence !== null ? lineNumber : null;
127
+ openingSentenceResolved = true;
128
+ }
129
+ const linkMatches = line.matchAll(MARKDOWN_LINK_PATTERN);
130
+ for (const match of linkMatches) {
131
+ const link = {
132
+ text: match[1],
133
+ target: match[2],
134
+ line: lineNumber
135
+ };
136
+ allLinks.push(link);
137
+ if (currentSection !== null) {
138
+ currentSection.links.push(link);
139
+ }
14
140
  }
15
141
  }
16
- return -1;
142
+ if (currentSection !== null) {
143
+ currentSection.endLine = findLastNonEmptyLine(lines, currentSection.startLine, lines.length - 1);
144
+ sections.push(currentSection);
145
+ }
146
+ return {
147
+ filePath,
148
+ rawContent: content,
149
+ title,
150
+ titleLine,
151
+ openingSentence,
152
+ openingSentenceLine,
153
+ sections,
154
+ allLinks
155
+ };
156
+ }
157
+ function shouldCheckForOpeningSentence(title, resolved, line) {
158
+ if (title === null || resolved) {
159
+ return false;
160
+ }
161
+ const trimmed = line.trim();
162
+ return trimmed !== "" && !isStructuralElement(trimmed);
163
+ }
164
+ function tryExtractOpeningSentence(lines, startIndex) {
165
+ const paragraph = collectParagraph(lines, startIndex);
166
+ return { sentence: extractFirstSentence(paragraph) };
17
167
  }
18
- function findFirstNonBlankLineAfter(lines, startIndex) {
19
- for (let i = startIndex + 1;i < lines.length; i++) {
168
+ function findLastNonEmptyLine(lines, contentStartIndex, fromIndex) {
169
+ for (let i = fromIndex;i >= contentStartIndex; i--) {
20
170
  if (lines[i].trim() !== "") {
21
- return i;
171
+ return i + 1;
22
172
  }
23
173
  }
24
- return -1;
174
+ return contentStartIndex;
25
175
  }
26
176
  var LIST_ITEM_PREFIXES = ["-", "*", "+"];
27
177
  var STRUCTURAL_PREFIXES = ["#", "```", ">"];
@@ -52,27 +202,9 @@ function extractFirstSentence(paragraph) {
52
202
  const match = paragraph.match(/^(.+?[.?!])(?:\s|$)/);
53
203
  return match ? match[1] : null;
54
204
  }
55
- function extractOpeningSentence(content) {
56
- const lines = content.split(`
57
- `);
58
- const h1Index = findH1Index(lines);
59
- if (h1Index === -1) {
60
- return null;
61
- }
62
- const paragraphStart = findFirstNonBlankLineAfter(lines, h1Index);
63
- if (paragraphStart === -1) {
64
- return null;
65
- }
66
- const trimmedFirstLine = lines[paragraphStart].trim();
67
- if (isStructuralElement(trimmedFirstLine)) {
68
- return null;
69
- }
70
- const paragraph = collectParagraph(lines, paragraphStart);
71
- return extractFirstSentence(paragraph);
72
- }
73
205
 
74
206
  // lib/lint/validators/content-validator.ts
75
- var REQUIRED_HEADINGS = ["## Blocked By", "## Definition of Done"];
207
+ var REQUIRED_TASK_HEADINGS = ["Blocked By", "Definition of Done"];
76
208
  var MAX_OPENING_SENTENCE_LENGTH = 150;
77
209
  var NON_IMPERATIVE_STARTERS = new Set([
78
210
  "the",
@@ -88,31 +220,32 @@ var NON_IMPERATIVE_STARTERS = new Set([
88
220
  "you",
89
221
  "i"
90
222
  ]);
91
- function validateOpeningSentence(filePath, content) {
92
- const openingSentence = extractOpeningSentence(content);
93
- if (!openingSentence) {
223
+ function validateOpeningSentence(artifact) {
224
+ if (!artifact.openingSentence) {
94
225
  return {
95
- file: filePath,
226
+ file: artifact.filePath,
227
+ line: artifact.titleLine ?? undefined,
96
228
  message: "Missing or malformed opening sentence after H1 heading"
97
229
  };
98
230
  }
99
231
  return null;
100
232
  }
101
- function validateOpeningSentenceLength(filePath, content) {
102
- const openingSentence = extractOpeningSentence(content);
233
+ function validateOpeningSentenceLength(artifact) {
234
+ const openingSentence = artifact.openingSentence;
103
235
  if (!openingSentence) {
104
236
  return null;
105
237
  }
106
238
  if (openingSentence.length > MAX_OPENING_SENTENCE_LENGTH) {
107
239
  return {
108
- file: filePath,
240
+ file: artifact.filePath,
241
+ line: artifact.openingSentenceLine ?? undefined,
109
242
  message: `Opening sentence is ${openingSentence.length} characters (max ${MAX_OPENING_SENTENCE_LENGTH}). Split into multiple sentences; only the first sentence is checked.`
110
243
  };
111
244
  }
112
245
  return null;
113
246
  }
114
- function validateImperativeOpeningSentence(filePath, content) {
115
- const openingSentence = extractOpeningSentence(content);
247
+ function validateImperativeOpeningSentence(artifact) {
248
+ const openingSentence = artifact.openingSentence;
116
249
  if (!openingSentence) {
117
250
  return null;
118
251
  }
@@ -121,19 +254,21 @@ function validateImperativeOpeningSentence(filePath, content) {
121
254
  if (NON_IMPERATIVE_STARTERS.has(lower) || lower.endsWith("ing")) {
122
255
  const preview = openingSentence.length > 40 ? `${openingSentence.slice(0, 40)}...` : openingSentence;
123
256
  return {
124
- file: filePath,
257
+ file: artifact.filePath,
258
+ line: artifact.openingSentenceLine ?? undefined,
125
259
  message: `Opening sentence should use imperative form (e.g., "Add X" not "This adds X"). Found: "${preview}"`
126
260
  };
127
261
  }
128
262
  return null;
129
263
  }
130
- function validateTaskHeadings(filePath, content) {
264
+ function validateTaskHeadings(artifact) {
131
265
  const violations = [];
132
- for (const heading of REQUIRED_HEADINGS) {
133
- if (!content.includes(heading)) {
266
+ const sectionHeadings = new Set(artifact.sections.map((s) => s.heading));
267
+ for (const heading of REQUIRED_TASK_HEADINGS) {
268
+ if (!sectionHeadings.has(heading)) {
134
269
  violations.push({
135
- file: filePath,
136
- message: `Missing required heading: "${heading}"`
270
+ file: artifact.filePath,
271
+ message: `Missing required heading: "## ${heading}"`
137
272
  });
138
273
  }
139
274
  }
@@ -202,17 +337,17 @@ function validateFilename(filePath) {
202
337
  }
203
338
  return null;
204
339
  }
205
- function validateTitleFilenameMatch(filePath, content) {
206
- const title = extractTitle(content);
340
+ function validateTitleFilenameMatch(artifact) {
341
+ const title = artifact.title;
207
342
  if (!title) {
208
343
  return null;
209
344
  }
210
- const parts = filePath.split("/");
345
+ const parts = artifact.filePath.split("/");
211
346
  const actualFilename = parts[parts.length - 1];
212
347
  const expectedFilename = titleToFilename(title);
213
348
  if (actualFilename !== expectedFilename) {
214
349
  return {
215
- file: filePath,
350
+ file: artifact.filePath,
216
351
  message: `Filename "${actualFilename}" does not match title "${title}" (expected "${expectedFilename}")`
217
352
  };
218
353
  }
@@ -252,10 +387,11 @@ function validateH2Heading(filePath, line, lineNumber, inOpenQuestions, currentQ
252
387
  }
253
388
  return violations;
254
389
  }
255
- function validateIdeaOpenQuestions(filePath, content) {
390
+ function validateIdeaOpenQuestions(artifact) {
256
391
  const violations = [];
257
- const lines = content.split(`
392
+ const lines = artifact.rawContent.split(`
258
393
  `);
394
+ const filePath = artifact.filePath;
259
395
  const topLevelStructureMessage = "Open Questions must use `### Question?` headings and `#### Option` headings at the top level. Put supporting markdown (including lists and code blocks) under an option heading. Run `dust new idea` to see the expected format.";
260
396
  let inOpenQuestions = false;
261
397
  let currentQuestionLine = null;
@@ -331,8 +467,8 @@ function validateIdeaOpenQuestions(filePath, content) {
331
467
  }
332
468
  return violations;
333
469
  }
334
- function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
335
- const title = extractTitle(content);
470
+ function validateIdeaTransitionTitle(artifact, ideasPath, fileSystem) {
471
+ const title = artifact.title;
336
472
  if (!title) {
337
473
  return null;
338
474
  }
@@ -342,7 +478,7 @@ function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
342
478
  const ideaFilename = titleToFilename(ideaTitle);
343
479
  if (!fileSystem.exists(`${ideasPath}/${ideaFilename}`)) {
344
480
  return {
345
- file: filePath,
481
+ file: artifact.filePath,
346
482
  message: `Idea transition task references non-existent idea: "${ideaTitle}" (expected file "${ideaFilename}" in ideas/)`
347
483
  };
348
484
  }
@@ -351,37 +487,9 @@ function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
351
487
  }
352
488
  return null;
353
489
  }
354
- function extractSectionContent(content, sectionHeading) {
355
- const lines = content.split(`
356
- `);
357
- let inSection = false;
358
- let sectionContent = "";
359
- let startLine = 0;
360
- for (let i = 0;i < lines.length; i++) {
361
- const line = lines[i];
362
- if (line.startsWith("## ")) {
363
- if (inSection)
364
- break;
365
- if (line.trimEnd() === `## ${sectionHeading}`) {
366
- inSection = true;
367
- startLine = i + 1;
368
- }
369
- continue;
370
- }
371
- if (line.startsWith("# ") && inSection)
372
- break;
373
- if (inSection) {
374
- sectionContent += `${line}
375
- `;
376
- }
377
- }
378
- if (!inSection)
379
- return null;
380
- return { content: sectionContent, startLine };
381
- }
382
- function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSystem) {
490
+ function validateWorkflowTaskBodySection(artifact, ideasPath, fileSystem) {
383
491
  const violations = [];
384
- const title = extractTitle(content);
492
+ const title = artifact.title;
385
493
  if (!title)
386
494
  return violations;
387
495
  let matchedPrefix = null;
@@ -394,10 +502,10 @@ function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSyste
394
502
  if (!matchedPrefix)
395
503
  return violations;
396
504
  const expectedHeading = WORKFLOW_PREFIX_TO_SECTION[matchedPrefix];
397
- const section = extractSectionContent(content, expectedHeading);
505
+ const section = artifact.sections.find((s) => s.heading === expectedHeading && s.level === 2);
398
506
  if (!section) {
399
507
  violations.push({
400
- file: filePath,
508
+ file: artifact.filePath,
401
509
  message: `Workflow task with "${matchedPrefix.trim()}" prefix is missing required "## ${expectedHeading}" section. Add a section with a link to the idea file, e.g.:
402
510
 
403
511
  ## ${expectedHeading}
@@ -406,25 +514,9 @@ function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSyste
406
514
  });
407
515
  return violations;
408
516
  }
409
- const linkRegex = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
410
- const links = [];
411
- const sectionLines = section.content.split(`
412
- `);
413
- for (let i = 0;i < sectionLines.length; i++) {
414
- const line = sectionLines[i];
415
- let match = linkRegex.exec(line);
416
- while (match !== null) {
417
- links.push({
418
- text: match[1],
419
- target: match[2],
420
- line: section.startLine + i + 1
421
- });
422
- match = linkRegex.exec(line);
423
- }
424
- }
425
- if (links.length === 0) {
517
+ if (section.links.length === 0) {
426
518
  violations.push({
427
- file: filePath,
519
+ file: artifact.filePath,
428
520
  message: `"## ${expectedHeading}" section contains no link. Add a markdown link to the idea file, e.g.:
429
521
 
430
522
  - [Idea Title](../ideas/idea-slug.md)`,
@@ -432,10 +524,10 @@ function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSyste
432
524
  });
433
525
  return violations;
434
526
  }
435
- const ideaLinks = links.filter((l) => l.target.includes("/ideas/") || l.target.startsWith("../ideas/"));
527
+ const ideaLinks = section.links.filter((l) => l.target.includes("/ideas/") || l.target.startsWith("../ideas/"));
436
528
  if (ideaLinks.length === 0) {
437
529
  violations.push({
438
- file: filePath,
530
+ file: artifact.filePath,
439
531
  message: `"## ${expectedHeading}" section contains no link to an idea file. Links must point to a file in ../ideas/, e.g.:
440
532
 
441
533
  - [Idea Title](../ideas/idea-slug.md)`,
@@ -451,7 +543,7 @@ function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSyste
451
543
  const ideaFilePath = `${ideasPath}/${ideaSlug}.md`;
452
544
  if (!fileSystem.exists(ideaFilePath)) {
453
545
  violations.push({
454
- file: filePath,
546
+ file: artifact.filePath,
455
547
  message: `Link to idea "${link.text}" points to non-existent file: ${ideaSlug}.md. Either create the idea file at ideas/${ideaSlug}.md or update the link to point to an existing idea.`,
456
548
  line: link.line
457
549
  });
@@ -464,153 +556,129 @@ function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSyste
464
556
  import { dirname, resolve } from "node:path";
465
557
  var SEMANTIC_RULES = [
466
558
  {
467
- section: "## Principles",
559
+ sectionHeading: "Principles",
468
560
  requiredPath: "/.dust/principles/",
469
561
  description: "principle"
470
562
  },
471
563
  {
472
- section: "## Blocked By",
564
+ sectionHeading: "Blocked By",
473
565
  requiredPath: "/.dust/tasks/",
474
566
  description: "task"
475
567
  }
476
568
  ];
477
- function validateLinks(filePath, content, fileSystem) {
569
+ function isExternalOrAnchorLink(target) {
570
+ return target.startsWith("http://") || target.startsWith("https://") || target.startsWith("#");
571
+ }
572
+ function isAnchorLink(target) {
573
+ return target.startsWith("#");
574
+ }
575
+ function isExternalLink(target) {
576
+ return target.startsWith("http://") || target.startsWith("https://");
577
+ }
578
+ function validateLinks(artifact, fileSystem) {
478
579
  const violations = [];
479
- const lines = content.split(`
480
- `);
481
- const fileDir = dirname(filePath);
482
- for (let i = 0;i < lines.length; i++) {
483
- const line = lines[i];
484
- const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
485
- let match = linkPattern.exec(line);
486
- while (match) {
487
- const linkTarget = match[2];
488
- const isExternalOrAnchorLink = linkTarget.startsWith("http://") || linkTarget.startsWith("https://") || linkTarget.startsWith("#");
489
- if (isExternalOrAnchorLink) {
490
- match = linkPattern.exec(line);
491
- continue;
492
- }
493
- if (linkTarget.startsWith("/")) {
494
- violations.push({
495
- file: filePath,
496
- message: `Absolute link not allowed: "${linkTarget}" (use a relative path instead)`,
497
- line: i + 1
498
- });
499
- match = linkPattern.exec(line);
500
- continue;
501
- }
502
- const targetPath = linkTarget.split("#")[0];
503
- const resolvedPath = resolve(fileDir, targetPath);
504
- if (!fileSystem.exists(resolvedPath)) {
505
- violations.push({
506
- file: filePath,
507
- message: `Broken link: "${linkTarget}"`,
508
- line: i + 1
509
- });
510
- }
511
- match = linkPattern.exec(line);
580
+ const fileDir = dirname(artifact.filePath);
581
+ for (const link of artifact.allLinks) {
582
+ if (isExternalOrAnchorLink(link.target)) {
583
+ continue;
584
+ }
585
+ if (link.target.startsWith("/")) {
586
+ violations.push({
587
+ file: artifact.filePath,
588
+ message: `Absolute link not allowed: "${link.target}" (use a relative path instead)`,
589
+ line: link.line
590
+ });
591
+ continue;
592
+ }
593
+ const targetPath = link.target.split("#")[0];
594
+ const resolvedPath = resolve(fileDir, targetPath);
595
+ if (!fileSystem.exists(resolvedPath)) {
596
+ violations.push({
597
+ file: artifact.filePath,
598
+ message: `Broken link: "${link.target}"`,
599
+ line: link.line
600
+ });
512
601
  }
513
602
  }
514
603
  return violations;
515
604
  }
516
- function validateSemanticLinks(filePath, content) {
605
+ function validateSectionLink(artifact, link, rule) {
606
+ const sectionLabel = `## ${rule.sectionHeading}`;
607
+ if (isAnchorLink(link.target)) {
608
+ return {
609
+ file: artifact.filePath,
610
+ message: `Link in "${sectionLabel}" must point to a ${rule.description} file, not an anchor: "${link.target}"`,
611
+ line: link.line
612
+ };
613
+ }
614
+ if (isExternalLink(link.target)) {
615
+ return {
616
+ file: artifact.filePath,
617
+ message: `Link in "${sectionLabel}" must point to a ${rule.description} file, not an external URL: "${link.target}"`,
618
+ line: link.line
619
+ };
620
+ }
621
+ const fileDir = dirname(artifact.filePath);
622
+ const targetPath = link.target.split("#")[0];
623
+ const resolvedPath = resolve(fileDir, targetPath);
624
+ if (!resolvedPath.includes(rule.requiredPath)) {
625
+ return {
626
+ file: artifact.filePath,
627
+ message: `Link in "${sectionLabel}" must point to a ${rule.description} file: "${link.target}"`,
628
+ line: link.line
629
+ };
630
+ }
631
+ return null;
632
+ }
633
+ function validateSemanticLinks(artifact) {
517
634
  const violations = [];
518
- const lines = content.split(`
519
- `);
520
- const fileDir = dirname(filePath);
521
- let currentSection = null;
522
- for (let i = 0;i < lines.length; i++) {
523
- const line = lines[i];
524
- if (line.startsWith("## ")) {
525
- currentSection = line;
526
- continue;
527
- }
528
- const rule = SEMANTIC_RULES.find((r) => r.section === currentSection);
635
+ for (const section of artifact.sections) {
636
+ const rule = SEMANTIC_RULES.find((r) => r.sectionHeading === section.heading);
529
637
  if (!rule)
530
638
  continue;
531
- const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
532
- let match = linkPattern.exec(line);
533
- while (match) {
534
- const linkTarget = match[2];
535
- if (linkTarget.startsWith("#")) {
536
- violations.push({
537
- file: filePath,
538
- message: `Link in "${rule.section}" must point to a ${rule.description} file, not an anchor: "${linkTarget}"`,
539
- line: i + 1
540
- });
541
- match = linkPattern.exec(line);
542
- continue;
543
- }
544
- if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
545
- violations.push({
546
- file: filePath,
547
- message: `Link in "${rule.section}" must point to a ${rule.description} file, not an external URL: "${linkTarget}"`,
548
- line: i + 1
549
- });
550
- match = linkPattern.exec(line);
551
- continue;
552
- }
553
- const targetPath = linkTarget.split("#")[0];
554
- const resolvedPath = resolve(fileDir, targetPath);
555
- if (!resolvedPath.includes(rule.requiredPath)) {
556
- violations.push({
557
- file: filePath,
558
- message: `Link in "${rule.section}" must point to a ${rule.description} file: "${linkTarget}"`,
559
- line: i + 1
560
- });
639
+ for (const link of section.links) {
640
+ const violation = validateSectionLink(artifact, link, rule);
641
+ if (violation) {
642
+ violations.push(violation);
561
643
  }
562
- match = linkPattern.exec(line);
563
644
  }
564
645
  }
565
646
  return violations;
566
647
  }
567
- function validatePrincipleHierarchyLinks(filePath, content) {
648
+ function validatePrincipleHierarchyLinks(artifact) {
568
649
  const violations = [];
569
- const lines = content.split(`
570
- `);
571
- const fileDir = dirname(filePath);
572
- let currentSection = null;
573
- for (let i = 0;i < lines.length; i++) {
574
- const line = lines[i];
575
- if (line.startsWith("## ")) {
576
- currentSection = line;
577
- continue;
578
- }
579
- if (currentSection !== "## Parent Principle" && currentSection !== "## Sub-Principles") {
650
+ const hierarchySections = ["Parent Principle", "Sub-Principles"];
651
+ for (const section of artifact.sections) {
652
+ if (!hierarchySections.includes(section.heading))
580
653
  continue;
581
- }
582
- const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
583
- let match = linkPattern.exec(line);
584
- while (match) {
585
- const linkTarget = match[2];
586
- if (linkTarget.startsWith("#")) {
654
+ const sectionLabel = `## ${section.heading}`;
655
+ const fileDir = dirname(artifact.filePath);
656
+ for (const link of section.links) {
657
+ if (isAnchorLink(link.target)) {
587
658
  violations.push({
588
- file: filePath,
589
- message: `Link in "${currentSection}" must point to a principle file, not an anchor: "${linkTarget}"`,
590
- line: i + 1
659
+ file: artifact.filePath,
660
+ message: `Link in "${sectionLabel}" must point to a principle file, not an anchor: "${link.target}"`,
661
+ line: link.line
591
662
  });
592
- match = linkPattern.exec(line);
593
663
  continue;
594
664
  }
595
- if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
665
+ if (isExternalLink(link.target)) {
596
666
  violations.push({
597
- file: filePath,
598
- message: `Link in "${currentSection}" must point to a principle file, not an external URL: "${linkTarget}"`,
599
- line: i + 1
667
+ file: artifact.filePath,
668
+ message: `Link in "${sectionLabel}" must point to a principle file, not an external URL: "${link.target}"`,
669
+ line: link.line
600
670
  });
601
- match = linkPattern.exec(line);
602
671
  continue;
603
672
  }
604
- const targetPath = linkTarget.split("#")[0];
673
+ const targetPath = link.target.split("#")[0];
605
674
  const resolvedPath = resolve(fileDir, targetPath);
606
675
  if (!resolvedPath.includes("/.dust/principles/")) {
607
676
  violations.push({
608
- file: filePath,
609
- message: `Link in "${currentSection}" must point to a principle file: "${linkTarget}"`,
610
- line: i + 1
677
+ file: artifact.filePath,
678
+ message: `Link in "${sectionLabel}" must point to a principle file: "${link.target}"`,
679
+ line: link.line
611
680
  });
612
681
  }
613
- match = linkPattern.exec(line);
614
682
  }
615
683
  }
616
684
  return violations;
@@ -618,58 +686,46 @@ function validatePrincipleHierarchyLinks(filePath, content) {
618
686
 
619
687
  // lib/lint/validators/principle-hierarchy.ts
620
688
  import { dirname as dirname2, resolve as resolve2 } from "node:path";
621
- var REQUIRED_PRINCIPLE_HEADINGS = ["## Parent Principle", "## Sub-Principles"];
622
- function validatePrincipleHierarchySections(filePath, content) {
689
+ var REQUIRED_PRINCIPLE_HEADINGS = ["Parent Principle", "Sub-Principles"];
690
+ function validatePrincipleHierarchySections(artifact) {
623
691
  const violations = [];
692
+ const sectionHeadings = new Set(artifact.sections.map((s) => s.heading));
624
693
  for (const heading of REQUIRED_PRINCIPLE_HEADINGS) {
625
- if (!content.includes(heading)) {
694
+ if (!sectionHeadings.has(heading)) {
626
695
  violations.push({
627
- file: filePath,
628
- message: `Missing required heading: "${heading}"`
696
+ file: artifact.filePath,
697
+ message: `Missing required heading: "## ${heading}"`
629
698
  });
630
699
  }
631
700
  }
632
701
  return violations;
633
702
  }
634
- function extractPrincipleRelationships(filePath, content) {
635
- const lines = content.split(`
636
- `);
637
- const fileDir = dirname2(filePath);
703
+ function isLocalPrincipleLink(target, resolvedPath) {
704
+ const isLocalLink = !target.startsWith("#") && !target.startsWith("http://") && !target.startsWith("https://");
705
+ return isLocalLink && resolvedPath.includes("/.dust/principles/");
706
+ }
707
+ function extractPrincipleRelationships(artifact) {
708
+ const fileDir = dirname2(artifact.filePath);
638
709
  const parentPrinciples = [];
639
710
  const subPrinciples = [];
640
- let currentSection = null;
641
- for (const line of lines) {
642
- if (line.startsWith("## ")) {
643
- currentSection = line;
711
+ for (const section of artifact.sections) {
712
+ if (section.heading !== "Parent Principle" && section.heading !== "Sub-Principles") {
644
713
  continue;
645
714
  }
646
- if (currentSection !== "## Parent Principle" && currentSection !== "## Sub-Principles") {
647
- continue;
648
- }
649
- const linkPattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "g");
650
- let match = linkPattern.exec(line);
651
- while (match) {
652
- const linkTarget = match[2];
653
- const isLocalLink = !linkTarget.startsWith("#") && !linkTarget.startsWith("http://") && !linkTarget.startsWith("https://");
654
- if (!isLocalLink) {
655
- match = linkPattern.exec(line);
656
- continue;
657
- }
658
- const targetPath = linkTarget.split("#")[0];
715
+ for (const link of section.links) {
716
+ const targetPath = link.target.split("#")[0];
659
717
  const resolvedPath = resolve2(fileDir, targetPath);
660
- if (!resolvedPath.includes("/.dust/principles/")) {
661
- match = linkPattern.exec(line);
718
+ if (!isLocalPrincipleLink(link.target, resolvedPath)) {
662
719
  continue;
663
720
  }
664
- if (currentSection === "## Parent Principle") {
721
+ if (section.heading === "Parent Principle") {
665
722
  parentPrinciples.push(resolvedPath);
666
723
  } else {
667
724
  subPrinciples.push(resolvedPath);
668
725
  }
669
- match = linkPattern.exec(line);
670
726
  }
671
727
  }
672
- return { filePath, parentPrinciples, subPrinciples };
728
+ return { filePath: artifact.filePath, parentPrinciples, subPrinciples };
673
729
  }
674
730
  function validateBidirectionalLinks(allPrincipleRelationships) {
675
731
  const violations = [];
@@ -732,70 +788,134 @@ function validateNoCycles(allPrincipleRelationships) {
732
788
  return violations;
733
789
  }
734
790
 
735
- // lib/validation/overlay-filesystem.ts
736
- function createOverlayFileSystem(base, patchFiles, deletedPaths = new Set) {
737
- const patchDirs = new Set;
738
- for (const path of patchFiles.keys()) {
739
- let dir = path;
740
- while (dir.includes("/")) {
741
- dir = dir.substring(0, dir.lastIndexOf("/"));
742
- if (dir)
743
- patchDirs.add(dir);
791
+ // lib/validation/validation-pipeline.ts
792
+ var CONTENT_DIRS = ["principles", "facts", "ideas", "tasks"];
793
+ async function parseArtifacts(fileSystem, dustPath) {
794
+ const artifacts = new Map;
795
+ const byType = {
796
+ ideas: [],
797
+ tasks: [],
798
+ principles: [],
799
+ facts: []
800
+ };
801
+ const rootFiles = [];
802
+ const violations = [];
803
+ let rootEntries;
804
+ try {
805
+ rootEntries = await fileSystem.readdir(dustPath);
806
+ } catch (error) {
807
+ if (error.code === "ENOENT") {
808
+ rootEntries = [];
809
+ } else {
810
+ throw error;
744
811
  }
745
812
  }
746
- function isDeleted(path) {
747
- return deletedPaths.has(path);
748
- }
749
- return {
750
- exists(path) {
751
- if (isDeleted(path))
752
- return false;
753
- return patchFiles.has(path) || patchDirs.has(path) || base.exists(path);
754
- },
755
- async readFile(path) {
756
- if (isDeleted(path)) {
757
- const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
758
- error.code = "ENOENT";
759
- throw error;
760
- }
761
- const patchContent = patchFiles.get(path);
762
- if (patchContent !== undefined) {
763
- return patchContent;
813
+ for (const entry of rootEntries) {
814
+ if (!entry.endsWith(".md"))
815
+ continue;
816
+ const filePath = `${dustPath}/${entry}`;
817
+ let content;
818
+ try {
819
+ content = await fileSystem.readFile(filePath);
820
+ } catch (error) {
821
+ if (error.code === "ENOENT") {
822
+ continue;
764
823
  }
765
- return base.readFile(path);
766
- },
767
- async readdir(path) {
768
- const prefix = `${path}/`;
769
- const entries = new Set;
770
- for (const patchPath of patchFiles.keys()) {
771
- if (patchPath.startsWith(prefix)) {
772
- const relative = patchPath.slice(prefix.length);
773
- const firstSegment = relative.split("/")[0];
774
- entries.add(firstSegment);
775
- }
824
+ throw error;
825
+ }
826
+ const artifact = parseArtifact(filePath, content);
827
+ artifacts.set(filePath, artifact);
828
+ rootFiles.push(artifact);
829
+ }
830
+ for (const dir of CONTENT_DIRS) {
831
+ const dirPath = `${dustPath}/${dir}`;
832
+ violations.push(...await validateContentDirectoryFiles(dirPath, fileSystem));
833
+ let entries;
834
+ try {
835
+ entries = await fileSystem.readdir(dirPath);
836
+ } catch (error) {
837
+ if (error.code === "ENOENT") {
838
+ continue;
776
839
  }
840
+ throw error;
841
+ }
842
+ for (const entry of entries) {
843
+ if (!entry.endsWith(".md"))
844
+ continue;
845
+ const filePath = `${dirPath}/${entry}`;
846
+ let content;
777
847
  try {
778
- const baseEntries = await base.readdir(path);
779
- for (const entry of baseEntries) {
780
- const entryPath = `${path}/${entry}`;
781
- if (!isDeleted(entryPath)) {
782
- entries.add(entry);
783
- }
784
- }
848
+ content = await fileSystem.readFile(filePath);
785
849
  } catch (error) {
786
- if (error.code !== "ENOENT") {
787
- throw error;
850
+ if (error.code === "ENOENT") {
851
+ continue;
788
852
  }
853
+ throw error;
789
854
  }
790
- return Array.from(entries);
791
- },
792
- isDirectory(path) {
793
- if (isDeleted(path))
794
- return false;
795
- return patchDirs.has(path) || base.isDirectory(path);
855
+ const artifact = parseArtifact(filePath, content);
856
+ artifacts.set(filePath, artifact);
857
+ byType[dir].push(artifact);
796
858
  }
859
+ }
860
+ return {
861
+ context: {
862
+ artifacts,
863
+ byType,
864
+ rootFiles,
865
+ dustPath,
866
+ fileSystem
867
+ },
868
+ violations
797
869
  };
798
870
  }
871
+ function validateArtifacts(context) {
872
+ const violations = [];
873
+ const { byType, rootFiles, dustPath, fileSystem } = context;
874
+ const ideasPath = `${dustPath}/ideas`;
875
+ for (const artifact of rootFiles) {
876
+ violations.push(...validateLinks(artifact, fileSystem));
877
+ }
878
+ for (const artifacts of Object.values(byType)) {
879
+ for (const artifact of artifacts) {
880
+ const openingSentenceViolation = validateOpeningSentence(artifact);
881
+ if (openingSentenceViolation)
882
+ violations.push(openingSentenceViolation);
883
+ const lengthViolation = validateOpeningSentenceLength(artifact);
884
+ if (lengthViolation)
885
+ violations.push(lengthViolation);
886
+ const titleViolation = validateTitleFilenameMatch(artifact);
887
+ if (titleViolation)
888
+ violations.push(titleViolation);
889
+ violations.push(...validateLinks(artifact, fileSystem));
890
+ }
891
+ }
892
+ for (const artifact of byType.ideas) {
893
+ violations.push(...validateIdeaOpenQuestions(artifact));
894
+ }
895
+ for (const artifact of byType.tasks) {
896
+ const filenameViolation = validateFilename(artifact.filePath);
897
+ if (filenameViolation)
898
+ violations.push(filenameViolation);
899
+ violations.push(...validateTaskHeadings(artifact));
900
+ violations.push(...validateSemanticLinks(artifact));
901
+ const imperativeViolation = validateImperativeOpeningSentence(artifact);
902
+ if (imperativeViolation)
903
+ violations.push(imperativeViolation);
904
+ const ideaTransitionViolation = validateIdeaTransitionTitle(artifact, ideasPath, fileSystem);
905
+ if (ideaTransitionViolation)
906
+ violations.push(ideaTransitionViolation);
907
+ violations.push(...validateWorkflowTaskBodySection(artifact, ideasPath, fileSystem));
908
+ }
909
+ const allPrincipleRelationships = [];
910
+ for (const artifact of byType.principles) {
911
+ violations.push(...validatePrincipleHierarchySections(artifact));
912
+ violations.push(...validatePrincipleHierarchyLinks(artifact));
913
+ allPrincipleRelationships.push(extractPrincipleRelationships(artifact));
914
+ }
915
+ violations.push(...validateBidirectionalLinks(allPrincipleRelationships));
916
+ violations.push(...validateNoCycles(allPrincipleRelationships));
917
+ return violations;
918
+ }
799
919
 
800
920
  // lib/validation/index.ts
801
921
  var ALLOWED_ROOT_DIRECTORIES = [
@@ -855,36 +975,6 @@ function relativizeViolations(violations, cwd) {
855
975
  file: relativizeViolationFilePath(violation.file, cwd)
856
976
  }));
857
977
  }
858
- var CONTENT_DIRS = ["principles", "facts", "ideas", "tasks"];
859
- function validateContentFile(filePath, content) {
860
- const violations = [];
861
- const openingSentence = validateOpeningSentence(filePath, content);
862
- if (openingSentence)
863
- violations.push(openingSentence);
864
- const openingSentenceLength = validateOpeningSentenceLength(filePath, content);
865
- if (openingSentenceLength)
866
- violations.push(openingSentenceLength);
867
- const titleFilename = validateTitleFilenameMatch(filePath, content);
868
- if (titleFilename)
869
- violations.push(titleFilename);
870
- return violations;
871
- }
872
- function validateTaskFile(filePath, content, ideasPath, overlayFs) {
873
- const violations = [];
874
- const filenameViolation = validateFilename(filePath);
875
- if (filenameViolation)
876
- violations.push(filenameViolation);
877
- violations.push(...validateTaskHeadings(filePath, content));
878
- violations.push(...validateSemanticLinks(filePath, content));
879
- const imperativeViolation = validateImperativeOpeningSentence(filePath, content);
880
- if (imperativeViolation)
881
- violations.push(imperativeViolation);
882
- const ideaTransition = validateIdeaTransitionTitle(filePath, content, ideasPath, overlayFs);
883
- if (ideaTransition)
884
- violations.push(ideaTransition);
885
- violations.push(...validateWorkflowTaskBodySection(filePath, content, ideasPath, overlayFs));
886
- return violations;
887
- }
888
978
  function parsePatchFiles(dustPath, patch) {
889
979
  const absolutePatchFiles = new Map;
890
980
  const deletedPaths = new Set;
@@ -898,75 +988,15 @@ function parsePatchFiles(dustPath, patch) {
898
988
  }
899
989
  return { absolutePatchFiles, deletedPaths };
900
990
  }
901
- function collectPatchDirs(patch) {
902
- const patchDirs = new Set;
903
- for (const relativePath of Object.keys(patch.files)) {
904
- const dir = relativePath.split("/")[0];
905
- if (CONTENT_DIRS.includes(dir)) {
906
- patchDirs.add(dir);
907
- }
908
- }
909
- return patchDirs;
910
- }
911
- async function validatePrincipleRelationships(dustPath, overlayFs) {
912
- const allRelationships = [];
913
- const principlesPath = `${dustPath}/principles`;
914
- try {
915
- const existingFiles = await overlayFs.readdir(principlesPath);
916
- for (const file of existingFiles) {
917
- if (!file.endsWith(".md"))
918
- continue;
919
- const filePath = `${principlesPath}/${file}`;
920
- const content = await overlayFs.readFile(filePath);
921
- allRelationships.push(extractPrincipleRelationships(filePath, content));
922
- }
923
- } catch (error) {
924
- if (error.code !== "ENOENT") {
925
- throw error;
926
- }
927
- }
928
- return [
929
- ...validateBidirectionalLinks(allRelationships),
930
- ...validateNoCycles(allRelationships)
931
- ];
932
- }
933
991
  async function validatePatch(fileSystem, dustPath, patch, options = {}) {
934
992
  const cwd = options.cwd ?? process.cwd();
935
993
  const { absolutePatchFiles, deletedPaths } = parsePatchFiles(dustPath, patch);
936
994
  const overlayFs = createOverlayFileSystem(fileSystem, absolutePatchFiles, deletedPaths);
937
995
  const violations = [];
938
996
  violations.push(...validatePatchRootEntries(fileSystem, dustPath, patch));
939
- const patchDirs = collectPatchDirs(patch);
940
- for (const dir of patchDirs) {
941
- violations.push(...await validateContentDirectoryFiles(`${dustPath}/${dir}`, overlayFs));
942
- }
943
- const ideasPath = `${dustPath}/ideas`;
944
- for (const [relativePath, content] of Object.entries(patch.files)) {
945
- if (content === null)
946
- continue;
947
- if (!relativePath.endsWith(".md"))
948
- continue;
949
- const filePath = `${dustPath}/${relativePath}`;
950
- const dir = relativePath.split("/")[0];
951
- violations.push(...validateLinks(filePath, content, overlayFs));
952
- if (CONTENT_DIRS.includes(dir)) {
953
- violations.push(...validateContentFile(filePath, content));
954
- }
955
- if (dir === "ideas") {
956
- violations.push(...validateIdeaOpenQuestions(filePath, content));
957
- }
958
- if (dir === "tasks") {
959
- violations.push(...validateTaskFile(filePath, content, ideasPath, overlayFs));
960
- }
961
- if (dir === "principles") {
962
- violations.push(...validatePrincipleHierarchySections(filePath, content));
963
- violations.push(...validatePrincipleHierarchyLinks(filePath, content));
964
- }
965
- }
966
- const hasPrinciplePatches = Object.keys(patch.files).some((p) => p.startsWith("principles/"));
967
- if (hasPrinciplePatches) {
968
- violations.push(...await validatePrincipleRelationships(dustPath, overlayFs));
969
- }
997
+ const { context, violations: parseViolations } = await parseArtifacts(overlayFs, dustPath);
998
+ violations.push(...parseViolations);
999
+ violations.push(...validateArtifacts(context));
970
1000
  return {
971
1001
  valid: violations.length === 0,
972
1002
  violations: relativizeViolations(violations, cwd)