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 +2 -1
- package/lib/lint.js +122 -40
- package/lib/lint.test.js +179 -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
|
}
|
|
@@ -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 =
|
|
269
|
+
const expectedName = extractExpectedXdrNameFromHeading(firstLine)
|
|
270
|
+
|| `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${match[2]}`;
|
|
267
271
|
lintXdrFrontmatter(content, expectedName, filePath, errors);
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
549
|
-
const
|
|
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 (
|
|
554
|
-
if (
|
|
600
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
601
|
+
if (ignoredLines[index]) {
|
|
555
602
|
continue;
|
|
556
603
|
}
|
|
557
604
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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
|
-
|
|
580
|
-
|
|
634
|
+
const normalizedTarget = normalizeLocalLinkTarget(rawTarget);
|
|
635
|
+
if (normalizedTarget) {
|
|
581
636
|
links.push({
|
|
582
637
|
rawTarget,
|
|
583
|
-
resolvedPath: path.resolve(baseDir,
|
|
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
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
715
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
716
|
+
if (fenceMatch) {
|
|
645
717
|
ignored[index] = true;
|
|
646
|
-
|
|
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
|
}
|
package/lib/lint.test.js
ADDED
|
@@ -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