xdrs-core 0.15.2 → 0.15.4

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/README.md CHANGED
@@ -153,7 +153,7 @@ Multiple scope packages can be combined in the same workspace by listing them as
153
153
  The published package exposes the `xdrs-core` CLI.
154
154
 
155
155
  - Bootstrap or extract managed XDR files with the existing `filedist`-backed commands such as `npx -y xdrs-core extract` and `npx -y xdrs-core check`.
156
- - Lint an XDR tree with `npx -y xdrs-core lint .`.
156
+ - Lint an XDR tree with `npx -y xdrs-core lint .`. By default, read-only files distributed from external scopes are skipped; use `--all` to include them.
157
157
 
158
158
  The `lint` command reads `./.xdrs/**` from the given workspace path and checks common consistency rules, including:
159
159
 
@@ -167,6 +167,7 @@ The `lint` command reads `./.xdrs/**` from the given workspace path and checks c
167
167
  - canonical index presence and link consistency
168
168
  - root index coverage for all discovered canonical indexes
169
169
  - XDR metadata section placement and `Valid` / `Applied to` field format
170
+ - local markdown links between XDR documents, skills, articles, researches, and plans (excluding fenced code blocks)
170
171
  - local image and `assets/` links resolving inside the sibling `assets/` folder for each document
171
172
 
172
173
  Examples:
package/lib/lint.js CHANGED
@@ -148,7 +148,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, igno
148
148
  errors.push(`Unexpected directory under scope ${scopeName}: ${toDisplayPath(entryPath)}`);
149
149
  continue;
150
150
  }
151
- lintTypeDirectory(xdrsRoot, scopeName, entry.name, errors, actualTypeIndexes);
151
+ lintTypeDirectory(xdrsRoot, scopeName, entry.name, errors, actualTypeIndexes, ignoreReadOnly);
152
152
  continue;
153
153
  }
154
154
 
@@ -156,7 +156,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, igno
156
156
  }
157
157
  }
158
158
 
159
- function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeIndexes) {
159
+ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeIndexes, ignoreReadOnly) {
160
160
  const typePath = path.join(xdrsRoot, scopeName, typeName);
161
161
  const indexPath = path.join(typePath, 'index.md');
162
162
  const xdrNumbers = new Map();
@@ -164,7 +164,7 @@ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeInde
164
164
 
165
165
  if (!existsFile(indexPath)) {
166
166
  errors.push(`Missing canonical index: ${toDisplayPath(indexPath)}`);
167
- } else {
167
+ } else if (!shouldSkipReadOnlyPath(indexPath, ignoreReadOnly)) {
168
168
  actualTypeIndexes.push(indexPath);
169
169
  }
170
170
 
@@ -183,15 +183,15 @@ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeInde
183
183
  continue;
184
184
  }
185
185
 
186
- artifacts.push(...lintSubjectDirectory(xdrsRoot, scopeName, typeName, entry.name, xdrNumbers, errors));
186
+ artifacts.push(...lintSubjectDirectory(xdrsRoot, scopeName, typeName, entry.name, xdrNumbers, errors, ignoreReadOnly));
187
187
  }
188
188
 
189
- if (existsFile(indexPath)) {
189
+ if (existsFile(indexPath) && !shouldSkipReadOnlyPath(indexPath, ignoreReadOnly)) {
190
190
  lintTypeIndex(indexPath, xdrsRoot, artifacts, errors);
191
191
  }
192
192
  }
193
193
 
194
- function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNumbers, errors) {
194
+ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNumbers, errors, ignoreReadOnly) {
195
195
  const subjectPath = path.join(xdrsRoot, scopeName, typeName, subjectName);
196
196
  const artifacts = [];
197
197
  const entries = safeReadDir(subjectPath, errors, `read subject directory ${scopeName}/${typeName}/${subjectName}`);
@@ -204,19 +204,19 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNum
204
204
  continue;
205
205
  }
206
206
  if (entry.name === 'skills') {
207
- artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
207
+ artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, ignoreReadOnly));
208
208
  continue;
209
209
  }
