xdrs-core 0.32.0 → 0.34.2

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
@@ -18,7 +18,6 @@ const ALLOWED_SUBJECTS = {
18
18
 
19
19
  const TYPE_NAMES = new Set(Object.keys(TYPE_TO_ID));
20
20
  const RESERVED_SCOPES = new Set(['_core', '_local']);
21
- const SCOPE_TYPE_NAMES = new Set(['core', 'reference', 'platform', 'domain', '_local']);
22
21
  const NUMBERED_FILE_RE = /^(\d{3,})-([a-z0-9-]+)\.md$/;
23
22
  const NUMBERED_DIR_RE = /^(\d{3,})-([a-z0-9-]+)$/;
24
23
  const REQUIRED_ROOT_INDEX_TEXT = 'XDRS scopes listed last override the ones listed first';
@@ -28,7 +27,7 @@ const SKILL_PACKAGE_OPTIONAL_DIRS = new Set(['scripts', 'references', RESOURCE_D
28
27
 
29
28
  const POLICY_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'apply-to', 'valid-from', 'license', 'metadata']);
30
29
  const SKILL_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'license', 'metadata', 'compatibility', 'allowed-tools']);
31
- const SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS = new Set(['scope-type', 'name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'follows']);
30
+ const SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS = new Set(['scope-type', 'name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'follows', 'related-scopes']);
32
31
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
33
32
  const SLIDE_FILE_RE = /^.+-slides(?:-[a-z0-9-]+)?\.md$/;
34
33
  const SLIDE_MAX_NAME_LENGTH = 64;
@@ -100,6 +99,42 @@ function lintWorkspace(targetPath, options = {}) {
100
99
  const rootEntries = safeReadDir(xdrsRoot, errors, 'read XDRS root directory');
101
100
  const scopeEntries = rootEntries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'));
102
101
 
102
+ // Pass 1: collect all core-type scopes by reading index.md directly (bypasses external-skip)
103
+ const coreScopes = [];
104
+ for (const scopeEntry of scopeEntries) {
105
+ const scopeIndexPath = path.join(xdrsRoot, scopeEntry.name, 'index.md');
106
+ if (!existsFile(scopeIndexPath)) continue;
107
+ const content = fs.readFileSync(scopeIndexPath, 'utf8');
108
+ const scopeTypeMatch = content.match(/^scope-type:\s*(.+)$/m);
109
+ if (scopeTypeMatch && scopeTypeMatch[1].trim() === 'core') {
110
+ coreScopes.push(path.join(xdrsRoot, scopeEntry.name));
111
+ }
112
+ }
113
+
114
+ // Pass 2: collect known scope-type names from NNN-{scope-type}-scope-type.md files in core scopes
115
+ const knownScopeTypes = new Map(); // scopeTypeName -> filePath
116
+ for (const coreScopePath of coreScopes) {
117
+ for (const typeName of Object.keys(TYPE_TO_ID)) {
118
+ const principlesDir = path.join(coreScopePath, typeName, 'principles');
119
+ if (!existsDirectory(principlesDir)) continue;
120
+ let principlesEntries;
121
+ try { principlesEntries = fs.readdirSync(principlesDir, { withFileTypes: true }); } catch { continue; }
122
+ for (const entry of principlesEntries) {
123
+ if (!entry.isFile()) continue;
124
+ const m = entry.name.match(NUMBERED_FILE_RE);
125
+ if (!m) continue;
126
+ const shortTitle = m[2];
127
+ if (!shortTitle.endsWith('-scope-type')) continue;
128
+ const rawTypeName = shortTitle.slice(0, -'-scope-type'.length);
129
+ // Special-case: 'local' maps to '_local' (underscore not allowed in NUMBERED_FILE_RE)
130
+ const scopeTypeName = rawTypeName === 'local' ? '_local' : rawTypeName;
131
+ if (!knownScopeTypes.has(scopeTypeName)) {
132
+ knownScopeTypes.set(scopeTypeName, path.join(principlesDir, entry.name));
133
+ }
134
+ }
135
+ }
136
+ }
137
+
103
138
  for (const entry of rootEntries) {
104
139
  if (entry.isFile() && entry.name !== 'index.md') {
105
140
  errors.push(`Unexpected file at .xdrs root: ${entry.name}`);
@@ -107,7 +142,7 @@ function lintWorkspace(targetPath, options = {}) {
107
142
  }
108
143
 
109
144
  for (const scopeEntry of scopeEntries) {
110
- lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, actualTypeIndexes, ignoreExternal, effectiveExternalScopes);
145
+ lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, actualTypeIndexes, ignoreExternal, effectiveExternalScopes, knownScopeTypes);
111
146
  }
112
147
 
113
148
  const rootIndexPath = path.join(xdrsRoot, 'index.md');
@@ -183,7 +218,23 @@ function lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope,
183
218
  }
184
219
  }
185
220
 
186
- function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot) {
221
+ /**
222
+ * Parse a scope-type policy file for a NN-parent-scope-type rule and return the parent type name.
223
+ * Returns null if no parent is declared or the file cannot be read.
224
+ */
225
+ function extractParentScopeType(policyFilePath) {
226
+ let content;
227
+ try { content = fs.readFileSync(policyFilePath, 'utf8'); } catch { return null; }
228
+ // Find a rule heading like "#### NN-parent-scope-type"
229
+ const headingMatch = content.match(/^#{4}\s+\d+-parent-scope-type\s*$/m);
230
+ if (!headingMatch) return null;
231
+ // Extract text after the heading until the next heading or end of file
232
+ const afterHeading = content.slice(content.indexOf(headingMatch[0]) + headingMatch[0].length);
233
+ const bodyMatch = afterHeading.match(/`([a-z0-9-]+)`/);
234
+ return bodyMatch ? bodyMatch[1] : null;
235
+ }
236
+
237
+ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes = new Map()) {
187
238
  const content = fs.readFileSync(scopeIndexPath, 'utf8');
188
239
  const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
189
240
  if (!fmMatch) {
@@ -205,10 +256,28 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot)
205
256
  errors.push(`Scope index frontmatter must include a scope-type field: ${toDisplayPath(scopeIndexPath)}`);
206
257
  return;
207
258
  }
208
- if (!SCOPE_TYPE_NAMES.has(scopeType)) {
209
- errors.push(`Scope index scope-type must be one of [${[...SCOPE_TYPE_NAMES].join(', ')}]: ${toDisplayPath(scopeIndexPath)}`);
259
+ if (!knownScopeTypes.has(scopeType) && scopeType !== '_local') {
260
+ errors.push(`Scope index scope-type "${scopeType}" has no corresponding ${scopeType}-scope-type policy in the principles subject of any core-type scope: ${toDisplayPath(scopeIndexPath)}`);
210
261
  return;
211
262
  }
263
+ // Validate inheritance chain: ensure all ancestors exist in knownScopeTypes
264
+ if (scopeType !== '_local') {
265
+ const visited = new Set([scopeType]);
266
+ let currentType = scopeType;
267
+ while (currentType) {
268
+ const policyPath = knownScopeTypes.get(currentType);
269
+ if (!policyPath) break;
270
+ const parentType = extractParentScopeType(policyPath);
271
+ if (!parentType) break;
272
+ if (visited.has(parentType)) break; // cycle detected
273
+ if (!knownScopeTypes.has(parentType)) {
274
+ errors.push(`Scope type "${currentType}" declares parent "${parentType}" but no ${parentType}-scope-type policy exists in any core-type scope: ${toDisplayPath(scopeIndexPath)}`);
275
+ break;
276
+ }
277
+ visited.add(parentType);
278
+ currentType = parentType;
279
+ }
280
+ }
212
281
  if (scopeName === '_core' && scopeType !== 'core') {
213
282
  errors.push(`Scope "_core" must have scope-type "core": ${toDisplayPath(scopeIndexPath)}`);
214
283
  }
@@ -236,8 +305,8 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot)
236
305
 
237
306
  if (!fm.description) {
238
307
  errors.push(`Scope index frontmatter must include a non-empty description field: ${toDisplayPath(scopeIndexPath)}`);
239
- } else if (fm.descriptionText && fm.descriptionText.length > 1024) {
240
- errors.push(`Scope index frontmatter description must be 1024 characters or fewer: ${toDisplayPath(scopeIndexPath)}`);
308
+ } else if (fm.descriptionText && countWords(fm.descriptionText) > 40) {
309
+ errors.push(`Scope index frontmatter description must be 40 words or fewer: ${toDisplayPath(scopeIndexPath)}`);
241
310
  }
242
311
 
243
312
  if (!fm.appliedTo) {
@@ -246,8 +315,8 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot)
246
315
  const words = countWords(fm.appliedTo);
247
316
  if (words === 0) {
248
317
  errors.push(`Scope index frontmatter apply-to must not be empty: ${toDisplayPath(scopeIndexPath)}`);
249
- } else if (words >= 40) {
250
- errors.push(`Scope index frontmatter apply-to must be under 40 words: ${toDisplayPath(scopeIndexPath)}`);
318
+ } else if (words > 30) {
319
+ errors.push(`Scope index frontmatter apply-to must be 30 words or fewer: ${toDisplayPath(scopeIndexPath)}`);
251
320
  }
252
321
  }
253
322
 
@@ -298,16 +367,74 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot)
298
367
  }
