xdrs-core 0.18.0 → 0.20.0

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/lib/lint.js CHANGED
@@ -22,7 +22,7 @@ const NUMBERED_FILE_RE = /^(\d{3,})-([a-z0-9-]+)\.md$/;
22
22
  const NUMBERED_DIR_RE = /^(\d{3,})-([a-z0-9-]+)$/;
23
23
  const REQUIRED_ROOT_INDEX_TEXT = 'XDRs in scopes listed last override the ones listed first';
24
24
  const SUBJECT_ARTIFACT_DIRS = new Set(['skills', 'articles', 'researches', 'plans']);
25
- const RESOURCE_DIR_NAME = 'assets';
25
+ const RESOURCE_DIR_NAME = '.assets';
26
26
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
27
27
 
28
28
  function runLintCli(args) {
@@ -34,7 +34,7 @@ function runLintCli(args) {
34
34
  const all = args.includes('--all');
35
35
  const pathArgs = args.filter((a) => !a.startsWith('--'));
36
36
  const targetPath = pathArgs[0] || '.';
37
- const result = lintWorkspace(targetPath, { ignoreReadOnly: !all });
37
+ const result = lintWorkspace(targetPath, { ignoreExternal: !all });
38
38
 
39
39
  if (result.errors.length === 0) {
40
40
  console.log(`Lint passed for ${toDisplayPath(result.xdrsRoot)}`);
@@ -53,12 +53,12 @@ function printHelp() {
53
53
  console.log('Usage: xdrs-core lint [options] [path]\n');
54
54
  console.log('Lint the XDR tree rooted at [path]/.xdrs or at [path] when [path] already points to .xdrs.');
55
55
  console.log('\nOptions:');
56
- console.log(' --all Check all files, including read-only files from other scopes (default: skip read-only files)');
56
+ console.log(' --all Check all files, including files from external scopes distributed via .filedist (default: skip external scopes)');
57
57
  console.log('\nAll other commands continue to be delegated to the bundled filedist CLI.');
58
58
  }
59
59
 
60
60
  function lintWorkspace(targetPath, options = {}) {
61
- const { ignoreReadOnly = true } = options;
61
+ const { ignoreExternal = true } = options;
62
62
  const resolvedTarget = path.resolve(targetPath);
63
63
  const xdrsRoot = path.basename(resolvedTarget) === '.xdrs'
64
64
  ? resolvedTarget
@@ -70,6 +70,10 @@ function lintWorkspace(targetPath, options = {}) {
70
70
  return { xdrsRoot, errors };
71
71
  }
72
72
 
73
+ const repoRoot = path.dirname(xdrsRoot);
74
+ const filedistPaths = loadFiledist(repoRoot);
75
+ const externalScopes = getExternalScopes(filedistPaths, xdrsRoot);
76
+
73
77
  const actualTypeIndexes = [];
74
78
  const rootEntries = safeReadDir(xdrsRoot, errors, 'read XDR root directory');
75
79
  const scopeEntries = rootEntries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'));
@@ -81,7 +85,7 @@ function lintWorkspace(targetPath, options = {}) {
81
85
  }
82
86
 
83
87
  for (const scopeEntry of scopeEntries) {
84
- lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, actualTypeIndexes, ignoreReadOnly);
88
+ lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, actualTypeIndexes, ignoreExternal, externalScopes);
85
89
  }
86
90
 
87
91
  const rootIndexPath = path.join(xdrsRoot, 'index.md');
@@ -109,8 +113,8 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors) {
109
113
  }
110
114
  }
111
115
 
112
- const linkedTypeIndexes = links.filter((linkPath) => isCanonicalTypeIndex(linkPath, xdrsRoot));
113
- const linkedSet = new Set(linkedTypeIndexes.map(normalizePath));
116
+ const linkedScopeIndexes = links.filter((linkPath) => isScopeIndex(linkPath, xdrsRoot));
117
+ const linkedSet = new Set(linkedScopeIndexes.map(normalizePath));
114
118
  const localScopePath = normalizePath(path.join(xdrsRoot, '_local'));
