xdrs-core 0.41.1 → 0.43.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.
Files changed (39) hide show
  1. package/.xdrs/_core/adrs/index.md +2 -1
  2. package/.xdrs/_core/adrs/principles/{001-xdrs-core.md → 001-xdrs-standards.md} +4 -4
  3. package/.xdrs/_core/adrs/principles/002-policy-standards.md +2 -2
  4. package/.xdrs/_core/adrs/principles/003-skill-standards.md +1 -1
  5. package/.xdrs/_core/adrs/principles/004-article-standards.md +1 -1
  6. package/.xdrs/_core/adrs/principles/006-research-standards.md +1 -1
  7. package/.xdrs/_core/adrs/principles/007-plan-standards.md +1 -1
  8. package/.xdrs/_core/adrs/principles/008-policy-structured-standards.md +1 -1
  9. package/.xdrs/_core/adrs/principles/009-presentation-standards.md +1 -1
  10. package/.xdrs/_core/adrs/principles/010-scope-governance.md +76 -27
  11. package/.xdrs/_core/adrs/principles/011-core-scope-type.md +3 -3
  12. package/.xdrs/_core/adrs/principles/012-reference-scope-type.md +1 -1
  13. package/.xdrs/_core/adrs/principles/013-platform-scope-type.md +1 -1
  14. package/.xdrs/_core/adrs/principles/014-standard-scope-type.md +1 -1
  15. package/.xdrs/_core/adrs/principles/015-local-scope-type.md +1 -1
  16. package/.xdrs/_core/adrs/principles/016-policy-subjects.md +1 -1
  17. package/.xdrs/_core/adrs/principles/017-policy-numbering-ranges.md +1 -1
  18. package/.xdrs/_core/adrs/principles/018-external-path-symlinks.md +49 -0
  19. package/.xdrs/_core/adrs/principles/articles/.assets/001-xdrs-overview-slides.md +1 -1
  20. package/.xdrs/_core/adrs/principles/articles/001-xdrs-overview.md +3 -3
  21. package/.xdrs/_core/adrs/principles/skills/.assets/conflict-detection.md +76 -0
  22. package/.xdrs/_core/adrs/principles/skills/001-review/001-review.test.int.js +2 -2
  23. package/.xdrs/_core/adrs/principles/skills/001-review/001-review.test.int.report +2 -2
  24. package/.xdrs/_core/adrs/principles/skills/001-review/SKILL.md +5 -4
  25. package/.xdrs/_core/adrs/principles/skills/002-write-policy/SKILL.md +12 -9
  26. package/.xdrs/_core/adrs/principles/skills/003-write-skill/SKILL.md +9 -6
  27. package/.xdrs/_core/adrs/principles/skills/004-write-article/004-write-article.test.int.js +1 -1
  28. package/.xdrs/_core/adrs/principles/skills/004-write-article/004-write-article.test.int.report +1 -1
  29. package/.xdrs/_core/adrs/principles/skills/004-write-article/SKILL.md +9 -6
  30. package/.xdrs/_core/adrs/principles/skills/005-write-research/005-write-research.test.int.report +1 -1
  31. package/.xdrs/_core/adrs/principles/skills/005-write-research/SKILL.md +9 -6
  32. package/.xdrs/_core/adrs/principles/skills/006-write-plan/SKILL.md +9 -6
  33. package/.xdrs/_core/adrs/principles/skills/007-write-presentation/SKILL.md +7 -4
  34. package/.xdrs/_core/adrs/principles/skills/008-write-xdrs-doc/SKILL.md +5 -2
  35. package/.xdrs/_core/index.md +5 -3
  36. package/README.md +32 -2
  37. package/lib/lint.js +207 -47
  38. package/lib/lint.test.js +694 -25
  39. package/package.json +1 -1
