xdrs-core 0.50.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,13 +23,18 @@ 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';
31
+ const SOURCE_SNAPSHOT_DIR_NAME = 'sources';
32
+ const SOURCE_TRACKING_FILE_NAME = 'source.md';
28
33
  const SKILL_PACKAGE_OPTIONAL_DIRS = new Set(['scripts', 'references', RESOURCE_DIR_NAME, 'dist']);
29
34
 
30
35
  const POLICY_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'freeze-reference']);
31
36
  const SKILL_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'license', 'metadata']);
32
- 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']);
33
38
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
34
39
  const SLIDE_FILE_RE = /^.+-slides(?:-[a-z0-9-]+)?\.md$/;
35
40
  const SLIDE_MAX_NAME_LENGTH = 64;
@@ -100,13 +105,14 @@ function runLintCli(args) {
100
105
  }
101
106
  }
102
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
+
103
114
  if (result.errors.length === 0) {
104
115
  console.log(`Lint passed for ${toDisplayPath(result.xdrsRoot)}`);
105
- if (result.readOnlyScopes.size > 0) {
106
- for (const scopeName of result.readOnlyScopes) {
107
- 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]`);
108
- }
109
- }
110
116
  return 0;
111
117
  }
112
118
 
@@ -142,7 +148,7 @@ function lintWorkspace(targetPath, options = {}) {
142
148
  const xdrsRoot = resolveXdrsRoot(resolvedTarget);
143
149
  const errors = [];
144
150
  const warnings = [];
145
- const readOnlyScopes = new Set();
151
+ const readOnlyScopes = new Map(); // scopeName -> [cause, ...] per _core-adr-policy-022.06
146
152
 
147
153
  if (!existsDirectory(xdrsRoot)) {
148
154
  errors.push(`Missing XDRS root directory: ${toDisplayPath(xdrsRoot)} [_core-adr-policy-001]`);
@@ -158,7 +164,13 @@ function lintWorkspace(targetPath, options = {}) {
158
164
  const rootEntries = safeReadDir(xdrsRoot, errors, 'read XDRS root directory');
159
165
  const scopeEntries = rootEntries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'));
160
166
 
161
- // 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)
162
174
  const knownScopeTypes = new Map(); // scopeTypeName -> filePath (first primary found, for downstream validation)
163
175
  const scopeTypePrimaryKeys = new Set(); // "scopeName|typeName|scopeTypeName" for companion co-location checks
164
176
  const scopeTypePrimaryByScope = new Map(); // "scopeName|scopeTypeName" -> [typeName, ...] for duplicate detection
@@ -166,6 +178,7 @@ function lintWorkspace(targetPath, options = {}) {
166
178
  for (const scopeEntry of scopeEntries) {
167
179
  const scopeName = scopeEntry.name;
168
180
  const scopePath = path.join(xdrsRoot, scopeName);
181
+ scopeMeta.set(scopeName, readScopeMeta(path.join(scopePath, 'index.md')));
169
182
  for (const typeName of Object.keys(TYPE_TO_ID)) {
170
183
  const principlesDir = path.join(scopePath, typeName, 'principles');
171
184
  if (!existsDirectory(principlesDir)) continue;
@@ -185,13 +198,17 @@ function lintWorkspace(targetPath, options = {}) {
185
198
  if (!knownScopeTypes.has(scopeTypeName)) {
186
199
  knownScopeTypes.set(scopeTypeName, filePath);
187
200
  }
201
+ if (!scopeTypeDefiningScopes.has(scopeTypeName)) {
202
+ scopeTypeDefiningScopes.set(scopeTypeName, new Set());
203
+ }
204
+ scopeTypeDefiningScopes.get(scopeTypeName).add(scopeName);
188
205
  scopeTypePrimaryKeys.add(`${scopeName}|${typeName}|${scopeTypeName}`);
189
206
  const scopeKey = `${scopeName}|${scopeTypeName}`;
190
207
  if (!scopeTypePrimaryByScope.has(scopeKey)) {
191
208
  scopeTypePrimaryByScope.set(scopeKey, []);
192
209
  }
193
210
  scopeTypePrimaryByScope.get(scopeKey).push(typeName);
194
- 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');
195
212
  } else {
196
213
  // Check if it's a companion file: title contains '-scope-type-' followed by a qualifier
197
214
  const stIdx = shortTitle.indexOf('-scope-type-');
@@ -200,7 +217,7 @@ function lintWorkspace(targetPath, options = {}) {
200
217
  // Special-case: 'local' maps to '_local'
201
218
  const scopeTypeName = rawScopeTypeName === 'local' ? '_local' : rawScopeTypeName;
202
219
  scopeTypeCompanions.push({ scopeTypeName, scopeName, typeName, filePath });
203
- 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');
204
221
  }
205
222
  }
206
223
  }
@@ -220,7 +237,7 @@ function lintWorkspace(targetPath, options = {}) {
220
237
  // Validate: each companion must have a corresponding primary in the same scope and type folder
221
238
  for (const { scopeTypeName, scopeName, typeName, filePath } of scopeTypeCompanions) {
222
239
  if (!scopeTypePrimaryKeys.has(`${scopeName}|${typeName}|${scopeTypeName}`)) {
223
- 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]`);
224
241
  }
