xdrs-core 0.15.1 → 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.
@@ -77,12 +77,14 @@ Choose a title that clearly states the question this XDR answers, not the answer
77
77
  Use the mandatory template from `002-xdr-standards`:
78
78
 
79
79
  ```
80
- # [scope]-[type]-[number]: [Short Title]
80
+ ---
81
+ name: [scope]-[type]-[number]-[short-title]
82
+ description: [What this decision is about and when to use it]
83
+ applied-to: [Optional. Contexts this decision applies to, under 40 words]
84
+ valid-from: [Optional. ISO date YYYY-MM-DD from when enforcement begins]
85
+ ---
81
86
 
82
- ## Metadata
83
- [Optional. Include only when at least one metadata field is present]
84
- Valid: [Optional. Use from YYYY-MM-DD to set a convergence date for adoption]
85
- Applied to: [Optional short applicability scope, under 40 words]
87
+ # [scope]-[type]-[number]: [Short Title]
86
88
 
87
89
  ## Context and Problem Statement
88
90
  [background, who is impacted, and the explicit question being answered - under 40 words]
@@ -103,10 +105,10 @@ Applied to: [Optional short applicability scope, under 40 words]
103
105
  ```
104
106
 
105
107
  Mandatory rules to apply while drafting:
106
- - Include `## Metadata` only when `Valid:` and/or `Applied to:` adds value; omit the whole section when none of those fields is defined.
107
- - When present, place `## Metadata` immediately before `## Context and Problem Statement`.
108
- - Keep `Applied to:` under 40 words and use `Valid:` only with `from YYYY-MM-DD` format.
109
- - When metadata is present, write it so a reader can decide whether the XDR should be used for the current case without guessing. `Valid:` sets a convergence date for adoption, `Applied to:` narrows the contexts where the decision applies, and the decision text defines any remaining boundaries.
108
+ - Include frontmatter `applied-to:` only when it adds value by narrowing the decision scope; omit it when the decision applies broadly.
109
+ - Include frontmatter `valid-from:` only when there is a specific future enforcement date; omit it when the decision is immediately effective.
110
+ - Keep `applied-to:` under 40 words and use `valid-from:` only with `YYYY-MM-DD` ISO format.
111
+ - When frontmatter metadata is present, write it so a reader can decide whether the XDR should be used for the current case without guessing. `valid-from:` sets a convergence date for adoption, `applied-to:` narrows the contexts where the decision applies, and the decision text defines any remaining boundaries.
110
112
  - Use mandatory language ("must", "always", "never") only for hard requirements; use advisory language ("should", "recommended") for guidance.
111
113
  - Do not duplicate content already in referenced XDRs — link instead.
112
114
  - Keep the decision itself authoritative in the XDR. Supporting artifacts may elaborate, but they should not restate the full decision when a short reference is enough.
@@ -122,7 +124,7 @@ Mandatory rules to apply while drafting:
122
124
  Check every item before finalizing:
123
125
 
124
126
  1. **Length**: Is it under 1300 words? Trim verbose explanations. Move detailed skills to a separate file and link.
125
- 2. **Metadata**: If metadata exists, is it directly before Context, limited to `Valid:` / `Applied to:`, omitted entirely when both are absent, and specific enough for a reader to decide whether the XDR is currently valid and applicable?
127
+ 2. **Frontmatter**: Are `applied-to:` and `valid-from:` present only when they add value, omitted entirely when not needed, and specific enough for a reader to decide whether the XDR is currently valid and applicable?
126
128
  3. **Originality**: Does every sentence add value that cannot be found in a generic web search? Remove obvious advice. Keep only the project-specific decision.
127
129
  4. **Clarity**: Is the chosen option unambiguous? Is the "why" clear in one reading?
128
130
  5. **Redundancy**: Is the XDR the primary source for the decision itself, with related documents linked instead of duplicated wherever possible?
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
@@ -31,8 +31,10 @@ function runLintCli(args) {
31
31
  return 0;
32
32
  }
33
33
 
34
- const targetPath = args[0] || '.';
35
- const result = lintWorkspace(targetPath);
34
+ const all = args.includes('--all');
35
+ const pathArgs = args.filter((a) => !a.startsWith('--'));
36
+ const targetPath = pathArgs[0] || '.';
37
+ const result = lintWorkspace(targetPath, { ignoreReadOnly: !all });
36
38
 