210
210
  if (entry.name === 'articles') {
211
- artifacts.push(...lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
211
+ artifacts.push(...lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, ignoreReadOnly));
212
212
  continue;
213
213
  }
214
214
  if (entry.name === 'researches') {
215
- artifacts.push(...lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
215
+ artifacts.push(...lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, ignoreReadOnly));
216
216
  continue;
217
217
  }
218
218
  if (entry.name === 'plans') {
219
- artifacts.push(...lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
219
+ artifacts.push(...lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, ignoreReadOnly));
220
220
  continue;
221
221
  }
222
222
 
@@ -229,6 +229,10 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNum
229
229
  continue;
230
230
  }
231
231
 
232
+ if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
233
+ continue;
234
+ }
235
+
232
236
  artifacts.push(entryPath);
233
237
  lintXdrFile(xdrsRoot, scopeName, typeName, entryPath, xdrNumbers, errors);
234
238
  }
@@ -244,7 +248,6 @@ function lintXdrFile(xdrsRoot, scopeName, typeName, filePath, xdrNumbers, errors
244
248
  }
245
249
 
246
250
  const number = match[1];
247
- const shortTitle = match[2];
248
251
  const previous = xdrNumbers.get(number);
249
252
  if (previous) {
250
253
  errors.push(`Duplicate XDR number ${number} in ${scopeName}/${typeName}: ${toDisplayPath(previous)} and ${toDisplayPath(filePath)}`);
@@ -263,9 +266,35 @@ function lintXdrFile(xdrsRoot, scopeName, typeName, filePath, xdrNumbers, errors
263
266
  errors.push(`XDR title must start with "${expectedHeader}": ${toDisplayPath(filePath)}`);
264
267
  }
265
268
 
266
- const expectedName = `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${shortTitle}`;
269
+ const expectedName = extractExpectedXdrNameFromHeading(firstLine)
270
+ || `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${match[2]}`;
267
271
  lintXdrFrontmatter(content, expectedName, filePath, errors);
268
- lintDocumentResourceLinks(filePath, errors);
272
+ lintDocumentLinks(filePath, errors);
273
+ }
274
+
275
+ function extractExpectedXdrNameFromHeading(headingLine) {
276
+ const match = headingLine.match(/^#\s+([a-z0-9_]+-(?:adr|bdr|edr)-\d{3,}):\s+(.+?)\s*$/);
277
+ if (!match) {
278
+ return null;
279
+ }
280
+
281
+ const identifier = match[1];
282
+ const titleSlug = slugifyTitle(match[2]);
283
+ if (!titleSlug) {
284
+ return null;
285
+ }
286
+
287
+ return `${identifier}-${titleSlug}`;
288
+ }
289
+
290
+ function slugifyTitle(title) {
291
+ return title
292
+ .normalize('NFKD')
293
+ .replace(/[\u0300-\u036f]/g, '')
294
+ .toLowerCase()
295
+ .replace(/[^a-z0-9]+/g, '-')
296
+ .replace(/^-+|-+$/g, '')
297
+ .replace(/--+/g, '-');
269
298
  }
270
299
 
271
300
  function lintXdrFrontmatter(content, expectedName, filePath, errors) {
@@ -297,7 +326,7 @@ function lintXdrFrontmatter(content, expectedName, filePath, errors) {
297
326
  }
298
327
  }
299
328
 
300
- function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors) {
329
+ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors, ignoreReadOnly) {
301
330
  const artifacts = [];
302
331
  const skillNumbers = new Map();
303
332
  const entries = safeReadDir(skillsPath, errors, `read skills directory ${scopeName}/${typeName}/${subjectName}/skills`);
@@ -328,13 +357,18 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
328
357
  }
329
358
 
330
359
  const skillFilePath = path.join(entryPath, 'SKILL.md');
331
- artifacts.push(skillFilePath);
332
360
 
333
361
  if (!existsFile(skillFilePath)) {
334
362
  errors.push(`Missing SKILL.md in skill package: ${toDisplayPath(entryPath)}`);
335
363
  continue;
336
364
  }
337
365
 