225
242
  }
226
243
 
@@ -230,11 +247,13 @@ function lintWorkspace(targetPath, options = {}) {
230
247
  }
231
248
  }
232
249
 
250
+ collectReadOnlyScopes(scopeMeta, activation, externalScopes, knownScopeTypes, scopeTypeDefiningScopes, readOnlyScopes);
251
+
233
252
  // Populated during lintScopeIndexFrontmatter: scopeName -> [extendedScopeNames...]
234
253
  const extendsRelationships = new Map();
235
254
 
236
255
  for (const scopeEntry of scopeEntries) {
237
- 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);
238
257
  }
239
258
 
240
259
  // Detect cycles in extends: chains (DFS)
@@ -261,17 +280,16 @@ function lintWorkspace(targetPath, options = {}) {
261
280
  detectExtendsCycles(scope, []);
262
281
  }
263
282
 
264
- const rootIndexPath = path.join(xdrsRoot, 'index.md');
265
283
  if (!existsFile(rootIndexPath)) {
266
284
  errors.push('Missing required root index: .xdrs/index.md [_core-adr-policy-001]');
267
285
  } else {
268
- lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, warnings, effectiveExternalScopes, extendsRelationships);
286
+ lintRootIndex(rootIndexPath, xdrsRoot, errors, warnings, effectiveExternalScopes, { activation, scopeMeta, knownScopeTypes });
269
287
  }
270
288
 
271
289
  return { xdrsRoot, errors, warnings, readOnlyScopes };
272
290
  }
273
291
 
274
- function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, warnings = [], externalScopes = new Set(), extendsRelationships = new Map()) {
292
+ function lintRootIndex(rootIndexPath, xdrsRoot, errors, warnings, externalScopes, ctx) {
275
293
  const content = fs.readFileSync(rootIndexPath, 'utf8');
276
294
  const repoRoot = path.dirname(xdrsRoot);
277
295
 
@@ -290,8 +308,6 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, warni
290
308
  }
291
309
  }
292
310
 
293
- const linkedScopeIndexes = links.filter((linkPath) => isScopeIndex(linkPath, xdrsRoot));
294
- const linkedSet = new Set(linkedScopeIndexes.map(normalizePath));
295
311
  const localScopePath = normalizePath(path.join(xdrsRoot, '_local'));
296
312
 
297
313
  for (const linkPath of links) {
@@ -300,38 +316,207 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, warni
300
316
  }
301
317
  }
302
318
 
303
- // Build set of all scopes referenced via any extends: chain
304
- const extendsReferencedScopes = new Set();
305
- for (const entries of extendsRelationships.values()) {
306
- 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
+ }
307
339
  }
308
340
 
309
- // Collect non-_local scopes that have type indexes and check their scope index is linked
310
- const scopesWithTypeIndexes = new Set();
311
- for (const indexPath of actualTypeIndexes) {
312
- const scopeName = path.basename(path.dirname(path.dirname(indexPath)));
313
- if (scopeName !== '_local') {
314
- scopesWithTypeIndexes.add(scopeName);
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]`);
344
+ }
345
+
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);
359
+ }
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
+ }
367
+
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
+ }
315
417
  }
316
418
  }
317
419
 
318
- for (const scopeName of scopesWithTypeIndexes) {
319
- const scopeIndexPath = normalizePath(path.join(xdrsRoot, scopeName, 'index.md'));
320
- if (!linkedSet.has(scopeIndexPath)) {
321
- // Exempt scopes that are only reachable via extends: chains
322
- if (extendsReferencedScopes.has(scopeName)) {
323
- // Scope is extends-only (not in root index) — exempt from mandatory link requirement
324
- // No warning here: absence from root index is expected and valid for extends-only scopes
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`);
325
480
  continue;