package/lib/lint.js CHANGED
@@ -149,9 +149,13 @@ function lintWorkspace(targetPath, options = {}) {
149
149
  const scopeEntries = rootEntries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'));
150
150
 
151
151
  // Pass 1: collect known scope-type names from NNN-{scope-type}-scope-type.md files in any scope
152
- const knownScopeTypes = new Map(); // scopeTypeName -> filePath
152
+ const knownScopeTypes = new Map(); // scopeTypeName -> filePath (first primary found, for downstream validation)
153
+ const scopeTypePrimaryKeys = new Set(); // "scopeName|typeName|scopeTypeName" for companion co-location checks
154
+ const scopeTypePrimaryByScope = new Map(); // "scopeName|scopeTypeName" -> [typeName, ...] for duplicate detection
155
+ const scopeTypeCompanions = []; // [{scopeTypeName, scopeName, typeName, filePath}]
153
156
  for (const scopeEntry of scopeEntries) {
154
- const scopePath = path.join(xdrsRoot, scopeEntry.name);
157
+ const scopeName = scopeEntry.name;
158
+ const scopePath = path.join(xdrsRoot, scopeName);
155
159
  for (const typeName of Object.keys(TYPE_TO_ID)) {
156
160
  const principlesDir = path.join(scopePath, typeName, 'principles');
157
161
  if (!existsDirectory(principlesDir)) continue;
@@ -162,17 +166,54 @@ function lintWorkspace(targetPath, options = {}) {
162
166
  const m = entry.name.match(NUMBERED_FILE_RE);
163
167
  if (!m) continue;
164
168
  const shortTitle = m[2];
165
- if (!shortTitle.endsWith('-scope-type')) continue;
166
- const rawTypeName = shortTitle.slice(0, -'-scope-type'.length);
167
- // Special-case: 'local' maps to '_local' (underscore not allowed in NUMBERED_FILE_RE)
168
- const scopeTypeName = rawTypeName === 'local' ? '_local' : rawTypeName;
169
- if (!knownScopeTypes.has(scopeTypeName)) {
170
- knownScopeTypes.set(scopeTypeName, path.join(principlesDir, entry.name));
169
+ const filePath = path.join(principlesDir, entry.name);
170
+ if (shortTitle.endsWith('-scope-type')) {
171
+ // Primary scope-type definition file
172
+ const rawScopeTypeName = shortTitle.slice(0, -'-scope-type'.length);
173
+ // Special-case: 'local' maps to '_local' (underscore not allowed in NUMBERED_FILE_RE)
174
+ const scopeTypeName = rawScopeTypeName === 'local' ? '_local' : rawScopeTypeName;
175
+ if (!knownScopeTypes.has(scopeTypeName)) {
176
+ knownScopeTypes.set(scopeTypeName, filePath);
177
+ }
178
+ scopeTypePrimaryKeys.add(`${scopeName}|${typeName}|${scopeTypeName}`);
179
+ const scopeKey = `${scopeName}|${scopeTypeName}`;
180
+ if (!scopeTypePrimaryByScope.has(scopeKey)) {
181
+ scopeTypePrimaryByScope.set(scopeKey, []);
182
+ }
183
+ scopeTypePrimaryByScope.get(scopeKey).push(typeName);
184
+ validateStructuredFormat(filePath, errors, '_core-adr-policy-010.08b-def-scope-type-must-be-structured');
185
+ } else {
186
+ // Check if it's a companion file: title contains '-scope-type-' followed by a qualifier
187
+ const stIdx = shortTitle.indexOf('-scope-type-');
188
+ if (stIdx > 0) {
189
+ const rawScopeTypeName = shortTitle.slice(0, stIdx);
190
+ // Special-case: 'local' maps to '_local'
191
+ const scopeTypeName = rawScopeTypeName === 'local' ? '_local' : rawScopeTypeName;
192
+ scopeTypeCompanions.push({ scopeTypeName, scopeName, typeName, filePath });
193
+ validateStructuredFormat(filePath, errors, '_core-adr-policy-010.08b-def-scope-type-must-be-structured');
194
+ }
171
195
  }
172
196
  }
173
197
  }
174
198
  }
175
199
 
200
+ // Validate: no duplicate primaries across type folders in the same scope
201
+ for (const [scopeKey, typeNames] of scopeTypePrimaryByScope) {
202
+ if (typeNames.length > 1) {
203
+ const pipeIdx = scopeKey.indexOf('|');
204
+ const sName = scopeKey.slice(0, pipeIdx);
205
+ const stName = scopeKey.slice(pipeIdx + 1);
206
+ errors.push(`Scope type "${stName}" has primary definitions in multiple type folders [${typeNames.join(', ')}] in scope "${sName}": only one primary is allowed per scope [_core-adr-policy-010.02-def-naming]`);
207
+ }
208
+ }
209
+
210
+ // Validate: each companion must have a corresponding primary in the same scope and type folder
211
+ for (const { scopeTypeName, scopeName, typeName, filePath } of scopeTypeCompanions) {
212
+ if (!scopeTypePrimaryKeys.has(`${scopeName}|${typeName}|${scopeTypeName}`)) {
213
+ 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]`);
214
+ }
215
+ }
216
+
176
217
  for (const entry of rootEntries) {
177
218
  if (entry.isFile() && entry.name !== 'index.md') {
178
219
  errors.push(`Unexpected file at .xdrs root: ${entry.name} [_core-adr-policy-001]`);
@@ -292,29 +333,64 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
292
333
  }
293
334
 
294
335
  const scopeTypeMatch = block.match(/^scope-type:\s*(.+)$/m);
295
- const scopeType = scopeTypeMatch ? scopeTypeMatch[1].trim() : null;
296
- if (!scopeType) {
336
+ const scopeTypeRaw = scopeTypeMatch ? scopeTypeMatch[1].trim() : null;
337
+ if (!scopeTypeRaw) {
297
338
  errors.push(`Scope index frontmatter must include a scope-type field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.08-def-valid-iff-policy-exists]`);
298
339
  return;
299
340
  }
300
- if (scopeType.startsWith('_') && scopeType !== '_local') {
301
- 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]`);
302
- return;
341
+
342
+ // Parse scope-type: supports single string or comma-separated list (e.g. "compiled, internal-docs")
343
+ const commaSeparatedScopeTypePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
344
+ let scopeTypes;
345
+ if (commaSeparatedScopeTypePattern.test(scopeTypeRaw)) {
346
+ scopeTypes = scopeTypeRaw.split(',').map((s) => s.trim());
347
+ } else {
348
+ scopeTypes = [scopeTypeRaw];
303
349
  }
304
- if (!knownScopeTypes.has(scopeType) && scopeType !== '_local') {
305
- errors.push(`Scope index scope-type "${scopeType}" has no corresponding ${scopeType}-scope-type policy in the principles of any scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.08-def-valid-iff-policy-exists]`);
306
- return;
350
+
351
+ // Validate each element: no duplicates, valid chars, no reserved prefix
352
+ const seenTypes = new Set();
353
+ let hasElementError = false;
354
+ const SCOPE_TYPE_ELEMENT_RE = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
355
+ for (const scopeType of scopeTypes) {
356
+ if (!SCOPE_TYPE_ELEMENT_RE.test(scopeType)) {
357
+ 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]`);
358
+ hasElementError = true;
359
+ continue;
360
+ }
361
+ if (scopeType.startsWith('_') && scopeType !== '_local') {
362
+ 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]`);
363
+ hasElementError = true;
364
+ continue;
365
+ }
366
+ if (seenTypes.has(scopeType)) {
367
+ errors.push(`Scope type list contains duplicate value "${scopeType}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.05b-def-multi-type-validation]`);
368
+ hasElementError = true;
369
+ continue;
370
+ }
371
+ seenTypes.add(scopeType);
307
372
  }