115
119
 
116
120
  for (const linkPath of links) {
@@ -119,21 +123,46 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors) {
119
123
  }
120
124
  }
121
125
 
126
+ // Collect non-_local scopes that have type indexes and check their scope index is linked
127
+ const scopesWithTypeIndexes = new Set();
122
128
  for (const indexPath of actualTypeIndexes) {
123
129
  const scopeName = path.basename(path.dirname(path.dirname(indexPath)));
124
- if (scopeName === '_local') {
125
- continue;
130
+ if (scopeName !== '_local') {
131
+ scopesWithTypeIndexes.add(scopeName);
132
+ }
133
+ }
134
+
135
+ for (const scopeName of scopesWithTypeIndexes) {
136
+ const scopeIndexPath = normalizePath(path.join(xdrsRoot, scopeName, 'index.md'));
137
+ if (!linkedSet.has(scopeIndexPath)) {
138
+ errors.push(`Root index is missing scope index link: ${toDisplayPath(path.join(xdrsRoot, scopeName, 'index.md'))}`);
139
+ }
140
+ }
141
+ }
142
+
143
+ function lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors) {
144
+ const content = fs.readFileSync(scopeIndexPath, 'utf8');
145
+ const repoRoot = path.dirname(xdrsRoot);
146
+ const links = parseLocalLinks(content, path.dirname(scopeIndexPath), repoRoot);
147
+ const linkedSet = new Set(links.map(normalizePath));
148
+
149
+ for (const linkPath of links) {
150
+ if (!fs.existsSync(linkPath)) {
151
+ errors.push(`Broken link in scope index: ${displayPath(scopeIndexPath, linkPath)}`);
126
152
  }
127
- if (!linkedSet.has(normalizePath(indexPath))) {
128
- errors.push(`Root index is missing canonical index link: ${toDisplayPath(indexPath)}`);
153
+ }
154
+
155
+ for (const typeIndexPath of typeIndexesInScope) {
156
+ if (!linkedSet.has(normalizePath(typeIndexPath))) {
157
+ errors.push(`Scope index ${toDisplayPath(scopeIndexPath)} is missing link to type index: ${toDisplayPath(typeIndexPath)}`);
129
158
  }
130
159
  }
131
160
  }
132
161
 
133
- function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, ignoreReadOnly) {
162
+ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, ignoreExternal, externalScopes) {
134
163
  const scopePath = path.join(xdrsRoot, scopeName);
135
164
 
136
- if (ignoreReadOnly && isReadOnly(scopePath)) {
165
+ if (ignoreExternal && externalScopes.has(scopeName)) {
137
166
  return;
138
167
  }
139
168
 
@@ -141,6 +170,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, igno
141
170
  errors.push(`Invalid scope name: ${toDisplayPath(scopePath)}`);
142
171
  }
143
172
 
173
+ const typeIndexesInScope = [];
144
174
  const entries = safeReadDir(scopePath, errors, `read scope directory ${scopeName}`);
145
175
  for (const entry of entries) {
146
176
  const entryPath = path.join(scopePath, entry.name);
@@ -149,15 +179,30 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, igno
149
179
  errors.push(`Unexpected directory under scope ${scopeName}: ${toDisplayPath(entryPath)}`);
150
180
  continue;
151
181
  }
152
- lintTypeDirectory(xdrsRoot, scopeName, entry.name, errors, actualTypeIndexes, ignoreReadOnly);
182
+ lintTypeDirectory(xdrsRoot, scopeName, entry.name, errors, actualTypeIndexes);
183
+ const typeIndexPath = path.join(entryPath, 'index.md');
184
+ if (existsFile(typeIndexPath)) {
185
+ typeIndexesInScope.push(typeIndexPath);
186
+ }
187
+ continue;
188
+ }
189
+
190
+ if (entry.name === 'index.md') {
153
191
  continue;
154
192
  }
155
193
 
156
194
  errors.push(`Unexpected file under scope ${scopeName}: ${toDisplayPath(entryPath)}`);