37
39
  if (result.errors.length === 0) {
38
40
  console.log(`Lint passed for ${toDisplayPath(result.xdrsRoot)}`);
@@ -48,12 +50,15 @@ function runLintCli(args) {
48
50
  }
49
51
 
50
52
  function printHelp() {
51
- console.log('Usage: xdrs-core lint [path]\n');
53
+ console.log('Usage: xdrs-core lint [options] [path]\n');
52
54
  console.log('Lint the XDR tree rooted at [path]/.xdrs or at [path] when [path] already points to .xdrs.');
53
- console.log('All other commands continue to be delegated to the bundled filedist CLI.');
55
+ console.log('\nOptions:');
56
+ console.log(' --all Check all files, including read-only files from other scopes (default: skip read-only files)');
57
+ console.log('\nAll other commands continue to be delegated to the bundled filedist CLI.');
54
58
  }
55
59
 
56
- function lintWorkspace(targetPath) {
60
+ function lintWorkspace(targetPath, options = {}) {
61
+ const { ignoreReadOnly = true } = options;
57
62
  const resolvedTarget = path.resolve(targetPath);
58
63
  const xdrsRoot = path.basename(resolvedTarget) === '.xdrs'
59
64
  ? resolvedTarget
@@ -76,7 +81,7 @@ function lintWorkspace(targetPath) {
76
81
  }
77
82
 
78
83
  for (const scopeEntry of scopeEntries) {
79
- lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, actualTypeIndexes);
84
+ lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, actualTypeIndexes, ignoreReadOnly);
80
85
  }
81
86
 
82
87
  const rootIndexPath = path.join(xdrsRoot, 'index.md');
@@ -124,9 +129,13 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors) {
124
129
  }
125
130
  }
126
131
 
127
- function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes) {
132
+ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, ignoreReadOnly) {
128
133
  const scopePath = path.join(xdrsRoot, scopeName);
129
134
 
135
+ if (ignoreReadOnly && isReadOnly(scopePath)) {
136
+ return;
137
+ }
138
+
130
139
  if (!isValidScopeName(scopeName)) {
131
140
  errors.push(`Invalid scope name: ${toDisplayPath(scopePath)}`);
132
141
  }
@@ -139,7 +148,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes) {
139
148
  errors.push(`Unexpected directory under scope ${scopeName}: ${toDisplayPath(entryPath)}`);
140
149
  continue;
141
150
  }
142
- lintTypeDirectory(xdrsRoot, scopeName, entry.name, errors, actualTypeIndexes);
151
+ lintTypeDirectory(xdrsRoot, scopeName, entry.name, errors, actualTypeIndexes, ignoreReadOnly);
143
152
  continue;
144
153
  }
145
154
 
@@ -147,7 +156,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes) {
147
156
  }
148
157
  }
149
158
 
150
- function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeIndexes) {
159
+ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeIndexes, ignoreReadOnly) {
151
160
  const typePath = path.join(xdrsRoot, scopeName, typeName);
152
161
  const indexPath = path.join(typePath, 'index.md');
153
162
  const xdrNumbers = new Map();
@@ -155,7 +164,7 @@ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeInde
155
164
 
156
165
  if (!existsFile(indexPath)) {
157
166
  errors.push(`Missing canonical index: ${toDisplayPath(indexPath)}`);
158
- } else {
167
+ } else if (!shouldSkipReadOnlyPath(indexPath, ignoreReadOnly)) {
159
168
  actualTypeIndexes.push(indexPath);
160
169
  }
161
170
 
@@ -174,15 +183,15 @@ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeInde
174
183
  continue;
175
184
  }
176
185
 
177
- artifacts.push(...lintSubjectDirectory(xdrsRoot, scopeName, typeName, entry.name, xdrNumbers, errors));
186
+ artifacts.push(...lintSubjectDirectory(xdrsRoot, scopeName, typeName, entry.name, xdrNumbers, errors, ignoreReadOnly));
178
187
  }
179
188
 
180
- if (existsFile(indexPath)) {
189
+ if (existsFile(indexPath) && !shouldSkipReadOnlyPath(indexPath, ignoreReadOnly)) {
181
190
  lintTypeIndex(indexPath, xdrsRoot, artifacts, errors);
182
191
  }
183
192
  }
184
193
 
185
- function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNumbers, errors) {
194
+ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNumbers, errors, ignoreReadOnly) {
186
195
  const subjectPath = path.join(xdrsRoot, scopeName, typeName, subjectName);
187
196
  const artifacts = [];
188
197
  const entries = safeReadDir(subjectPath, errors, `read subject directory ${scopeName}/${typeName}/${subjectName}`);
@@ -195,19 +204,19 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNum
195
204
  continue;
196
205
  }
197
206
  if (entry.name === 'skills') {
198
- artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
207
+ artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, ignoreReadOnly));
199
208
  continue;
200
209
  }
201
210
  if (entry.name === 'articles') {
202
- artifacts.push(...lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
211
+ artifacts.push(...lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, ignoreReadOnly));
203
212
  continue;
204
213
  }
