xdrs-core 0.15.2 → 0.15.3

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
  }
@@ -265,7 +269,7 @@ function lintXdrFile(xdrsRoot, scopeName, typeName, filePath, xdrNumbers, errors
265
269
 
266
270
  const expectedName = `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${shortTitle}`;
267
271
  lintXdrFrontmatter(content, expectedName, filePath, errors);
268
- lintDocumentResourceLinks(filePath, errors);
272
+ lintDocumentLinks(filePath, errors);
269
273
  }
270
274
 
271
275
  function lintXdrFrontmatter(content, expectedName, filePath, errors) {
@@ -297,7 +301,7 @@ function lintXdrFrontmatter(content, expectedName, filePath, errors) {
297
301
  }
298
302
  }
299
303
 
300
- function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors) {
304
+ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors, ignoreReadOnly) {
301
305
  const artifacts = [];
302
306
  const skillNumbers = new Map();
303
307
  const entries = safeReadDir(skillsPath, errors, `read skills directory ${scopeName}/${typeName}/${subjectName}/skills`);
@@ -328,13 +332,18 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
328
332
  }
329
333
 
330
334
  const skillFilePath = path.join(entryPath, 'SKILL.md');
331
- artifacts.push(skillFilePath);
332
335
 
333
336
  if (!existsFile(skillFilePath)) {
334
337
  errors.push(`Missing SKILL.md in skill package: ${toDisplayPath(entryPath)}`);
335
338
  continue;
336
339
  }
337
340
 
341
+ if (shouldSkipReadOnlyPath(skillFilePath, ignoreReadOnly)) {
342
+ continue;
343
+ }
344
+
345
+ artifacts.push(skillFilePath);
346
+
338
347
  const skillContent = fs.readFileSync(skillFilePath, 'utf8');
339
348
  const skillFm = extractFrontmatter(skillContent);
340
349
  if (!skillFm.present) {
@@ -350,13 +359,13 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
350
359
  }
351
360
  }
352
361
 
353
- lintDocumentResourceLinks(skillFilePath, errors);
362
+ lintDocumentLinks(skillFilePath, errors);
354
363
  }
355
364
 
356
365
  return artifacts;
357
366
  }
358
367
 
359
- function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, articlesPath, errors) {
368
+ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, articlesPath, errors, ignoreReadOnly) {
360
369
  const artifacts = [];
361
370
  const articleNumbers = new Map();
362
371
  const entries = safeReadDir(articlesPath, errors, `read articles directory ${scopeName}/${typeName}/${subjectName}/articles`);
@@ -378,6 +387,10 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
378
387
  continue;
379
388
  }
380
389
 
390
+ if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
391
+ continue;
392
+ }
393
+
381
394
  artifacts.push(entryPath);
382
395
 
383
396
  const number = match[1];
@@ -399,13 +412,13 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
399
412
  errors.push(`Article title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
400
413
  }
401
414
 
402
- lintDocumentResourceLinks(entryPath, errors);
415
+ lintDocumentLinks(entryPath, errors);
403
416
  }
404
417
 
405
418
  return artifacts;
406
419
  }
407
420
 
408
- function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, researchPath, errors) {
421
+ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, researchPath, errors, ignoreReadOnly) {
409
422
  const artifacts = [];
410
423
  const researchNumbers = new Map();
411
424
  const entries = safeReadDir(researchPath, errors, `read research directory ${scopeName}/${typeName}/${subjectName}/researches`);
@@ -427,6 +440,10 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
427
440
  continue;
428
441
  }
429
442
 
443
+ if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
444
+ continue;
445
+ }
446
+
430
447
  artifacts.push(entryPath);
431
448
 
432
449
  const number = match[1];
@@ -448,13 +465,13 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
448
465
  errors.push(`Research title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
449
466
  }
450
467
 
451
- lintDocumentResourceLinks(entryPath, errors);
468
+ lintDocumentLinks(entryPath, errors);
452
469
  }
453
470
 
454
471
  return artifacts;
455
472
  }
456
473
 
457
- function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPath, errors) {
474
+ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPath, errors, ignoreReadOnly) {
458
475
  const artifacts = [];
459
476
  const planNumbers = new Map();
460
477
  const entries = safeReadDir(plansPath, errors, `read plans directory ${scopeName}/${typeName}/${subjectName}/plans`);
@@ -476,6 +493,10 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
476
493
  continue;
477
494
  }
478
495
 
