@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.
package/dist/patch.js ADDED
@@ -0,0 +1,1632 @@
1
+ // lib/validation/index.ts
2
+ import { relative } from "node:path";
3
+
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
+ };
67
+ }
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;
83
+ for (let i = 0;i < lines.length; 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
+ }
140
+ }
141
+ }
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) };
167
+ }
168
+ function findLastNonEmptyLine(lines, contentStartIndex, fromIndex) {
169
+ for (let i = fromIndex;i >= contentStartIndex; i--) {
170
+ if (lines[i].trim() !== "") {
171
+ return i + 1;
172
+ }
173
+ }
174
+ return contentStartIndex;
175
+ }
176
+ var LIST_ITEM_PREFIXES = ["-", "*", "+"];
177
+ var STRUCTURAL_PREFIXES = ["#", "```", ">"];
178
+ function isStructuralElement(line) {
179
+ if (STRUCTURAL_PREFIXES.some((prefix) => line.startsWith(prefix))) {
180
+ return true;
181
+ }
182
+ if (LIST_ITEM_PREFIXES.some((prefix) => line.startsWith(prefix))) {
183
+ return true;
184
+ }
185
+ return /^\d+\./.test(line);
186
+ }
187
+ function isBlockBreak(line) {
188
+ return STRUCTURAL_PREFIXES.some((prefix) => line.startsWith(prefix));
189
+ }
190
+ function collectParagraph(lines, startIndex) {
191
+ const parts = [];
192
+ for (let i = startIndex;i < lines.length; i++) {
193
+ const line = lines[i].trim();
194
+ if (line === "" || isBlockBreak(line)) {
195
+ break;
196
+ }
197
+ parts.push(line);
198
+ }
199
+ return parts.join(" ");
200
+ }
201
+ function extractFirstSentence(paragraph) {
202
+ const match = paragraph.match(/^(.+?[.?!])(?:\s|$)/);
203
+ return match ? match[1] : null;
204
+ }
205
+
206
+ // lib/lint/validators/content-validator.ts
207
+ var REQUIRED_TASK_HEADINGS = ["Blocked By", "Definition of Done"];
208
+ var MAX_OPENING_SENTENCE_LENGTH = 150;
209
+ var NON_IMPERATIVE_STARTERS = new Set([
210
+ "the",
211
+ "a",
212
+ "an",
213
+ "this",
214
+ "that",
215
+ "these",
216
+ "those",
217
+ "we",
218
+ "it",
219
+ "they",
220
+ "you",
221
+ "i"
222
+ ]);
223
+ function validateOpeningSentence(artifact) {
224
+ if (!artifact.openingSentence) {
225
+ return {
226
+ file: artifact.filePath,
227
+ line: artifact.titleLine ?? undefined,
228
+ message: "Missing or malformed opening sentence after H1 heading"
229
+ };
230
+ }
231
+ return null;
232
+ }
233
+ function validateOpeningSentenceLength(artifact) {
234
+ const openingSentence = artifact.openingSentence;
235
+ if (!openingSentence) {
236
+ return null;
237
+ }
238
+ if (openingSentence.length > MAX_OPENING_SENTENCE_LENGTH) {
239
+ return {
240
+ file: artifact.filePath,
241
+ line: artifact.openingSentenceLine ?? undefined,
242
+ message: `Opening sentence is ${openingSentence.length} characters (max ${MAX_OPENING_SENTENCE_LENGTH}). Split into multiple sentences; only the first sentence is checked.`
243
+ };
244
+ }
245
+ return null;
246
+ }
247
+ function validateImperativeOpeningSentence(artifact) {
248
+ const openingSentence = artifact.openingSentence;
249
+ if (!openingSentence) {
250
+ return null;
251
+ }
252
+ const firstWord = openingSentence.split(/\s/)[0].replace(/[^a-zA-Z]/g, "");
253
+ const lower = firstWord.toLowerCase();
254
+ if (NON_IMPERATIVE_STARTERS.has(lower) || lower.endsWith("ing")) {
255
+ const preview = openingSentence.length > 40 ? `${openingSentence.slice(0, 40)}...` : openingSentence;
256
+ return {
257
+ file: artifact.filePath,
258
+ line: artifact.openingSentenceLine ?? undefined,
259
+ message: `Opening sentence should use imperative form (e.g., "Add X" not "This adds X"). Found: "${preview}"`
260
+ };
261
+ }
262
+ return null;
263
+ }
264
+ function validateTaskHeadings(artifact) {
265
+ const violations = [];
266
+ const sectionHeadings = new Set(artifact.sections.map((s) => s.heading));
267
+ for (const heading of REQUIRED_TASK_HEADINGS) {
268
+ if (!sectionHeadings.has(heading)) {
269
+ violations.push({
270
+ file: artifact.filePath,
271
+ message: `Missing required heading: "## ${heading}"`
272
+ });
273
+ }
274
+ }
275
+ return violations;
276
+ }
277
+
278
+ // lib/lint/validators/directory-validator.ts
279
+ async function validateContentDirectoryFiles(dirPath, fileSystem) {
280
+ const violations = [];
281
+ let entries;
282
+ try {
283
+ entries = await fileSystem.readdir(dirPath);
284
+ } catch (error) {
285
+ if (error.code === "ENOENT") {
286
+ return [];
287
+ }
288
+ throw error;
289
+ }
290
+ for (const entry of entries) {
291
+ const entryPath = `${dirPath}/${entry}`;
292
+ if (entry.startsWith(".")) {
293
+ violations.push({
294
+ file: entryPath,
295
+ message: `Hidden file "${entry}" found in content directory`
296
+ });
297
+ continue;
298
+ }
299
+ if (fileSystem.isDirectory(entryPath)) {
300
+ violations.push({
301
+ file: entryPath,
302
+ message: `Subdirectory "${entry}" found in content directory (content directories should be flat)`
303
+ });
304
+ continue;
305
+ }
306
+ if (!entry.endsWith(".md")) {
307
+ violations.push({
308
+ file: entryPath,
309
+ message: `Non-markdown file "${entry}" found in content directory`
310
+ });
311
+ }
312
+ }
313
+ return violations;
314
+ }
315
+
316
+ // lib/markdown/markdown-utilities.ts
317
+ var MARKDOWN_LINK_PATTERN2 = /\[([^\]]+)\]\(([^)]+)\)/;
318
+
319
+ // lib/artifacts/workflow-tasks.ts
320
+ var IDEA_TRANSITION_PREFIXES = [
321
+ "Refine Idea: ",
322
+ "Decompose Idea: ",
323
+ "Shelve Idea: ",
324
+ "Expedite Idea: "
325
+ ];
326
+ function titleToFilename(title) {
327
+ return `${title.toLowerCase().replace(/\./g, "-").replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")}.md`;
328
+ }
329
+
330
+ // lib/lint/validators/filename-validator.ts
331
+ var SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*\.md$/;
332
+ function validateFilename(filePath) {
333
+ const parts = filePath.split("/");
334
+ const filename = parts[parts.length - 1];
335
+ if (!SLUG_PATTERN.test(filename)) {
336
+ return {
337
+ file: filePath,
338
+ message: `Filename "${filename}" does not match slug-style naming`
339
+ };
340
+ }
341
+ return null;
342
+ }
343
+ function validateTitleFilenameMatch(artifact) {
344
+ const title = artifact.title;
345
+ if (!title) {
346
+ return null;
347
+ }
348
+ const parts = artifact.filePath.split("/");
349
+ const actualFilename = parts[parts.length - 1];
350
+ const expectedFilename = titleToFilename(title);
351
+ if (actualFilename !== expectedFilename) {
352
+ return {
353
+ file: artifact.filePath,
354
+ message: `Filename "${actualFilename}" does not match title "${title}" (expected "${expectedFilename}")`
355
+ };
356
+ }
357
+ return null;
358
+ }
359
+
360
+ // lib/lint/validators/idea-validator.ts
361
+ var WORKFLOW_PREFIX_TO_SECTION = {
362
+ "Refine Idea: ": "Refines Idea",
363
+ "Decompose Idea: ": "Decomposes Idea",
364
+ "Shelve Idea: ": "Shelves Idea",
365
+ "Expedite Idea: ": "Expedites Idea"
366
+ };
367
+ function validateH2Heading(filePath, line, lineNumber, inOpenQuestions, currentQuestionLine) {
368
+ const violations = [];
369
+ if (inOpenQuestions && currentQuestionLine !== null) {
370
+ violations.push({
371
+ file: filePath,
372
+ message: "Question has no options listed beneath it",
373
+ line: currentQuestionLine
374
+ });
375
+ }
376
+ if (inOpenQuestions && line !== "## Open Questions") {
377
+ violations.push({
378
+ file: filePath,
379
+ message: "Open Questions must be the last section in an idea file. Move this section above ## Open Questions.",
380
+ line: lineNumber
381
+ });
382
+ }
383
+ const headingText = line.slice(3).trimEnd();
384
+ if (headingText.toLowerCase() === "open questions" && headingText !== "Open Questions") {
385
+ violations.push({
386
+ file: filePath,
387
+ message: `Heading "${line.trimEnd()}" should be "## Open Questions"`,
388
+ line: lineNumber
389
+ });
390
+ }
391
+ return violations;
392
+ }
393
+ function validateIdeaOpenQuestions(artifact) {
394
+ const violations = [];
395
+ const lines = artifact.rawContent.split(`
396
+ `);
397
+ const filePath = artifact.filePath;
398
+ 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.";
399
+ let inOpenQuestions = false;
400
+ let currentQuestionLine = null;
401
+ let inOption = false;
402
+ let inCodeBlock = false;
403
+ for (let i = 0;i < lines.length; i++) {
404
+ const line = lines[i];
405
+ const trimmedLine = line.trimEnd();
406
+ const nonWhitespaceLine = line.trim();
407
+ if (inOpenQuestions && line.startsWith("```")) {
408
+ if (!inOption && !inCodeBlock) {
409
+ violations.push({
410
+ file: filePath,
411
+ message: topLevelStructureMessage,
412
+ line: i + 1
413
+ });
414
+ }
415
+ inCodeBlock = !inCodeBlock;
416
+ continue;
417
+ }
418
+ if (inCodeBlock)
419
+ continue;
420
+ if (line.startsWith("## ")) {
421
+ violations.push(...validateH2Heading(filePath, line, i + 1, inOpenQuestions, currentQuestionLine));
422
+ inOpenQuestions = line === "## Open Questions";
423
+ currentQuestionLine = null;
424
+ inOption = false;
425
+ inCodeBlock = false;
426
+ continue;
427
+ }
428
+ if (!inOpenQuestions)
429
+ continue;
430
+ if (line.startsWith("### ")) {
431
+ inOption = false;
432
+ if (currentQuestionLine !== null) {
433
+ violations.push({
434
+ file: filePath,
435
+ message: "Question has no options listed beneath it",
436
+ line: currentQuestionLine
437
+ });
438
+ }
439
+ if (!trimmedLine.endsWith("?")) {
440
+ violations.push({
441
+ file: filePath,
442
+ message: 'Questions must end with "?" (e.g., "### Should we take our own payments?")',
443
+ line: i + 1
444
+ });
445
+ currentQuestionLine = null;
446
+ } else {
447
+ currentQuestionLine = i + 1;
448
+ }
449
+ continue;
450
+ }
451
+ if (line.startsWith("#### ")) {
452
+ currentQuestionLine = null;
453
+ inOption = true;
454
+ continue;
455
+ }
456
+ if (nonWhitespaceLine && !inOption) {
457
+ violations.push({
458
+ file: filePath,
459
+ message: topLevelStructureMessage,
460
+ line: i + 1
461
+ });
462
+ }
463
+ }
464
+ if (inOpenQuestions && currentQuestionLine !== null) {
465
+ violations.push({
466
+ file: filePath,
467
+ message: "Question has no options listed beneath it",
468
+ line: currentQuestionLine
469
+ });
470
+ }
471
+ return violations;
472
+ }
473
+ function validateIdeaTransitionTitle(artifact, ideasPath, fileSystem) {
474
+ const title = artifact.title;
475
+ if (!title) {
476
+ return null;
477
+ }
478
+ for (const prefix of IDEA_TRANSITION_PREFIXES) {
479
+ if (title.startsWith(prefix)) {
480
+ const ideaTitle = title.slice(prefix.length);
481
+ const ideaFilename = titleToFilename(ideaTitle);
482
+ if (!fileSystem.exists(`${ideasPath}/${ideaFilename}`)) {
483
+ return {
484
+ file: artifact.filePath,
485
+ message: `Idea transition task references non-existent idea: "${ideaTitle}" (expected file "${ideaFilename}" in ideas/)`
486
+ };
487
+ }
488
+ return null;
489
+ }
490
+ }
491
+ return null;
492
+ }
493
+ function validateWorkflowTaskBodySection(artifact, ideasPath, fileSystem) {
494
+ const violations = [];
495
+ const title = artifact.title;
496
+ if (!title)
497
+ return violations;
498
+ let matchedPrefix = null;
499
+ for (const prefix of IDEA_TRANSITION_PREFIXES) {
500
+ if (title.startsWith(prefix)) {
501
+ matchedPrefix = prefix;
502
+ break;
503
+ }
504
+ }
505
+ if (!matchedPrefix)
506
+ return violations;
507
+ const expectedHeading = WORKFLOW_PREFIX_TO_SECTION[matchedPrefix];
508
+ const section = artifact.sections.find((s) => s.heading === expectedHeading && s.level === 2);
509
+ if (!section) {
510
+ violations.push({
511
+ file: artifact.filePath,
512
+ message: `Workflow task with "${matchedPrefix.trim()}" prefix is missing required "## ${expectedHeading}" section. Add a section with a link to the idea file, e.g.:
513
+
514
+ ## ${expectedHeading}
515
+
516
+ - [Idea Title](../ideas/idea-slug.md)`
517
+ });
518
+ return violations;
519
+ }
520
+ if (section.links.length === 0) {
521
+ violations.push({
522
+ file: artifact.filePath,
523
+ message: `"## ${expectedHeading}" section contains no link. Add a markdown link to the idea file, e.g.:
524
+
525
+ - [Idea Title](../ideas/idea-slug.md)`,
526
+ line: section.startLine
527
+ });
528
+ return violations;
529
+ }
530
+ const ideaLinks = section.links.filter((l) => l.target.includes("/ideas/") || l.target.startsWith("../ideas/"));
531
+ if (ideaLinks.length === 0) {
532
+ violations.push({
533
+ file: artifact.filePath,
534
+ message: `"## ${expectedHeading}" section contains no link to an idea file. Links must point to a file in ../ideas/, e.g.:
535
+
536
+ - [Idea Title](../ideas/idea-slug.md)`,
537
+ line: section.startLine
538
+ });
539
+ return violations;
540
+ }
541
+ for (const link of ideaLinks) {
542
+ const slugMatch = link.target.match(/([^/]+)\.md$/);
543
+ if (!slugMatch)
544
+ continue;
545
+ const ideaSlug = slugMatch[1];
546
+ const ideaFilePath = `${ideasPath}/${ideaSlug}.md`;
547
+ if (!fileSystem.exists(ideaFilePath)) {
548
+ violations.push({
549
+ file: artifact.filePath,
550
+ 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.`,
551
+ line: link.line
552
+ });
553
+ }
554
+ }
555
+ return violations;
556
+ }
557
+
558
+ // lib/lint/validators/link-validator.ts
559
+ import { dirname, resolve } from "node:path";
560
+ var SEMANTIC_RULES = [
561
+ {
562
+ sectionHeading: "Principles",
563
+ requiredPath: "/.dust/principles/",
564
+ description: "principle"
565
+ },
566
+ {
567
+ sectionHeading: "Blocked By",
568
+ requiredPath: "/.dust/tasks/",
569
+ description: "task"
570
+ }
571
+ ];
572
+ function isExternalOrAnchorLink(target) {
573
+ return target.startsWith("http://") || target.startsWith("https://") || target.startsWith("#");
574
+ }
575
+ function isAnchorLink(target) {
576
+ return target.startsWith("#");
577
+ }
578
+ function isExternalLink(target) {
579
+ return target.startsWith("http://") || target.startsWith("https://");
580
+ }
581
+ function validateLinks(artifact, fileSystem) {
582
+ const violations = [];
583
+ const fileDir = dirname(artifact.filePath);
584
+ for (const link of artifact.allLinks) {
585
+ if (isExternalOrAnchorLink(link.target)) {
586
+ continue;
587
+ }
588
+ if (link.target.startsWith("/")) {
589
+ violations.push({
590
+ file: artifact.filePath,
591
+ message: `Absolute link not allowed: "${link.target}" (use a relative path instead)`,
592
+ line: link.line
593
+ });
594
+ continue;
595
+ }
596
+ const targetPath = link.target.split("#")[0];
597
+ const resolvedPath = resolve(fileDir, targetPath);
598
+ if (!fileSystem.exists(resolvedPath)) {
599
+ violations.push({
600
+ file: artifact.filePath,
601
+ message: `Broken link: "${link.target}"`,
602
+ line: link.line
603
+ });
604
+ }
605
+ }
606
+ return violations;
607
+ }
608
+ function validateSectionLink(artifact, link, rule) {
609
+ const sectionLabel = `## ${rule.sectionHeading}`;
610
+ if (isAnchorLink(link.target)) {
611
+ return {
612
+ file: artifact.filePath,
613
+ message: `Link in "${sectionLabel}" must point to a ${rule.description} file, not an anchor: "${link.target}"`,
614
+ line: link.line
615
+ };
616
+ }
617
+ if (isExternalLink(link.target)) {
618
+ return {
619
+ file: artifact.filePath,
620
+ message: `Link in "${sectionLabel}" must point to a ${rule.description} file, not an external URL: "${link.target}"`,
621
+ line: link.line
622
+ };
623
+ }
624
+ const fileDir = dirname(artifact.filePath);
625
+ const targetPath = link.target.split("#")[0];
626
+ const resolvedPath = resolve(fileDir, targetPath);
627
+ if (!resolvedPath.includes(rule.requiredPath)) {
628
+ return {
629
+ file: artifact.filePath,
630
+ message: `Link in "${sectionLabel}" must point to a ${rule.description} file: "${link.target}"`,
631
+ line: link.line
632
+ };
633
+ }
634
+ return null;
635
+ }
636
+ function validateSemanticLinks(artifact) {
637
+ const violations = [];
638
+ for (const section of artifact.sections) {
639
+ const rule = SEMANTIC_RULES.find((r) => r.sectionHeading === section.heading);
640
+ if (!rule)
641
+ continue;
642
+ for (const link of section.links) {
643
+ const violation = validateSectionLink(artifact, link, rule);
644
+ if (violation) {
645
+ violations.push(violation);
646
+ }
647
+ }
648
+ }
649
+ return violations;
650
+ }
651
+ function validatePrincipleHierarchyLinks(artifact) {
652
+ const violations = [];
653
+ const hierarchySections = ["Parent Principle", "Sub-Principles"];
654
+ for (const section of artifact.sections) {
655
+ if (!hierarchySections.includes(section.heading))
656
+ continue;
657
+ const sectionLabel = `## ${section.heading}`;
658
+ const fileDir = dirname(artifact.filePath);
659
+ for (const link of section.links) {
660
+ if (isAnchorLink(link.target)) {
661
+ violations.push({
662
+ file: artifact.filePath,
663
+ message: `Link in "${sectionLabel}" must point to a principle file, not an anchor: "${link.target}"`,
664
+ line: link.line
665
+ });
666
+ continue;
667
+ }
668
+ if (isExternalLink(link.target)) {
669
+ violations.push({
670
+ file: artifact.filePath,
671
+ message: `Link in "${sectionLabel}" must point to a principle file, not an external URL: "${link.target}"`,
672
+ line: link.line
673
+ });
674
+ continue;
675
+ }
676
+ const targetPath = link.target.split("#")[0];
677
+ const resolvedPath = resolve(fileDir, targetPath);
678
+ if (!resolvedPath.includes("/.dust/principles/")) {
679
+ violations.push({
680
+ file: artifact.filePath,
681
+ message: `Link in "${sectionLabel}" must point to a principle file: "${link.target}"`,
682
+ line: link.line
683
+ });
684
+ }
685
+ }
686
+ }
687
+ return violations;
688
+ }
689
+
690
+ // lib/lint/validators/principle-hierarchy.ts
691
+ import { dirname as dirname2, resolve as resolve2 } from "node:path";
692
+ var REQUIRED_PRINCIPLE_HEADINGS = ["Parent Principle", "Sub-Principles"];
693
+ function validatePrincipleHierarchySections(artifact) {
694
+ const violations = [];
695
+ const sectionHeadings = new Set(artifact.sections.map((s) => s.heading));
696
+ for (const heading of REQUIRED_PRINCIPLE_HEADINGS) {
697
+ if (!sectionHeadings.has(heading)) {
698
+ violations.push({
699
+ file: artifact.filePath,
700
+ message: `Missing required heading: "## ${heading}"`
701
+ });
702
+ }
703
+ }
704
+ return violations;
705
+ }
706
+ function isLocalPrincipleLink(target, resolvedPath) {
707
+ const isLocalLink = !target.startsWith("#") && !target.startsWith("http://") && !target.startsWith("https://");
708
+ return isLocalLink && resolvedPath.includes("/.dust/principles/");
709
+ }
710
+ function extractPrincipleRelationships(artifact) {
711
+ const fileDir = dirname2(artifact.filePath);
712
+ const parentPrinciples = [];
713
+ const subPrinciples = [];
714
+ for (const section of artifact.sections) {
715
+ if (section.heading !== "Parent Principle" && section.heading !== "Sub-Principles") {
716
+ continue;
717
+ }
718
+ for (const link of section.links) {
719
+ const targetPath = link.target.split("#")[0];
720
+ const resolvedPath = resolve2(fileDir, targetPath);
721
+ if (!isLocalPrincipleLink(link.target, resolvedPath)) {
722
+ continue;
723
+ }
724
+ if (section.heading === "Parent Principle") {
725
+ parentPrinciples.push(resolvedPath);
726
+ } else {
727
+ subPrinciples.push(resolvedPath);
728
+ }
729
+ }
730
+ }
731
+ return { filePath: artifact.filePath, parentPrinciples, subPrinciples };
732
+ }
733
+ function validateBidirectionalLinks(allPrincipleRelationships) {
734
+ const violations = [];
735
+ const relationshipMap = new Map;
736
+ for (const rel of allPrincipleRelationships) {
737
+ relationshipMap.set(rel.filePath, rel);
738
+ }
739
+ for (const rel of allPrincipleRelationships) {
740
+ for (const parentPath of rel.parentPrinciples) {
741
+ const parentRel = relationshipMap.get(parentPath);
742
+ if (parentRel && !parentRel.subPrinciples.includes(rel.filePath)) {
743
+ violations.push({
744
+ file: rel.filePath,
745
+ message: `Parent principle "${parentPath}" does not list this principle as a sub-principle`
746
+ });
747
+ }
748
+ }
749
+ for (const subPrinciplePath of rel.subPrinciples) {
750
+ const subPrincipleRel = relationshipMap.get(subPrinciplePath);
751
+ if (subPrincipleRel && !subPrincipleRel.parentPrinciples.includes(rel.filePath)) {
752
+ violations.push({
753
+ file: rel.filePath,
754
+ message: `Sub-principle "${subPrinciplePath}" does not list this principle as its parent`
755
+ });
756
+ }
757
+ }
758
+ }
759
+ return violations;
760
+ }
761
+ function validateNoCycles(allPrincipleRelationships) {
762
+ const violations = [];
763
+ const relationshipMap = new Map;
764
+ for (const rel of allPrincipleRelationships) {
765
+ relationshipMap.set(rel.filePath, rel);
766
+ }
767
+ for (const rel of allPrincipleRelationships) {
768
+ const visited = new Set;
769
+ const path = [];
770
+ let current = rel.filePath;
771
+ while (current) {
772
+ if (visited.has(current)) {
773
+ const cycleStart = path.indexOf(current);
774
+ const cyclePath = path.slice(cycleStart).concat(current);
775
+ violations.push({
776
+ file: rel.filePath,
777
+ message: `Cycle detected in principle hierarchy: ${cyclePath.join(" -> ")}`
778
+ });
779
+ break;
780
+ }
781
+ visited.add(current);
782
+ path.push(current);
783
+ const currentRel = relationshipMap.get(current);
784
+ if (currentRel && currentRel.parentPrinciples.length > 0) {
785
+ current = currentRel.parentPrinciples[0];
786
+ } else {
787
+ current = null;
788
+ }
789
+ }
790
+ }
791
+ return violations;
792
+ }
793
+
794
+ // lib/validation/validation-pipeline.ts
795
+ var CONTENT_DIRS = ["principles", "facts", "ideas", "tasks"];
796
+ async function parseArtifacts(fileSystem, dustPath) {
797
+ const artifacts = new Map;
798
+ const byType = {
799
+ ideas: [],
800
+ tasks: [],
801
+ principles: [],
802
+ facts: []
803
+ };
804
+ const rootFiles = [];
805
+ const violations = [];
806
+ let rootEntries;
807
+ try {
808
+ rootEntries = await fileSystem.readdir(dustPath);
809
+ } catch (error) {
810
+ if (error.code === "ENOENT") {
811
+ rootEntries = [];
812
+ } else {
813
+ throw error;
814
+ }
815
+ }
816
+ for (const entry of rootEntries) {
817
+ if (!entry.endsWith(".md"))
818
+ continue;
819
+ const filePath = `${dustPath}/${entry}`;
820
+ let content;
821
+ try {
822
+ content = await fileSystem.readFile(filePath);
823
+ } catch (error) {
824
+ if (error.code === "ENOENT") {
825
+ continue;
826
+ }
827
+ throw error;
828
+ }
829
+ const artifact = parseArtifact(filePath, content);
830
+ artifacts.set(filePath, artifact);
831
+ rootFiles.push(artifact);
832
+ }
833
+ for (const dir of CONTENT_DIRS) {
834
+ const dirPath = `${dustPath}/${dir}`;
835
+ violations.push(...await validateContentDirectoryFiles(dirPath, fileSystem));
836
+ let entries;
837
+ try {
838
+ entries = await fileSystem.readdir(dirPath);
839
+ } catch (error) {
840
+ if (error.code === "ENOENT") {
841
+ continue;
842
+ }
843
+ throw error;
844
+ }
845
+ for (const entry of entries) {
846
+ if (!entry.endsWith(".md"))
847
+ continue;
848
+ const filePath = `${dirPath}/${entry}`;
849
+ let content;
850
+ try {
851
+ content = await fileSystem.readFile(filePath);
852
+ } catch (error) {
853
+ if (error.code === "ENOENT") {
854
+ continue;
855
+ }
856
+ throw error;
857
+ }
858
+ const artifact = parseArtifact(filePath, content);
859
+ artifacts.set(filePath, artifact);
860
+ byType[dir].push(artifact);
861
+ }
862
+ }
863
+ return {
864
+ context: {
865
+ artifacts,
866
+ byType,
867
+ rootFiles,
868
+ dustPath,
869
+ fileSystem
870
+ },
871
+ violations
872
+ };
873
+ }
874
+ function validateArtifacts(context) {
875
+ const violations = [];
876
+ const { byType, rootFiles, dustPath, fileSystem } = context;
877
+ const ideasPath = `${dustPath}/ideas`;
878
+ for (const artifact of rootFiles) {
879
+ violations.push(...validateLinks(artifact, fileSystem));
880
+ }
881
+ for (const artifacts of Object.values(byType)) {
882
+ for (const artifact of artifacts) {
883
+ const openingSentenceViolation = validateOpeningSentence(artifact);
884
+ if (openingSentenceViolation)
885
+ violations.push(openingSentenceViolation);
886
+ const lengthViolation = validateOpeningSentenceLength(artifact);
887
+ if (lengthViolation)
888
+ violations.push(lengthViolation);
889
+ const titleViolation = validateTitleFilenameMatch(artifact);
890
+ if (titleViolation)
891
+ violations.push(titleViolation);
892
+ violations.push(...validateLinks(artifact, fileSystem));
893
+ }
894
+ }
895
+ for (const artifact of byType.ideas) {
896
+ violations.push(...validateIdeaOpenQuestions(artifact));
897
+ }
898
+ for (const artifact of byType.tasks) {
899
+ const filenameViolation = validateFilename(artifact.filePath);
900
+ if (filenameViolation)
901
+ violations.push(filenameViolation);
902
+ violations.push(...validateTaskHeadings(artifact));
903
+ violations.push(...validateSemanticLinks(artifact));
904
+ const imperativeViolation = validateImperativeOpeningSentence(artifact);
905
+ if (imperativeViolation)
906
+ violations.push(imperativeViolation);
907
+ const ideaTransitionViolation = validateIdeaTransitionTitle(artifact, ideasPath, fileSystem);
908
+ if (ideaTransitionViolation)
909
+ violations.push(ideaTransitionViolation);
910
+ violations.push(...validateWorkflowTaskBodySection(artifact, ideasPath, fileSystem));
911
+ }
912
+ const allPrincipleRelationships = [];
913
+ for (const artifact of byType.principles) {
914
+ violations.push(...validatePrincipleHierarchySections(artifact));
915
+ violations.push(...validatePrincipleHierarchyLinks(artifact));
916
+ allPrincipleRelationships.push(extractPrincipleRelationships(artifact));
917
+ }
918
+ violations.push(...validateBidirectionalLinks(allPrincipleRelationships));
919
+ violations.push(...validateNoCycles(allPrincipleRelationships));
920
+ return violations;
921
+ }
922
+
923
+ // lib/validation/index.ts
924
+ var ALLOWED_ROOT_DIRECTORIES = [
925
+ "config",
926
+ "facts",
927
+ "ideas",
928
+ "principles",
929
+ "tasks"
930
+ ];
931
+ var ALLOWED_ROOT_FILES = ["repository.md"];
932
+ var ALLOWED_ROOT_PATHS = [
933
+ ...ALLOWED_ROOT_DIRECTORIES.map((directory) => `${directory}/`),
934
+ ...ALLOWED_ROOT_FILES
935
+ ].join(", ");
936
+ function validatePatchRootEntries(fileSystem, dustPath, patch) {
937
+ const violations = [];
938
+ const sortedPaths = Object.keys(patch.files).toSorted();
939
+ const reportedUnexpectedRootDirectories = new Set;
940
+ for (const relativePath of sortedPaths) {
941
+ const content = patch.files[relativePath];
942
+ if (content === null)
943
+ continue;
944
+ const [rootEntry] = relativePath.split("/");
945
+ if (!rootEntry)
946
+ continue;
947
+ if (ALLOWED_ROOT_DIRECTORIES.includes(rootEntry) || ALLOWED_ROOT_FILES.includes(rootEntry)) {
948
+ continue;
949
+ }
950
+ const rootEntryPath = `${dustPath}/${rootEntry}`;
951
+ if (relativePath.includes("/")) {
952
+ if (!reportedUnexpectedRootDirectories.has(rootEntry) && !fileSystem.isDirectory(rootEntryPath)) {
953
+ violations.push({
954
+ file: rootEntryPath,
955
+ message: `Unexpected directory "${rootEntry}" in .dust/. Allowed root paths: ${ALLOWED_ROOT_PATHS}`
956
+ });
957
+ reportedUnexpectedRootDirectories.add(rootEntry);
958
+ }
959
+ continue;
960
+ }
961
+ violations.push({
962
+ file: rootEntryPath,
963
+ message: `Unexpected file "${rootEntry}" in .dust/. Allowed root paths: ${ALLOWED_ROOT_PATHS}`
964
+ });
965
+ }
966
+ return violations;
967
+ }
968
+ function relativizeViolationFilePath(filePath, cwd) {
969
+ const relativePath = relative(cwd, filePath);
970
+ if (relativePath === "" || relativePath === "." || relativePath === ".." || relativePath.startsWith("../") || relativePath.startsWith("..\\")) {
971
+ return filePath;
972
+ }
973
+ return relativePath;
974
+ }
975
+ function relativizeViolations(violations, cwd) {
976
+ return violations.map((violation) => ({
977
+ ...violation,
978
+ file: relativizeViolationFilePath(violation.file, cwd)
979
+ }));
980
+ }
981
+ function parsePatchFiles(dustPath, patch) {
982
+ const absolutePatchFiles = new Map;
983
+ const deletedPaths = new Set;
984
+ for (const [relativePath, content] of Object.entries(patch.files)) {
985
+ const absolutePath = `${dustPath}/${relativePath}`;
986
+ if (content === null) {
987
+ deletedPaths.add(absolutePath);
988
+ } else {
989
+ absolutePatchFiles.set(absolutePath, content);
990
+ }
991
+ }
992
+ return { absolutePatchFiles, deletedPaths };
993
+ }
994
+ async function validatePatch(fileSystem, dustPath, patch, options = {}) {
995
+ const cwd = options.cwd ?? process.cwd();
996
+ const { absolutePatchFiles, deletedPaths } = parsePatchFiles(dustPath, patch);
997
+ const overlayFs = createOverlayFileSystem(fileSystem, absolutePatchFiles, deletedPaths);
998
+ const violations = [];
999
+ violations.push(...validatePatchRootEntries(fileSystem, dustPath, patch));
1000
+ const { context, violations: parseViolations } = await parseArtifacts(overlayFs, dustPath);
1001
+ violations.push(...parseViolations);
1002
+ violations.push(...validateArtifacts(context));
1003
+ return {
1004
+ valid: violations.length === 0,
1005
+ violations: relativizeViolations(violations, cwd)
1006
+ };
1007
+ }
1008
+
1009
+ // lib/patch/fact.ts
1010
+ function serializeFact(input) {
1011
+ return `# ${input.title}
1012
+
1013
+ ${input.body}
1014
+ `;
1015
+ }
1016
+ function buildFactFiles(input, slug) {
1017
+ const content = serializeFact(input);
1018
+ return {
1019
+ [`facts/${slug}.md`]: content
1020
+ };
1021
+ }
1022
+
1023
+ // lib/patch/idea.ts
1024
+ function renderOpenQuestionsSection(openQuestions) {
1025
+ const parts = ["## Open Questions", ""];
1026
+ for (const q of openQuestions) {
1027
+ parts.push(`### ${q.question}`);
1028
+ parts.push("");
1029
+ for (const o of q.options) {
1030
+ parts.push(`#### ${o.name}`);
1031
+ parts.push("");
1032
+ if (o.description) {
1033
+ parts.push(o.description);
1034
+ parts.push("");
1035
+ }
1036
+ }
1037
+ }
1038
+ return parts.join(`
1039
+ `);
1040
+ }
1041
+ function serializeIdea(input) {
1042
+ const parts = [];
1043
+ parts.push(`# ${input.title}`);
1044
+ parts.push("");
1045
+ if (input.body) {
1046
+ parts.push(input.body.trimEnd());
1047
+ parts.push("");
1048
+ }
1049
+ if (input.openQuestions && input.openQuestions.length > 0) {
1050
+ parts.push(renderOpenQuestionsSection(input.openQuestions));
1051
+ }
1052
+ return parts.join(`
1053
+ `);
1054
+ }
1055
+ function buildIdeaFiles(input, slug) {
1056
+ const content = serializeIdea(input);
1057
+ return {
1058
+ [`ideas/${slug}.md`]: content
1059
+ };
1060
+ }
1061
+
1062
+ // lib/patch/principle.ts
1063
+ function slugToTitle(slug) {
1064
+ return slug.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
1065
+ }
1066
+ function renderParentPrincipleSection(parentPrinciple) {
1067
+ if (parentPrinciple === null || parentPrinciple === undefined) {
1068
+ return `## Parent Principle
1069
+
1070
+ - (none)
1071
+ `;
1072
+ }
1073
+ const title = slugToTitle(parentPrinciple);
1074
+ return `## Parent Principle
1075
+
1076
+ - [${title}](${parentPrinciple}.md)
1077
+ `;
1078
+ }
1079
+ function renderSubPrinciplesSection(subPrinciples) {
1080
+ if (subPrinciples.length === 0) {
1081
+ return `## Sub-Principles
1082
+
1083
+ - (none)
1084
+ `;
1085
+ }
1086
+ const links = subPrinciples.map((slug) => {
1087
+ const title = slugToTitle(slug);
1088
+ return `- [${title}](${slug}.md)`;
1089
+ });
1090
+ return `## Sub-Principles
1091
+
1092
+ ${links.join(`
1093
+ `)}
1094
+ `;
1095
+ }
1096
+ function serializePrinciple(input) {
1097
+ const sections = [];
1098
+ sections.push(`# ${input.title}`);
1099
+ if (input.body) {
1100
+ sections.push(input.body);
1101
+ }
1102
+ sections.push(renderParentPrincipleSection(input.parentPrinciple ?? null));
1103
+ sections.push(renderSubPrinciplesSection(input.subPrinciples ?? []));
1104
+ return sections.join(`
1105
+
1106
+ `);
1107
+ }
1108
+ function buildPrincipleFiles(input, slug) {
1109
+ const content = serializePrinciple(input);
1110
+ return {
1111
+ [`principles/${slug}.md`]: content
1112
+ };
1113
+ }
1114
+
1115
+ // lib/patch/task.ts
1116
+ var WORKFLOW_SECTION_HEADINGS = {
1117
+ "capture-idea": "Captures Idea",
1118
+ "refine-idea": "Refines Idea",
1119
+ "decompose-idea": "Decomposes Idea",
1120
+ "shelve-idea": "Shelves Idea"
1121
+ };
1122
+ var WORKFLOW_TITLE_PREFIXES = {
1123
+ "capture-idea": "Add Idea: ",
1124
+ "refine-idea": "Refine Idea: ",
1125
+ "decompose-idea": "Decompose Idea: ",
1126
+ "shelve-idea": "Shelve Idea: "
1127
+ };
1128
+ var WORKFLOW_OPENING_SENTENCES = {
1129
+ "capture-idea": "Research this idea thoroughly and create an idea file.",
1130
+ "refine-idea": "Thoroughly research this idea and refine it into a well-defined proposal.",
1131
+ "decompose-idea": "Create one or more well-defined tasks from this idea.",
1132
+ "shelve-idea": "Archive this idea and remove it from the active backlog."
1133
+ };
1134
+ var WORKFLOW_DEFAULT_DEFINITION_OF_DONE = {
1135
+ "capture-idea": ["Idea file is created in .dust/ideas/"],
1136
+ "refine-idea": [
1137
+ "Idea is thoroughly researched with relevant codebase context"
1138
+ ],
1139
+ "decompose-idea": ["One or more new tasks are created in .dust/tasks/"],
1140
+ "shelve-idea": ["Idea file is deleted"]
1141
+ };
1142
+ function ideaSlugToTitle(slug) {
1143
+ return slug.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
1144
+ }
1145
+ function renderPrinciplesSection(principles) {
1146
+ const links = principles.map((slug) => `- [${ideaSlugToTitle(slug)}](../principles/${slug}.md)`);
1147
+ return `## Principles
1148
+
1149
+ ${links.join(`
1150
+ `)}
1151
+ `;
1152
+ }
1153
+ function renderBlockedBySection(blockedBy) {
1154
+ if (blockedBy.length === 0) {
1155
+ return `## Blocked By
1156
+
1157
+ (none)
1158
+ `;
1159
+ }
1160
+ const links = blockedBy.map((slug) => `- [${ideaSlugToTitle(slug)}](${slug}.md)`);
1161
+ return `## Blocked By
1162
+
1163
+ ${links.join(`
1164
+ `)}
1165
+ `;
1166
+ }
1167
+ function renderDefinitionOfDoneSection(items) {
1168
+ return `## Definition of Done
1169
+
1170
+ ${items.map((item) => `- ${item}`).join(`
1171
+ `)}
1172
+ `;
1173
+ }
1174
+ function serializeStandardTask(input) {
1175
+ const sections = [];
1176
+ sections.push(`# ${input.title}`);
1177
+ if (input.body) {
1178
+ sections.push(input.body);
1179
+ }
1180
+ if (input.principles && input.principles.length > 0) {
1181
+ sections.push(renderPrinciplesSection(input.principles));
1182
+ }
1183
+ sections.push(renderBlockedBySection(input.blockedBy ?? []));
1184
+ sections.push(renderDefinitionOfDoneSection(input.definitionOfDone));
1185
+ return sections.join(`
1186
+
1187
+ `);
1188
+ }
1189
+ function serializeWorkflowTask(input) {
1190
+ const ideaTitle = ideaSlugToTitle(input.ideaSlug);
1191
+ const prefix = WORKFLOW_TITLE_PREFIXES[input.type];
1192
+ const title = `${prefix}${ideaTitle}`;
1193
+ const openingSentence = WORKFLOW_OPENING_SENTENCES[input.type];
1194
+ const sectionHeading = WORKFLOW_SECTION_HEADINGS[input.type];
1195
+ const definitionOfDone = input.definitionOfDone ?? WORKFLOW_DEFAULT_DEFINITION_OF_DONE[input.type];
1196
+ const ideaSection = `## ${sectionHeading}
1197
+
1198
+ - [${ideaTitle}](../ideas/${input.ideaSlug}.md)`;
1199
+ return `# ${title}
1200
+
1201
+ ${openingSentence}
1202
+
1203
+ ${ideaSection}
1204
+
1205
+ ## Blocked By
1206
+
1207
+ (none)
1208
+
1209
+ ## Definition of Done
1210
+
1211
+ ${definitionOfDone.map((item) => `- ${item}`).join(`
1212
+ `)}
1213
+ `;
1214
+ }
1215
+ function serializeTask(input) {
1216
+ if (input.type) {
1217
+ return serializeWorkflowTask(input);
1218
+ }
1219
+ return serializeStandardTask(input);
1220
+ }
1221
+ function buildTaskFiles(input, slug) {
1222
+ const content = serializeTask(input);
1223
+ return {
1224
+ [`tasks/${slug}.md`]: content
1225
+ };
1226
+ }
1227
+
1228
+ // lib/patch/index.ts
1229
+ var CONTENT_DIRS2 = ["principles", "facts", "ideas", "tasks"];
1230
+ function validatePrincipleHierarchy(principles, existingPrinciples, deletedPrinciples) {
1231
+ const violations = [];
1232
+ const allPrinciples = new Map;
1233
+ for (const [slug, rel] of existingPrinciples) {
1234
+ if (!deletedPrinciples.has(slug)) {
1235
+ allPrinciples.set(slug, rel);
1236
+ }
1237
+ }
1238
+ for (const [slug, input] of Object.entries(principles)) {
1239
+ allPrinciples.set(slug, {
1240
+ slug,
1241
+ parentPrinciple: input.parentPrinciple ?? null,
1242
+ subPrinciples: input.subPrinciples ?? []
1243
+ });
1244
+ }
1245
+ for (const [slug, rel] of allPrinciples) {
1246
+ const filePath = `principles/${slug}.md`;
1247
+ if (rel.parentPrinciple !== null) {
1248
+ const parent = allPrinciples.get(rel.parentPrinciple);
1249
+ if (parent && !parent.subPrinciples.includes(slug)) {
1250
+ violations.push({
1251
+ file: filePath,
1252
+ message: `Parent principle "${rel.parentPrinciple}" does not list this principle as a sub-principle`
1253
+ });
1254
+ }
1255
+ }
1256
+ for (const subSlug of rel.subPrinciples) {
1257
+ const child = allPrinciples.get(subSlug);
1258
+ if (child && child.parentPrinciple !== slug) {
1259
+ violations.push({
1260
+ file: filePath,
1261
+ message: `Sub-principle "${subSlug}" does not list this principle as its parent`
1262
+ });
1263
+ }
1264
+ }
1265
+ }
1266
+ return violations;
1267
+ }
1268
+ async function loadExistingPrincipleRelationships(fileSystem, dustPath) {
1269
+ const relationships = new Map;
1270
+ const principlesDir = `${dustPath}/principles`;
1271
+ let entries;
1272
+ try {
1273
+ entries = await fileSystem.readdir(principlesDir);
1274
+ } catch (error) {
1275
+ if (error.code === "ENOENT") {
1276
+ return relationships;
1277
+ }
1278
+ throw error;
1279
+ }
1280
+ for (const entry of entries) {
1281
+ if (!entry.endsWith(".md"))
1282
+ continue;
1283
+ const slug = entry.slice(0, -3);
1284
+ const filePath = `${principlesDir}/${entry}`;
1285
+ const content = await fileSystem.readFile(filePath);
1286
+ const rel = parsePrincipleRelationships(content, slug);
1287
+ relationships.set(slug, rel);
1288
+ }
1289
+ return relationships;
1290
+ }
1291
+ function parsePrincipleRelationships(content, slug) {
1292
+ const parentPrinciple = extractSingleSlugFromSection(content, "Parent Principle");
1293
+ const subPrinciples = extractSlugsFromSection(content, "Sub-Principles");
1294
+ return { slug, parentPrinciple, subPrinciples };
1295
+ }
1296
+ function extractSingleSlugFromSection(content, sectionHeading) {
1297
+ const slugs = extractSlugsFromSection(content, sectionHeading);
1298
+ return slugs.length === 1 ? slugs[0] : null;
1299
+ }
1300
+ function extractSlugsFromSection(content, sectionHeading) {
1301
+ const lines = content.split(`
1302
+ `);
1303
+ const slugs = [];
1304
+ let inSection = false;
1305
+ for (const line of lines) {
1306
+ if (line.startsWith("## ")) {
1307
+ inSection = line.trimEnd() === `## ${sectionHeading}`;
1308
+ continue;
1309
+ }
1310
+ if (!inSection)
1311
+ continue;
1312
+ if (line.startsWith("# "))
1313
+ break;
1314
+ const linkMatch = line.match(MARKDOWN_LINK_PATTERN2);
1315
+ if (linkMatch) {
1316
+ const target = linkMatch[2];
1317
+ const slugMatch = target.match(/([^/]+)\.md$/);
1318
+ if (slugMatch) {
1319
+ slugs.push(slugMatch[1]);
1320
+ }
1321
+ }
1322
+ }
1323
+ return slugs;
1324
+ }
1325
+ function updatePrincipleHierarchyOnDeletion(content, deletedPaths, sourceFilePath) {
1326
+ return cleanupPrincipleHierarchySections(removeLinksToDeletedPaths(content, deletedPaths, sourceFilePath));
1327
+ }
1328
+ function cleanupPrincipleHierarchySections(content) {
1329
+ const lines = content.split(`
1330
+ `);
1331
+ const result = [];
1332
+ let inHierarchySection = false;
1333
+ let hierarchySectionHeading = "";
1334
+ let sectionItems = [];
1335
+ let hasValidContent = false;
1336
+ for (let i = 0;i < lines.length; i++) {
1337
+ const line = lines[i];
1338
+ if (line.startsWith("## ")) {
1339
+ if (inHierarchySection) {
1340
+ result.push(hierarchySectionHeading, "");
1341
+ if (hasValidContent) {
1342
+ result.push(...sectionItems.filter((item) => MARKDOWN_LINK_PATTERN2.test(item)));
1343
+ } else {
1344
+ result.push("- (none)");
1345
+ }
1346
+ inHierarchySection = false;
1347
+ sectionItems = [];
1348
+ hasValidContent = false;
1349
+ }
1350
+ const heading = line.trimEnd();
1351
+ if (heading === "## Parent Principle" || heading === "## Sub-Principles") {
1352
+ inHierarchySection = true;
1353
+ hierarchySectionHeading = heading;
1354
+ continue;
1355
+ }
1356
+ }
1357
+ if (inHierarchySection) {
1358
+ if (line.startsWith("- ")) {
1359
+ sectionItems.push(line);
1360
+ if (MARKDOWN_LINK_PATTERN2.test(line)) {
1361
+ hasValidContent = true;
1362
+ }
1363
+ } else if (line.trim() === "(none)") {} else if (line.trim() !== "") {
1364
+ sectionItems.push(line);
1365
+ hasValidContent = true;
1366
+ }
1367
+ continue;
1368
+ }
1369
+ result.push(line);
1370
+ }
1371
+ if (inHierarchySection) {
1372
+ result.push(hierarchySectionHeading, "");
1373
+ if (hasValidContent) {
1374
+ result.push(...sectionItems.filter((item) => MARKDOWN_LINK_PATTERN2.test(item)));
1375
+ } else {
1376
+ result.push("- (none)");
1377
+ }
1378
+ }
1379
+ return result.join(`
1380
+ `);
1381
+ }
1382
+ async function findReferencesToDeletedPaths(fileSystem, dustPath, deletedPaths) {
1383
+ const updates = new Map;
1384
+ for (const dir of CONTENT_DIRS2) {
1385
+ const dirPath = `${dustPath}/${dir}`;
1386
+ let entries;
1387
+ try {
1388
+ entries = await fileSystem.readdir(dirPath);
1389
+ } catch (error) {
1390
+ if (error.code === "ENOENT") {
1391
+ continue;
1392
+ }
1393
+ throw error;
1394
+ }
1395
+ for (const entry of entries) {
1396
+ if (!entry.endsWith(".md"))
1397
+ continue;
1398
+ const filePath = `${dirPath}/${entry}`;
1399
+ const relativePath = `${dir}/${entry}`;
1400
+ if (deletedPaths.has(relativePath))
1401
+ continue;
1402
+ const content = await fileSystem.readFile(filePath);
1403
+ const updatedContent = removeLinksToDeletedPaths(content, deletedPaths, relativePath);
1404
+ if (updatedContent !== content) {
1405
+ updates.set(relativePath, updatedContent);
1406
+ }
1407
+ }
1408
+ }
1409
+ return updates;
1410
+ }
1411
+ function removeLinksToDeletedPaths(content, deletedPaths, sourceFilePath) {
1412
+ const globalPattern = new RegExp(MARKDOWN_LINK_PATTERN2.source, "g");
1413
+ const sourceDir = sourceFilePath.substring(0, sourceFilePath.lastIndexOf("/"));
1414
+ let linksRemoved = false;
1415
+ let result = content.replace(globalPattern, (match, text, target) => {
1416
+ const normalizedTarget = normalizeTargetPath(target, sourceDir);
1417
+ if (normalizedTarget !== null && deletedPaths.has(normalizedTarget)) {
1418
+ linksRemoved = true;
1419
+ return text;
1420
+ }
1421
+ return match;
1422
+ });
1423
+ if (linksRemoved) {
1424
+ result = cleanupBlockedBySection(result);
1425
+ }
1426
+ return result;
1427
+ }
1428
+ function filterBlockedByItems(items) {
1429
+ return items.filter((item) => MARKDOWN_LINK_PATTERN2.test(item));
1430
+ }
1431
+ function renderBlockedByContent(heading, items, hasRealContent) {
1432
+ const result = [heading];
1433
+ if (hasRealContent) {
1434
+ result.push(...filterBlockedByItems(items));
1435
+ } else {
1436
+ result.push("", "(none)");
1437
+ }
1438
+ return result;
1439
+ }
1440
+ function cleanupBlockedBySection(content) {
1441
+ const lines = content.split(`
1442
+ `);
1443
+ const result = [];
1444
+ let inBlockedBy = false;
1445
+ let blockedByStart = -1;
1446
+ let blockedByItems = [];
1447
+ let hasRealContent = false;
1448
+ for (let i = 0;i < lines.length; i++) {
1449
+ const line = lines[i];
1450
+ if (line.startsWith("## ")) {
1451
+ if (inBlockedBy) {
1452
+ const sectionLines = renderBlockedByContent(lines[blockedByStart], blockedByItems, hasRealContent);
1453
+ result.push(...sectionLines);
1454
+ inBlockedBy = false;
1455
+ blockedByItems = [];
1456
+ hasRealContent = false;
1457
+ }
1458
+ if (line.trimEnd() === "## Blocked By") {
1459
+ inBlockedBy = true;
1460
+ blockedByStart = i;
1461
+ continue;
1462
+ }
1463
+ }
1464
+ if (inBlockedBy) {
1465
+ if (line.startsWith("- ")) {
1466
+ blockedByItems.push(line);
1467
+ if (MARKDOWN_LINK_PATTERN2.test(line)) {
1468
+ hasRealContent = true;
1469
+ }
1470
+ } else if (line.trim() === "(none)") {
1471
+ hasRealContent = false;
1472
+ } else if (line.trim() !== "") {
1473
+ blockedByItems.push(line);
1474
+ hasRealContent = true;
1475
+ }
1476
+ continue;
1477
+ }
1478
+ result.push(line);
1479
+ }
1480
+ if (inBlockedBy) {
1481
+ const sectionLines = renderBlockedByContent(lines[blockedByStart], blockedByItems, hasRealContent);
1482
+ result.push(...sectionLines);
1483
+ }
1484
+ return result.join(`
1485
+ `);
1486
+ }
1487
+ function normalizeTargetPath(target, sourceDir) {
1488
+ if (target.startsWith("http://") || target.startsWith("https://")) {
1489
+ return null;
1490
+ }
1491
+ if (target.startsWith("../") || target.startsWith("./")) {
1492
+ const parts = [...sourceDir.split("/"), ...target.split("/")];
1493
+ const resolved = [];
1494
+ for (const part of parts) {
1495
+ if (part === "..") {
1496
+ resolved.pop();
1497
+ } else if (part !== "." && part !== "") {
1498
+ resolved.push(part);
1499
+ }
1500
+ }
1501
+ return resolved.join("/");
1502
+ }
1503
+ if (!target.includes("/")) {
1504
+ return `${sourceDir}/${target}`;
1505
+ }
1506
+ if (target.startsWith("facts/") || target.startsWith("ideas/") || target.startsWith("tasks/") || target.startsWith("principles/")) {
1507
+ return target;
1508
+ }
1509
+ return null;
1510
+ }
1511
+ function processFacts(facts, accumulator) {
1512
+ for (const [slug, factInput] of Object.entries(facts)) {
1513
+ if (factInput === null) {
1514
+ const relativePath = `facts/${slug}.md`;
1515
+ accumulator.files[relativePath] = null;
1516
+ accumulator.deletedPaths.add(relativePath);
1517
+ } else {
1518
+ const factFiles = buildFactFiles(factInput, slug);
1519
+ Object.assign(accumulator.files, factFiles);
1520
+ }
1521
+ }
1522
+ }
1523
+ function processTasks(tasks, accumulator) {
1524
+ for (const [slug, taskInput] of Object.entries(tasks)) {
1525
+ if (taskInput === null) {
1526
+ const relativePath = `tasks/${slug}.md`;
1527
+ accumulator.files[relativePath] = null;
1528
+ accumulator.deletedPaths.add(relativePath);
1529
+ } else {
1530
+ const taskFiles = buildTaskFiles(taskInput, slug);
1531
+ Object.assign(accumulator.files, taskFiles);
1532
+ }
1533
+ }
1534
+ }
1535
+ function processIdeas(ideas, accumulator) {
1536
+ for (const [slug, ideaInput] of Object.entries(ideas)) {
1537
+ if (ideaInput === null) {
1538
+ const relativePath = `ideas/${slug}.md`;
1539
+ accumulator.files[relativePath] = null;
1540
+ accumulator.deletedPaths.add(relativePath);
1541
+ } else {
1542
+ const ideaFiles = buildIdeaFiles(ideaInput, slug);
1543
+ Object.assign(accumulator.files, ideaFiles);
1544
+ }
1545
+ }
1546
+ }
1547
+ async function processPrinciples(fileSystem, dustPath, principles, accumulator) {
1548
+ const deletedSlugs = new Set;
1549
+ const hierarchyViolations = [];
1550
+ const existingPrinciples = await loadExistingPrincipleRelationships(fileSystem, dustPath);
1551
+ const principleUpdates = {};
1552
+ for (const [slug, principleInput] of Object.entries(principles)) {
1553
+ if (principleInput === null) {
1554
+ const relativePath = `principles/${slug}.md`;
1555
+ accumulator.files[relativePath] = null;
1556
+ accumulator.deletedPaths.add(relativePath);
1557
+ deletedSlugs.add(slug);
1558
+ } else {
1559
+ principleUpdates[slug] = principleInput;
1560
+ const principleFiles = buildPrincipleFiles(principleInput, slug);
1561
+ Object.assign(accumulator.files, principleFiles);
1562
+ }
1563
+ }
1564
+ if (Object.keys(principleUpdates).length > 0) {
1565
+ hierarchyViolations.push(...validatePrincipleHierarchy(principleUpdates, existingPrinciples, deletedSlugs));
1566
+ }
1567
+ if (deletedSlugs.size > 0) {
1568
+ await updateRelatedPrinciplesOnDeletion(fileSystem, dustPath, existingPrinciples, deletedSlugs, accumulator);
1569
+ }
1570
+ return { deletedSlugs, hierarchyViolations };
1571
+ }
1572
+ async function updateRelatedPrinciplesOnDeletion(fileSystem, dustPath, existingPrinciples, deletedSlugs, accumulator) {
1573
+ const deletedPaths = new Set([...deletedSlugs].map((slug) => `principles/${slug}.md`));
1574
+ for (const [slug, rel] of existingPrinciples) {
1575
+ if (deletedSlugs.has(slug))
1576
+ continue;
1577
+ const relativePath = `principles/${slug}.md`;
1578
+ if (relativePath in accumulator.files)
1579
+ continue;
1580
+ const referencesDeleted = rel.parentPrinciple !== null && deletedSlugs.has(rel.parentPrinciple) || rel.subPrinciples.some((sub) => deletedSlugs.has(sub));
1581
+ if (referencesDeleted) {
1582
+ const filePath = `${dustPath}/${relativePath}`;
1583
+ const content = await fileSystem.readFile(filePath);
1584
+ const updatedContent = updatePrincipleHierarchyOnDeletion(content, deletedPaths, relativePath);
1585
+ if (updatedContent !== content) {
1586
+ accumulator.files[relativePath] = updatedContent;
1587
+ }
1588
+ }
1589
+ }
1590
+ }
1591
+ async function buildArtifactPatch(fileSystem, dustPath, input, options = {}) {
1592
+ const accumulator = {
1593
+ files: {},
1594
+ deletedPaths: new Set
1595
+ };
1596
+ let hierarchyViolations = [];
1597
+ if (input.facts) {
1598
+ processFacts(input.facts, accumulator);
1599
+ }
1600
+ if (input.ideas) {
1601
+ processIdeas(input.ideas, accumulator);
1602
+ }
1603
+ if (input.principles) {
1604
+ const result = await processPrinciples(fileSystem, dustPath, input.principles, accumulator);
1605
+ hierarchyViolations = result.hierarchyViolations;
1606
+ }
1607
+ if (input.tasks) {
1608
+ processTasks(input.tasks, accumulator);
1609
+ }
1610
+ if (accumulator.deletedPaths.size > 0) {
1611
+ const referenceUpdates = await findReferencesToDeletedPaths(fileSystem, dustPath, accumulator.deletedPaths);
1612
+ for (const [path, content] of referenceUpdates) {
1613
+ if (!(path in accumulator.files)) {
1614
+ accumulator.files[path] = content;
1615
+ }
1616
+ }
1617
+ }
1618
+ const patch = { files: accumulator.files };
1619
+ const validationResult = await validatePatch(fileSystem, dustPath, patch, options);
1620
+ return {
1621
+ valid: validationResult.valid && hierarchyViolations.length === 0,
1622
+ violations: [...hierarchyViolations, ...validationResult.violations],
1623
+ patch
1624
+ };
1625
+ }
1626
+ export {
1627
+ serializeTask,
1628
+ serializePrinciple,
1629
+ serializeIdea,
1630
+ serializeFact,
1631
+ buildArtifactPatch
1632
+ };