157
195
  }
196
+
197
+ const scopeIndexPath = path.join(scopePath, 'index.md');
198
+ if (!existsFile(scopeIndexPath)) {
199
+ errors.push(`Missing required scope index: ${toDisplayPath(scopeIndexPath)}`);
200
+ } else {
201
+ lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors);
202
+ }
158
203
  }
159
204
 
160
- function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeIndexes, ignoreReadOnly) {
205
+ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeIndexes) {
161
206
  const typePath = path.join(xdrsRoot, scopeName, typeName);
162
207
  const indexPath = path.join(typePath, 'index.md');
163
208
  const xdrNumbers = new Map();
@@ -165,7 +210,7 @@ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeInde
165
210
 
166
211
  if (!existsFile(indexPath)) {
167
212
  errors.push(`Missing canonical index: ${toDisplayPath(indexPath)}`);
168
- } else if (!shouldSkipReadOnlyPath(indexPath, ignoreReadOnly)) {
213
+ } else {
169
214
  actualTypeIndexes.push(indexPath);
170
215
  }
171
216
 
@@ -184,15 +229,15 @@ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeInde
184
229
  continue;
185
230
  }
186
231
 
187
- artifacts.push(...lintSubjectDirectory(xdrsRoot, scopeName, typeName, entry.name, xdrNumbers, errors, ignoreReadOnly));
232
+ artifacts.push(...lintSubjectDirectory(xdrsRoot, scopeName, typeName, entry.name, xdrNumbers, errors));
188
233
  }
189
234
 
190
- if (existsFile(indexPath) && !shouldSkipReadOnlyPath(indexPath, ignoreReadOnly)) {
235
+ if (existsFile(indexPath)) {
191
236
  lintTypeIndex(indexPath, xdrsRoot, artifacts, errors);
192
237
  }
193
238
  }
194
239
 
195
- function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNumbers, errors, ignoreReadOnly) {
240
+ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNumbers, errors) {
196
241
  const subjectPath = path.join(xdrsRoot, scopeName, typeName, subjectName);
197
242
  const artifacts = [];
198
243
  const entries = safeReadDir(subjectPath, errors, `read subject directory ${scopeName}/${typeName}/${subjectName}`);
@@ -205,19 +250,19 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNum
205
250
  continue;
206
251
  }
207
252
  if (entry.name === 'skills') {
208
- artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, ignoreReadOnly));
253
+ artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
209
254
  continue;
210
255
  }
211
256
  if (entry.name === 'articles') {
212
- artifacts.push(...lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, ignoreReadOnly));
257
+ artifacts.push(...lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
213
258
  continue;
214
259
  }
215
260
  if (entry.name === 'researches') {
216
- artifacts.push(...lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, ignoreReadOnly));
261
+ artifacts.push(...lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
217
262
  continue;
218
263
  }
219
264
  if (entry.name === 'plans') {
220
- artifacts.push(...lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, ignoreReadOnly));
265
+ artifacts.push(...lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
221
266
  continue;
222
267
  }
223
268
 
@@ -230,14 +275,14 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNum
230
275
  continue;
231
276
  }
232
277
 
233
- if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
234
- continue;
235
- }
236
-
237
278
  artifacts.push(entryPath);
238
279
  lintXdrFile(xdrsRoot, scopeName, typeName, entryPath, xdrNumbers, errors);
239
280
  }
240
281
 
282
+ const subjectAssetsDir = path.join(subjectPath, RESOURCE_DIR_NAME);
283
+ const xdrDocsInSubject = artifacts.filter((p) => path.dirname(p) === subjectPath);
284
+ lintOrphanAssets(subjectAssetsDir, xdrDocsInSubject, xdrsRoot, errors);
285
+
241
286
  return artifacts;
242
287
  }
243
288
 
@@ -327,7 +372,7 @@ function lintXdrFrontmatter(content, expectedName, filePath, errors) {
327
372
  }
328
373
  }
329
374
 