366
+ if (shouldSkipReadOnlyPath(skillFilePath, ignoreReadOnly)) {
367
+ continue;
368
+ }
369
+
370
+ artifacts.push(skillFilePath);
371
+
338
372
  const skillContent = fs.readFileSync(skillFilePath, 'utf8');
339
373
  const skillFm = extractFrontmatter(skillContent);
340
374
  if (!skillFm.present) {
@@ -350,13 +384,13 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
350
384
  }
351
385
  }
352
386
 
353
- lintDocumentResourceLinks(skillFilePath, errors);
387
+ lintDocumentLinks(skillFilePath, errors);
354
388
  }
355
389
 
356
390
  return artifacts;
357
391
  }
358
392
 
359
- function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, articlesPath, errors) {
393
+ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, articlesPath, errors, ignoreReadOnly) {
360
394
  const artifacts = [];
361
395
  const articleNumbers = new Map();
362
396
  const entries = safeReadDir(articlesPath, errors, `read articles directory ${scopeName}/${typeName}/${subjectName}/articles`);
@@ -378,6 +412,10 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
378
412
  continue;
379
413
  }
380
414
 
415
+ if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
416
+ continue;
417
+ }
418
+
381
419
  artifacts.push(entryPath);
382
420
 
383
421
  const number = match[1];
@@ -399,13 +437,13 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
399
437
  errors.push(`Article title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
400
438
  }
401
439
 
402
- lintDocumentResourceLinks(entryPath, errors);
440
+ lintDocumentLinks(entryPath, errors);
403
441
  }
404
442
 
405
443
  return artifacts;
406
444
  }
407
445
 
408
- function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, researchPath, errors) {
446
+ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, researchPath, errors, ignoreReadOnly) {
409
447
  const artifacts = [];
410
448
  const researchNumbers = new Map();
411
449
  const entries = safeReadDir(researchPath, errors, `read research directory ${scopeName}/${typeName}/${subjectName}/researches`);
@@ -427,6 +465,10 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
427
465
  continue;
428
466
  }
429
467
 
468
+ if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
469
+ continue;
470
+ }
471
+
430
472
  artifacts.push(entryPath);
431
473
 
432
474
  const number = match[1];
@@ -448,13 +490,13 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
448
490
  errors.push(`Research title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
449
491
  }
450
492
 
451
- lintDocumentResourceLinks(entryPath, errors);
493
+ lintDocumentLinks(entryPath, errors);
452
494
  }
453
495
 
454
496
  return artifacts;
455
497
  }
456
498
 
457
- function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPath, errors) {
499
+ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPath, errors, ignoreReadOnly) {
458
500
  const artifacts = [];
459
501
  const planNumbers = new Map();
460
502
  const entries = safeReadDir(plansPath, errors, `read plans directory ${scopeName}/${typeName}/${subjectName}/plans`);
@@ -476,6 +518,10 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
476
518
  continue;
477
519
  }
478
520
 
521
+ if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
522
+ continue;
523
+ }
524
+
479
525
  artifacts.push(entryPath);
480
526
 
481
527
  const number = match[1];
@@ -498,7 +544,7 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
498
544
  }
499
545
 
500
546
  lintPlanExpectedEndDate(content, entryPath, errors);
501
- lintDocumentResourceLinks(entryPath, errors);
547
+ lintDocumentLinks(entryPath, errors);
502
548
  }
503
549
 
504
550
  return artifacts;
@@ -545,23 +591,32 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors) {
545
591
  }
546
592
  }
547
593
 