308
- // Validate inheritance chain: ensure all ancestors exist in knownScopeTypes
309
- if (scopeType !== '_local') {
310
- const visited = new Set([scopeType]);
311
- let currentType = scopeType;
373
+ if (hasElementError) return;
374
+
375
+ // Use first element for single-value checks that only apply to the primary type
376
+ const scopeType = scopeTypes[0];
377
+
378
+ // Validate each type exists in knownScopeTypes and check parent chains with global cycle detection
379
+ const globalVisited = new Set(scopeTypes);
380
+ for (const st of scopeTypes) {
381
+ if (st === '_local') continue;
382
+ if (!knownScopeTypes.has(st)) {
383
+ 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]`);
384
+ continue;
385
+ }
386
+ // Validate parent chain
387
+ let currentType = st;
312
388
  while (currentType) {
313
389
  const policyPath = knownScopeTypes.get(currentType);
314
390
  if (!policyPath) break;
315
391
  const parentType = extractParentScopeType(policyPath);
316
392
  if (!parentType) break;
317
- if (visited.has(parentType)) {
393
+ if (globalVisited.has(parentType)) {
318
394
  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]`);
319
395
  break;
320
396
  }
@@ -322,7 +398,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
322
398
  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]`);
323
399
  break;
324
400
  }
325
- visited.add(parentType);
401
+ globalVisited.add(parentType);
326
402
  currentType = parentType;
327
403
  }
328
404
  }
@@ -332,16 +408,16 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
332
408
  if (scopeName === '_local' && scopeType !== '_local') {
333
409
  errors.push(`Scope "_local" must have scope-type "_local": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-015.01-scope-type-name]`);
334
410
  }
335
- if (scopeType === 'core' && !scopeName.includes('core')) {
411
+ if (scopeTypes.includes('core') && !scopeName.includes('core')) {
336
412
  errors.push(`Scope with type "core" must have "core" in its name: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-011.03-naming-convention]`);
337
413
  }
338
- if (scopeType === '_local' && scopeName !== '_local') {
414
+ if (scopeTypes.includes('_local') && scopeName !== '_local') {
339
415
  errors.push(`Scope type "_local" is reserved for the "_local" scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-015.03-exclusive-reservation]`);
340
416
  }
341
- if (scopeType === 'reference' && !scopeName.includes('-ref-')) {
417
+ if (scopeTypes.includes('reference') && !scopeName.includes('-ref-')) {
342
418
  errors.push(`Scope with type "reference" must follow the naming pattern {domain}-ref-{name} (e.g. "security-ref-baseline"): ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-012.03-naming-convention]`);
343
419
  }
344
- if (scopeType === 'platform' && !scopeName.includes('-plat-')) {
420
+ if (scopeTypes.includes('platform') && !scopeName.includes('-plat-')) {
345
421
  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]`);