496
+ if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
497
+ continue;
498
+ }
499
+
479
500
  artifacts.push(entryPath);
480
501
 
481
502
  const number = match[1];
@@ -498,7 +519,7 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
498
519
  }
499
520
 
500
521
  lintPlanExpectedEndDate(content, entryPath, errors);
501
- lintDocumentResourceLinks(entryPath, errors);
522
+ lintDocumentLinks(entryPath, errors);
502
523
  }
503
524
 
504
525
  return artifacts;
@@ -545,23 +566,32 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors) {
545
566
  }
546
567
  }
547
568
 
548
- function lintDocumentResourceLinks(documentPath, errors) {
549
- const content = fs.readFileSync(documentPath, 'utf8');
569
+ function lintDocumentLinks(documentPath, errors) {
570
+ const lines = fs.readFileSync(documentPath, 'utf8').split(/\r?\n/);
571
+ const ignoredLines = findIgnoredMarkdownLines(lines);
550
572
  const documentDir = path.dirname(documentPath);
551
573
  const resourceDir = path.join(documentDir, RESOURCE_DIR_NAME);
552
574
 
553
- for (const link of parseLocalLinkTargets(content, documentDir)) {
554
- if (!shouldValidateResourceLink(link.rawTarget)) {
575
+ for (let index = 0; index < lines.length; index += 1) {
576
+ if (ignoredLines[index]) {
555
577
  continue;
556
578
  }
557
579
 
558
- if (!fs.existsSync(link.resolvedPath)) {
559
- errors.push(`Broken asset link in ${toDisplayPath(documentPath)}: ${link.rawTarget}`);
560
- continue;
561
- }
580
+ for (const link of parseLocalLinkTargets(lines[index], documentDir)) {
581
+ const isResourceLink = shouldValidateResourceLink(link.rawTarget);
582
+
583
+ if (!fs.existsSync(link.resolvedPath)) {
584
+ if (isResourceLink) {
585
+ errors.push(`Broken asset link in ${toDisplayPath(documentPath)}: ${link.rawTarget}`);
586
+ } else {
587
+ errors.push(`Broken local link in ${toDisplayPath(documentPath)}:${index + 1}: ${link.rawTarget}`);
588
+ }
589
+ continue;
590
+ }
562
591
 
563
- if (!isPathInside(resourceDir, link.resolvedPath)) {
564
- errors.push(`Asset links in ${toDisplayPath(documentPath)} must point to ${toDisplayPath(resourceDir)}: ${link.rawTarget}`);
592
+ if (isResourceLink && !isPathInside(resourceDir, link.resolvedPath)) {
593
+ errors.push(`Asset links in ${toDisplayPath(documentPath)} must point to ${toDisplayPath(resourceDir)}: ${link.rawTarget}`);
594
+ }
565
595
  }
566
596
  }
567
597
  }
@@ -576,11 +606,11 @@ function parseLocalLinkTargets(markdown, baseDir) {
576
606
  let match = linkRe.exec(markdown);
577
607
  while (match) {
578
608
  const rawTarget = match[1].trim();
579
- if (isLocalLink(rawTarget)) {
580
- const targetWithoutAnchor = rawTarget.split('#')[0];
609
+ const normalizedTarget = normalizeLocalLinkTarget(rawTarget);
610
+ if (normalizedTarget) {
581
611
  links.push({
582
612
  rawTarget,
583
- resolvedPath: path.resolve(baseDir, targetWithoutAnchor)
613
+ resolvedPath: path.resolve(baseDir, normalizedTarget)
584
614
  });
585
615
  }
586
616
  match = linkRe.exec(markdown);
@@ -588,6 +618,17 @@ function parseLocalLinkTargets(markdown, baseDir) {
588
618
  return links;
589
619
  }
590
620
 
621
+ function normalizeLocalLinkTarget(target) {
622
+ if (!isLocalLink(target)) {
623
+ return null;
624
+ }
625
+
626
+ const bracketWrappedTarget = target.match(/^<(.+)>$/);
627
+ const cleanedTarget = bracketWrappedTarget ? bracketWrappedTarget[1] : target;
628
+
629
+ return cleanedTarget.split('#')[0].split('?')[0] || null;
630
+ }
631
+
591
632
  function isLocalLink(target) {
592
633
  return target !== ''
593
634
  && !target.startsWith('#')
@@ -602,9 +643,13 @@ function isCanonicalTypeIndex(filePath, xdrsRoot) {
602
643
  }
603
644
 
604
645
  function shouldValidateResourceLink(rawTarget) {
605
- const targetWithoutAnchor = rawTarget.split('#')[0];
606
- const normalizedTarget = targetWithoutAnchor.replace(/\\/g, '/');
607
- const extension = path.extname(targetWithoutAnchor).toLowerCase();
646
+ const normalizedTargetPath = normalizeLocalLinkTarget(rawTarget);
647
+ if (!normalizedTargetPath) {
648
+ return false;
649
+ }
650
+
651
+ const normalizedTarget = normalizedTargetPath.replace(/\\/g, '/');
652
+ const extension = path.extname(normalizedTargetPath).toLowerCase();
608
653
 
609
654
  return normalizedTarget === RESOURCE_DIR_NAME
610
655
  || normalizedTarget.startsWith(`${RESOURCE_DIR_NAME}/`)
@@ -638,12 +683,20 @@ function stripFrontmatter(content) {
638
683
  function findIgnoredMarkdownLines(lines) {
639
684
  const ignored = [];
640
685
  let inCodeFence = false;
686
+ let activeFence = null;
641
687
 
642
688
  for (let index = 0; index < lines.length; index += 1) {
643
689
  const trimmed = lines[index].trim();
644
- if (/^```/.test(trimmed)) {
690
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
691
+ if (fenceMatch) {
645
692
  ignored[index] = true;
646
- inCodeFence = !inCodeFence;
693
+ if (activeFence === fenceMatch[1][0]) {
694
+ activeFence = null;
695
+ inCodeFence = false;
696
+ } else if (activeFence === null) {
697
+ activeFence = fenceMatch[1][0];
698
+ inCodeFence = true;
699
+ }
647
700
  continue;
648
701
  }
649
702
 
@@ -762,6 +815,10 @@ function isReadOnly(filePath) {
762
815
  }
763
816
  }
764
817
 
818
+ function shouldSkipReadOnlyPath(filePath, ignoreReadOnly) {
819
+ return ignoreReadOnly && isReadOnly(filePath);
820
+ }
821
+
765
822
  function displayPath(indexPath, targetPath) {
766
823
  return `${toDisplayPath(indexPath)} -> ${toDisplayPath(targetPath)}`;
767
824
  }
@@ -0,0 +1,153 @@
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
+ function createWorkspace(name, files) {
86
+ const workspaceRoot = path.join(tmpRoot, name);
87
+ fs.mkdirSync(workspaceRoot, { recursive: true });
88
+
89
+ for (const [relativePath, content] of Object.entries(files)) {
90
+ const filePath = path.join(workspaceRoot, relativePath);
91
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
92
+ fs.writeFileSync(filePath, content, 'utf8');
93
+ }
94
+
95
+ return workspaceRoot;
96
+ }
97
+
98
+ function rootIndex() {
99
+ return [
100
+ '# XDR Standards Index',
101
+ '',
102
+ '## Scope Indexes',
103
+ '',
104
+ 'XDRs in scopes listed last override the ones listed first',
105
+ '',
106
+ '### _local (reserved)',
107
+ '',
108
+ 'Project-local XDRs stay in the workspace tree only.',
109
+ ].join('\n');
110
+ }
111
+
112
+ function localAdrIndex(entries) {
113
+ return [
114
+ '# _local ADR Index',
115
+ '',
116
+ 'Local ADRs for tests.',
117
+ '',
118
+ '## principles',
119
+ '',
120
+ ...entries,
121
+ ''
122
+ ].join('\n');
123
+ }
124
+
125
+ function xdrDocument(body) {
126
+ return [
127
+ '---',
128
+ 'name: _local-adr-001-main',
129
+ 'description: Test XDR document',
130
+ '---',
131
+ '',
132
+ '# _local-adr-001: Main decision',
133
+ '',
134
+ '## Context and Problem Statement',
135
+ '',
136
+ body,
137
+ ''
138
+ ].join('\n');
139
+ }
140
+
141
+ function skillDocument(body) {
142
+ return [
143
+ '---',
144
+ 'name: 001-check-links',
145
+ 'description: Test skill document',
146
+ '---',
147
+ '',
148
+ '# Test skill',
149
+ '',
150
+ body,
151
+ ''
152
+ ].join('\n');
153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xdrs-core",
3
- "version": "0.15.2",
3
+ "version": "0.15.3",
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",