@joshski/dust 0.1.97 → 0.1.99

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_PATTERN2 = /\[([^\]]+)\]\(([^)]+)\)/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;
14
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_PATTERN2);
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
+ }
140
+ }
141
+ }
142
+ if (currentSection !== null) {
143
+ currentSection.endLine = findLastNonEmptyLine(lines, currentSection.startLine, lines.length - 1);
144
+ sections.push(currentSection);
15
145
  }
16
- return -1;
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,28 @@ 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);
205
+
206
+ // lib/artifacts/workflow-tasks.ts
207
+ var IDEA_TRANSITION_PREFIXES = [
208
+ "Refine Idea: ",
209
+ "Decompose Idea: ",
210
+ "Shelve Idea: ",
211
+ "Expedite Idea: "
212
+ ];
213
+ function titleToFilename(title) {
214
+ return `${title.toLowerCase().replace(/\./g, "-").replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")}.md`;
72
215
  }
73
216
 
217
+ // lib/artifacts/index.ts
218
+ var ARTIFACT_TYPES = [
219
+ "facts",
220
+ "ideas",
221
+ "principles",
222
+ "tasks"
223
+ ];
224
+
74
225
  // lib/lint/validators/content-validator.ts
75
- var REQUIRED_HEADINGS = ["## Blocked By", "## Definition of Done"];
226
+ var REQUIRED_TASK_HEADINGS = ["Blocked By", "Definition of Done"];
76
227
  var MAX_OPENING_SENTENCE_LENGTH = 150;