346
422
  }
347
423
 
@@ -528,6 +604,26 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeInd
528
604
  errors.push(`Invalid scope name: ${toDisplayPath(scopePath)} [_core-adr-policy-001]`);
529
605
  }
530
606
 
607
+ // Determine if this scope is a core-type scope (but not _core which is exempt)
608
+ // Used to enforce structured format on all policies in core-type scopes
609
+ let isCoreTypeNonExempt = false;
610
+ if (scopeName !== '_core') {
611
+ const scopeIndexPath = path.join(scopePath, 'index.md');
612
+ if (existsFile(scopeIndexPath)) {
613
+ try {
614
+ const idxContent = fs.readFileSync(scopeIndexPath, 'utf8');
615
+ const stMatch = idxContent.match(/^scope-type:\s*(.+)$/m);
616
+ if (stMatch) {
617
+ const stRaw = stMatch[1].trim();
618
+ const stList = stRaw.startsWith('[') && stRaw.endsWith(']')
619
+ ? stRaw.slice(1, -1).split(',').map((s) => s.trim())
620
+ : [stRaw];
621
+ isCoreTypeNonExempt = stList.includes('core');
622
+ }
623
+ } catch { /* ignore read errors; will be caught later */ }
624
+ }
625
+ }
626
+
531
627
  const typeIndexesInScope = [];
532
628
  const entries = safeReadDir(scopePath, errors, `read scope directory ${scopeName}`);
533
629
  for (const entry of entries) {
@@ -537,7 +633,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeInd
537
633
  errors.push(`Unexpected directory under scope ${scopeName}: ${toDisplayPath(entryPath)} [_core-adr-policy-001]`);
538
634
  continue;
539
635
  }
540
- lintTypeDirectory(xdrsRoot, scopeName, entry.name, errors, warnings, actualTypeIndexes, externalScopes);
636
+ lintTypeDirectory(xdrsRoot, scopeName, entry.name, errors, warnings, actualTypeIndexes, externalScopes, isCoreTypeNonExempt);
541
637
  const typeIndexPath = path.join(entryPath, 'index.md');
542
638
  if (existsFile(typeIndexPath)) {
543
639
  typeIndexesInScope.push(typeIndexPath);
@@ -559,37 +655,62 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeInd
559
655
  lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes);
560
656
  lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes, readOnlyScopes);
561
657
  }
562
- lintScopeLocalStandards(xdrsRoot, scopeName, errors);
658
+ lintLocalMetaPolicies(xdrsRoot, scopeName, errors);
563
659
  }
564
660
 