330
- function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors, ignoreReadOnly) {
375
+ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors) {
331
376
  const artifacts = [];
332
377
  const skillNumbers = new Map();
333
378
  const entries = safeReadDir(skillsPath, errors, `read skills directory ${scopeName}/${typeName}/${subjectName}/skills`);
@@ -364,10 +409,6 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
364
409
  continue;
365
410
  }
366
411
 
367
- if (shouldSkipReadOnlyPath(skillFilePath, ignoreReadOnly)) {
368
- continue;
369
- }
370
-
371
412
  artifacts.push(skillFilePath);
372
413
 
373
414
  const skillContent = fs.readFileSync(skillFilePath, 'utf8');
@@ -386,12 +427,13 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
386
427
  }
387
428
 
388
429
  lintDocumentLinks(skillFilePath, xdrsRoot, scopeName, errors);
430
+ lintOrphanAssets(path.join(entryPath, RESOURCE_DIR_NAME), [skillFilePath], xdrsRoot, errors);
389
431
  }
390
432
 
391
433
  return artifacts;
392
434
  }
393
435
 
394
- function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, articlesPath, errors, ignoreReadOnly) {
436
+ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, articlesPath, errors) {
395
437
  const artifacts = [];
396
438
  const articleNumbers = new Map();
397
439
  const entries = safeReadDir(articlesPath, errors, `read articles directory ${scopeName}/${typeName}/${subjectName}/articles`);
@@ -413,10 +455,6 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
413
455
  continue;
414
456
  }
415
457
 
416
- if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
417
- continue;
418
- }
419
-
420
458
  artifacts.push(entryPath);
421
459
 
422
460
  const number = match[1];
@@ -441,10 +479,12 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
441
479
  lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors);
442
480
  }
443
481
 
482
+ lintOrphanAssets(path.join(articlesPath, RESOURCE_DIR_NAME), artifacts, xdrsRoot, errors);
483
+
444
484
  return artifacts;
445
485
  }
446
486
 
447
- function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, researchPath, errors, ignoreReadOnly) {
487
+ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, researchPath, errors) {
448
488
  const artifacts = [];
449
489
  const researchNumbers = new Map();
450
490
  const entries = safeReadDir(researchPath, errors, `read research directory ${scopeName}/${typeName}/${subjectName}/researches`);
@@ -466,10 +506,6 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
466
506
  continue;
467
507
  }
468
508
 
469
- if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
470
- continue;
471
- }
472
-
473
509
  artifacts.push(entryPath);
474
510
 
475
511
  const number = match[1];
@@ -494,10 +530,12 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
494
530
  lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors);
495
531
  }
496
532
 
533
+ lintOrphanAssets(path.join(researchPath, RESOURCE_DIR_NAME), artifacts, xdrsRoot, errors);
534
+
497
535
  return artifacts;
498
536
  }
499
537
 
500
- function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPath, errors, ignoreReadOnly) {
538
+ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPath, errors) {
501
539
  const artifacts = [];
502
540
  const planNumbers = new Map();
503
541
  const entries = safeReadDir(plansPath, errors, `read plans directory ${scopeName}/${typeName}/${subjectName}/plans`);
@@ -519,10 +557,6 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
519
557
  continue;
520
558
  }
521
559
 
522
- if (shouldSkipReadOnlyPath(entryPath, ignoreReadOnly)) {
523
- continue;
524
- }
525
-
526
560
  artifacts.push(entryPath);
527
561
 
528
562
  const number = match[1];
@@ -548,6 +582,8 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
548
582
  lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors);
549
583
  }
550
584
 
585
+ lintOrphanAssets(path.join(plansPath, RESOURCE_DIR_NAME), artifacts, xdrsRoot, errors);
586
+
551
587
  return artifacts;
552
588
  }
553
589
 
@@ -599,6 +635,46 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors) {
599
635
  }
600
636
  }
601
637
 