77
228
  var NON_IMPERATIVE_STARTERS = new Set([
78
229
  "the",
@@ -88,31 +239,32 @@ var NON_IMPERATIVE_STARTERS = new Set([
88
239
  "you",
89
240
  "i"
90
241
  ]);
91
- function validateOpeningSentence(filePath, content) {
92
- const openingSentence = extractOpeningSentence(content);
93
- if (!openingSentence) {
242
+ function validateOpeningSentence(artifact) {
243
+ if (!artifact.openingSentence) {
94
244
  return {
95
- file: filePath,
245
+ file: artifact.filePath,
246
+ line: artifact.titleLine ?? undefined,
96
247
  message: "Missing or malformed opening sentence after H1 heading"
97
248
  };
98
249
  }
99
250
  return null;
100
251
  }
101
- function validateOpeningSentenceLength(filePath, content) {
102
- const openingSentence = extractOpeningSentence(content);
252
+ function validateOpeningSentenceLength(artifact) {
253
+ const openingSentence = artifact.openingSentence;
103
254
  if (!openingSentence) {
104
255
  return null;
105
256
  }
106
257
  if (openingSentence.length > MAX_OPENING_SENTENCE_LENGTH) {
107
258
  return {
108
- file: filePath,
259
+ file: artifact.filePath,
260
+ line: artifact.openingSentenceLine ?? undefined,
109
261
  message: `Opening sentence is ${openingSentence.length} characters (max ${MAX_OPENING_SENTENCE_LENGTH}). Split into multiple sentences; only the first sentence is checked.`
110
262
  };
111
263
  }
112
264
  return null;
113
265
  }
114
- function validateImperativeOpeningSentence(filePath, content) {
115
- const openingSentence = extractOpeningSentence(content);
266
+ function validateImperativeOpeningSentence(artifact) {
267
+ const openingSentence = artifact.openingSentence;
116
268
  if (!openingSentence) {
117
269
  return null;
118
270
  }
@@ -121,19 +273,21 @@ function validateImperativeOpeningSentence(filePath, content) {
121
273
  if (NON_IMPERATIVE_STARTERS.has(lower) || lower.endsWith("ing")) {
122
274
  const preview = openingSentence.length > 40 ? `${openingSentence.slice(0, 40)}...` : openingSentence;
123
275
  return {
124
- file: filePath,
276
+ file: artifact.filePath,
277
+ line: artifact.openingSentenceLine ?? undefined,
125
278
  message: `Opening sentence should use imperative form (e.g., "Add X" not "This adds X"). Found: "${preview}"`
126
279
  };
127
280
  }
128
281
  return null;
129
282
  }
130
- function validateTaskHeadings(filePath, content) {
283
+ function validateTaskHeadings(artifact) {
131
284
  const violations = [];
132
- for (const heading of REQUIRED_HEADINGS) {
133
- if (!content.includes(heading)) {
285
+ const sectionHeadings = new Set(artifact.sections.map((s) => s.heading));
286
+ for (const heading of REQUIRED_TASK_HEADINGS) {
287
+ if (!sectionHeadings.has(heading)) {
134
288
  violations.push({
135
- file: filePath,
136
- message: `Missing required heading: "${heading}"`
289
+ file: artifact.filePath,
290
+ message: `Missing required heading: "## ${heading}"`
137
291
  });
138
292
  }
139
293
  }
@@ -141,6 +295,7 @@ function validateTaskHeadings(filePath, content) {
141
295
  }
142
296
 
143
297
  // lib/lint/validators/directory-validator.ts
298
+ var EXPECTED_DIRECTORIES = [...ARTIFACT_TYPES, "config"];
144
299
  async function validateContentDirectoryFiles(dirPath, fileSystem) {
145
300
  const violations = [];
146
301
  let entries;
@@ -178,17 +333,6 @@ async function validateContentDirectoryFiles(dirPath, fileSystem) {
178
333
  return violations;
179
334
  }
180
335
 
181
- // lib/artifacts/workflow-tasks.ts
182
- var IDEA_TRANSITION_PREFIXES = [
183
- "Refine Idea: ",
184
- "Decompose Idea: ",
185
- "Shelve Idea: ",
186
- "Expedite Idea: "
187
- ];
188
- function titleToFilename(title) {
189
- return `${title.toLowerCase().replace(/\./g, "-").replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")}.md`;
190
- }
191
-
192
336
  // lib/lint/validators/filename-validator.ts
193
337
  var SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*\.md$/;
194
338
  function validateFilename(filePath) {
@@ -202,17 +346,17 @@ function validateFilename(filePath) {
202
346
  }
203
347
  return null;
204
348
  }
205
- function validateTitleFilenameMatch(filePath, content) {
206
- const title = extractTitle(content);
349
+ function validateTitleFilenameMatch(artifact) {
350
+ const title = artifact.title;
207
351
  if (!title) {
208
352
  return null;
209
353
  }
210
- const parts = filePath.split("/");
354
+ const parts = artifact.filePath.split("/");
211
355
  const actualFilename = parts[parts.length - 1];
212
356
  const expectedFilename = titleToFilename(title);
213
357
  if (actualFilename !== expectedFilename) {
214
358
  return {
215
- file: filePath,
359
+ file: artifact.filePath,
216
360
  message: `Filename "${actualFilename}" does not match title "${title}" (expected "${expectedFilename}")`
217
361
  };
218
362
  }
@@ -252,10 +396,11 @@ function validateH2Heading(filePath, line, lineNumber, inOpenQuestions, currentQ
252
396
  }
253
397
  return violations;
254
398
  }
255
- function validateIdeaOpenQuestions(filePath, content) {
399
+ function validateIdeaOpenQuestions(artifact) {
256
400
  const violations = [];
257
- const lines = content.split(`
401
+ const lines = artifact.rawContent.split(`
258
402
  `);
403
+ const filePath = artifact.filePath;
259
404
  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
405
  let inOpenQuestions = false;
261
406
  let currentQuestionLine = null;
@@ -331,18 +476,24 @@ function validateIdeaOpenQuestions(filePath, content) {
331
476
  }
332
477
  return violations;
333
478
  }
334
- function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
335
- const title = extractTitle(content);
479
+ function validateIdeaTransitionTitle(artifact, ideasPath, fileSystem) {
480
+ const title = artifact.title;
336
481
  if (!title) {
337
482
  return null;
338
483
  }
339
484
  for (const prefix of IDEA_TRANSITION_PREFIXES) {
340
485
  if (title.startsWith(prefix)) {
486
+ if (prefix === "Expedite Idea: ") {
487
+ const hasIdeaDescriptionSection = artifact.sections.some((s) => s.heading === "Idea Description" && s.level === 2);
488
+ if (hasIdeaDescriptionSection) {
489
+ return null;
490
+ }
491
+ }
341
492
  const ideaTitle = title.slice(prefix.length);
342
493
  const ideaFilename = titleToFilename(ideaTitle);
343
494
  if (!fileSystem.exists(`${ideasPath}/${ideaFilename}`)) {
344
495
  return {
345
- file: filePath,
496
+ file: artifact.filePath,
346
497
  message: `Idea transition task references non-existent idea: "${ideaTitle}" (expected file "${ideaFilename}" in ideas/)`
347
498
  };
348
499
  }
@@ -351,37 +502,9 @@ function validateIdeaTransitionTitle(filePath, content, ideasPath, fileSystem) {
351
502
  }
352
503
  return null;
353
504
  }
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) {
505
+ function validateWorkflowTaskBodySection(artifact, ideasPath, fileSystem) {
383
506
  const violations = [];
384
- const title = extractTitle(content);
507
+ const title = artifact.title;
385
508
  if (!title)
386
509
  return violations;
387
510
  let matchedPrefix = null;
@@ -394,10 +517,10 @@ function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSyste
394
517
  if (!matchedPrefix)
395
518
  return violations;
396
519
  const expectedHeading = WORKFLOW_PREFIX_TO_SECTION[matchedPrefix];
397
- const section = extractSectionContent(content, expectedHeading);
520
+ const section = artifact.sections.find((s) => s.heading === expectedHeading && s.level === 2);
398
521
  if (!section) {
399
522
  violations.push({
400
- file: filePath,
523
+ file: artifact.filePath,
401
524
  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
525
 
403
526
  ## ${expectedHeading}
@@ -406,25 +529,9 @@ function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSyste
406
529
  });
407
530
  return violations;
408
531
  }
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) {
532
+ if (section.links.length === 0) {
426
533
  violations.push({
427
- file: filePath,
534
+ file: artifact.filePath,
428
535
  message: `"## ${expectedHeading}" section contains no link. Add a markdown link to the idea file, e.g.:
429
536
 
430
537
  - [Idea Title](../ideas/idea-slug.md)`,
@@ -432,10 +539,10 @@ function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSyste
432
539
  });
433
540
  return violations;
434
541
  }
435
- const ideaLinks = links.filter((l) => l.target.includes("/ideas/") || l.target.startsWith("../ideas/"));
542
+ const ideaLinks = section.links.filter((l) => l.target.includes("/ideas/") || l.target.startsWith("../ideas/"));
436
543
  if (ideaLinks.length === 0) {
437
544
  violations.push({
438
- file: filePath,
545
+ file: artifact.filePath,
439
546
  message: `"## ${expectedHeading}" section contains no link to an idea file. Links must point to a file in ../ideas/, e.g.:
440
547
 
441
548
  - [Idea Title](../ideas/idea-slug.md)`,
@@ -451,7 +558,7 @@ function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSyste
451
558
  const ideaFilePath = `${ideasPath}/${ideaSlug}.md`;
452
559
  if (!fileSystem.exists(ideaFilePath)) {
453
560
  violations.push({
454
- file: filePath,
561
+ file: artifact.filePath,
455
562
  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
563
  line: link.line
457
564
  });
@@ -464,153 +571,129 @@ function validateWorkflowTaskBodySection(filePath, content, ideasPath, fileSyste
464
571
  import { dirname, resolve } from "node:path";
465
572
  var SEMANTIC_RULES = [
466
573
  {
467
- section: "## Principles",
574
+ sectionHeading: "Principles",
468
575
  requiredPath: "/.dust/principles/",
469
576
  description: "principle"
470
577
  },
471
578
  {
472
- section: "## Blocked By",
579
+ sectionHeading: "Blocked By",
473
580
  requiredPath: "/.dust/tasks/",
474
581
  description: "task"
475
582
  }
476
583
  ];
477
- function validateLinks(filePath, content, fileSystem) {
584
+ function isExternalOrAnchorLink(target) {
585
+ return target.startsWith("http://") || target.startsWith("https://") || target.startsWith("#");
586
+ }
587
+ function isAnchorLink(target) {
588
+ return target.startsWith("#");
589
+ }
590
+ function isExternalLink(target) {
591
+ return target.startsWith("http://") || target.startsWith("https://");
592
+ }
593
+ function validateLinks(artifact, fileSystem) {
478
594
  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);
595
+ const fileDir = dirname(artifact.filePath);
596
+ for (const link of artifact.allLinks) {
597
+ if (isExternalOrAnchorLink(link.target)) {
598
+ continue;
599
+ }
600
+ if (link.target.startsWith("/")) {
601
+ violations.push({
602
+ file: artifact.filePath,
603
+ message: `Absolute link not allowed: "${link.target}" (use a relative path instead)`,
604
+ line: link.line
605
+ });
606
+ continue;
607
+ }
608
+ const targetPath = link.target.split("#")[0];
609
+ const resolvedPath = resolve(fileDir, targetPath);
610
+ if (!fileSystem.exists(resolvedPath)) {
611
+ violations.push({
612
+ file: artifact.filePath,
613
+ message: `Broken link: "${link.target}"`,
614
+ line: link.line
615
+ });
512
616
  }
513
617
  }
514
618
  return violations;
515
619
  }
516
- function validateSemanticLinks(filePath, content) {
620
+ function validateSectionLink(artifact, link, rule) {
621
+ const sectionLabel = `## ${rule.sectionHeading}`;
622
+ if (isAnchorLink(link.target)) {
623
+ return {
624
+ file: artifact.filePath,
625
+ message: `Link in "${sectionLabel}" must point to a ${rule.description} file, not an anchor: "${link.target}"`,
626
+ line: link.line
627
+ };
628
+ }
629
+ if (isExternalLink(link.target)) {
630
+ return {
631
+ file: artifact.filePath,
632
+ message: `Link in "${sectionLabel}" must point to a ${rule.description} file, not an external URL: "${link.target}"`,
633
+ line: link.line
634
+ };
635
+ }
636
+ const fileDir = dirname(artifact.filePath);
637
+ const targetPath = link.target.split("#")[0];
638
+ const resolvedPath = resolve(fileDir, targetPath);
639
+ if (!resolvedPath.includes(rule.requiredPath)) {
640
+ return {
641
+ file: artifact.filePath,
642
+ message: `Link in "${sectionLabel}" must point to a ${rule.description} file: "${link.target}"`,
643
+ line: link.line
644
+ };
645
+ }
646
+ return null;
647
+ }
648
+ function validateSemanticLinks(artifact) {
517
649
  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);
650
+ for (const section of artifact.sections) {
651
+ const rule = SEMANTIC_RULES.find((r) => r.sectionHeading === section.heading);
529
652
  if (!rule)
530
653
  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
- });
654
+ for (const link of section.links) {
655
+ const violation = validateSectionLink(artifact, link, rule);
656
+ if (violation) {
657
+ violations.push(violation);
561
658
  }
562
- match = linkPattern.exec(line);
563
659
  }
564
660
  }
565
661
  return violations;
566
662
  }
567
- function validatePrincipleHierarchyLinks(filePath, content) {
663
+ function validatePrincipleHierarchyLinks(artifact) {
568
664
  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") {
665
+ const hierarchySections = ["Parent Principle", "Sub-Principles"];
666
+ for (const section of artifact.sections) {
667
+ if (!hierarchySections.includes(section.heading))
580
668
  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("#")) {
669
+ const sectionLabel = `## ${section.heading}`;
670
+ const fileDir = dirname(artifact.filePath);
671
+ for (const link of section.links) {
672
+ if (isAnchorLink(link.target)) {
587
673
  violations.push({
588
- file: filePath,
589
- message: `Link in "${currentSection}" must point to a principle file, not an anchor: "${linkTarget}"`,
590
- line: i + 1
674
+ file: artifact.filePath,
675
+ message: `Link in "${sectionLabel}" must point to a principle file, not an anchor: "${link.target}"`,
676
+ line: link.line
591
677
  });
592
- match = linkPattern.exec(line);
593
678
  continue;
594
679
  }
595
- if (linkTarget.startsWith("http://") || linkTarget.startsWith("https://")) {
680
+ if (isExternalLink(link.target)) {
596
681
  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
682
+ file: artifact.filePath,
683
+ message: `Link in "${sectionLabel}" must point to a principle file, not an external URL: "${link.target}"`,
684
+ line: link.line
600
685
  });
601
- match = linkPattern.exec(line);
602
686
  continue;
603
687
  }
604
- const targetPath = linkTarget.split("#")[0];
688
+ const targetPath = link.target.split("#")[0];
605
689
  const resolvedPath = resolve(fileDir, targetPath);
606
690
  if (!resolvedPath.includes("/.dust/principles/")) {
607
691
  violations.push({
608
- file: filePath,
609
- message: `Link in "${currentSection}" must point to a principle file: "${linkTarget}"`,
610
- line: i + 1
692
+ file: artifact.filePath,
693
+ message: `Link in "${sectionLabel}" must point to a principle file: "${link.target}"`,
694
+ line: link.line
611
695
  });
612
696
  }
613
- match = linkPattern.exec(line);
614
697
  }
615
698
  }
616
699
  return violations;
@@ -618,58 +701,46 @@ function validatePrincipleHierarchyLinks(filePath, content) {
618
701
 
619
702
  // lib/lint/validators/principle-hierarchy.ts
620
703
  import { dirname as dirname2, resolve as resolve2 } from "node:path";
621
- var REQUIRED_PRINCIPLE_HEADINGS = ["## Parent Principle", "## Sub-Principles"];
622
- function validatePrincipleHierarchySections(filePath, content) {
704
+ var REQUIRED_PRINCIPLE_HEADINGS = ["Parent Principle", "Sub-Principles"];
705
+ function validatePrincipleHierarchySections(artifact) {
623
706
  const violations = [];
707
+ const sectionHeadings = new Set(artifact.sections.map((s) => s.heading));
624
708
  for (const heading of REQUIRED_PRINCIPLE_HEADINGS) {
625
- if (!content.includes(heading)) {
709
+ if (!sectionHeadings.has(heading)) {
626
710
  violations.push({
627
- file: filePath,
628
- message: `Missing required heading: "${heading}"`
711
+ file: artifact.filePath,
712
+ message: `Missing required heading: "## ${heading}"`
629
713
  });
630
714
  }
631
715
  }
632
716
  return violations;
633
717
  }
634
- function extractPrincipleRelationships(filePath, content) {
635
- const lines = content.split(`
636
- `);
637
- const fileDir = dirname2(filePath);
718
+ function isLocalPrincipleLink(target, resolvedPath) {
719
+ const isLocalLink = !target.startsWith("#") && !target.startsWith("http://") && !target.startsWith("https://");
720
+ return isLocalLink && resolvedPath.includes("/.dust/principles/");
721
+ }
722
+ function extractPrincipleRelationships(artifact) {
723
+ const fileDir = dirname2(artifact.filePath);
638
724
  const parentPrinciples = [];
639
725
  const subPrinciples = [];
640
- let currentSection = null;
641
- for (const line of lines) {
642
- if (line.startsWith("## ")) {
643
- currentSection = line;
726
+ for (const section of artifact.sections) {
727
+ if (section.heading !== "Parent Principle" && section.heading !== "Sub-Principles") {
644
728
  continue;
645
729
  }
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];
730
+ for (const link of section.links) {
731
+ const targetPath = link.target.split("#")[0];
659
732
  const resolvedPath = resolve2(fileDir, targetPath);
660
- if (!resolvedPath.includes("/.dust/principles/")) {
661
- match = linkPattern.exec(line);
733
+ if (!isLocalPrincipleLink(link.target, resolvedPath)) {
662
734
  continue;
663
735
  }
664
- if (currentSection === "## Parent Principle") {
736
+ if (section.heading === "Parent Principle") {
665
737
  parentPrinciples.push(resolvedPath);
666
738
  } else {
667
739
  subPrinciples.push(resolvedPath);
668
740
  }
669
- match = linkPattern.exec(line);
670
741
  }
671
742
  }
672
- return { filePath, parentPrinciples, subPrinciples };
743
+ return { filePath: artifact.filePath, parentPrinciples, subPrinciples };
673
744
  }
674
745
  function validateBidirectionalLinks(allPrincipleRelationships) {
675
746
  const violations = [];
@@ -732,70 +803,133 @@ function validateNoCycles(allPrincipleRelationships) {
732
803
  return violations;
733
804
  }
734
805
 
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);
806
+ // lib/validation/validation-pipeline.ts
807
+ async function parseArtifacts(fileSystem, dustPath) {
808
+ const artifacts = new Map;
809
+ const byType = {
810
+ facts: [],
811
+ ideas: [],
812
+ principles: [],
813
+ tasks: []
814
+ };
815
+ const rootFiles = [];
816
+ const violations = [];
817
+ let rootEntries;
818
+ try {
819
+ rootEntries = await fileSystem.readdir(dustPath);
820
+ } catch (error) {
821
+ if (error.code === "ENOENT") {
822
+ rootEntries = [];
823
+ } else {
824
+ throw error;
744
825
  }
745
826
  }
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;
827
+ for (const entry of rootEntries) {
828
+ if (!entry.endsWith(".md"))
829
+ continue;
830
+ const filePath = `${dustPath}/${entry}`;
831
+ let content;
832
+ try {
833
+ content = await fileSystem.readFile(filePath);
834
+ } catch (error) {
835
+ if (error.code === "ENOENT") {
836
+ continue;
764
837
  }
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
- }
838
+ throw error;
839
+ }
840
+ const artifact = parseArtifact(filePath, content);
841
+ artifacts.set(filePath, artifact);
842
+ rootFiles.push(artifact);
843
+ }
844
+ for (const dir of ARTIFACT_TYPES) {
845
+ const dirPath = `${dustPath}/${dir}`;
846
+ violations.push(...await validateContentDirectoryFiles(dirPath, fileSystem));
847
+ let entries;
848
+ try {
849
+ entries = await fileSystem.readdir(dirPath);
850
+ } catch (error) {
851
+ if (error.code === "ENOENT") {
852
+ continue;
776
853
  }
854
+ throw error;
855
+ }
856
+ for (const entry of entries) {
857
+ if (!entry.endsWith(".md"))
858
+ continue;
859
+ const filePath = `${dirPath}/${entry}`;
860
+ let content;
777
861
  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
- }
862
+ content = await fileSystem.readFile(filePath);
785
863
  } catch (error) {
786
- if (error.code !== "ENOENT") {
787
- throw error;
864
+ if (error.code === "ENOENT") {
865
+ continue;
788
866
  }
867
+ throw error;
789
868
  }
790
- return Array.from(entries);
791
- },
792
- isDirectory(path) {
793
- if (isDeleted(path))
794
- return false;
795
- return patchDirs.has(path) || base.isDirectory(path);
869
+ const artifact = parseArtifact(filePath, content);
870
+ artifacts.set(filePath, artifact);
871
+ byType[dir].push(artifact);
796
872
  }
873
+ }
874
+ return {
875
+ context: {
876
+ artifacts,
877
+ byType,
878
+ rootFiles,
879
+ dustPath,
880
+ fileSystem
881
+ },
882
+ violations
797
883
  };
798
884
  }
885
+ function validateArtifacts(context) {
886
+ const violations = [];
887
+ const { byType, rootFiles, dustPath, fileSystem } = context;
888
+ const ideasPath = `${dustPath}/ideas`;
889
+ for (const artifact of rootFiles) {
890
+ violations.push(...validateLinks(artifact, fileSystem));
891
+ }
892
+ for (const artifacts of Object.values(byType)) {
893
+ for (const artifact of artifacts) {
894
+ const openingSentenceViolation = validateOpeningSentence(artifact);
895
+ if (openingSentenceViolation)
896
+ violations.push(openingSentenceViolation);
897
+ const lengthViolation = validateOpeningSentenceLength(artifact);
898
+ if (lengthViolation)
899
+ violations.push(lengthViolation);
900
+ const titleViolation = validateTitleFilenameMatch(artifact);
901
+ if (titleViolation)
902
+ violations.push(titleViolation);
903
+ violations.push(...validateLinks(artifact, fileSystem));
904
+ }
905
+ }
906
+ for (const artifact of byType.ideas) {
907
+ violations.push(...validateIdeaOpenQuestions(artifact));
908
+ }
909
+ for (const artifact of byType.tasks) {
910
+ const filenameViolation = validateFilename(artifact.filePath);
911
+ if (filenameViolation)
912
+ violations.push(filenameViolation);
913
+ violations.push(...validateTaskHeadings(artifact));
914
+ violations.push(...validateSemanticLinks(artifact));
915
+ const imperativeViolation = validateImperativeOpeningSentence(artifact);
916
+ if (imperativeViolation)
917
+ violations.push(imperativeViolation);
918
+ const ideaTransitionViolation = validateIdeaTransitionTitle(artifact, ideasPath, fileSystem);
919
+ if (ideaTransitionViolation)
920
+ violations.push(ideaTransitionViolation);
921
+ violations.push(...validateWorkflowTaskBodySection(artifact, ideasPath, fileSystem));
922
+ }
923
+ const allPrincipleRelationships = [];
924
+ for (const artifact of byType.principles) {
925
+ violations.push(...validatePrincipleHierarchySections(artifact));
926
+ violations.push(...validatePrincipleHierarchyLinks(artifact));
927
+ allPrincipleRelationships.push(extractPrincipleRelationships(artifact));
928
+ }
929
+ violations.push(...validateBidirectionalLinks(allPrincipleRelationships));
930
+ violations.push(...validateNoCycles(allPrincipleRelationships));
931
+ return violations;
932
+ }
799
933
 
800
934
  // lib/validation/index.ts
801
935
  var ALLOWED_ROOT_DIRECTORIES = [
@@ -855,36 +989,6 @@ function relativizeViolations(violations, cwd) {
855
989
  file: relativizeViolationFilePath(violation.file, cwd)
856
990
  }));
857
991
  }
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
992
  function parsePatchFiles(dustPath, patch) {
889
993
  const absolutePatchFiles = new Map;
890
994
  const deletedPaths = new Set;
@@ -898,75 +1002,15 @@ function parsePatchFiles(dustPath, patch) {
898
1002
  }
899
1003
  return { absolutePatchFiles, deletedPaths };
900
1004
  }
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
1005
  async function validatePatch(fileSystem, dustPath, patch, options = {}) {
934
1006
  const cwd = options.cwd ?? process.cwd();
935
1007
  const { absolutePatchFiles, deletedPaths } = parsePatchFiles(dustPath, patch);
936
1008
  const overlayFs = createOverlayFileSystem(fileSystem, absolutePatchFiles, deletedPaths);
937
1009
  const violations = [];
938
1010
  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
- }
1011
+ const { context, violations: parseViolations } = await parseArtifacts(overlayFs, dustPath);
1012
+ violations.push(...parseViolations);
1013
+ violations.push(...validateArtifacts(context));
970
1014
  return {
971
1015
  valid: violations.length === 0,
972
1016
  violations: relativizeViolations(violations, cwd)