205
214
  if (entry.name === 'researches') {
206
- artifacts.push(...lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
215
+ artifacts.push(...lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, ignoreReadOnly));
207
216
  continue;
208
217
  }
209
218
  if (entry.name === 'plans') {
210
- artifacts.push(...lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
219
+ artifacts.push(...lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, ignoreReadOnly));
211
220
  continue;
212
221
  }
213
222
 
@@ -220,6 +229,10 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNum
220
229
  continue;
221
230
  }
222
231
 
232
+ if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
233
+ continue;
234
+ }
235
+
223
236
  artifacts.push(entryPath);
224
237
  lintXdrFile(xdrsRoot, scopeName, typeName, entryPath, xdrNumbers, errors);
225
238
  }
@@ -256,7 +269,7 @@ function lintXdrFile(xdrsRoot, scopeName, typeName, filePath, xdrNumbers, errors
256
269
 
257
270
  const expectedName = `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${shortTitle}`;
258
271
  lintXdrFrontmatter(content, expectedName, filePath, errors);
259
- lintDocumentResourceLinks(filePath, errors);
272
+ lintDocumentLinks(filePath, errors);
260
273
  }
261
274
 
262
275
  function lintXdrFrontmatter(content, expectedName, filePath, errors) {
@@ -288,7 +301,7 @@ function lintXdrFrontmatter(content, expectedName, filePath, errors) {
288
301
  }
289
302
  }
290
303
 
291
- function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors) {
304
+ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors, ignoreReadOnly) {
292
305
  const artifacts = [];
293
306
  const skillNumbers = new Map();
294
307
  const entries = safeReadDir(skillsPath, errors, `read skills directory ${scopeName}/${typeName}/${subjectName}/skills`);
@@ -319,13 +332,18 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
319
332
  }
320
333
 
321
334
  const skillFilePath = path.join(entryPath, 'SKILL.md');
322
- artifacts.push(skillFilePath);
323
335
 
324
336
  if (!existsFile(skillFilePath)) {
325
337
  errors.push(`Missing SKILL.md in skill package: ${toDisplayPath(entryPath)}`);
326
338
  continue;
327
339
  }
328
340
 
341
+ if (shouldSkipReadOnlyPath(skillFilePath, ignoreReadOnly)) {
342
+ continue;
343
+ }
344
+
345
+ artifacts.push(skillFilePath);
346
+
329
347
  const skillContent = fs.readFileSync(skillFilePath, 'utf8');
330
348
  const skillFm = extractFrontmatter(skillContent);
331
349
  if (!skillFm.present) {
@@ -341,13 +359,13 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
341
359
  }
342
360
  }
343
361
 
344
- lintDocumentResourceLinks(skillFilePath, errors);
362
+ lintDocumentLinks(skillFilePath, errors);
345
363
  }
346
364
 
347
365
  return artifacts;
348
366
  }
349
367
 
350
- function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, articlesPath, errors) {
368
+ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, articlesPath, errors, ignoreReadOnly) {
351
369
  const artifacts = [];
352
370
  const articleNumbers = new Map();
353
371
  const entries = safeReadDir(articlesPath, errors, `read articles directory ${scopeName}/${typeName}/${subjectName}/articles`);
@@ -369,6 +387,10 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
369
387
  continue;
370
388
  }
371
389
 
390
+ if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
391
+ continue;
392
+ }
393
+
372
394
  artifacts.push(entryPath);
373
395
 
374
396
  const number = match[1];
@@ -390,13 +412,13 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
390
412
  errors.push(`Article title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
391
413
  }
392
414
 
393
- lintDocumentResourceLinks(entryPath, errors);
415
+ lintDocumentLinks(entryPath, errors);
394
416
  }
395
417
 
396
418
  return artifacts;
397
419
  }
398
420
 
399
- function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, researchPath, errors) {
421
+ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, researchPath, errors, ignoreReadOnly) {
400
422
  const artifacts = [];
401
423
  const researchNumbers = new Map();
402
424
  const entries = safeReadDir(researchPath, errors, `read research directory ${scopeName}/${typeName}/${subjectName}/researches`);
@@ -418,6 +440,10 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
418
440
  continue;
419
441
  }
420
442
 
443
+ if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
444
+ continue;
445
+ }
446
+
421
447
  artifacts.push(entryPath);
422
448
 
423
449
  const number = match[1];
@@ -439,13 +465,13 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
439
465
  errors.push(`Research title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
440
466
  }
441
467
 
442
- lintDocumentResourceLinks(entryPath, errors);
468
+ lintDocumentLinks(entryPath, errors);
443
469
  }
444
470
 
445
471
  return artifacts;
446
472
  }
447
473
 