299
368
  return items;
300
369
  })();
370
+ for (const entry of entries) {
371
+ if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(entry)) {
372
+ const followedIndexPath = path.join(xdrsRoot, entry, 'index.md');
373
+ if (!existsFile(followedIndexPath)) {
374
+ errors.push(`Scope index frontmatter follows references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)}`);
375
+ } else {
376
+ const followedContent = fs.readFileSync(followedIndexPath, 'utf8');
377
+ const followedTypeMatch = followedContent.match(/^scope-type:\s*(.+)$/m);
378
+ if (!followedTypeMatch || followedTypeMatch[1].trim() !== 'core') {
379
+ errors.push(`Scope index frontmatter follows references scope "${entry}" which is not a core-type scope: ${toDisplayPath(scopeIndexPath)}`);
380
+ }
381
+ }
382
+ }
383
+ }
384
+ }
385
+ }
386
+
387
+ const relatedScopesLineMatch = block.match(/^related-scopes:[ \t]*(.*)/m);
388
+ if (relatedScopesLineMatch !== null) {
389
+ const relatedScopesValue = relatedScopesLineMatch[1].trim();
390
+ const scopeNamePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
391
+ if (relatedScopesValue) {
392
+ if (!scopeNamePattern.test(relatedScopesValue)) {
393
+ errors.push(`Scope index frontmatter related-scopes must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)}`);
394
+ }
395
+ } else {
396
+ const relatedScopesStart = block.indexOf(relatedScopesLineMatch[0]) + relatedScopesLineMatch[0].length;
397
+ const listItems = [];
398
+ let hasInvalidEntry = false;
399
+ for (const line of block.slice(relatedScopesStart).split('\n')) {
400
+ const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
401
+ if (itemMatch) {
402
+ const item = (itemMatch[1] || '').trim();
403
+ if (!item || !scopeNamePattern.test(item)) {
404
+ errors.push(`Scope index frontmatter related-scopes entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)}`);
405
+ hasInvalidEntry = true;
406
+ break;
407
+ }
408
+ listItems.push(item);
409
+ } else if (line.trim() && !/^\s/.test(line)) {
410
+ break;
411
+ }
412
+ }
413
+ if (!hasInvalidEntry && listItems.length === 0) {
414
+ errors.push(`Scope index frontmatter related-scopes must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)}`);
415
+ }
416
+ }
417
+ if (xdrsRoot) {
418
+ const entries = relatedScopesValue ? [relatedScopesValue] : (() => {
419
+ const items = [];
420
+ const relatedScopesStart = block.indexOf(relatedScopesLineMatch[0]) + relatedScopesLineMatch[0].length;
421
+ for (const line of block.slice(relatedScopesStart).split('\n')) {
422
+ const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
423
+ if (itemMatch && itemMatch[1]) items.push(itemMatch[1].trim());
424
+ else if (line.trim() && !/^\s/.test(line)) break;
425
+ }
426
+ return items;
427
+ })();
301
428
  for (const entry of entries) {
302
429
  if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(entry) && !existsFile(path.join(xdrsRoot, entry, 'index.md'))) {
303
- errors.push(`Scope index frontmatter follows references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)}`);
430
+ errors.push(`Scope index frontmatter related-scopes references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)}`);
304
431
  }
305
432
  }
306
433
  }
307
434
  }
308
435
  }
309
436
 
310
- function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, ignoreExternal, externalScopes) {
437
+ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, ignoreExternal, externalScopes, knownScopeTypes = new Map()) {
311
438
  const scopePath = path.join(xdrsRoot, scopeName);
312
439
 
313
440
  if (ignoreExternal && externalScopes.has(scopeName)) {
@@ -347,7 +474,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, igno
347
474
  errors.push(`Missing required scope index: ${toDisplayPath(scopeIndexPath)}`);
348
475
  } else {
349
476
  lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes);
350
- lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot);
477
+ lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes);
351
478
  }
352
479
  }
353
480