xdrs-core 0.51.0 → 0.52.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
@@ -23,6 +23,9 @@ const NUMBERED_FILE_RE = /^(\d{3,})-([a-z0-9-]+)\.md$/;
23
23
  // kebab-case; digits are allowed anywhere in the name, including as a prefix.
24
24
  const SKILL_DIR_RE = /^[a-z0-9-]+$/;
25
25
  const REQUIRED_ROOT_INDEX_TEXT = 'XDRS scopes listed last override the ones listed first';
26
+ // Activation tags and legend per _core-adr-policy-022 rules 03 and 09
27
+ const ACTIVATION_TAGS = new Set(['extends-only', 'disabled']);
28
+ const ACTIVATION_LEGEND_TEXT = 'Scopes tagged `disabled` MUST be ignored; `extends-only` scopes apply only via extends (see _core-adr-policy-022)';
26
29
  const SUBJECT_ARTIFACT_DIRS = new Set(['skills', 'articles', 'researches', 'initiatives']);
27
30
  const RESOURCE_DIR_NAME = '.assets';
28
31
  const SOURCE_SNAPSHOT_DIR_NAME = 'sources';
@@ -31,7 +34,7 @@ const SKILL_PACKAGE_OPTIONAL_DIRS = new Set(['scripts', 'references', RESOURCE_D
31
34
 
32
35
  const POLICY_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'freeze-reference']);
33
36
  const SKILL_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'license', 'metadata']);
