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 +2 -1
- package/lib/lint.js +95 -38
- package/lib/lint.test.js +153 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
549
|
-
const
|
|
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 (
|
|
554
|
-
if (
|
|
575
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
576
|
+
if (ignoredLines[index]) {
|
|
555
577
|
continue;
|
|
556
578
|
}
|
|
557
579
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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
|
-
|
|
580
|
-
|
|
609
|
+
const normalizedTarget = normalizeLocalLinkTarget(rawTarget);
|
|
610
|
+
if (normalizedTarget) {
|
|
581
611
|
links.push({
|
|
582
612
|
rawTarget,
|
|
583
|
-
resolvedPath: path.resolve(baseDir,
|
|
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
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
690
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
691
|
+
if (fenceMatch) {
|
|
645
692
|
ignored[index] = true;
|
|
646
|
-
|
|
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
|
}
|
package/lib/lint.test.js
ADDED
|
@@ -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