565
- function lintScopeLocalStandards(xdrsRoot, scopeName, errors) {
661
+ function lintLocalMetaPolicies(xdrsRoot, scopeName, errors) {
566
662
  const scopePath = path.join(xdrsRoot, scopeName);
567
- const corePolicySuffix = `${scopeName}-core`;
568
- const foundPolicies = [];
663
+ // Track qualifiers across all type folders: qualifier ('' for primary) -> filePath
664
+ const seenQualifiers = new Map();
569
665
  for (const typeName of Object.keys(TYPE_TO_ID)) {
570
666
  const principlesDir = path.join(scopePath, typeName, 'principles');
571
667
  if (!existsDirectory(principlesDir)) continue;
572
668
  let principlesEntries;
573
669
  try { principlesEntries = fs.readdirSync(principlesDir, { withFileTypes: true }); } catch { continue; }
670
+ // Collect primary and companions per type folder
671
+ let primaryInFolder = null;
672
+ const companionsInFolder = [];
574
673
  for (const entry of principlesEntries) {
575
674
  if (!entry.isFile()) continue;
576
- if (!NUMBERED_FILE_RE.test(entry.name)) continue;
675
+ const m = entry.name.match(NUMBERED_FILE_RE);
676
+ if (!m) continue;
677
+ const title = m[2];
678
+ // Exclude scope-type definition files
679
+ if (title.endsWith('-scope-type')) continue;
680
+ const titleParts = title.split('-');
681
+ if (titleParts[0] !== 'core') continue;
682
+ // Determine qualifier ('') for primary, qualifier string for companions
683
+ const qualifier = titleParts.length === 1 ? '' : titleParts.slice(1).join('-');
577
684
  const filePath = path.join(principlesDir, entry.name);
578
- let content;
579
- try { content = fs.readFileSync(filePath, 'utf8'); } catch { continue; }
580
- const fm = extractFrontmatter(content);
581
- if (fm.name && (fm.name === corePolicySuffix || fm.name.endsWith(`-${corePolicySuffix}`))) {
582
- foundPolicies.push(filePath);
685
+ if (qualifier === '') {
686
+ primaryInFolder = { filePath, qualifier };
687
+ } else {
688
+ companionsInFolder.push({ filePath, qualifier });
583
689
  }
584
690
  }
585
- }
586
- if (foundPolicies.length > 1) {
587
- const paths = foundPolicies.map((p) => toDisplayPath(p)).join(', ');
588
- errors.push(`Scope "${scopeName}" has more than one scope-local standards policy (name ending with "${corePolicySuffix}"): ${paths} [_core-adr-policy-010.10-local-placement]`);
691
+ // Error: companion exists without primary in same type folder
692
+ for (const companion of companionsInFolder) {
693
+ if (!primaryInFolder) {
694
+ 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]`);
695
+ }
696
+ }
697
+ // Check qualifier uniqueness across all type folders
698
+ const allInFolder = primaryInFolder ? [primaryInFolder, ...companionsInFolder] : companionsInFolder;
699
+ for (const { filePath, qualifier } of allInFolder) {
700
+ const existing = seenQualifiers.get(qualifier);
701
+ if (existing) {
702
+ const label = qualifier === '' ? 'primary (no qualifier)' : `qualifier "${qualifier}"`;
703
+ 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]`);
704
+ } else {
705
+ seenQualifiers.set(qualifier, filePath);
706
+ }
707
+ // Validate structured format for all local meta-policy files
708
+ validateStructuredFormat(filePath, errors, '_core-adr-policy-010.12b-local-must-be-structured');
709
+ }
589
710
  }
590
711
  }
591
712
 
592
- function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, warnings, actualTypeIndexes, externalScopes = new Set()) {
713
+ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, warnings, actualTypeIndexes, externalScopes = new Set(), enforceStructured = false) {
593
714
  const typePath = path.join(xdrsRoot, scopeName, typeName);
594
715
  const indexPath = path.join(typePath, 'index.md');
595
716
  const xdrsNumbers = new Map();
@@ -620,7 +741,7 @@ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, warnings, actu
620
741
  warnings.push(`Invalid subject folder for ${typeName}: ${toDisplayPath(entryPath)} [_core-adr-policy-001] [exempt: freeze-reference]`);
621
742
  }
622
743
 
623
- artifacts.push(...lintSubjectDirectory(xdrsRoot, scopeName, typeName, entry.name, xdrsNumbers, errors, warnings, externalScopes));
744
+ artifacts.push(...lintSubjectDirectory(xdrsRoot, scopeName, typeName, entry.name, xdrsNumbers, errors, warnings, externalScopes, enforceStructured));
624
745
  }
625
746
 
626
747
  if (existsFile(indexPath)) {
@@ -628,7 +749,7 @@ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, warnings, actu
628
749
  }
629
750
  }
630
751
 
631
- function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNumbers, errors, warnings, externalScopes = new Set()) {
752
+ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNumbers, errors, warnings, externalScopes = new Set(), enforceStructured = false) {
632
753
  const subjectPath = path.join(xdrsRoot, scopeName, typeName, subjectName);
633
754
  const artifacts = [];
634
755
  const entries = safeReadDir(subjectPath, errors, `read subject directory ${scopeName}/${typeName}/${subjectName}`);
@@ -667,7 +788,7 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNu
667
788
  }
668
789
 
669
790
  artifacts.push(entryPath);