34
- const SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS = new Set(['scope-type', 'name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'follows', 'extends']);
37
+ const SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS = new Set(['scope-type', 'name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'follows', 'extends', 'extends-only']);
35
38
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
36
39
  const SLIDE_FILE_RE = /^.+-slides(?:-[a-z0-9-]+)?\.md$/;
37
40
  const SLIDE_MAX_NAME_LENGTH = 64;
@@ -102,13 +105,14 @@ function runLintCli(args) {
102
105
  }
103
106
  }
104
107
 
108
+ for (const [scopeName, causes] of result.readOnlyScopes) {
109
+ for (const cause of causes) {
110
+ console.log(`- READ-ONLY scope "${scopeName}": ${cause}; no changes must be made to this scope [_core-adr-policy-022.06-read-only-dependencies]`);
111
+ }
112
+ }
113
+
105
114
  if (result.errors.length === 0) {
106
115
  console.log(`Lint passed for ${toDisplayPath(result.xdrsRoot)}`);
107
- if (result.readOnlyScopes.size > 0) {
108
- for (const scopeName of result.readOnlyScopes) {
109
- console.log(`- READ-ONLY scope "${scopeName}": one or more follows -core scopes are not present in this workspace; no changes must be made to this scope [_core-adr-policy-011.08-unavailable-core-read-only]`);
110
- }
111
- }
112
116
  return 0;
113
117
  }
114
118
 
@@ -144,7 +148,7 @@ function lintWorkspace(targetPath, options = {}) {
144
148
  const xdrsRoot = resolveXdrsRoot(resolvedTarget);
145
149
  const errors = [];
146
150
  const warnings = [];
147
- const readOnlyScopes = new Set();
151
+ const readOnlyScopes = new Map(); // scopeName -> [cause, ...] per _core-adr-policy-022.06
148
152
 
149
153
  if (!existsDirectory(xdrsRoot)) {
150
154
  errors.push(`Missing XDRS root directory: ${toDisplayPath(xdrsRoot)} [_core-adr-policy-001]`);
@@ -160,7 +164,13 @@ function lintWorkspace(targetPath, options = {}) {
160
164
  const rootEntries = safeReadDir(xdrsRoot, errors, 'read XDRS root directory');
161
165
  const scopeEntries = rootEntries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'));
162
166
 
163
- // Pass 1: collect known scope-type names from NNN-{scope-type}-scope-type.md files in any scope
167
+ const rootIndexPath = path.join(xdrsRoot, 'index.md');
168
+ const activation = existsFile(rootIndexPath) ? parseRootIndexActivation(rootIndexPath, xdrsRoot, errors) : new Map();
169
+
170
+ // Pass 1: collect known scope-type names from NNN-{scope-type}-scope-type.md files in any scope,
171
+ // plus the follows:/extends:/extends-only fields of every scope index (including external scopes)
172
+ const scopeMeta = new Map(); // scopeName -> readScopeMeta() result (null when index.md is missing)
173
+ const scopeTypeDefiningScopes = new Map(); // scopeTypeName -> Set(scopeName)
164
174
  const knownScopeTypes = new Map(); // scopeTypeName -> filePath (first primary found, for downstream validation)
165
175
  const scopeTypePrimaryKeys = new Set(); // "scopeName|typeName|scopeTypeName" for companion co-location checks
166
176
  const scopeTypePrimaryByScope = new Map(); // "scopeName|scopeTypeName" -> [typeName, ...] for duplicate detection
@@ -168,6 +178,7 @@ function lintWorkspace(targetPath, options = {}) {
168
178
  for (const scopeEntry of scopeEntries) {
169
179
  const scopeName = scopeEntry.name;
170
180
  const scopePath = path.join(xdrsRoot, scopeName);
181
+ scopeMeta.set(scopeName, readScopeMeta(path.join(scopePath, 'index.md')));
171
182
  for (const typeName of Object.keys(TYPE_TO_ID)) {
172
183
  const principlesDir = path.join(scopePath, typeName, 'principles');
173
184
  if (!existsDirectory(principlesDir)) continue;
@@ -187,13 +198,17 @@ function lintWorkspace(targetPath, options = {}) {
187
198
  if (!knownScopeTypes.has(scopeTypeName)) {
188
199
  knownScopeTypes.set(scopeTypeName, filePath);
189
200
  }
201
+ if (!scopeTypeDefiningScopes.has(scopeTypeName)) {
202
+ scopeTypeDefiningScopes.set(scopeTypeName, new Set());
203
+ }
204
+ scopeTypeDefiningScopes.get(scopeTypeName).add(scopeName);
190
205
  scopeTypePrimaryKeys.add(`${scopeName}|${typeName}|${scopeTypeName}`);
191
206
  const scopeKey = `${scopeName}|${scopeTypeName}`;
192
207
  if (!scopeTypePrimaryByScope.has(scopeKey)) {
193
208
  scopeTypePrimaryByScope.set(scopeKey, []);
194
209
  }
195
210
  scopeTypePrimaryByScope.get(scopeKey).push(typeName);
196
- validateStructuredFormat(filePath, errors, '_core-adr-policy-010.08b-def-scope-type-must-be-structured');
211
+ validateStructuredFormat(filePath, errors, '_core-adr-policy-010.11-def-scope-type-must-be-structured');
197
212
  } else {
198
213
  // Check if it's a companion file: title contains '-scope-type-' followed by a qualifier
199
214
  const stIdx = shortTitle.indexOf('-scope-type-');
@@ -202,7 +217,7 @@ function lintWorkspace(targetPath, options = {}) {
202
217
  // Special-case: 'local' maps to '_local'
203
218
  const scopeTypeName = rawScopeTypeName === 'local' ? '_local' : rawScopeTypeName;
204
219
  scopeTypeCompanions.push({ scopeTypeName, scopeName, typeName, filePath });
205
- validateStructuredFormat(filePath, errors, '_core-adr-policy-010.08b-def-scope-type-must-be-structured');
220
+ validateStructuredFormat(filePath, errors, '_core-adr-policy-010.11-def-scope-type-must-be-structured');
206
221
  }
207
222
  }
208
223
  }
@@ -222,7 +237,7 @@ function lintWorkspace(targetPath, options = {}) {
222
237
  // Validate: each companion must have a corresponding primary in the same scope and type folder
223
238
  for (const { scopeTypeName, scopeName, typeName, filePath } of scopeTypeCompanions) {
224
239
  if (!scopeTypePrimaryKeys.has(`${scopeName}|${typeName}|${scopeTypeName}`)) {
225
- errors.push(`Companion scope-type file has no corresponding primary "${scopeTypeName}-scope-type" policy in the same scope "${scopeName}" and type folder "${typeName}": ${toDisplayPath(filePath)} [_core-adr-policy-010.02b-companion-requires-primary]`);
240
+ errors.push(`Companion scope-type file has no corresponding primary "${scopeTypeName}-scope-type" policy in the same scope "${scopeName}" and type folder "${typeName}": ${toDisplayPath(filePath)} [_core-adr-policy-010.03-def-companion-files]`);
226
241
  }
227
242
  }
228
243
 
@@ -232,11 +247,13 @@ function lintWorkspace(targetPath, options = {}) {
232
247
  }
233
248
  }
234
249
 
250
+ collectReadOnlyScopes(scopeMeta, activation, externalScopes, knownScopeTypes, scopeTypeDefiningScopes, readOnlyScopes);
251
+
235
252
  // Populated during lintScopeIndexFrontmatter: scopeName -> [extendedScopeNames...]
236
253
  const extendsRelationships = new Map();
237
254
 
238
255
  for (const scopeEntry of scopeEntries) {
239
- lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, warnings, actualTypeIndexes, ignoreExternal, effectiveExternalScopes, knownScopeTypes, readOnlyScopes, extendsRelationships);
256
+ lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, warnings, actualTypeIndexes, ignoreExternal, effectiveExternalScopes, knownScopeTypes, extendsRelationships, externalScopes);
240
257
  }
241
258
 
242
259
  // Detect cycles in extends: chains (DFS)
@@ -263,17 +280,16 @@ function lintWorkspace(targetPath, options = {}) {
263
280
  detectExtendsCycles(scope, []);
264
281
  }
265
282
 
266
- const rootIndexPath = path.join(xdrsRoot, 'index.md');
267
283
  if (!existsFile(rootIndexPath)) {
268
284
  errors.push('Missing required root index: .xdrs/index.md [_core-adr-policy-001]');
269
285
  } else {
270
- lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, warnings, effectiveExternalScopes, extendsRelationships);
286
+ lintRootIndex(rootIndexPath, xdrsRoot, errors, warnings, effectiveExternalScopes, { activation, scopeMeta, knownScopeTypes });
271
287
  }
272
288
 
273
289
  return { xdrsRoot, errors, warnings, readOnlyScopes };
274
290
  }
275
291
 
276
- function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, warnings = [], externalScopes = new Set(), extendsRelationships = new Map()) {
292
+ function lintRootIndex(rootIndexPath, xdrsRoot, errors, warnings, externalScopes, ctx) {
277
293
  const content = fs.readFileSync(rootIndexPath, 'utf8');
278
294
  const repoRoot = path.dirname(xdrsRoot);
279
295
 
@@ -292,8 +308,6 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, warni
292
308
  }
293
309
  }
294
310
 
295
- const linkedScopeIndexes = links.filter((linkPath) => isScopeIndex(linkPath, xdrsRoot));
296
- const linkedSet = new Set(linkedScopeIndexes.map(normalizePath));
297
311
  const localScopePath = normalizePath(path.join(xdrsRoot, '_local'));
298
312
 
299
313
  for (const linkPath of links) {
@@ -302,38 +316,207 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, warni
302
316
  }
303
317
  }
304
318
 
305
- // Build set of all scopes referenced via any extends: chain
306
- const extendsReferencedScopes = new Set();
307
- for (const entries of extendsRelationships.values()) {
308
- for (const e of entries) extendsReferencedScopes.add(e);
319
+ const { activation, scopeMeta, knownScopeTypes } = ctx;
320
+ const display = toDisplayPath(rootIndexPath);
321
+ const tagOf = (scopeName) => (activation.get(scopeName) || {}).tag || null;
322
+
323
+ // Every scope folder with an index.md (including external scopes), except _local, is linked
324
+ for (const [scopeName, meta] of scopeMeta) {
325
+ if (!meta || scopeName === '_local') continue;
326
+ const fieldTrue = meta.extendsOnly === 'true';
327
+ const suggestedLine = `[View scope ${scopeName}](${scopeName}/index.md)${fieldTrue ? ' `extends-only`' : ''}`;
328
+ const entry = activation.get(scopeName);
329
+ if (!entry) {
330
+ errors.push(`Root index is missing scope index link: ${toDisplayPath(path.join(xdrsRoot, scopeName, 'index.md'))}; add the line: ${suggestedLine} [_core-adr-policy-022.01-root-index-completeness]`);
331
+ continue;
332
+ }
333
+ if (fieldTrue && !entry.tag) {
334
+ errors.push(`Scope "${scopeName}" declares extends-only: true, so its root index link on line ${entry.line} must carry an activation tag; change it to: ${suggestedLine} (or tag it \`disabled\`): ${display} [_core-adr-policy-022.07-extends-only-field]`);
335
+ }
336
+ if (entry.tag === 'extends-only' && scopeName !== '_core' && isCoreTypeScope(meta.scopeTypes, knownScopeTypes)) {
337
+ errors.push(`Core-type scope "${scopeName}" must not be tagged \`extends-only\` (line ${entry.line}): ${display} [_core-adr-policy-022.07-extends-only-field]`);
338
+ }
339
+ }
340
+
341
+ const hasTags = [...activation.values()].some((entry) => entry.tag);
342
+ if (hasTags && !content.includes(ACTIVATION_LEGEND_TEXT)) {
343
+ errors.push(`Root index uses activation tags but is missing the legend line: ${ACTIVATION_LEGEND_TEXT}: ${display} [_core-adr-policy-022.09-root-index-ownership]`);
309
344
  }
310
345
 
311
- // Collect non-_local scopes that have type indexes and check their scope index is linked
312
- const scopesWithTypeIndexes = new Set();
313
- for (const indexPath of actualTypeIndexes) {
314
- const scopeName = path.basename(path.dirname(path.dirname(indexPath)));
315
- if (scopeName !== '_local') {
316
- scopesWithTypeIndexes.add(scopeName);
346
+ // extends-only scopes must be reached (transitively) through the extends: chain of an active scope
347
+ const reached = new Set();
348
+ const visit = (scopeName) => {
349
+ for (const target of (scopeMeta.get(scopeName) || {}).extends || []) {
350
+ if (reached.has(target) || !scopeMeta.get(target) || tagOf(target) === 'disabled') continue;
351
+ reached.add(target);
352
+ visit(target);
353
+ }
354
+ };
355
+ for (const [scopeName, meta] of scopeMeta) {
356
+ if (!meta) continue;
357
+ if (scopeName === '_local' || (activation.has(scopeName) && !tagOf(scopeName))) {
358
+ visit(scopeName);
317
359
  }
318
360
  }
361
+ for (const [scopeName, entry] of activation) {
362
+ if (entry.tag === 'extends-only' && !reached.has(scopeName)) {
363
+ warnings.push(`Scope "${scopeName}" is tagged \`extends-only\` but no active scope extends it, so its policies never apply: ${display} [_core-adr-policy-022.04-extends-only]`);
364
+ }
365
+ }
366
+ }
319
367
 
320
- for (const scopeName of scopesWithTypeIndexes) {
321
- const scopeIndexPath = normalizePath(path.join(xdrsRoot, scopeName, 'index.md'));
322
- if (!linkedSet.has(scopeIndexPath)) {
323
- // Exempt scopes that are only reachable via extends: chains
324
- if (extendsReferencedScopes.has(scopeName)) {
325
- // Scope is extends-only (not in root index) — exempt from mandatory link requirement
326
- // No warning here: absence from root index is expected and valid for extends-only scopes
368
+ // Parses root index scope links and their activation tags (_core-adr-policy-022 rules 01 and 03).
369
+ // Returns Map scopeName -> { tag, line }. Links inside fenced code blocks are ignored.
370
+ function parseRootIndexActivation(rootIndexPath, xdrsRoot, errors) {
371
+ const activation = new Map();
372
+ const content = fs.readFileSync(rootIndexPath, 'utf8');
373
+ const repoRoot = path.dirname(xdrsRoot);
374
+ const baseDir = path.dirname(rootIndexPath);
375
+ const display = toDisplayPath(rootIndexPath);
376
+ const lines = content.split(/\r?\n/);
377
+ const ignoredLines = findIgnoredMarkdownLines(lines);
378
+
379
+ for (let index = 0; index < lines.length; index += 1) {
380
+ if (ignoredLines[index]) continue;
381
+ const line = lines[index];
382
+ const lineNumber = index + 1;
383
+ const allowedTagOffsets = new Set();
384
+ let hasScopeLink = false;
385
+
386
+ for (const match of line.matchAll(/!?\[[^\]]+\]\(([^)]+)\)/g)) {
387
+ const target = normalizeLocalLinkTarget(match[1].trim());
388
+ if (!target) continue;
389
+ const linkPath = target.startsWith('/') ? path.join(repoRoot, target) : path.resolve(baseDir, target);
390
+ if (!isScopeIndex(linkPath, xdrsRoot)) continue;
391
+ hasScopeLink = true;
392
+ const scopeName = relativeFrom(xdrsRoot, linkPath).split(path.sep)[0];
393
+ const linkEnd = match.index + match[0].length;
394
+ const tagMatch = line.slice(linkEnd).match(/^[ \t]*`([^`]*)`/);
395
+ let tag = null;
396
+ if (tagMatch && ACTIVATION_TAGS.has(tagMatch[1])) {
397
+ tag = tagMatch[1];
398
+ allowedTagOffsets.add(linkEnd + tagMatch[0].length - tagMatch[1].length - 2);
399
+ }
400
+ if (scopeName === '_local') continue; // reported by lintRootIndex
401
+ if (tag && scopeName === '_core') {
402
+ errors.push(`Scope "_core" must not carry an activation tag (line ${lineNumber}): ${display} [_core-adr-policy-022.03-activation-tag-syntax]`);
403
+ }
404
+ const existing = activation.get(scopeName);
405
+ if (existing) {
406
+ errors.push(`Root index links scope "${scopeName}" more than once (lines ${existing.line} and ${lineNumber}); keep a single link: ${display} [_core-adr-policy-022.01-root-index-completeness]`);
407
+ } else {
408
+ activation.set(scopeName, { tag, line: lineNumber });
409
+ }
410
+ }
411
+
412
+ if (!hasScopeLink) continue;
413
+ for (const span of line.matchAll(/`[^`]*`/g)) {
414
+ if (!allowedTagOffsets.has(span.index)) {
415
+ errors.push(`Root index line ${lineNumber} has invalid inline code ${span[0]}: a scope link line may only carry one activation tag (\`extends-only\` or \`disabled\`, lowercase) right after the link: ${display} [_core-adr-policy-022.03-activation-tag-syntax]`);
416
+ }
417
+ }
418
+ }
419
+
420
+ return activation;
421
+ }
422
+
423
+ // Reads the fields of a scope index needed before per-scope lint runs (including external scopes).
424
+ // Returns null when the scope has no readable index.md.
425
+ function readScopeMeta(scopeIndexPath) {
426
+ if (!existsFile(scopeIndexPath)) return null;
427
+ let content;
428
+ try { content = fs.readFileSync(scopeIndexPath, 'utf8'); } catch { return null; }
429
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
430
+ const block = fmMatch ? fmMatch[1] : '';
431
+ const listField = (key) => {
432
+ const lineMatch = block.match(new RegExp(`^${key}:[ \\t]*(.*)`, 'm'));
433
+ return lineMatch ? deriveScopeNameListEntries(block, lineMatch, lineMatch[1].trim()) : [];
434
+ };
435
+ const scopeTypeMatch = block.match(/^scope-type:[ \t]*(.+)$/m);
436
+ const extendsOnlyMatch = block.match(/^extends-only:[ \t]*(.*)$/m);
437
+ return {
438
+ hasFrontmatter: Boolean(fmMatch),
439
+ scopeTypes: scopeTypeMatch ? parseScopeTypeField(scopeTypeMatch[1].trim()) : [],
440
+ follows: listField('follows'),
441
+ extends: listField('extends'),
442
+ extendsOnly: extendsOnlyMatch ? extendsOnlyMatch[1].trim() : null,
443
+ };
444
+ }
445
+
446
+ // Computes read-only scopes and their causes per _core-adr-policy-022.06. Missing dependencies of
447
+ // local scopes are reported as lint errors elsewhere; here they only make external scopes read-only.
448
+ function collectReadOnlyScopes(scopeMeta, activation, externalScopes, knownScopeTypes, scopeTypeDefiningScopes, readOnlyScopes) {
449
+ const isDisabled = (scopeName) => (activation.get(scopeName) || {}).tag === 'disabled';
450
+ for (const [scopeName, meta] of scopeMeta) {
451
+ if (!meta) continue;
452
+ const isExternal = externalScopes.has(scopeName);
453
+ const causes = [];
454
+ if (isExternal && !meta.hasFrontmatter) {
455
+ causes.push('its scope index has no frontmatter; fix it in the source package');
456
+ }
457
+ if (isExternal && meta.extendsOnly !== null && meta.extendsOnly !== 'true' && meta.extendsOnly !== 'false') {
458
+ causes.push(`its scope index has an invalid extends-only value "${meta.extendsOnly}" (treated as absent); fix it in the source package`);
459
+ }
460
+ if (isExternal && meta.hasFrontmatter && meta.scopeTypes.length === 0) {
461
+ causes.push('its scope index has no scope-type; fix it in the source package');
462
+ }
463
+ for (const [field, targets] of [['follows', meta.follows], ['extends', meta.extends]]) {
464
+ for (const target of targets) {
465
+ if (target === scopeName || RESERVED_SCOPES.has(target)) continue;
466
+ const targetMeta = scopeMeta.get(target);
467
+ if (!targetMeta) {
468
+ if (isExternal) causes.push(`${field}: scope "${target}" is not present in this workspace; install it`);
469
+ } else if (isDisabled(target)) {
470
+ causes.push(`${field}: scope "${target}" is tagged \`disabled\` in the root index; enable it or remove the ${field}: reference`);
471
+ } else if (!targetMeta.hasFrontmatter) {
472
+ causes.push(`${field}: scope "${target}" has an invalid scope index; fix it`);
473
+ }
474
+ }
475
+ }
476
+ for (const scopeType of resolveScopeTypeChain(meta.scopeTypes, knownScopeTypes)) {
477
+ if (scopeType === '_local') continue;
478
+ if (!knownScopeTypes.has(scopeType)) {
479
+ if (isExternal) causes.push(`scope type "${scopeType}" has no ${scopeType}-scope-type policy in this workspace; install the scope that defines it`);
327
480
  continue;
328
481
  }
329
- errors.push(`Root index is missing scope index link: ${toDisplayPath(path.join(xdrsRoot, scopeName, 'index.md'))} [_core-adr-policy-001]`);
330
- } else if (extendsReferencedScopes.has(scopeName)) {
331
- // Scope is in BOTH root index and extends chain — ambiguous dual precedence
332
- warnings.push(`Scope "${scopeName}" appears in both the root index and an extends: chain; precedence may be ambiguous for consumers that do not follow extends declarations: ${toDisplayPath(path.join(xdrsRoot, scopeName, 'index.md'))} [_core-adr-policy-010.35-extends-index]`);
482
+ const definers = [...scopeTypeDefiningScopes.get(scopeType)].filter((s) => s !== scopeName);
483
+ if (definers.length > 0 && definers.every(isDisabled)) {
484
+ causes.push(`scope type "${scopeType}" is defined only in disabled scope(s) ${definers.map((s) => `"${s}"`).join(', ')}; enable one of them`);
485
+ }
486
+ }
487
+ if (causes.length > 0) {
488
+ readOnlyScopes.set(scopeName, causes);
333
489
  }
334
490
  }
335
491
  }
336
492
 
493
+ // Parses a scope-type value in either "a, b" or "[a, b]" form into its elements.
494
+ function parseScopeTypeField(raw) {
495
+ const inner = raw.startsWith('[') && raw.endsWith(']') ? raw.slice(1, -1) : raw;
496
+ return inner.split(',').map((s) => s.trim()).filter(Boolean);
497
+ }
498
+
499
+ // Resolves the flat scope-type chain (declared types plus all parent types, deduplicated) using
500
+ // _core-adr-policy-010.09 parent declarations. Types without a known policy end their chain.
501
+ function resolveScopeTypeChain(scopeTypes, knownScopeTypes) {
502
+ const chain = [];
503
+ const seen = new Set();
504
+ for (const scopeType of scopeTypes) {
505
+ let current = scopeType;
506
+ while (current && !seen.has(current)) {
507
+ seen.add(current);
508
+ chain.push(current);
509
+ const policyPath = knownScopeTypes.get(current);
510
+ current = policyPath ? extractParentScopeType(policyPath) : null;
511
+ }
512
+ }
513
+ return chain;
514
+ }
515
+
516
+ function isCoreTypeScope(scopeTypes, knownScopeTypes) {
517
+ return resolveScopeTypeChain(scopeTypes, knownScopeTypes).includes('core');
518
+ }
519
+
337
520
  function lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes = new Set()) {
338
521
  const content = fs.readFileSync(scopeIndexPath, 'utf8');
339
522
  const repoRoot = path.dirname(xdrsRoot);
@@ -370,7 +553,59 @@ function extractParentScopeType(policyFilePath) {
370
553
  return bodyMatch ? bodyMatch[1] : null;
371
554
  }
372
555
 
373
- function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes = new Map(), readOnlyScopes = new Set(), extendsRelationships = new Map()) {
556
+ // Parse a scope-type frontmatter value into its individual elements. Supports a single
557
+ // value or a comma-separated list (e.g. "compiled, internal-docs"), matching the grammar
558
+ // defined in _core-adr-policy-010 rules 06 and 07.
559
+ function parseScopeTypeList(raw) {
560
+ const commaSeparatedScopeTypePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
561
+ return commaSeparatedScopeTypePattern.test(raw) ? raw.split(',').map((s) => s.trim()) : [raw];
562
+ }
563
+
564
+ // Shared by the follows:/extends: frontmatter fields, which both accept a scope name, a
565
+ // comma-separated list of scope names, or an indented YAML list of scope names.
566
+ const SCOPE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
567
+ const SCOPE_NAME_LIST_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
568
+
569
+ // Parses the indented "- item" list entries following a scalar-less field line (e.g. a
570
+ // `follows:` key with no inline value). Returns raw trimmed strings in order, including empty
571
+ // ones for bare "-" markers so callers can distinguish "no items" from "blank item".
572
+ function parseYamlListItems(block, lineMatch) {
573
+ const items = [];
574
+ const start = block.indexOf(lineMatch[0]) + lineMatch[0].length;
575
+ for (const line of block.slice(start).split('\n')) {
576
+ const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
577
+ if (itemMatch) {
578
+ items.push((itemMatch[1] || '').trim());
579
+ } else if (line.trim() && !/^\s/.test(line)) {
580
+ break;
581
+ }
582
+ }
583
+ return items;
584
+ }
585
+
586
+ // Validates the raw shape of a follows:/extends: frontmatter field (inline scalar, inline
587
+ // comma-separated list, or indented YAML list). Returns 'empty' when the field has no usable
588
+ // value, 'item' when a list entry is blank or not a valid scope name, or null when valid.
589
+ function validateScopeNameListFormat(block, lineMatch, inlineValue) {
590
+ if (inlineValue) {
591
+ return (SCOPE_NAME_PATTERN.test(inlineValue) || SCOPE_NAME_LIST_PATTERN.test(inlineValue)) ? null : 'empty';
592
+ }
593
+ const items = parseYamlListItems(block, lineMatch);
594
+ if (items.length === 0) return 'empty';
595
+ return items.some((item) => !item || !SCOPE_NAME_PATTERN.test(item)) ? 'item' : null;
596
+ }
597
+
598
+ // Derives the final array of entry names declared in a follows:/extends: frontmatter field.
599
+ function deriveScopeNameListEntries(block, lineMatch, inlineValue) {
600
+ if (inlineValue) {
601
+ if (SCOPE_NAME_LIST_PATTERN.test(inlineValue)) return inlineValue.split(',').map((s) => s.trim());
602
+ return SCOPE_NAME_PATTERN.test(inlineValue) ? [inlineValue] : [];
603
+ }
604
+ return parseYamlListItems(block, lineMatch).filter(Boolean);
605
+ }
606
+
607
+ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes = new Map(), extendsRelationships = new Map(), externalScopes = new Set()) {
608
+ const isExternal = externalScopes.has(scopeName);
374
609
  const content = fs.readFileSync(scopeIndexPath, 'utf8');
375
610
  const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
376
611
  if (!fmMatch) {
@@ -389,18 +624,15 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
389
624
  const scopeTypeMatch = block.match(/^scope-type:\s*(.+)$/m);
390
625
  const scopeTypeRaw = scopeTypeMatch ? scopeTypeMatch[1].trim() : null;
391
626
  if (!scopeTypeRaw) {
392
- errors.push(`Scope index frontmatter must include a scope-type field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.08-def-valid-iff-policy-exists]`);
627
+ // External scopes with no scope-type become read-only instead (_core-adr-policy-022.06)
628
+ if (!isExternal) {
629
+ errors.push(`Scope index frontmatter must include a scope-type field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.10-def-valid-iff-policy-exists]`);
630
+ }
393
631
  return;
394
632
  }
395
633
 
396
634
  // Parse scope-type: supports single string or comma-separated list (e.g. "compiled, internal-docs")
397
- const commaSeparatedScopeTypePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
398
- let scopeTypes;
399
- if (commaSeparatedScopeTypePattern.test(scopeTypeRaw)) {
400
- scopeTypes = scopeTypeRaw.split(',').map((s) => s.trim());
401
- } else {
402
- scopeTypes = [scopeTypeRaw];
403
- }
635
+ const scopeTypes = parseScopeTypeList(scopeTypeRaw);
404
636
 
405
637
  // Validate each element: no duplicates, valid chars, no reserved prefix
406
638
  const seenTypes = new Set();
@@ -408,17 +640,17 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
408
640
  const SCOPE_TYPE_ELEMENT_RE = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
409
641
  for (const scopeType of scopeTypes) {
410
642
  if (!SCOPE_TYPE_ELEMENT_RE.test(scopeType)) {
411
- errors.push(`Scope type element "${scopeType}" contains invalid characters (must match [a-zA-Z_][a-zA-Z0-9_-]*): ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.06-def-no-underscore-prefix]`);
643
+ errors.push(`Scope type element "${scopeType}" contains invalid characters (must match [a-zA-Z_][a-zA-Z0-9_-]*): ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.08-def-no-underscore-prefix]`);
412
644
  hasElementError = true;
413
645
  continue;
414
646
  }
415
647
  if (scopeType.startsWith('_') && scopeType !== '_local') {
416
- errors.push(`Scope type "${scopeType}" uses a reserved "_" prefix; only "_local" is a valid underscore-prefixed scope type: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.06-def-no-underscore-prefix]`);
648
+ errors.push(`Scope type "${scopeType}" uses a reserved "_" prefix; only "_local" is a valid underscore-prefixed scope type: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.08-def-no-underscore-prefix]`);
417
649
  hasElementError = true;
418
650
  continue;
419
651
  }
420
652
  if (seenTypes.has(scopeType)) {
421
- errors.push(`Scope type list contains duplicate value "${scopeType}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.05b-def-multi-type-validation]`);
653
+ errors.push(`Scope type list contains duplicate value "${scopeType}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.07-def-multi-type-validation]`);
422
654
  hasElementError = true;
423
655
  continue;
424
656
  }
@@ -434,7 +666,9 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
434
666
  for (const st of scopeTypes) {
435
667
  if (st === '_local') continue;
436
668
  if (!knownScopeTypes.has(st)) {
437
- errors.push(`Scope index scope-type "${st}" has no corresponding ${st}-scope-type policy in the principles of any scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.08-def-valid-iff-policy-exists]`);
669
+ if (!isExternal) {
670
+ errors.push(`Scope index scope-type "${st}" has no corresponding ${st}-scope-type policy in the principles of any scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.10-def-valid-iff-policy-exists]`);
671
+ }
438
672
  continue;
439
673
  }
440
674
  // Validate parent chain
@@ -445,11 +679,13 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
445
679
  const parentType = extractParentScopeType(policyPath);
446
680
  if (!parentType) break;
447
681
  if (globalVisited.has(parentType)) {
448
- errors.push(`Scope type "${currentType}" declares parent "${parentType}" which creates a cycle in the scope-type inheritance chain: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.07-def-parent-scope-type]`);
682
+ errors.push(`Scope type "${currentType}" declares parent "${parentType}" which creates a cycle in the scope-type inheritance chain: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.09-def-parent-scope-type]`);
449
683
  break;
450
684
  }
451
685
  if (!knownScopeTypes.has(parentType)) {
452
- errors.push(`Scope type "${currentType}" declares parent "${parentType}" but no ${parentType}-scope-type policy exists in any scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.07-def-parent-scope-type]`);
686
+ if (!isExternal) {
687
+ errors.push(`Scope type "${currentType}" declares parent "${parentType}" but no ${parentType}-scope-type policy exists in any scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.09-def-parent-scope-type]`);
688
+ }
453
689
  break;
454
690
  }
455
691
  globalVisited.add(parentType);
@@ -462,7 +698,11 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
462
698
  if (scopeName === '_local' && scopeType !== '_local') {
463
699
  errors.push(`Scope "_local" must have scope-type "_local": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-015.01-scope-type-name]`);
464
700
  }
465
- if (scopeTypes.includes('core') && !scopeName.includes('core')) {
701
+ const isCoreType = isCoreTypeScope(scopeTypes, knownScopeTypes);
702
+ if (isCoreType && scopeTypes.length > 1) {
703
+ errors.push(`Core-type scope must declare exactly one scope type (found "${scopeTypes.join(', ')}"): ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-011.10-no-type-combination]`);
704
+ }
705
+ if (isCoreType && !scopeName.includes('core')) {
466
706
  errors.push(`Scope with type "core" must have "core" in its name: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-011.03-naming-convention]`);
467
707
  }
468
708
  if (scopeTypes.includes('_local') && scopeName !== '_local') {
@@ -475,6 +715,19 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
475
715
  errors.push(`Scope with type "platform" must follow the naming pattern {domain}-plat-{name} (e.g. "cloud-plat-aws"): ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-013.03-naming-convention]`);
476
716
  }
477
717
 
718
+ const extendsOnlyMatch = block.match(/^extends-only:[ \t]*(.*)$/m);
719
+ if (extendsOnlyMatch) {
720
+ const extendsOnlyValue = extendsOnlyMatch[1].trim();
721
+ if (extendsOnlyValue !== 'true' && extendsOnlyValue !== 'false') {
722
+ // External scopes with an invalid value become read-only instead (_core-adr-policy-022.06)
723
+ if (!isExternal) {
724
+ errors.push(`Scope index frontmatter extends-only must be unquoted lowercase true or false (found "${extendsOnlyValue}"): ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-022.07-extends-only-field]`);
725
+ }
726
+ } else if (extendsOnlyValue === 'true' && (RESERVED_SCOPES.has(scopeName) || isCoreType)) {
727
+ errors.push(`Scope index frontmatter extends-only: true is not allowed on _core, _local or core-type scopes: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-022.07-extends-only-field]`);
728
+ }
729
+ }
730
+
478
731
  if (!fm.name) {
479
732
  errors.push(`Scope index frontmatter must include a non-empty name field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
480
733
  } else if (fm.name !== scopeName) {
@@ -508,49 +761,14 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
508
761
  const followsLineMatch = block.match(/^follows:[ \t]*(.*)/m);
509
762
  if (followsLineMatch !== null) {
510
763
  const followsValue = followsLineMatch[1].trim();
511
- const scopeNamePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
512
- const commaSeparatedPattern = /^[a-zA-Z_][a-zA-Z0-9_-]*(\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
513
- if (followsValue) {
514
- if (!scopeNamePattern.test(followsValue) && !commaSeparatedPattern.test(followsValue)) {
515
- errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
516
- }
517
- } else {
518
- const followsStart = block.indexOf(followsLineMatch[0]) + followsLineMatch[0].length;
519
- const listItems = [];
520
- let hasInvalidEntry = false;
521
- for (const line of block.slice(followsStart).split('\n')) {
522
- const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
523
- if (itemMatch) {
524
- const item = (itemMatch[1] || '').trim();
525
- if (!item || !scopeNamePattern.test(item)) {
526
- errors.push(`Scope index frontmatter follows entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
527
- hasInvalidEntry = true;
528
- break;
529
- }
530
- listItems.push(item);
531
- } else if (line.trim() && !/^\s/.test(line)) {
532
- break;
533
- }
534
- }
535
- if (!hasInvalidEntry && listItems.length === 0) {
536
- errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
537
- }
764
+ const followsFormatError = validateScopeNameListFormat(block, followsLineMatch, followsValue);
765
+ if (followsFormatError === 'item') {
766
+ errors.push(`Scope index frontmatter follows entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
767
+ } else if (followsFormatError === 'empty') {
768
+ errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
538
769
  }
539
770
  if (xdrsRoot) {
540
- const entries = followsValue
541
- ? (commaSeparatedPattern.test(followsValue)
542
- ? followsValue.split(',').map((s) => s.trim())
543
- : [followsValue])
544
- : (() => {
545
- const items = [];
546
- const followsStart = block.indexOf(followsLineMatch[0]) + followsLineMatch[0].length;
547
- for (const line of block.slice(followsStart).split('\n')) {
548
- const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
549
- if (itemMatch && itemMatch[1]) items.push(itemMatch[1].trim());
550
- else if (line.trim() && !/^\s/.test(line)) break;
551
- }
552
- return items;
553
- })();
771
+ const entries = deriveScopeNameListEntries(block, followsLineMatch, followsValue);
554
772
  followsEntries = entries;
555
773
  const seenFollows = new Set();
556
774
  for (const entry of entries) {
@@ -558,7 +776,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
558
776
  errors.push(`Scope index frontmatter follows has duplicate entry "${entry}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
559
777
  }
560
778
  seenFollows.add(entry);
561
- if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(entry)) {
779
+ if (SCOPE_NAME_PATTERN.test(entry)) {
562
780
  if (entry === scopeName) {
563
781
  errors.push(`Scope index frontmatter follows must not reference the scope itself: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
564
782
  continue;
@@ -569,11 +787,16 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
569
787
  }
570
788
  const followedIndexPath = path.join(xdrsRoot, entry, 'index.md');
571
789
  if (!existsFile(followedIndexPath)) {
572
- errors.push(`Scope index frontmatter follows references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
790
+ // Missing -core is expected for an external companion scope: it becomes read-only
791
+ // (collected by collectReadOnlyScopes per _core-adr-policy-022.06), not a lint error.
792
+ if (!isExternal) {
793
+ errors.push(`Scope index frontmatter follows references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
794
+ }
573
795
  } else {
574
796
  const followedContent = fs.readFileSync(followedIndexPath, 'utf8');
575
797
  const followedTypeMatch = followedContent.match(/^scope-type:\s*(.+)$/m);
576
- if (!followedTypeMatch || followedTypeMatch[1].trim() !== 'core') {
798
+ const followedTypes = followedTypeMatch ? parseScopeTypeField(followedTypeMatch[1].trim()) : [];
799
+ if (!isCoreTypeScope(followedTypes, knownScopeTypes)) {
577
800
  errors.push(`Scope index frontmatter follows references scope "${entry}" which is not a core-type scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
578
801
  }
579
802
  }
@@ -587,48 +810,13 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
587
810
  const extendsLineMatch = block.match(/^extends:[ \t]*(.*)/m);
588
811
  if (extendsLineMatch !== null) {
589
812
  const extendsValue = extendsLineMatch[1].trim();
590
- const scopeNamePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
591
- const commaSeparatedPattern = /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
592
- if (extendsValue) {
593
- if (!scopeNamePattern.test(extendsValue) && !commaSeparatedPattern.test(extendsValue)) {
594
- errors.push(`Scope index frontmatter extends must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.27-extends-declaration]`);
595
- }
596
- } else {
597
- const extendsStart = block.indexOf(extendsLineMatch[0]) + extendsLineMatch[0].length;
598
- const listItems = [];
599
- let hasInvalidEntry = false;
600
- for (const line of block.slice(extendsStart).split('\n')) {
601
- const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
602
- if (itemMatch) {
603
- const item = (itemMatch[1] || '').trim();
604
- if (!item || !scopeNamePattern.test(item)) {
605
- errors.push(`Scope index frontmatter extends entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.27-extends-declaration]`);
606
- hasInvalidEntry = true;
607
- break;
608
- }
609
- listItems.push(item);
610
- } else if (line.trim() && !/^\s/.test(line)) {
611
- break;
612
- }
613
- }
614
- if (!hasInvalidEntry && listItems.length === 0) {
615
- errors.push(`Scope index frontmatter extends must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.27-extends-declaration]`);
616
- }
813
+ const extendsFormatError = validateScopeNameListFormat(block, extendsLineMatch, extendsValue);
814
+ if (extendsFormatError === 'item') {
815
+ errors.push(`Scope index frontmatter extends entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.27-extends-declaration]`);
816
+ } else if (extendsFormatError === 'empty') {
817
+ errors.push(`Scope index frontmatter extends must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.27-extends-declaration]`);
617
818
  }
618
- const rawEntries = extendsValue
619
- ? (commaSeparatedPattern.test(extendsValue)
620
- ? extendsValue.split(',').map((s) => s.trim())
621
- : (scopeNamePattern.test(extendsValue) ? [extendsValue] : []))
622
- : (() => {
623
- const items = [];
624
- const extendsStart = block.indexOf(extendsLineMatch[0]) + extendsLineMatch[0].length;
625
- for (const line of block.slice(extendsStart).split('\n')) {
626
- const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
627
- if (itemMatch && itemMatch[1]) items.push(itemMatch[1].trim());
628
- else if (line.trim() && !/^\s/.test(line)) break;
629
- }
630
- return items;
631
- })();
819
+ const rawEntries = deriveScopeNameListEntries(block, extendsLineMatch, extendsValue);
632
820
  const seenExtends = new Set();
633
821
  for (const entry of rawEntries) {
634
822
  if (seenExtends.has(entry)) {
@@ -636,7 +824,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
636
824
  continue;
637
825
  }
638
826
  seenExtends.add(entry);
639
- if (!scopeNamePattern.test(entry)) continue;
827
+ if (!SCOPE_NAME_PATTERN.test(entry)) continue;
640
828
  if (entry === scopeName) {
641
829
  errors.push(`Scope index frontmatter extends must not reference the scope itself: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.30-extends-no-self]`);
642
830
  continue;
@@ -657,7 +845,9 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
657
845
  continue;
658
846
  }
659
847
  if (!existsFile(path.join(xdrsRoot, entry, 'index.md'))) {
660
- errors.push(`Scope index frontmatter extends references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.32-extends-mandatory-presence]`);
848
+ if (!isExternal) {
849
+ errors.push(`Scope index frontmatter extends references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.32-extends-mandatory-presence]`);
850
+ }
661
851
  }
662
852
  }
663
853
  if (extendsEntries.length > 0) {
@@ -668,7 +858,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
668
858
  }
669
859
 
670
860
 
671
- function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeIndexes, ignoreExternal, externalScopes, knownScopeTypes = new Map(), readOnlyScopes = new Set(), extendsRelationships = new Map()) {
861
+ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeIndexes, ignoreExternal, externalScopes, knownScopeTypes = new Map(), extendsRelationships = new Map(), trackedExternalScopes = new Set()) {
672
862
  const scopePath = path.join(xdrsRoot, scopeName);
673
863
 
674
864
  if (ignoreExternal && externalScopes.has(scopeName)) {
@@ -690,12 +880,8 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeInd
690
880
  const idxContent = fs.readFileSync(scopeIndexPath, 'utf8');
691
881
  const stMatch = idxContent.match(/^scope-type:\s*(.+)$/m);
692
882
  if (stMatch) {
693
- const stRaw = stMatch[1].trim();
694
- // Handle both "[type1, type2]" YAML array format and "type1, type2" inline CSV format
695
- const stList = stRaw.startsWith('[') && stRaw.endsWith(']')
696
- ? stRaw.slice(1, -1).split(',').map((s) => s.trim())
697
- : stRaw.split(',').map((s) => s.trim());
698
- isCoreTypeNonExempt = stList.includes('core');
883
+ const stList = parseScopeTypeField(stMatch[1].trim());
884
+ isCoreTypeNonExempt = isCoreTypeScope(stList, knownScopeTypes);
699
885
  isCompiledScope = stList.includes('compiled');
700
886
  }
701
887
  } catch { /* ignore read errors; will be caught later */ }
@@ -731,7 +917,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeInd
731
917
  errors.push(`Missing required scope index: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
732
918
  } else {
733
919
  lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes);
734
- lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes, readOnlyScopes, extendsRelationships);
920
+ lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes, extendsRelationships, trackedExternalScopes);
735
921
  }
736
922
  lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope);
737
923
  }
@@ -769,7 +955,7 @@ function lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope = fa
769
955
  // Error: companion exists without primary in same type folder
770
956
  for (const companion of companionsInFolder) {
771
957
  if (!primaryInFolder) {
772
- errors.push(`Local meta-policy companion "${path.basename(companion.filePath)}" requires a primary "NNN-core.md" in the same type folder "${typeName}": ${toDisplayPath(companion.filePath)} [_core-adr-policy-010.10b-local-primary-required]`);
958
+ errors.push(`Local meta-policy companion "${path.basename(companion.filePath)}" requires a primary "NNN-core.md" in the same type folder "${typeName}": ${toDisplayPath(companion.filePath)} [_core-adr-policy-010.15-local-primary-required]`);
773
959
  }
774
960
  }
775
961
  // For compiled scopes: primary meta-policy must include ## Sources, ## Selectors, and ## Sync Settings
@@ -832,12 +1018,12 @@ function lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope = fa
832
1018
  const existing = seenQualifiers.get(qualifier);
833
1019
  if (existing) {
834
1020
  const label = qualifier === '' ? 'primary (no qualifier)' : `qualifier "${qualifier}"`;
835
- errors.push(`Local meta-policy ${label} appears in multiple type folders in scope "${scopeName}": ${toDisplayPath(existing)} and ${toDisplayPath(filePath)} [_core-adr-policy-010.10c-local-unique-qualifier]`);
1021
+ errors.push(`Local meta-policy ${label} appears in multiple type folders in scope "${scopeName}": ${toDisplayPath(existing)} and ${toDisplayPath(filePath)} [_core-adr-policy-010.16-local-unique-qualifier]`);
836
1022
  } else {
837
1023
  seenQualifiers.set(qualifier, filePath);
838
1024
  }
839
1025
  // Validate structured format for all local meta-policy files
840
- validateStructuredFormat(filePath, errors, '_core-adr-policy-010.12b-local-must-be-structured');
1026
+ validateStructuredFormat(filePath, errors, '_core-adr-policy-010.19-local-must-be-structured');
841
1027
  }
842
1028
  }
843
1029
  }
@@ -963,7 +1149,7 @@ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePat
963
1149
  // Enforce 'core' word reservation: only allowed as first word of filename title in principles/
964
1150
  const titleParts = match[2].split('-');
965
1151
  if (titleParts.includes('core') && !(subjectName === 'principles' && titleParts[0] === 'core')) {
966
- const msg = `Policy filename must not use "core" as a name word unless it is a local meta-policy in "principles/" starting with "core" (e.g., "NNN-core.md" or "NNN-core-{qualifier}.md"): ${toDisplayPath(filePath)} [_core-adr-policy-010.09-local-naming]`;
1152
+ const msg = `Policy filename must not use "core" as a name word unless it is a local meta-policy in "principles/" starting with "core" (e.g., "NNN-core.md" or "NNN-core-{qualifier}.md"): ${toDisplayPath(filePath)} [_core-adr-policy-010.13-local-naming]`;
967
1153
  if (freezeReference) { warnings.push(`${msg} [exempt: freeze-reference]`); } else { errors.push(msg); }
968
1154
  }
969
1155
 
@@ -971,7 +1157,7 @@ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePat
971
1157
  || `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${match[2]}`;
972
1158
  lintXdrsElementFrontmatter(content, expectedName, filePath, errors, warnings, freezeReference);
973
1159
  if (enforceStructured) {
974
- validateStructuredFormatContent(content, filePath, errors, '_core-adr-policy-010.08c-def-core-scope-policies-must-be-structured');
1160
+ validateStructuredFormatContent(content, filePath, errors, '_core-adr-policy-010.12-def-core-scope-policies-must-be-structured');
975
1161
  } else if (isCompiledScope) {
976
1162
  validateStructuredFormatContent(content, filePath, errors, '_core-adr-policy-019.04-structured-format-required');
977
1163
  } else {