326
481
  }
327
- errors.push(`Root index is missing scope index link: ${toDisplayPath(path.join(xdrsRoot, scopeName, 'index.md'))} [_core-adr-policy-001]`);
328
- } else if (extendsReferencedScopes.has(scopeName)) {
329
- // Scope is in BOTH root index and extends chain — ambiguous dual precedence
330
- 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);
331
489
  }
332
490
  }
333
491
  }
334
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
+
335
520
  function lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes = new Set()) {
336
521
  const content = fs.readFileSync(scopeIndexPath, 'utf8');
337
522
  const repoRoot = path.dirname(xdrsRoot);
@@ -368,7 +553,59 @@ function extractParentScopeType(policyFilePath) {
368
553
  return bodyMatch ? bodyMatch[1] : null;
369
554
  }
370
555
 
371
- 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);
372
609
  const content = fs.readFileSync(scopeIndexPath, 'utf8');
373
610
  const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
374
611
  if (!fmMatch) {
@@ -387,18 +624,15 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
387
624
  const scopeTypeMatch = block.match(/^scope-type:\s*(.+)$/m);
388
625
  const scopeTypeRaw = scopeTypeMatch ? scopeTypeMatch[1].trim() : null;
389
626
  if (!scopeTypeRaw) {
390
- 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
+ }
391
631
  return;
392
632
  }
393
633
 
394
634
  // Parse scope-type: supports single string or comma-separated list (e.g. "compiled, internal-docs")
395
- const commaSeparatedScopeTypePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
396
- let scopeTypes;
397
- if (commaSeparatedScopeTypePattern.test(scopeTypeRaw)) {
398
- scopeTypes = scopeTypeRaw.split(',').map((s) => s.trim());
399
- } else {
400
- scopeTypes = [scopeTypeRaw];
401
- }
635
+ const scopeTypes = parseScopeTypeList(scopeTypeRaw);
402
636
 
403
637
  // Validate each element: no duplicates, valid chars, no reserved prefix
404
638
  const seenTypes = new Set();
@@ -406,17 +640,17 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
406
640
  const SCOPE_TYPE_ELEMENT_RE = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
407
641
  for (const scopeType of scopeTypes) {
408
642
  if (!SCOPE_TYPE_ELEMENT_RE.test(scopeType)) {
409
- 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]`);
410
644
  hasElementError = true;
411
645
  continue;
412
646
  }
413
647
  if (scopeType.startsWith('_') && scopeType !== '_local') {
414
- 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]`);
415
649
  hasElementError = true;
416
650
  continue;
417
651
  }
418
652
  if (seenTypes.has(scopeType)) {
419
- 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]`);
420
654
  hasElementError = true;
421
655
  continue;
422
656
  }
@@ -432,7 +666,9 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
432
666
  for (const st of scopeTypes) {
433
667
  if (st === '_local') continue;
434
668
  if (!knownScopeTypes.has(st)) {
435
- 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
+ }
436
672
  continue;
437
673
  }
438
674
  // Validate parent chain
@@ -443,11 +679,13 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
443
679
  const parentType = extractParentScopeType(policyPath);
444
680
  if (!parentType) break;
445
681
  if (globalVisited.has(parentType)) {
446
- 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]`);
447
683
  break;
448
684
  }
449
685
  if (!knownScopeTypes.has(parentType)) {
450
- 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
+ }
451
689
  break;
452
690
  }
453
691
  globalVisited.add(parentType);
@@ -460,7 +698,11 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
460
698
  if (scopeName === '_local' && scopeType !== '_local') {
461
699
  errors.push(`Scope "_local" must have scope-type "_local": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-015.01-scope-type-name]`);
462
700
  }
463
- 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')) {
464
706
  errors.push(`Scope with type "core" must have "core" in its name: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-011.03-naming-convention]`);
465
707
  }