670
- lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, entryPath, xdrsNumbers, errors, warnings, externalScopes);
791
+ lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, entryPath, xdrsNumbers, errors, warnings, externalScopes, enforceStructured);
671
792
  }
672
793
 
673
794
  const subjectAssetsDir = path.join(subjectPath, RESOURCE_DIR_NAME);
@@ -677,7 +798,7 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNu
677
798
  return artifacts;
678
799
  }
679
800
 
680
- function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePath, xdrsNumbers, errors, warnings, externalScopes = new Set()) {
801
+ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePath, xdrsNumbers, errors, warnings, externalScopes = new Set(), enforceStructured = false) {
681
802
  const baseName = path.basename(filePath);
682
803
  const match = baseName.match(NUMBERED_FILE_RE);
683
804
  if (!match) {
@@ -707,10 +828,21 @@ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePat
707
828
  warnings.push(`Policy title must start with "${expectedHeader}": ${toDisplayPath(filePath)} [_core-adr-policy-002] [exempt: freeze-reference]`);
708
829
  }
709
830
 
831
+ // Enforce 'core' word reservation: only allowed as first word of filename title in principles/
832
+ const titleParts = match[2].split('-');
833
+ if (titleParts.includes('core') && !(subjectName === 'principles' && titleParts[0] === 'core')) {
834
+ 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]`;
835
+ if (freezeReference) { warnings.push(`${msg} [exempt: freeze-reference]`); } else { errors.push(msg); }
836
+ }
837
+
710
838
  const expectedName = extractExpectedXdrsNameFromHeading(firstLine)
711
839
  || `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${match[2]}`;
712
840
  lintXdrsElementFrontmatter(content, expectedName, filePath, errors, warnings, freezeReference);
713
- lintStructuredRuleBlocks(content, filePath, errors);
841
+ if (enforceStructured) {
842
+ validateStructuredFormatContent(content, filePath, errors, '_core-adr-policy-010.08c-def-core-scope-policies-must-be-structured');
843
+ } else {
844
+ lintStructuredRuleBlocks(content, filePath, errors);
845
+ }
714
846
  lintRequiredSections(content, filePath, XDRS_REQUIRED_SECTIONS, 'Policy', errors);
715
847
  lintNoEmojis(content, filePath, 'Policy', errors);
716
848
  lintWordCount(content, filePath, 'Policy', POLICY_MAX_WORDS, errors);
@@ -801,6 +933,11 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
801
933
  continue;
802
934
  }
803
935
 
936
+ // .assets is allowed as a shared resources directory within skills/
937
+ if (entry.name === RESOURCE_DIR_NAME) {
938
+ continue;
939
+ }
940
+
804
941
  const match = entry.name.match(NUMBERED_DIR_RE);
805
942
  if (!match) {
806
943
  errors.push(`Invalid skill package name: ${toDisplayPath(entryPath)} [_core-adr-policy-003]`);
@@ -1485,6 +1622,29 @@ function stripFrontmatter(content) {
1485
1622
  return match ? content.slice(match[0].length) : content;
1486
1623
  }
1487
1624
 
1625
+ /**
1626
+ * Validate that a policy file follows the structured format defined in policy-008:
1627
+ * must have a "### Details" section and at least one "#### NN-rulename" rule block.
1628
+ */
1629
+ function validateStructuredFormat(filePath, errors, errorCode) {
1630
+ let content;
1631
+ try { content = fs.readFileSync(filePath, 'utf8'); } catch { return; }
1632
+ validateStructuredFormatContent(content, filePath, errors, errorCode);
1633
+ }
1634
+
1635
+ function validateStructuredFormatContent(content, filePath, errors, errorCode) {
1636
+ const body = stripFrontmatter(content);
1637
+ if (!/^### Details\s*$/m.test(body)) {
1638
+ errors.push(`Policy must have a "### Details" section: ${toDisplayPath(filePath)} [${errorCode}]`);
1639
+ return;
1640
+ }
1641
+ if (!/^#### \d{2}-[a-z][a-z0-9-]*$/m.test(body)) {
1642
+ errors.push(`Policy must have at least one structured rule block (#### NN-rulename) in ### Details: ${toDisplayPath(filePath)} [${errorCode}]`);
1643
+ return;
1644
+ }
1645
+ lintStructuredRuleBlocks(content, filePath, errors);
1646
+ }
1647
+
1488
1648
  function findIgnoredMarkdownLines(lines) {
1489
1649
  const ignored = [];
1490
1650
  let inCodeFence = false;