548
- function lintDocumentResourceLinks(documentPath, errors) {
549
- const content = fs.readFileSync(documentPath, 'utf8');
594
+ function lintDocumentLinks(documentPath, errors) {
595
+ const lines = fs.readFileSync(documentPath, 'utf8').split(/\r?\n/);
596
+ const ignoredLines = findIgnoredMarkdownLines(lines);
550
597
  const documentDir = path.dirname(documentPath);
551
598
  const resourceDir = path.join(documentDir, RESOURCE_DIR_NAME);
552
599
 
553
- for (const link of parseLocalLinkTargets(content, documentDir)) {
554
- if (!shouldValidateResourceLink(link.rawTarget)) {
600
+ for (let index = 0; index < lines.length; index += 1) {
601
+ if (ignoredLines[index]) {
555
602
  continue;
556
603
  }
557
604
 
558
- if (!fs.existsSync(link.resolvedPath)) {
559
- errors.push(`Broken asset link in ${toDisplayPath(documentPath)}: ${link.rawTarget}`);
560
- continue;
561
- }
605
+ for (const link of parseLocalLinkTargets(lines[index], documentDir)) {
606
+ const isResourceLink = shouldValidateResourceLink(link.rawTarget);
607
+
608
+ if (!fs.existsSync(link.resolvedPath)) {
609
+ if (isResourceLink) {
610
+ errors.push(`Broken asset link in ${toDisplayPath(documentPath)}: ${link.rawTarget}`);
611
+ } else {
612
+ errors.push(`Broken local link in ${toDisplayPath(documentPath)}:${index + 1}: ${link.rawTarget}`);
613
+ }
614
+ continue;
615
+ }
562
616
 
563
- if (!isPathInside(resourceDir, link.resolvedPath)) {
564
- errors.push(`Asset links in ${toDisplayPath(documentPath)} must point to ${toDisplayPath(resourceDir)}: ${link.rawTarget}`);
617
+ if (isResourceLink && !isPathInside(resourceDir, link.resolvedPath)) {
618
+ errors.push(`Asset links in ${toDisplayPath(documentPath)} must point to ${toDisplayPath(resourceDir)}: ${link.rawTarget}`);
619
+ }
565
620
  }
566
621
  }
567
622
  }
@@ -576,11 +631,11 @@ function parseLocalLinkTargets(markdown, baseDir) {
576
631
  let match = linkRe.exec(markdown);
577
632
  while (match) {
578
633
  const rawTarget = match[1].trim();
579
- if (isLocalLink(rawTarget)) {
580
- const targetWithoutAnchor = rawTarget.split('#')[0];
634
+ const normalizedTarget = normalizeLocalLinkTarget(rawTarget);
635
+ if (normalizedTarget) {
581
636
  links.push({
582
637
  rawTarget,
583
- resolvedPath: path.resolve(baseDir, targetWithoutAnchor)
638
+ resolvedPath: path.resolve(baseDir, normalizedTarget)
584
639
  });
585
640
  }
586
641
  match = linkRe.exec(markdown);
@@ -588,6 +643,17 @@ function parseLocalLinkTargets(markdown, baseDir) {
588
643
  return links;
589
644
  }
590
645
 
646
+ function normalizeLocalLinkTarget(target) {
647
+ if (!isLocalLink(target)) {
648
+ return null;
649
+ }
650
+
651
+ const bracketWrappedTarget = target.match(/^<(.+)>$/);
652
+ const cleanedTarget = bracketWrappedTarget ? bracketWrappedTarget[1] : target;
653
+
654
+ return cleanedTarget.split('#')[0].split('?')[0] || null;
655
+ }
656
+
591
657
  function isLocalLink(target) {
592
658
  return target !== ''
593
659
  && !target.startsWith('#')
@@ -602,9 +668,13 @@ function isCanonicalTypeIndex(filePath, xdrsRoot) {
602
668
  }
603
669
 
604
670
  function shouldValidateResourceLink(rawTarget) {
605
- const targetWithoutAnchor = rawTarget.split('#')[0];
606
- const normalizedTarget = targetWithoutAnchor.replace(/\\/g, '/');
607
- const extension = path.extname(targetWithoutAnchor).toLowerCase();
671
+ const normalizedTargetPath = normalizeLocalLinkTarget(rawTarget);
672
+ if (!normalizedTargetPath) {
673
+ return false;
674
+ }
675
+
676
+ const normalizedTarget = normalizedTargetPath.replace(/\\/g, '/');
677
+ const extension = path.extname(normalizedTargetPath).toLowerCase();
608
678
 
609
679
  return normalizedTarget === RESOURCE_DIR_NAME
610
680
  || normalizedTarget.startsWith(`${RESOURCE_DIR_NAME}/`)
@@ -638,12 +708,20 @@ function stripFrontmatter(content) {
638
708
  function findIgnoredMarkdownLines(lines) {
639
709
  const ignored = [];
640
710
  let inCodeFence = false;
711
+ let activeFence = null;
641
712
 
642
713
  for (let index = 0; index < lines.length; index += 1) {
643
714
  const trimmed = lines[index].trim();
644
- if (/^```/.test(trimmed)) {
715
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
716
+ if (fenceMatch) {
645
717
  ignored[index] = true;
646
- inCodeFence = !inCodeFence;
718
+ if (activeFence === fenceMatch[1][0]) {
719
+ activeFence = null;
720
+ inCodeFence = false;
721
+ } else if (activeFence === null) {
722
+ activeFence = fenceMatch[1][0];
723
+ inCodeFence = true;
724
+ }
647
725
  continue;
648
726
  }
649
727
 
@@ -762,6 +840,10 @@ function isReadOnly(filePath) {
762
840
  }
763
841
  }
764
842
 
843
+ function shouldSkipReadOnlyPath(filePath, ignoreReadOnly) {
844
+ return ignoreReadOnly && isReadOnly(filePath);
845
+ }
846
+
765
847
  function displayPath(indexPath, targetPath) {
766
848
  return `${toDisplayPath(indexPath)} -> ${toDisplayPath(targetPath)}`;
767
849
  }
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const { lintWorkspace } = require('./lint');
8
+
9
+ let tmpRoot;
10
+
11
+ beforeAll(() => {
12
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xdrs-core-lint-'));
13
+ });
14
+
15
+ afterAll(() => {
16
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
17
+ });
18
+
19
+ test('reports broken local document links in XDR files', () => {
20
+ const workspaceRoot = createWorkspace('broken-xdr-link', {
21
+ '.xdrs/index.md': rootIndex(),
22
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
23
+ '- [001-main](principles/001-main.md) - Main decision'
24
+ ]),
25
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument(`See [Missing](002-missing.md).`),
26
+ });
27
+
28
+ const result = lintWorkspace(workspaceRoot);
29
+
30
+ expect(result.errors.join('\n')).toContain('Broken local link in');
31
+ expect(result.errors.join('\n')).toContain('002-missing.md');
32
+ });
33
+
34
+ test('ignores local links inside fenced code blocks', () => {
35
+ const workspaceRoot = createWorkspace('ignore-code-fence', {
36
+ '.xdrs/index.md': rootIndex(),
37
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
38
+ '- [001-main](principles/001-main.md) - Main decision'
39
+ ]),
40
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument([
41
+ '```markdown',
42
+ '[Missing](002-missing.md)',
43
+ '```'
44
+ ].join('\n')),
45
+ });
46
+
47
+ const result = lintWorkspace(workspaceRoot);
48
+
49
+ expect(result.errors.join('\n')).not.toContain('Broken local link in');
50
+ });
51
+
52
+ test('reports broken local document links in skill files', () => {
53
+ const workspaceRoot = createWorkspace('broken-skill-link', {
54
+ '.xdrs/index.md': rootIndex(),
55
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
56
+ '- [001-check-links](principles/skills/001-check-links/SKILL.md) - Check local links'
57
+ ]),
58
+ '.xdrs/_local/adrs/principles/skills/001-check-links/SKILL.md': skillDocument('See [Missing](missing.md).'),
59
+ });
60
+
61
+ const result = lintWorkspace(workspaceRoot);
62
+
63
+ expect(result.errors.join('\n')).toContain('Broken local link in');
64
+ expect(result.errors.join('\n')).toContain('missing.md');
65
+ });
66
+
67
+ test('skips read-only files by default and checks them when ignoreReadOnly is false', () => {
68
+ const workspaceRoot = createWorkspace('readonly-default-skip', {
69
+ '.xdrs/index.md': rootIndex(),
70
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
71
+ '- [001-main](principles/001-main.md) - Main decision'
72
+ ]),
73
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('See [Missing](002-missing.md).'),
74
+ });
75
+ const filePath = path.join(workspaceRoot, '.xdrs/_local/adrs/principles/001-main.md');
76
+ fs.chmodSync(filePath, 0o444);
77
+
78
+ const defaultResult = lintWorkspace(workspaceRoot);
79
+ const allResult = lintWorkspace(workspaceRoot, { ignoreReadOnly: false });
80
+
81
+ expect(defaultResult.errors.join('\n')).not.toContain('Broken local link in');
82
+ expect(allResult.errors.join('\n')).toContain('Broken local link in');
83
+ });
84
+
85
+ test('derives expected frontmatter name from the markdown heading title', () => {
86
+ const workspaceRoot = createWorkspace('heading-name-match', {
87
+ '.xdrs/index.md': rootIndex(),
88
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
89
+ '- [002-scope-guidelines](principles/002-xdr-scope-guidelines.md) - Scope guidelines'
90
+ ]),
91
+ '.xdrs/_local/adrs/principles/002-xdr-scope-guidelines.md': [
92
+ '---',
93
+ 'name: _local-adr-002-xdr-scope-guidelines-for-agentme',
94
+ 'description: Test XDR document',
95
+ '---',
96
+ '',
97
+ '# _local-adr-002: XDR scope guidelines for agentme',
98
+ '',
99
+ '## Context and Problem Statement',
100
+ '',
101
+ 'Test body.',
102
+ ''
103
+ ].join('\n'),
104
+ });
105
+
106
+ const result = lintWorkspace(workspaceRoot, { ignoreReadOnly: false });
107
+
108
+ expect(result.errors.join('\n')).not.toContain('XDR frontmatter name must be');
109
+ });
110
+
111
+ function createWorkspace(name, files) {
112
+ const workspaceRoot = path.join(tmpRoot, name);
113
+ fs.mkdirSync(workspaceRoot, { recursive: true });
114
+
115
+ for (const [relativePath, content] of Object.entries(files)) {
116
+ const filePath = path.join(workspaceRoot, relativePath);
117
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
118
+ fs.writeFileSync(filePath, content, 'utf8');
119
+ }
120
+
121
+ return workspaceRoot;
122
+ }
123
+
124
+ function rootIndex() {
125
+ return [
126
+ '# XDR Standards Index',
127
+ '',
128
+ '## Scope Indexes',
129
+ '',
130
+ 'XDRs in scopes listed last override the ones listed first',
131
+ '',
132
+ '### _local (reserved)',
133
+ '',
134
+ 'Project-local XDRs stay in the workspace tree only.',
135
+ ].join('\n');
136
+ }
137
+
138
+ function localAdrIndex(entries) {
139
+ return [
140
+ '# _local ADR Index',
141
+ '',
142
+ 'Local ADRs for tests.',
143
+ '',
144
+ '## principles',
145
+ '',
146
+ ...entries,
147
+ ''
148
+ ].join('\n');
149
+ }
150
+
151
+ function xdrDocument(body) {
152
+ return [
153
+ '---',
154
+ 'name: _local-adr-001-main',
155
+ 'description: Test XDR document',
156
+ '---',
157
+ '',
158
+ '# _local-adr-001: Main decision',
159
+ '',
160
+ '## Context and Problem Statement',
161
+ '',
162
+ body,
163
+ ''
164
+ ].join('\n');
165
+ }
166
+
167
+ function skillDocument(body) {
168
+ return [
169
+ '---',
170
+ 'name: 001-check-links',
171
+ 'description: Test skill document',
172
+ '---',
173
+ '',
174
+ '# Test skill',
175
+ '',
176
+ body,
177
+ ''
178
+ ].join('\n');
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xdrs-core",
3
- "version": "0.15.2",
3
+ "version": "0.15.4",
4
4
  "description": "A standard way to organize Decision Records (XDRs) across scopes, subjects, and teams so that AI agents can reliably query and follow them.",
5
5
  "repository": {
6
6
  "type": "git",