638
+ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
639
+ if (!existsDirectory(assetsDir)) {
640
+ return;
641
+ }
642
+
643
+ const entries = safeReadDir(assetsDir, errors, `read assets directory ${toDisplayPath(assetsDir)}`);
644
+ if (entries.length === 0) {
645
+ return;
646
+ }
647
+
648
+ const assetFiles = entries
649
+ .filter((entry) => entry.isFile())
650
+ .map((entry) => normalizePath(path.join(assetsDir, entry.name)));
651
+
652
+ if (assetFiles.length === 0) {
653
+ return;
654
+ }
655
+
656
+ const repoRoot = path.dirname(xdrsRoot);
657
+ const referencedAssets = new Set();
658
+
659
+ for (const docPath of documentPaths) {
660
+ if (!existsFile(docPath)) {
661
+ continue;
662
+ }
663
+ const content = fs.readFileSync(docPath, 'utf8');
664
+ const docDir = path.dirname(docPath);
665
+ const links = parseLocalLinks(content, docDir, repoRoot);
666
+ for (const linkPath of links) {
667
+ referencedAssets.add(normalizePath(linkPath));
668
+ }
669
+ }
670
+
671
+ for (const assetPath of assetFiles) {
672
+ if (!referencedAssets.has(assetPath)) {
673
+ errors.push(`Orphan asset file not referenced by any document: ${toDisplayPath(assetPath)}`);
674
+ }
675
+ }
676
+ }
677
+
602
678
  function lintDocumentLinks(documentPath, xdrsRoot, scopeName, errors) {
603
679
  const lines = fs.readFileSync(documentPath, 'utf8').split(/\r?\n/);
604
680
  const ignoredLines = findIgnoredMarkdownLines(lines);
@@ -693,6 +769,11 @@ function isCanonicalTypeIndex(filePath, xdrsRoot) {
693
769
  return relative.length === 3 && TYPE_NAMES.has(relative[1]) && relative[2] === 'index.md';
694
770
  }
695
771
 
772
+ function isScopeIndex(filePath, xdrsRoot) {
773
+ const relative = relativeFrom(xdrsRoot, filePath).split(path.sep);
774
+ return relative.length === 2 && relative[1] === 'index.md';
775
+ }
776
+
696
777
  function shouldValidateResourceLink(rawTarget) {
697
778
  const normalizedTargetPath = normalizeLocalLinkTarget(rawTarget);
698
779
  if (!normalizedTargetPath) {
@@ -857,17 +938,38 @@ function existsFile(filePath) {
857
938
  }
858
939
  }
859
940
 
860
- function isReadOnly(filePath) {
861
- try {
862
- fs.accessSync(filePath, fs.constants.W_OK);
863
- return false;
864
- } catch {
865
- return true;
941
+ function loadFiledist(repoRoot) {
942
+ const filedistPath = path.join(repoRoot, '.filedist');
943
+ if (!existsFile(filedistPath)) {
944
+ return new Set();
866
945
  }
946
+ const content = fs.readFileSync(filedistPath, 'utf8');
947
+ const paths = new Set();
948
+ for (const line of content.split(/\r?\n/)) {
949
+ const trimmed = line.trim();
950
+ if (!trimmed) {
951
+ continue;
952
+ }
953
+ const parts = trimmed.split('|');
954
+ if (parts.length >= 2) {
955
+ paths.add(normalizePath(path.join(repoRoot, parts[0])));
956
+ }
957
+ }
958
+ return paths;
867
959
  }
868
960
 
869
- function shouldSkipReadOnlyPath(filePath, ignoreReadOnly) {
870
- return ignoreReadOnly && isReadOnly(filePath);
961
+ function getExternalScopes(filedistPaths, xdrsRoot) {
962
+ const externalScopes = new Set();
963
+ for (const filePath of filedistPaths) {
964
+ const relative = path.relative(xdrsRoot, filePath);
965
+ if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
966
+ const parts = relative.split(path.sep);
967
+ if (parts.length >= 1 && parts[0]) {
968
+ externalScopes.add(parts[0]);
969
+ }
970
+ }
971
+ }
972
+ return externalScopes;
871
973
  }
872
974
 
873
975
  function displayPath(indexPath, targetPath) {