466
708
  if (scopeTypes.includes('_local') && scopeName !== '_local') {
@@ -473,6 +715,19 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
473
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]`);
474
716
  }
475
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
+
476
731
  if (!fm.name) {
477
732
  errors.push(`Scope index frontmatter must include a non-empty name field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
478
733
  } else if (fm.name !== scopeName) {
@@ -506,49 +761,14 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
506
761
  const followsLineMatch = block.match(/^follows:[ \t]*(.*)/m);
507
762
  if (followsLineMatch !== null) {
508
763
  const followsValue = followsLineMatch[1].trim();
509
- const scopeNamePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
510
- const commaSeparatedPattern = /^[a-zA-Z_][a-zA-Z0-9_-]*(\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
511
- if (followsValue) {
512
- if (!scopeNamePattern.test(followsValue) && !commaSeparatedPattern.test(followsValue)) {
513
- errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
514
- }
515
- } else {
516
- const followsStart = block.indexOf(followsLineMatch[0]) + followsLineMatch[0].length;
517
- const listItems = [];
518
- let hasInvalidEntry = false;
519
- for (const line of block.slice(followsStart).split('\n')) {
520
- const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
521
- if (itemMatch) {
522
- const item = (itemMatch[1] || '').trim();
523
- if (!item || !scopeNamePattern.test(item)) {
524
- errors.push(`Scope index frontmatter follows entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
525
- hasInvalidEntry = true;
526
- break;
527
- }
528
- listItems.push(item);
529
- } else if (line.trim() && !/^\s/.test(line)) {
530
- break;
531
- }
532
- }
533
- if (!hasInvalidEntry && listItems.length === 0) {
534
- errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
535
- }
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]`);
536
769
  }
537
770
  if (xdrsRoot) {
538
- const entries = followsValue
539
- ? (commaSeparatedPattern.test(followsValue)
540
- ? followsValue.split(',').map((s) => s.trim())
541
- : [followsValue])
542
- : (() => {
543
- const items = [];
544
- const followsStart = block.indexOf(followsLineMatch[0]) + followsLineMatch[0].length;
545
- for (const line of block.slice(followsStart).split('\n')) {
546
- const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
547
- if (itemMatch && itemMatch[1]) items.push(itemMatch[1].trim());
548
- else if (line.trim() && !/^\s/.test(line)) break;
549
- }
550
- return items;
551
- })();
771
+ const entries = deriveScopeNameListEntries(block, followsLineMatch, followsValue);
552
772
  followsEntries = entries;
553
773
  const seenFollows = new Set();
554
774
  for (const entry of entries) {
@@ -556,7 +776,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
556
776
  errors.push(`Scope index frontmatter follows has duplicate entry "${entry}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
557
777
  }
558
778
  seenFollows.add(entry);
559
- if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(entry)) {
779
+ if (SCOPE_NAME_PATTERN.test(entry)) {
560
780
  if (entry === scopeName) {
561
781
  errors.push(`Scope index frontmatter follows must not reference the scope itself: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
562
782
  continue;
@@ -567,11 +787,16 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
567
787
  }
568
788
  const followedIndexPath = path.join(xdrsRoot, entry, 'index.md');
569
789
  if (!existsFile(followedIndexPath)) {
570
- 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
+ }
571
795
  } else {
572
796
  const followedContent = fs.readFileSync(followedIndexPath, 'utf8');
573
797
  const followedTypeMatch = followedContent.match(/^scope-type:\s*(.+)$/m);
574
- if (!followedTypeMatch || followedTypeMatch[1].trim() !== 'core') {
798
+ const followedTypes = followedTypeMatch ? parseScopeTypeField(followedTypeMatch[1].trim()) : [];
799
+ if (!isCoreTypeScope(followedTypes, knownScopeTypes)) {
575
800
  errors.push(`Scope index frontmatter follows references scope "${entry}" which is not a core-type scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
576
801
  }
577
802
  }
@@ -585,48 +810,13 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
585
810
  const extendsLineMatch = block.match(/^extends:[ \t]*(.*)/m);
586
811
  if (extendsLineMatch !== null) {
587
812
  const extendsValue = extendsLineMatch[1].trim();
588
- const scopeNamePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
589
- const commaSeparatedPattern = /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
590
- if (extendsValue) {
591
- if (!scopeNamePattern.test(extendsValue) && !commaSeparatedPattern.test(extendsValue)) {
592
- 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]`);
593
- }
594
- } else {
595
- const extendsStart = block.indexOf(extendsLineMatch[0]) + extendsLineMatch[0].length;
596
- const listItems = [];
597
- let hasInvalidEntry = false;
598
- for (const line of block.slice(extendsStart).split('\n')) {
599
- const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
600
- if (itemMatch) {
601
- const item = (itemMatch[1] || '').trim();
602
- if (!item || !scopeNamePattern.test(item)) {
603
- errors.push(`Scope index frontmatter extends entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.27-extends-declaration]`);
604
- hasInvalidEntry = true;
605
- break;
606
- }
607
- listItems.push(item);
608
- } else if (line.trim() && !/^\s/.test(line)) {
609
- break;
610
- }
611
- }
612
- if (!hasInvalidEntry && listItems.length === 0) {
613
- 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]`);
614
- }
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]`);
615
818
  }
616
- const rawEntries = extendsValue
617
- ? (commaSeparatedPattern.test(extendsValue)
618
- ? extendsValue.split(',').map((s) => s.trim())
619
- : (scopeNamePattern.test(extendsValue) ? [extendsValue] : []))
620
- : (() => {
621
- const items = [];
622
- const extendsStart = block.indexOf(extendsLineMatch[0]) + extendsLineMatch[0].length;
623
- for (const line of block.slice(extendsStart).split('\n')) {
624
- const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
625
- if (itemMatch && itemMatch[1]) items.push(itemMatch[1].trim());
626
- else if (line.trim() && !/^\s/.test(line)) break;
627
- }
628
- return items;
629
- })();
819
+ const rawEntries = deriveScopeNameListEntries(block, extendsLineMatch, extendsValue);
630
820
  const seenExtends = new Set();
631
821
  for (const entry of rawEntries) {
632
822
  if (seenExtends.has(entry)) {
@@ -634,7 +824,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
634
824
  continue;
635
825
  }
636
826
  seenExtends.add(entry);
637
- if (!scopeNamePattern.test(entry)) continue;
827
+ if (!SCOPE_NAME_PATTERN.test(entry)) continue;
638
828
  if (entry === scopeName) {
639
829
  errors.push(`Scope index frontmatter extends must not reference the scope itself: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.30-extends-no-self]`);
640
830
  continue;
@@ -655,7 +845,9 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
655
845
  continue;
656
846
  }
657
847
  if (!existsFile(path.join(xdrsRoot, entry, 'index.md'))) {
658
- 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
+ }
659
851
  }
660
852
  }
661
853
  if (extendsEntries.length > 0) {
@@ -666,7 +858,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
666
858
  }
667
859
 
668
860
 
669
- 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()) {
670
862
  const scopePath = path.join(xdrsRoot, scopeName);
671
863
 
672
864
  if (ignoreExternal && externalScopes.has(scopeName)) {
@@ -688,12 +880,8 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeInd
688
880
  const idxContent = fs.readFileSync(scopeIndexPath, 'utf8');
689
881
  const stMatch = idxContent.match(/^scope-type:\s*(.+)$/m);
690
882
  if (stMatch) {
691
- const stRaw = stMatch[1].trim();
692
- // Handle both "[type1, type2]" YAML array format and "type1, type2" inline CSV format
693
- const stList = stRaw.startsWith('[') && stRaw.endsWith(']')
694
- ? stRaw.slice(1, -1).split(',').map((s) => s.trim())
695
- : stRaw.split(',').map((s) => s.trim());
696
- isCoreTypeNonExempt = stList.includes('core');
883
+ const stList = parseScopeTypeField(stMatch[1].trim());
884
+ isCoreTypeNonExempt = isCoreTypeScope(stList, knownScopeTypes);
697
885
  isCompiledScope = stList.includes('compiled');
698
886
  }
699
887
  } catch { /* ignore read errors; will be caught later */ }
@@ -729,7 +917,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeInd
729
917
  errors.push(`Missing required scope index: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
730
918
  } else {
731
919
  lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes);
732
- lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes, readOnlyScopes, extendsRelationships);
920
+ lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes, extendsRelationships, trackedExternalScopes);
733
921
  }
734
922
  lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope);
735
923
  }
@@ -767,22 +955,61 @@ function lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope = fa
767
955
  // Error: companion exists without primary in same type folder
768
956
  for (const companion of companionsInFolder) {
769
957
  if (!primaryInFolder) {
770
- 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]`);
771
959
  }
772
960
  }
773
- // For compiled scopes: primary meta-policy must include ## Sources and ## Selectors
961
+ // For compiled scopes: primary meta-policy must include ## Sources, ## Selectors, and ## Sync Settings
774
962
  if (isCompiledScope && primaryInFolder) {
775
963
  let metaContent;
776
964
  try { metaContent = fs.readFileSync(primaryInFolder.filePath, 'utf8'); } catch { /* will be caught elsewhere */ }
777
965
  if (metaContent !== undefined) {
778
966
  const metaLines = metaContent.split(/\r?\n/);
779
967
  const metaIgnored = findIgnoredMarkdownLines(metaLines);
780
- if (findHeadingLine(metaLines, metaIgnored, '## Sources') === -1) {
968
+ const metaHeadingLines = findHeadingLines(metaLines, metaIgnored);
969
+
970
+ const sourcesHeadingIdx = findHeadingLine(metaLines, metaIgnored, '## Sources');
971
+ if (sourcesHeadingIdx === -1) {
781
972
  errors.push(`Compiled scope meta-policy must include a "## Sources" section: ${toDisplayPath(primaryInFolder.filePath)} [_core-adr-policy-019.07-compilation-meta-policy]`);
973
+ } else {
974
+ const [sourcesStart, sourcesEnd] = sectionBodyRange(metaLines, metaHeadingLines, sourcesHeadingIdx);
975
+ for (let i = sourcesStart; i < sourcesEnd; i += 1) {
976
+ if (metaIgnored[i]) continue;
977
+ const trimmed = metaLines[i].trim();
978
+ if (!trimmed.startsWith('-')) continue;
979
+ if (!/^-\s+\[(web|git|local)\]\s+[a-z0-9-]+:\s+.+$/.test(trimmed)) {
980
+ errors.push(`Compiled scope "## Sources" bullet must include a NAME slug in the format "- [web|git|local] name: value": ${toDisplayPath(primaryInFolder.filePath)}:${i + 1} [_core-adr-policy-019.07-compilation-meta-policy]`);
981
+ }
982
+ }
782
983
  }
984
+
783
985
  if (findHeadingLine(metaLines, metaIgnored, '## Selectors') === -1) {
784
986
  errors.push(`Compiled scope meta-policy must include a "## Selectors" section: ${toDisplayPath(primaryInFolder.filePath)} [_core-adr-policy-019.07-compilation-meta-policy]`);
785
987
  }
988
+
989
+ const syncHeadingIdx = findHeadingLine(metaLines, metaIgnored, '## Sync Settings');
990
+ if (syncHeadingIdx === -1) {
991
+ errors.push(`Compiled scope meta-policy must include a "## Sync Settings" section: ${toDisplayPath(primaryInFolder.filePath)} [_core-adr-policy-019.07-compilation-meta-policy]`);
992
+ } else {
993
+ const [syncStart, syncEnd] = sectionBodyRange(metaLines, metaHeadingLines, syncHeadingIdx);
994
+ let hasValidResyncPeriod = false;
995
+ for (let i = syncStart; i < syncEnd; i += 1) {
996
+ if (metaIgnored[i]) continue;
997
+ const trimmed = metaLines[i].trim();
998
+ if (!trimmed.startsWith('-')) continue;
999
+ const periodMatch = trimmed.match(/^-\s+Source re-sync period days:\s*(\d+)\s*$/);
1000
+ if (periodMatch) {
1001
+ hasValidResyncPeriod = Number(periodMatch[1]) > 0;
1002
+ continue;
1003
+ }
1004
+ const storageMatch = trimmed.match(/^-\s+Source storage:\s*(.+)$/);
1005
+ if (storageMatch && storageMatch[1].trim() !== 'temporary') {
1006
+ errors.push(`Compiled scope "## Sync Settings" "Source storage" value must be "temporary" if present: ${toDisplayPath(primaryInFolder.filePath)}:${i + 1} [_core-adr-policy-019.07-compilation-meta-policy]`);
1007
+ }
1008
+ }
1009
+ if (!hasValidResyncPeriod) {
1010
+ errors.push(`Compiled scope "## Sync Settings" section must include a "Source re-sync period days: N" bullet with a positive integer: ${toDisplayPath(primaryInFolder.filePath)} [_core-adr-policy-019.07-compilation-meta-policy]`);
1011
+ }
1012
+ }
786
1013
  }
787
1014
  }
788
1015
  // Check qualifier uniqueness across all type folders
@@ -791,12 +1018,12 @@ function lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope = fa
791
1018
  const existing = seenQualifiers.get(qualifier);
792
1019
  if (existing) {
793
1020
  const label = qualifier === '' ? 'primary (no qualifier)' : `qualifier "${qualifier}"`;
794
- 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]`);
795
1022
  } else {
796
1023
  seenQualifiers.set(qualifier, filePath);
797
1024
  }
798
1025
  // Validate structured format for all local meta-policy files
799
- 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');
800
1027
  }
801
1028
  }
802
1029
  }
@@ -884,7 +1111,7 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNu
884
1111
 
885
1112
  const subjectAssetsDir = path.join(subjectPath, RESOURCE_DIR_NAME);
886
1113
  const xdrsDocsInSubject = artifacts.filter((p) => path.dirname(p) === subjectPath);
887
- lintOrphanAssets(subjectAssetsDir, xdrsDocsInSubject, xdrsRoot, errors);
1114
+ lintOrphanAssets(subjectAssetsDir, xdrsDocsInSubject, xdrsRoot, errors, isCompiledScope);
888
1115
 
889
1116
  return artifacts;
890
1117
  }
@@ -922,7 +1149,7 @@ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePat
922
1149
  // Enforce 'core' word reservation: only allowed as first word of filename title in principles/
923
1150
  const titleParts = match[2].split('-');
924
1151
  if (titleParts.includes('core') && !(subjectName === 'principles' && titleParts[0] === 'core')) {
925
- 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]`;
926
1153
  if (freezeReference) { warnings.push(`${msg} [exempt: freeze-reference]`); } else { errors.push(msg); }
927
1154
  }
928
1155
 
@@ -930,7 +1157,7 @@ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePat
930
1157
  || `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${match[2]}`;
931
1158
  lintXdrsElementFrontmatter(content, expectedName, filePath, errors, warnings, freezeReference);
932
1159
  if (enforceStructured) {
933
- 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');
934
1161
  } else if (isCompiledScope) {
935
1162
  validateStructuredFormatContent(content, filePath, errors, '_core-adr-policy-019.04-structured-format-required');
936
1163
  } else {
@@ -1833,11 +2060,16 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors, externalScopes =
1833
2060
  }
1834
2061
  }
1835
2062
 
1836
- function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
2063
+ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors, isCompiledScope = false) {
1837
2064
  if (!existsDirectory(assetsDir)) {
1838
2065
  return;
1839
2066
  }
1840
2067
 
2068
+ const sourcesDir = path.join(assetsDir, SOURCE_SNAPSHOT_DIR_NAME);
2069
+ if (isCompiledScope) {
2070
+ lintSourceSnapshotTracking(sourcesDir, errors);
2071
+ }
2072
+
1841
2073
  const assetTree = collectAssetTree(assetsDir, errors);
1842
2074
  if (assetTree.files.length === 0 && assetTree.directories.length === 0) {
1843
2075
  return;
@@ -1859,6 +2091,9 @@ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
1859
2091
  }
1860
2092
 
1861
2093
  for (const assetPath of assetTree.files) {
2094
+ if (isCompiledScope && isPathInside(sourcesDir, assetPath)) {
2095
+ continue;
2096
+ }
1862
2097
  if (!referencedAssets.has(assetPath)) {
1863
2098
  errors.push(`Orphan asset file not referenced by any document: ${toDisplayPath(assetPath)} [_core-adr-policy-001]`);
1864
2099
  }
@@ -1866,6 +2101,9 @@ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
1866
2101
 
1867
2102
  // Lint slide/presentation files inside .assets
1868
2103
  for (const assetPath of assetTree.files) {
2104
+ if (isCompiledScope && isPathInside(sourcesDir, assetPath)) {
2105
+ continue;
2106
+ }
1869
2107
  const fileName = path.basename(assetPath);
1870
2108
  if (SLIDE_FILE_RE.test(fileName)) {
1871
2109
  lintSlideFile(assetPath, documentPaths, xdrsRoot, errors);
@@ -1873,6 +2111,47 @@ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
1873
2111
  }
1874
2112
  }
1875
2113
 
2114
+ function lintSourceSnapshotTracking(sourcesDir, errors) {
2115
+ if (!existsDirectory(sourcesDir)) {
2116
+ return;
2117
+ }
2118
+
2119
+ const entries = safeReadDir(sourcesDir, errors, `read source snapshot directory ${toDisplayPath(sourcesDir)}`);
2120
+ for (const entry of entries) {
2121
+ if (!entry.isDirectory()) {
2122
+ continue;
2123
+ }
2124
+ const trackingFilePath = path.join(sourcesDir, entry.name, SOURCE_TRACKING_FILE_NAME);
2125
+ if (!existsFile(trackingFilePath)) {
2126
+ errors.push(`Compiled source snapshot folder must contain a "${SOURCE_TRACKING_FILE_NAME}" tracking file: ${toDisplayPath(trackingFilePath)} [_core-adr-policy-019.12-source-storage-tracking]`);
2127
+ continue;
2128
+ }
2129
+ validateSourceTrackingFile(trackingFilePath, errors);
2130
+ }
2131
+ }
2132
+
2133
+ function validateSourceTrackingFile(filePath, errors) {
2134
+ const content = fs.readFileSync(filePath, 'utf8');
2135
+ const lines = content.split(/\r?\n/);
2136
+
2137
+ if (firstNonEmptyLine(content) !== '# Source') {
2138
+ errors.push(`Source tracking file must start with a "# Source" heading: ${toDisplayPath(filePath)} [_core-adr-policy-019.12-source-storage-tracking]`);
2139
+ }
2140
+
2141
+ const timestampRe = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
2142
+ for (const field of ['last-fetch-timestamp', 'last-compilation-timestamp']) {
2143
+ const fieldLine = lines.find((line) => line.trim().startsWith(`${field}:`));
2144
+ if (!fieldLine) {
2145
+ errors.push(`Source tracking file must include a "${field}:" line: ${toDisplayPath(filePath)} [_core-adr-policy-019.12-source-storage-tracking]`);
2146
+ continue;
2147
+ }
2148
+ const value = fieldLine.trim().slice(`${field}:`.length).trim();
2149
+ if (!timestampRe.test(value)) {
2150
+ errors.push(`Source tracking file "${field}" must match "YYYY-MM-DD HH:MM:SS": ${toDisplayPath(filePath)} [_core-adr-policy-019.12-source-storage-tracking]`);
2151
+ }
2152
+ }
2153
+ }
2154
+
1876
2155
  function lintSlideFile(filePath, documentPaths, xdrsRoot, errors) {
1877
2156
  const fileName = path.basename(filePath);
1878
2157
 
@@ -1983,6 +2262,10 @@ function lintDocumentLinks(documentPath, xdrsRoot, scopeName, errors, externalSc
1983
2262
  if (isResourceLink && !isPathInside(resourceDir, link.resolvedPath)) {
1984
2263
  errors.push(`Asset links in ${toDisplayPath(documentPath)} must point to ${toDisplayPath(resourceDir)}: ${link.rawTarget} [_core-adr-policy-001]`);
1985
2264
  }
2265
+
2266
+ if (isSourceSnapshotPath(link.resolvedPath)) {
2267
+ errors.push(`Document must not link directly to compiled-source snapshot content, which is not authoritative (see _core-adr-policy-019 rule 14) in ${toDisplayPath(documentPath)}:${index + 1}: ${link.rawTarget} [_core-adr-policy-019.14-source-not-authoritative]`);
2268
+ }
1986
2269
  }
1987
2270
  }
1988
2271
  }
@@ -2069,6 +2352,11 @@ function shouldValidateResourceLink(rawTarget) {
2069
2352
  || IMAGE_EXTENSIONS.has(extension);
2070
2353
  }
2071
2354
 
2355
+ function isSourceSnapshotPath(resolvedPath) {
2356
+ const normalized = normalizePath(resolvedPath).split(path.sep).join('/');
2357
+ return normalized.includes(`/${RESOURCE_DIR_NAME}/${SOURCE_SNAPSHOT_DIR_NAME}/`);
2358
+ }
2359
+
2072
2360
  function extractDescriptionText(block) {
2073
2361
  const lines = block.split(/\r?\n/);
2074
2362
  for (let i = 0; i < lines.length; i++) {