448
- function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPath, errors) {
474
+ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPath, errors, ignoreReadOnly) {
449
475
  const artifacts = [];
450
476
  const planNumbers = new Map();
451
477
  const entries = safeReadDir(plansPath, errors, `read plans directory ${scopeName}/${typeName}/${subjectName}/plans`);
@@ -467,6 +493,10 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
467
493
  continue;
468
494
  }
469
495
 
496
+ if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
497
+ continue;
498
+ }
499
+
470
500
  artifacts.push(entryPath);
471
501
 
472
502
  const number = match[1];
@@ -489,7 +519,7 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
489
519
  }
490
520
 
491
521
  lintPlanExpectedEndDate(content, entryPath, errors);
492
- lintDocumentResourceLinks(entryPath, errors);
522
+ lintDocumentLinks(entryPath, errors);
493
523
  }
494
524
 
495
525
  return artifacts;
@@ -536,23 +566,32 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors) {
536
566
  }
537
567
  }
538
568
 
539
- function lintDocumentResourceLinks(documentPath, errors) {
540
- 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);
541
572
  const documentDir = path.dirname(documentPath);
542
573
  const resourceDir = path.join(documentDir, RESOURCE_DIR_NAME);
543
574
 
544
- for (const link of parseLocalLinkTargets(content, documentDir)) {
545
- if (!shouldValidateResourceLink(link.rawTarget)) {
575
+ for (let index = 0; index < lines.length; index += 1) {
576
+ if (ignoredLines[index]) {
546
577
  continue;
547
578
  }
548
579
 
549
- if (!fs.existsSync(link.resolvedPath)) {
550
- errors.push(`Broken asset link in ${toDisplayPath(documentPath)}: ${link.rawTarget}`);
551
- continue;
552
- }
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
+ }
553
591
 
554
- if (!isPathInside(resourceDir, link.resolvedPath)) {
555
- 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
+ }
556
595
  }
557
596
  }
558
597
  }
@@ -567,11 +606,11 @@ function parseLocalLinkTargets(markdown, baseDir) {
567
606
  let match = linkRe.exec(markdown);
568
607
  while (match) {
569
608
  const rawTarget = match[1].trim();
570
- if (isLocalLink(rawTarget)) {
571
- const targetWithoutAnchor = rawTarget.split('#')[0];
609
+ const normalizedTarget = normalizeLocalLinkTarget(rawTarget);
610
+ if (normalizedTarget) {
572
611
  links.push({
573
612
  rawTarget,
574
- resolvedPath: path.resolve(baseDir, targetWithoutAnchor)
613
+ resolvedPath: path.resolve(baseDir, normalizedTarget)
575
614
  });
576
615
  }
577
616
  match = linkRe.exec(markdown);
@@ -579,6 +618,17 @@ function parseLocalLinkTargets(markdown, baseDir) {
579
618
  return links;
580
619
  }
581
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
+
582
632
  function isLocalLink(target) {
583
633
  return target !== ''
584
634
  && !target.startsWith('#')
@@ -593,9 +643,13 @@ function isCanonicalTypeIndex(filePath, xdrsRoot) {
593
643
  }
594
644
 
595
645
  function shouldValidateResourceLink(rawTarget) {
596
- const targetWithoutAnchor = rawTarget.split('#')[0];
597
- const normalizedTarget = targetWithoutAnchor.replace(/\\/g, '/');
598
- 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();
599
653
 
600
654
  return normalizedTarget === RESOURCE_DIR_NAME
601
655
  || normalizedTarget.startsWith(`${RESOURCE_DIR_NAME}/`)
@@ -629,12 +683,20 @@ function stripFrontmatter(content) {
629
683
  function findIgnoredMarkdownLines(lines) {
630
684
  const ignored = [];
631
685
  let inCodeFence = false;
686
+ let activeFence = null;
632
687
 
633
688
  for (let index = 0; index < lines.length; index += 1) {
634
689
  const trimmed = lines[index].trim();
635
- if (/^```/.test(trimmed)) {
690
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
691
+ if (fenceMatch) {
636
692
  ignored[index] = true;
637
- 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
+ }
638
700
  continue;
639
701
  }
640
702
 
@@ -744,6 +806,19 @@ function existsFile(filePath) {
744
806
  }
745
807
  }
746
808
 
809
+ function isReadOnly(filePath) {
810
+ try {
811
+ fs.accessSync(filePath, fs.constants.W_OK);
812
+ return false;
813
+ } catch {
814
+ return true;
815
+ }
816
+ }
817
+
818
+ function shouldSkipReadOnlyPath(filePath, ignoreReadOnly) {
819
+ return ignoreReadOnly && isReadOnly(filePath);
820
+ }
821
+
747
822
  function displayPath(indexPath, targetPath) {
748
823
  return `${toDisplayPath(indexPath)} -> ${toDisplayPath(targetPath)}`;
749
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.1",
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",