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.
- package/.xdrs/_core/adrs/principles/skills/002-write-xdr/SKILL.md +12 -10
- package/README.md +2 -1
- package/lib/lint.js +120 -45
- package/lib/lint.test.js +153 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
-
|
|
108
|
-
- Keep `
|
|
109
|
-
- When metadata is present, write it so a reader can decide whether the XDR should be used for the current case without guessing. `
|
|
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. **
|
|
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
|
|
35
|
-
const
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
540
|
-
const
|
|
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 (
|
|
545
|
-
if (
|
|
575
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
576
|
+
if (ignoredLines[index]) {
|
|
546
577
|
continue;
|
|
547
578
|
}
|
|
548
579
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
571
|
-
|
|
609
|
+
const normalizedTarget = normalizeLocalLinkTarget(rawTarget);
|
|
610
|
+
if (normalizedTarget) {
|
|
572
611
|
links.push({
|
|
573
612
|
rawTarget,
|
|
574
|
-
resolvedPath: path.resolve(baseDir,
|
|
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
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
690
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
691
|
+
if (fenceMatch) {
|
|
636
692
|
ignored[index] = true;
|
|
637
|
-
|
|
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
|
}
|
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