xdrs-core 0.31.2 → 0.32.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.
@@ -65,6 +65,22 @@ Policies can be of different kinds, depending on the nature of the decision:
65
65
  - **Types:** `adrs`, `bdrs`, `edrs`
66
66
  - there can exist sufixes to the standard scope names (e.g: `business-x-mobileapp`, `business-y-servicedesk`)
67
67
  - The `-core` suffix is a reserved special suffix (e.g., `security-core`, `platform-core`) that designates a scope as the meta governance layer for all scopes sharing the same prefix. See `_core-adr-policy-010` for the full convention.
68
+ - **Scope types:** Every scope MUST declare its type via a `scope-type` field in its `index.md` YAML frontmatter. Allowed values:
69
+ - `core`: A meta-policy scope defining governance conventions for a group of scopes. It may set mandatory conventions (naming rules, subject taxonomy, scope ordering) enforced on related scopes. The built-in `_core` is the framework-level instance that applies globally; area-specific `core` scopes (e.g., `myarea-core`) layer governance on top. A scope with this type MUST have `core` in its name (e.g., `_core`, `myarea-core`, `security-core`). Area `core` scopes MUST NOT contradict `_core` structural rules; they may only extend them.
70
+ - `reference`: A blueprint, standard, or reference architecture meant to be copied or adapted — not a live service. Examples of what belongs here: industry standards (ISO, SOC2, PCI-DSS), vendor best-practice patterns, reference architectures. A scope with this type MUST have the word `reference` somewhere in its name (e.g., `domain1-reference-mobile`, `security-reference-baseline`, `aws-reference-landing-zone`).
71
+ - `platform`: An existing implementation, live service, or operational area that can be consumed directly without local instantiation. Defines something that exists and can be used, not merely a blueprint. A scope with this type MUST have the word `platform` somewhere in its name (e.g., `domain2-platform-aws`, `domain2-platform-callcenter`, `cloud-platform`).
72
+ - `domain`: The default type. Any business area, product domain, team, or general-purpose scope that does not fit one of the specific types above. No naming requirement.
73
+ - `_local`: Workspace-local decisions only. Never shared or distributed outside the local workspace. Reserved exclusively for the `_local` scope.
74
+ - Scope name suffixes are unlimited and may be used in any scope type. For `reference` and `platform` types only the presence of the keyword in the name is enforced, not its position (e.g., `domain2-reference-people-framework` and `domain2-platform-callcenter` are both valid).
75
+ - When writing or generating a new XDRS root `index.md`, use the following default ordering (scopes listed later override earlier ones): `core → reference → platform → domain → _local`.
76
+ - **Scope index frontmatter fields:** Every scope's `index.md` MUST include the following YAML frontmatter. Fields match the Policy frontmatter standard (`_core-adr-policy-002`) in purpose, adjusted for scope-level semantics:
77
+ - `name` (required): The scope identifier, must exactly match the scope directory name (e.g., `myteam`, `cloud-platform-aws`). Used by tools to verify scope identity.
78
+ - `description` (required): Short overview of what this scope covers and who the intended audience is. Used by AI agents for discovery and relevance matching. Max 40 words.
79
+ - `scope-type` (required): Scope classification type, see scope types above.
80
+ - `apply-to` (required): Declares in which contexts — teams, systems, codebases, or environments — the decisions in this scope are relevant. Max 30 words.
81
+ - `valid-from` (required): ISO date (YYYY-MM-DD) from which this scope became active.
82
+ - `metadata` (optional): Arbitrary key-value map for additional scope metadata.
83
+ - `follows` (optional): List of core scope names whose Policies apply as mandatory conventions to all documents in this scope, in addition to `_core` which always applies. Last-listed scope takes precedence when the same topic is covered by multiple scopes (e.g., `follows: [myarea-core, shared-standards]`).
68
84
  - **Subjects:** MUST be one of the following depending on the type of the Policy. Use the subject to indicate the main concern of the decision.
69
85
  - **ADR subjects**
70
86
  - `principles`: Cross-cutting architecture and policy foundations.
@@ -30,7 +30,8 @@ Performs a structured review of code changes or files against the Policies in th
30
30
  - Check `valid-from:` first. If a date is present and has not yet been reached, the decision SHOULD be adopted for new implementations but is not enforced during reviews.
31
31
  - Check `apply-to:` second. Keep only Policies whose stated scope fits the files, systems, or workflows under review.
32
32
  - Check the decision text itself last for additional boundaries or exceptions that metadata does not encode.
33
- 2. Filter relevance based on file types, domains, and architectural patterns in scope.
33
+ 2. **Meta-policy scopes:** For each scope containing files under review, read its `index.md` frontmatter and check for a `follows` field. `_core` Policies always apply to all scopes. If a scope declares `follows:` with additional core scope names (e.g., `follows: [myarea-core, shared-standards]`), verify that each listed scope directory exists in the workspace (e.g., `.xdrs/[scope-name]/index.md`). If any listed scope is missing, STOP immediately — do not proceed with the review — and tell the user: "Scope `[scope-name]` is listed in `follows` but not found in the workspace. Install it before proceeding." Once all `follows` scopes are confirmed present, load their Policies and apply them as mandatory governance. Last-listed scope in `follows` takes precedence when the same topic is addressed by multiple scopes.
34
+ 3. Filter relevance based on file types, domains, and architectural patterns in scope.
34
35
 
35
36
  ### Phase 3: Policy Review
36
37
 
@@ -158,6 +158,7 @@ Check every item before finalizing:
158
158
  5. **Redundancy**: Is the Policy the primary source for the decision itself, with related documents linked instead of duplicated wherever possible?
159
159
  6. **Conflicts section**: Is it present and filled if Phase 3 found any conflicts?
160
160
  7. **Index entries**: Will the new Policy be added to `[scope]/[type]/index.md` and the Policy root `index.md`?
161
+ 8. **Meta-policy compliance**: Check the target scope's `index.md` for a `follows` frontmatter field. `_core` Policies always apply to all scopes. If `follows` lists additional core scope names (e.g., `follows: [myarea-core]`), verify that each listed scope directory exists in the workspace (e.g., `.xdrs/[scope-name]/index.md`). If any listed scope is missing, STOP immediately and tell the user: "Scope `[scope-name]` is listed in `follows` but not found in the workspace. Install it before proceeding." Once all `follows` scopes are confirmed present, verify the document satisfies all requirements from those Policies. Scopes are applied in order; last-listed scope in `follows` takes precedence when the same topic is covered by multiple scopes.
161
162
 
162
163
  If any check fails, revise and re-run this phase before proceeding.
163
164
 
@@ -112,6 +112,7 @@ Before writing files, verify:
112
112
  3. **Length**: Under 6500 words? Trim verbose explanations.
113
113
  4. **Duplication**: Does this overlap an existing skill? If yes, revise.
114
114
  5. **References**: Are all related XDRs and skills linked, including the cases where the skill operationalizes multiple XDRs?
115
+ 6. **Meta-policy compliance**: Check the target scope's `index.md` for a `follows` frontmatter field. `_core` Policies always apply to all scopes. If `follows` lists additional core scope names, verify that each listed scope directory exists in the workspace (e.g., `.xdrs/[scope-name]/index.md`). If any listed scope is missing, STOP immediately and tell the user: "Scope `[scope-name]` is listed in `follows` but not found in the workspace. Install it before proceeding." Once all `follows` scopes are confirmed present, verify the skill's activation criteria, phase structure, and content satisfy any requirements from those Policies. Last-listed scope in `follows` takes precedence.
115
116
 
116
117
  If any check fails, revise before continuing.
117
118
 
@@ -109,6 +109,7 @@ Rules to apply while drafting:
109
109
  3. Add back-references in the Policies, Research documents, and Skills that the article synthesizes, under their `## References`
110
110
  section.
111
111
  4. Evaluate whether the scope index at `.xdrs/[scope]/index.md` should be updated to reflect the new article. If the scope index does not exist, create it following article standards and the scope index rules in `_core-adr-policy-001`.
112
+ 5. **Meta-policy compliance**: Check the target scope's `index.md` for a `follows` frontmatter field. `_core` Policies always apply to all scopes. If `follows` lists additional core scope names, verify that each listed scope directory exists in the workspace (e.g., `.xdrs/[scope-name]/index.md`). If any listed scope is missing, STOP immediately and tell the user: "Scope `[scope-name]` is listed in `follows` but not found in the workspace. Install it before proceeding." Once all `follows` scopes are confirmed present, verify the article satisfies any content or structural requirements from those Policies. Last-listed scope in `follows` takes precedence.
112
113
 
113
114
  ### Phase 7: Verify with Lint
114
115
 
@@ -237,6 +237,7 @@ Before writing files, verify:
237
237
  5. **Standalone focus**: Does the text read as a technical paper rather than commentary about future ADRs, repository process, or artifact management?
238
238
  6. **Ratio fit**: Does the document stay within section word limits and pass the Python ratio check, or does the introduction explicitly justify the deviation?
239
239
  7. **References**: Are all related Policies, research docs, skills, articles, and external sources linked when relevant?
240
+ 8. **Meta-policy compliance**: Check the target scope's `index.md` for a `follows` frontmatter field. `_core` Policies always apply to all scopes. If `follows` lists additional core scope names, verify that each listed scope directory exists in the workspace (e.g., `.xdrs/[scope-name]/index.md`). If any listed scope is missing, STOP immediately and tell the user: "Scope `[scope-name]` is listed in `follows` but not found in the workspace. Install it before proceeding." Once all `follows` scopes are confirmed present, verify the research satisfies any structural or content requirements from those Policies. Last-listed scope in `follows` takes precedence.
240
241
 
241
242
  If any check fails, revise before continuing.
242
243
 
@@ -125,6 +125,7 @@ Rules to apply while drafting:
125
125
  2. Add a link to the plan in the canonical index for that scope+type (`.xdrs/[scope]/[type]/index.md`).
126
126
  3. Add back-references in the Policies, Research documents, and Skills that the plan relates to, under their `## References` section.
127
127
  4. Evaluate whether the scope index at `.xdrs/[scope]/index.md` should be updated to reflect the new plan. If the scope index does not exist, create it following article standards and the scope index rules in `_core-adr-policy-001`.
128
+ 5. **Meta-policy compliance**: Check the target scope's `index.md` for a `follows` frontmatter field. `_core` Policies always apply to all scopes. If `follows` lists additional core scope names, verify that each listed scope directory exists in the workspace (e.g., `.xdrs/[scope-name]/index.md`). If any listed scope is missing, STOP immediately and tell the user: "Scope `[scope-name]` is listed in `follows` but not found in the workspace. Install it before proceeding." Once all `follows` scopes are confirmed present, verify the plan satisfies any structural or content requirements from those Policies. Last-listed scope in `follows` takes precedence.
128
129
 
129
130
  ### Phase 7: Verify with Lint
130
131
 
@@ -1,3 +1,11 @@
1
+ ---
2
+ scope-type: core
3
+ name: _core
4
+ description: Defines the XDRS framework structure, types, document standards, and conventions. Use when working with any XDRS scope or building AI-agent-compatible documentation.
5
+ apply-to: All XDRS scopes and document types
6
+ valid-from: 2025-01-01
7
+ ---
8
+
1
9
  # _core Scope Overview
2
10
 
3
11
  ## Overview
@@ -39,6 +47,10 @@ Slide presentations that support XDRS documents follow [_core-adr-policy-009](ad
39
47
 
40
48
  [_core-adr-policy-010](adrs/principles/010-core-scope-naming.md) defines the `-core` suffix naming convention for XDRS scopes. A scope named `[domain]-core` (e.g., `security-core`) holds meta governance content — writing standards, templates, ownership, and process guidance — for all scopes sharing the same prefix. It must not contain consumable policies, and all contributors to same-prefix scopes must follow its standards.
41
49
 
50
+ ### Scope types
51
+
52
+ Every XDRS scope declares a `scope-type` in its `index.md` YAML frontmatter. [_core-adr-policy-001](adrs/principles/001-xdrs-core.md) defines five allowed types: `core` (reserved for this scope), `reference` (blueprints and standards to be copied or adapted), `platform` (live services or areas usable directly), `domain` (the default for business areas, products, or teams), and `_local` (reserved for workspace-local decisions). The recommended root index ordering is `core → reference → platform → domain → _local`.
53
+
42
54
  ### Available skills
43
55
 
44
56
  The `_core` scope ships with seven skills that automate the most common framework operations:
package/.xdrs/index.md CHANGED
@@ -6,6 +6,8 @@ This index points to all type- and scope-specific XDRS indexes. It covers Archit
6
6
 
7
7
  XDRS scopes listed last override the ones listed first
8
8
 
9
+ Default ordering by scope type: `core → reference → platform → domain → _local`
10
+
9
11
  ### _core
10
12
 
11
13
  Defines how XDRS framework work
package/lib/lint.js CHANGED
@@ -18,6 +18,7 @@ const ALLOWED_SUBJECTS = {
18
18
 
19
19
  const TYPE_NAMES = new Set(Object.keys(TYPE_TO_ID));
20
20
  const RESERVED_SCOPES = new Set(['_core', '_local']);
21
+ const SCOPE_TYPE_NAMES = new Set(['core', 'reference', 'platform', 'domain', '_local']);
21
22
  const NUMBERED_FILE_RE = /^(\d{3,})-([a-z0-9-]+)\.md$/;
22
23
  const NUMBERED_DIR_RE = /^(\d{3,})-([a-z0-9-]+)$/;
23
24
  const REQUIRED_ROOT_INDEX_TEXT = 'XDRS scopes listed last override the ones listed first';
@@ -27,6 +28,7 @@ const SKILL_PACKAGE_OPTIONAL_DIRS = new Set(['scripts', 'references', RESOURCE_D
27
28
 
28
29
  const POLICY_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'apply-to', 'valid-from', 'license', 'metadata']);
29
30
  const SKILL_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'license', 'metadata', 'compatibility', 'allowed-tools']);
31
+ const SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS = new Set(['scope-type', 'name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'follows']);
30
32
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
31
33
  const SLIDE_FILE_RE = /^.+-slides(?:-[a-z0-9-]+)?\.md$/;
32
34
  const SLIDE_MAX_NAME_LENGTH = 64;
@@ -181,6 +183,130 @@ function lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope,
181
183
  }
182
184
  }
183
185
 
186
+ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot) {
187
+ const content = fs.readFileSync(scopeIndexPath, 'utf8');
188
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
189
+ if (!fmMatch) {
190
+ errors.push(`Scope index must start with a YAML frontmatter block: ${toDisplayPath(scopeIndexPath)}`);
191
+ return;
192
+ }
193
+ const block = fmMatch[1];
194
+ const fm = extractFrontmatter(content);
195
+
196
+ for (const key of fm.topLevelKeys) {
197
+ if (!SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS.has(key)) {
198
+ errors.push(`Scope index frontmatter has unknown field "${key}": ${toDisplayPath(scopeIndexPath)}`);
199
+ }
200
+ }
201
+
202
+ const scopeTypeMatch = block.match(/^scope-type:\s*(.+)$/m);
203
+ const scopeType = scopeTypeMatch ? scopeTypeMatch[1].trim() : null;
204
+ if (!scopeType) {
205
+ errors.push(`Scope index frontmatter must include a scope-type field: ${toDisplayPath(scopeIndexPath)}`);
206
+ return;
207
+ }
208
+ if (!SCOPE_TYPE_NAMES.has(scopeType)) {
209
+ errors.push(`Scope index scope-type must be one of [${[...SCOPE_TYPE_NAMES].join(', ')}]: ${toDisplayPath(scopeIndexPath)}`);
210
+ return;
211
+ }
212
+ if (scopeName === '_core' && scopeType !== 'core') {
213
+ errors.push(`Scope "_core" must have scope-type "core": ${toDisplayPath(scopeIndexPath)}`);
214
+ }
215
+ if (scopeName === '_local' && scopeType !== '_local') {
216
+ errors.push(`Scope "_local" must have scope-type "_local": ${toDisplayPath(scopeIndexPath)}`);
217
+ }
218
+ if (scopeType === 'core' && !scopeName.includes('core')) {
219
+ errors.push(`Scope with type "core" must have "core" in its name: ${toDisplayPath(scopeIndexPath)}`);
220
+ }
221
+ if (scopeType === '_local' && scopeName !== '_local') {
222
+ errors.push(`Scope type "_local" is reserved for the "_local" scope: ${toDisplayPath(scopeIndexPath)}`);
223
+ }
224
+ if (scopeType === 'reference' && !scopeName.includes('reference')) {
225
+ errors.push(`Scope with type "reference" must have "reference" in its name: ${toDisplayPath(scopeIndexPath)}`);
226
+ }
227
+ if (scopeType === 'platform' && !scopeName.includes('platform')) {
228
+ errors.push(`Scope with type "platform" must have "platform" in its name: ${toDisplayPath(scopeIndexPath)}`);
229
+ }
230
+
231
+ if (!fm.name) {
232
+ errors.push(`Scope index frontmatter must include a non-empty name field: ${toDisplayPath(scopeIndexPath)}`);
233
+ } else if (fm.name !== scopeName) {
234
+ errors.push(`Scope index frontmatter name must match scope directory name "${scopeName}": ${toDisplayPath(scopeIndexPath)}`);
235
+ }
236
+
237
+ if (!fm.description) {
238
+ errors.push(`Scope index frontmatter must include a non-empty description field: ${toDisplayPath(scopeIndexPath)}`);
239
+ } else if (fm.descriptionText && fm.descriptionText.length > 1024) {
240
+ errors.push(`Scope index frontmatter description must be 1024 characters or fewer: ${toDisplayPath(scopeIndexPath)}`);
241
+ }
242
+
243
+ if (!fm.appliedTo) {
244
+ errors.push(`Scope index frontmatter must include an apply-to field: ${toDisplayPath(scopeIndexPath)}`);
245
+ } else {
246
+ const words = countWords(fm.appliedTo);
247
+ if (words === 0) {
248
+ errors.push(`Scope index frontmatter apply-to must not be empty: ${toDisplayPath(scopeIndexPath)}`);
249
+ } else if (words >= 40) {
250
+ errors.push(`Scope index frontmatter apply-to must be under 40 words: ${toDisplayPath(scopeIndexPath)}`);
251
+ }
252
+ }
253
+
254
+ if (!fm.validFrom) {
255
+ errors.push(`Scope index frontmatter must include a valid-from field: ${toDisplayPath(scopeIndexPath)}`);
256
+ } else if (!isIsoDate(fm.validFrom)) {
257
+ errors.push(`Scope index frontmatter valid-from must be a valid ISO date YYYY-MM-DD: ${toDisplayPath(scopeIndexPath)}`);
258
+ }
259
+
260
+ const followsLineMatch = block.match(/^follows:[ \t]*(.*)/m);
261
+ if (followsLineMatch !== null) {
262
+ const followsValue = followsLineMatch[1].trim();
263
+ const scopeNamePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
264
+ if (followsValue) {
265
+ if (!scopeNamePattern.test(followsValue)) {
266
+ errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)}`);
267
+ }
268
+ } else {
269
+ const followsStart = block.indexOf(followsLineMatch[0]) + followsLineMatch[0].length;
270
+ const listItems = [];
271
+ let hasInvalidEntry = false;
272
+ for (const line of block.slice(followsStart).split('\n')) {
273
+ const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
274
+ if (itemMatch) {
275
+ const item = (itemMatch[1] || '').trim();
276
+ if (!item || !scopeNamePattern.test(item)) {
277
+ errors.push(`Scope index frontmatter follows entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)}`);
278
+ hasInvalidEntry = true;
279
+ break;
280
+ }
281
+ listItems.push(item);
282
+ } else if (line.trim() && !/^\s/.test(line)) {
283
+ break;
284
+ }
285
+ }
286
+ if (!hasInvalidEntry && listItems.length === 0) {
287
+ errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)}`);
288
+ }
289
+ }
290
+ if (xdrsRoot) {
291
+ const entries = followsValue ? [followsValue] : (() => {
292
+ const items = [];
293
+ const followsStart = block.indexOf(followsLineMatch[0]) + followsLineMatch[0].length;
294
+ for (const line of block.slice(followsStart).split('\n')) {
295
+ const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
296
+ if (itemMatch && itemMatch[1]) items.push(itemMatch[1].trim());
297
+ else if (line.trim() && !/^\s/.test(line)) break;
298
+ }
299
+ return items;
300
+ })();
301
+ for (const entry of entries) {
302
+ if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(entry) && !existsFile(path.join(xdrsRoot, entry, 'index.md'))) {
303
+ errors.push(`Scope index frontmatter follows references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)}`);
304
+ }
305
+ }
306
+ }
307
+ }
308
+ }
309
+
184
310
  function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, ignoreExternal, externalScopes) {
185
311
  const scopePath = path.join(xdrsRoot, scopeName);
186
312
 
@@ -221,6 +347,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, igno
221
347
  errors.push(`Missing required scope index: ${toDisplayPath(scopeIndexPath)}`);
222
348
  } else {
223
349
  lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes);
350
+ lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot);
224
351
  }
225
352
  }
226
353
 
package/lib/lint.test.js CHANGED
@@ -391,7 +391,7 @@ test('reports non-_local XDRS element linking to _local scope document', () => {
391
391
  '- [001-main](principles/001-main.md) - Main decision'
392
392
  ]),
393
393
  '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Local decision.'),
394
- '.xdrs/myteam/index.md': '# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
394
+ '.xdrs/myteam/index.md': teamScopeIndex(),
395
395
  '.xdrs/myteam/adrs/index.md': teamAdrIndex([
396
396
  '- [001-team](principles/001-team.md) - Team decision'
397
397
  ]),
@@ -453,7 +453,7 @@ test('reports non-_local canonical index linking to _local scope document', () =
453
453
  '- [001-main](principles/001-main.md) - Main decision'
454
454
  ]),
455
455
  '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Local decision.'),
456
- '.xdrs/myteam/index.md': '# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
456
+ '.xdrs/myteam/index.md': teamScopeIndex(),
457
457
  '.xdrs/myteam/adrs/index.md': teamAdrIndex([
458
458
  '- [_local 001](../../_local/adrs/principles/001-main.md) - Cross-scope link'
459
459
  ]),
@@ -556,7 +556,7 @@ test('reports non-_local XDRS element linking to _local scope via absolute path'
556
556
  '- [001-main](principles/001-main.md) - Main decision'
557
557
  ]),
558
558
  '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Local decision.'),
559
- '.xdrs/myteam/index.md': '# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
559
+ '.xdrs/myteam/index.md': teamScopeIndex(),
560
560
  '.xdrs/myteam/adrs/index.md': teamAdrIndex([
561
561
  '- [001-team](principles/001-team.md) - Team decision'
562
562
  ]),
@@ -573,7 +573,7 @@ test('reports non-_local XDRS element linking to _local scope via absolute path'
573
573
  test('allows index.md at scope level', () => {
574
574
  const workspaceRoot = createWorkspace('scope-index-allowed', {
575
575
  '.xdrs/index.md': rootIndex(),
576
- '.xdrs/_local/index.md': '# _local Scope Overview\n\nOverview of local scope.\n\n[ADRs](adrs/index.md)\n',
576
+ '.xdrs/_local/index.md': localScopeIndex(),
577
577
  '.xdrs/_local/adrs/index.md': localAdrIndex([
578
578
  '- [001-main](principles/001-main.md) - Main decision'
579
579
  ]),
@@ -588,7 +588,7 @@ test('allows index.md at scope level', () => {
588
588
  test('reports scope index missing link to type index', () => {
589
589
  const workspaceRoot = createWorkspace('scope-index-missing-type-link', {
590
590
  '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
591
- '.xdrs/myteam/index.md': '# myteam Scope Overview\n\nNo type links here.\n',
591
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team scope for tests.\napply-to: Test team only\nvalid-from: 2026-01-01\n---\n\n# myteam Scope Overview\n\nNo type links here.\n',
592
592
  '.xdrs/myteam/adrs/index.md': teamAdrIndex([
593
593
  '- [001-team](principles/001-team.md) - Team decision'
594
594
  ]),
@@ -605,7 +605,7 @@ test('reports scope index missing link to type index', () => {
605
605
  test('passes when scope index links to all type indexes', () => {
606
606
  const workspaceRoot = createWorkspace('scope-index-links-all-types', {
607
607
  '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
608
- '.xdrs/myteam/index.md': '# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
608
+ '.xdrs/myteam/index.md': teamScopeIndex(),
609
609
  '.xdrs/myteam/adrs/index.md': teamAdrIndex([
610
610
  '- [001-team](principles/001-team.md) - Team decision'
611
611
  ]),
@@ -620,7 +620,7 @@ test('passes when scope index links to all type indexes', () => {
620
620
  test('reports broken link in scope index', () => {
621
621
  const workspaceRoot = createWorkspace('scope-index-broken-link', {
622
622
  '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
623
- '.xdrs/myteam/index.md': '# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n[Missing](missing/index.md)\n',
623
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team scope for tests.\napply-to: Test team only\nvalid-from: 2026-01-01\n---\n\n# myteam Scope Overview\n\nTeam scope for tests.\n\n[ADRs](adrs/index.md)\n[Missing](missing/index.md)\n',
624
624
  '.xdrs/myteam/adrs/index.md': teamAdrIndex([
625
625
  '- [001-team](principles/001-team.md) - Team decision'
626
626
  ]),
@@ -648,6 +648,405 @@ test('reports missing scope index', () => {
648
648
  expect(result.errors.join('\n')).toContain('_local/index.md');
649
649
  });
650
650
 
651
+ // ─── Scope type checks ────────────────────────────────────────────────────────
652
+
653
+ test('reports missing scope-type frontmatter in scope index', () => {
654
+ const workspaceRoot = createWorkspace('scope-type-missing-frontmatter', {
655
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
656
+ '.xdrs/myteam/index.md': '# myteam Scope Overview\n\nNo frontmatter here.\n\n[ADRs](adrs/index.md)\n',
657
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex([
658
+ '- [001-team](principles/001-team.md) - Team decision'
659
+ ]),
660
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
661
+ });
662
+
663
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
664
+
665
+ expect(result.errors.join('\n')).toContain('Scope index must start with a YAML frontmatter block');
666
+ expect(result.errors.join('\n')).toContain('myteam/index.md');
667
+ });
668
+
669
+ test('reports missing scope-type field in scope index frontmatter', () => {
670
+ const workspaceRoot = createWorkspace('scope-type-missing-field', {
671
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
672
+ '.xdrs/myteam/index.md': '---\nsome-field: value\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
673
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex([
674
+ '- [001-team](principles/001-team.md) - Team decision'
675
+ ]),
676
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
677
+ });
678
+
679
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
680
+
681
+ expect(result.errors.join('\n')).toContain('Scope index frontmatter must include a scope-type field');
682
+ });
683
+
684
+ test('reports invalid scope-type value in scope index frontmatter', () => {
685
+ const workspaceRoot = createWorkspace('scope-type-invalid-value', {
686
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
687
+ '.xdrs/myteam/index.md': '---\nscope-type: invalid-type\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
688
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex([
689
+ '- [001-team](principles/001-team.md) - Team decision'
690
+ ]),
691
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
692
+ });
693
+
694
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
695
+
696
+ expect(result.errors.join('\n')).toContain('scope-type must be one of');
697
+ expect(result.errors.join('\n')).toContain('myteam/index.md');
698
+ });
699
+
700
+ test('accepts all valid scope-type values', () => {
701
+ const workspaceRoot = createWorkspace('valid-domain-type-check', {
702
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
703
+ '.xdrs/myteam/index.md': teamScopeIndex(),
704
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex([
705
+ '- [001-team](principles/001-team.md) - Team decision'
706
+ ]),
707
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
708
+ });
709
+
710
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
711
+
712
+ expect(result.errors.join('\n')).not.toContain('Scope index scope-type must be one of');
713
+ expect(result.errors.join('\n')).not.toContain('Scope index frontmatter must include');
714
+ expect(result.errors.join('\n')).not.toContain('Scope index must start with');
715
+ });
716
+
717
+ test('accepts scope-type reference when scope name contains reference', () => {
718
+ const workspaceRoot = createWorkspace('valid-reference-type', {
719
+ '.xdrs/index.md': rootIndex(['[security-reference-baseline](security-reference-baseline/index.md)']),
720
+ '.xdrs/security-reference-baseline/index.md': '---\nscope-type: reference\nname: security-reference-baseline\ndescription: Security reference architecture baseline for all teams.\napply-to: All teams following security reference patterns\nvalid-from: 2026-01-01\n---\n\n# security-reference-baseline Scope Overview\n\nSecurity reference.\n\n[ADRs](adrs/index.md)\n',
721
+ '.xdrs/security-reference-baseline/adrs/index.md': [
722
+ '# security-reference-baseline ADR Index', '', 'Security reference ADRs.', '', '## principles', '',
723
+ '- [001-baseline](principles/001-baseline.md) - Security baseline', ''
724
+ ].join('\n'),
725
+ '.xdrs/security-reference-baseline/adrs/principles/001-baseline.md': [
726
+ '---', 'name: security-reference-baseline-adr-policy-001-baseline',
727
+ 'description: Security baseline policy', 'apply-to: All scopes', 'valid-from: 2026-01-01', '---', '',
728
+ '# security-reference-baseline-adr-policy-001: Security baseline', '',
729
+ '## Context and Problem Statement', '', 'Baseline context.', '',
730
+ '## Decision Outcome', '', 'Baseline outcome.', ''
731
+ ].join('\n'),
732
+ });
733
+
734
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
735
+
736
+ expect(result.errors.join('\n')).not.toContain('Scope index scope-type must be one of');
737
+ expect(result.errors.join('\n')).not.toContain('Scope with type "reference"');
738
+ expect(result.errors.join('\n')).not.toContain('Scope index frontmatter must include');
739
+ });
740
+
741
+ test('reports scope-type reference when scope name does not contain reference', () => {
742
+ const workspaceRoot = createWorkspace('scope-type-reference-invalid-name', {
743
+ '.xdrs/index.md': rootIndex(['[security-baseline](security-baseline/index.md)']),
744
+ '.xdrs/security-baseline/index.md': '---\nscope-type: reference\nname: security-baseline\ndescription: Security baseline policy decisions.\napply-to: All teams following security patterns\nvalid-from: 2026-01-01\n---\n\n# security-baseline Scope Overview\n\nSecurity baseline.\n\n[ADRs](adrs/index.md)\n',
745
+ '.xdrs/security-baseline/adrs/index.md': teamAdrIndex([
746
+ '- [001-team](principles/001-team.md) - Team decision'
747
+ ]),
748
+ '.xdrs/security-baseline/adrs/principles/001-team.md': [
749
+ '---', 'name: security-baseline-adr-policy-001-team',
750
+ 'description: Security baseline policy', 'apply-to: All scopes', 'valid-from: 2026-01-01', '---', '',
751
+ '# security-baseline-adr-policy-001: Team decision', '',
752
+ '## Context and Problem Statement', '', 'Body.', '',
753
+ '## Decision Outcome', '', 'Outcome.', ''
754
+ ].join('\n'),
755
+ });
756
+
757
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
758
+
759
+ expect(result.errors.join('\n')).toContain('Scope with type "reference" must have "reference" in its name');
760
+ });
761
+
762
+ test('accepts scope-type platform when scope name contains platform', () => {
763
+ const workspaceRoot = createWorkspace('valid-platform-type', {
764
+ '.xdrs/index.md': rootIndex(['[cloud-platform](cloud-platform/index.md)']),
765
+ '.xdrs/cloud-platform/index.md': '---\nscope-type: platform\nname: cloud-platform\ndescription: Cloud platform infrastructure decisions for all teams.\napply-to: All teams using the cloud platform\nvalid-from: 2026-01-01\n---\n\n# cloud-platform Scope Overview\n\nCloud platform.\n\n[ADRs](adrs/index.md)\n',
766
+ '.xdrs/cloud-platform/adrs/index.md': [
767
+ '# cloud-platform ADR Index', '', 'Cloud platform ADRs.', '', '## principles', '',
768
+ '- [001-baseline](principles/001-baseline.md) - Cloud baseline', ''
769
+ ].join('\n'),
770
+ '.xdrs/cloud-platform/adrs/principles/001-baseline.md': [
771
+ '---', 'name: cloud-platform-adr-policy-001-baseline',
772
+ 'description: Cloud platform baseline', 'apply-to: All scopes', 'valid-from: 2026-01-01', '---', '',
773
+ '# cloud-platform-adr-policy-001: Cloud baseline', '',
774
+ '## Context and Problem Statement', '', 'Platform context.', '',
775
+ '## Decision Outcome', '', 'Platform outcome.', ''
776
+ ].join('\n'),
777
+ });
778
+
779
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
780
+
781
+ expect(result.errors.join('\n')).not.toContain('Scope index scope-type must be one of');
782
+ expect(result.errors.join('\n')).not.toContain('Scope with type "platform"');
783
+ expect(result.errors.join('\n')).not.toContain('Scope index frontmatter must include');
784
+ });
785
+
786
+ test('reports scope-type platform when scope name does not contain platform', () => {
787
+ const workspaceRoot = createWorkspace('scope-type-platform-invalid-name', {
788
+ '.xdrs/index.md': rootIndex(['[cloud-infra](cloud-infra/index.md)']),
789
+ '.xdrs/cloud-infra/index.md': '---\nscope-type: platform\nname: cloud-infra\ndescription: Cloud infrastructure decisions.\napply-to: All teams using cloud infrastructure\nvalid-from: 2026-01-01\n---\n\n# cloud-infra Scope Overview\n\nCloud infrastructure.\n\n[ADRs](adrs/index.md)\n',
790
+ '.xdrs/cloud-infra/adrs/index.md': [
791
+ '# cloud-infra ADR Index', '', 'Cloud infra ADRs.', '', '## principles', '',
792
+ '- [001-infra](principles/001-infra.md) - Infra', ''
793
+ ].join('\n'),
794
+ '.xdrs/cloud-infra/adrs/principles/001-infra.md': [
795
+ '---', 'name: cloud-infra-adr-policy-001-infra',
796
+ 'description: Cloud infra policy', 'apply-to: All scopes', 'valid-from: 2026-01-01', '---', '',
797
+ '# cloud-infra-adr-policy-001: Infra', '',
798
+ '## Context and Problem Statement', '', 'Body.', '',
799
+ '## Decision Outcome', '', 'Outcome.', ''
800
+ ].join('\n'),
801
+ });
802
+
803
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
804
+
805
+ expect(result.errors.join('\n')).toContain('Scope with type "platform" must have "platform" in its name');
806
+ });
807
+
808
+ test('reports scope-type core when scope name does not contain core', () => {
809
+ const workspaceRoot = createWorkspace('scope-type-core-invalid-name', {
810
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
811
+ '.xdrs/myteam/index.md': '---\nscope-type: core\nname: myteam\ndescription: Team scope for tests.\napply-to: Test team only\nvalid-from: 2026-01-01\n---\n\n# myteam Scope Overview\n\nTeam scope for tests.\n\n[ADRs](adrs/index.md)\n',
812
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex([
813
+ '- [001-team](principles/001-team.md) - Team decision'
814
+ ]),
815
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
816
+ });
817
+
818
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
819
+
820
+ expect(result.errors.join('\n')).toContain('Scope with type "core" must have "core" in its name');
821
+ });
822
+
823
+ test('accepts scope-type core when scope name contains core', () => {
824
+ const workspaceRoot = createWorkspace('valid-core-type', {
825
+ '.xdrs/index.md': rootIndex(['[myarea-core](myarea-core/index.md)']),
826
+ '.xdrs/myarea-core/index.md': '---\nscope-type: core\nname: myarea-core\ndescription: Meta-governance scope for the myarea group of scopes.\napply-to: All scopes in the myarea group\nvalid-from: 2026-01-01\n---\n\n# myarea-core Scope Overview\n\nMeta-governance.\n\n[ADRs](adrs/index.md)\n',
827
+ '.xdrs/myarea-core/adrs/index.md': teamAdrIndex([
828
+ '- [001-team](principles/001-team.md) - Team decision'
829
+ ]),
830
+ '.xdrs/myarea-core/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
831
+ });
832
+
833
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
834
+
835
+ expect(result.errors.join('\n')).not.toContain('Scope with type "core"');
836
+ expect(result.errors.join('\n')).not.toContain('Scope index frontmatter');
837
+ });
838
+
839
+ test('reports scope-type _local used on non-_local scope', () => {
840
+ const workspaceRoot = createWorkspace('scope-type-local-reserved', {
841
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
842
+ '.xdrs/myteam/index.md': '---\nscope-type: _local\nname: myteam\ndescription: Team scope for tests.\napply-to: Test team only\nvalid-from: 2026-01-01\n---\n\n# myteam Scope Overview\n\nTeam scope for tests.\n\n[ADRs](adrs/index.md)\n',
843
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex([
844
+ '- [001-team](principles/001-team.md) - Team decision'
845
+ ]),
846
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
847
+ });
848
+
849
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
850
+
851
+ expect(result.errors.join('\n')).toContain('Scope type "_local" is reserved for the "_local" scope');
852
+ });
853
+
854
+ test('reports _local scope with wrong scope-type', () => {
855
+ const workspaceRoot = createWorkspace('scope-type-local-wrong-type', {
856
+ '.xdrs/index.md': rootIndex(),
857
+ '.xdrs/_local/index.md': '---\nscope-type: domain\nname: _local\ndescription: Local scope for tests.\napply-to: Test workspace only\nvalid-from: 2026-01-01\n---\n\n# _local Scope Overview\n\nLocal scope for tests.\n\n[ADRs](adrs/index.md)\n',
858
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
859
+ '- [001-main](principles/001-main.md) - Main decision'
860
+ ]),
861
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Test body.'),
862
+ });
863
+
864
+ const result = lintWorkspace(workspaceRoot);
865
+
866
+ expect(result.errors.join('\n')).toContain('Scope "_local" must have scope-type "_local"');
867
+ });
868
+
869
+ test('reports missing name field in scope index frontmatter', () => {
870
+ const workspaceRoot = createWorkspace('scope-index-missing-name', {
871
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
872
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
873
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
874
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
875
+ });
876
+
877
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
878
+
879
+ expect(result.errors.join('\n')).toContain('Scope index frontmatter must include a non-empty name field');
880
+ });
881
+
882
+ test('reports name mismatch in scope index frontmatter', () => {
883
+ const workspaceRoot = createWorkspace('scope-index-name-mismatch', {
884
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
885
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: wrong-name\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
886
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
887
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
888
+ });
889
+
890
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
891
+
892
+ expect(result.errors.join('\n')).toContain('Scope index frontmatter name must match scope directory name "myteam"');
893
+ });
894
+
895
+ test('reports missing description field in scope index frontmatter', () => {
896
+ const workspaceRoot = createWorkspace('scope-index-missing-description', {
897
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
898
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\napply-to: Test team\nvalid-from: 2026-01-01\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
899
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
900
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
901
+ });
902
+
903
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
904
+
905
+ expect(result.errors.join('\n')).toContain('Scope index frontmatter must include a non-empty description field');
906
+ });
907
+
908
+ test('reports missing apply-to field in scope index frontmatter', () => {
909
+ const workspaceRoot = createWorkspace('scope-index-missing-apply-to', {
910
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
911
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\nvalid-from: 2026-01-01\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
912
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
913
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
914
+ });
915
+
916
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
917
+
918
+ expect(result.errors.join('\n')).toContain('Scope index frontmatter must include an apply-to field');
919
+ });
920
+
921
+ test('reports missing valid-from field in scope index frontmatter', () => {
922
+ const workspaceRoot = createWorkspace('scope-index-missing-valid-from', {
923
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
924
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
925
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
926
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
927
+ });
928
+
929
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
930
+
931
+ expect(result.errors.join('\n')).toContain('Scope index frontmatter must include a valid-from field');
932
+ });
933
+
934
+ test('reports invalid valid-from date in scope index frontmatter', () => {
935
+ const workspaceRoot = createWorkspace('scope-index-invalid-valid-from', {
936
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
937
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: not-a-date\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
938
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
939
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
940
+ });
941
+
942
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
943
+
944
+ expect(result.errors.join('\n')).toContain('Scope index frontmatter valid-from must be a valid ISO date YYYY-MM-DD');
945
+ });
946
+
947
+ test('reports unknown field in scope index frontmatter', () => {
948
+ const workspaceRoot = createWorkspace('scope-index-unknown-field', {
949
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
950
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nunknown-field: value\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
951
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
952
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
953
+ });
954
+
955
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
956
+
957
+ expect(result.errors.join('\n')).toContain('Scope index frontmatter has unknown field "unknown-field"');
958
+ });
959
+
960
+ test('accepts all valid scope index frontmatter fields', () => {
961
+ const workspaceRoot = createWorkspace('valid-all-scope-fields', {
962
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
963
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nlicense: MIT\nmetadata:\n owner: team\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
964
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
965
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
966
+ });
967
+
968
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
969
+
970
+ expect(result.errors.join('\n')).not.toContain('Scope index frontmatter');
971
+ });
972
+
973
+ test('accepts follows as a list of core scope names in scope index frontmatter', () => {
974
+ const workspaceRoot = createWorkspace('valid-scope-follows-list', {
975
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)', '[myarea-core](myarea-core/index.md)', '[shared-standards-core](shared-standards-core/index.md)']),
976
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nfollows:\n - myarea-core\n - shared-standards-core\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
977
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
978
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
979
+ '.xdrs/myarea-core/index.md': '---\nscope-type: core\nname: myarea-core\ndescription: Meta-governance for myarea.\napply-to: All myarea scopes\nvalid-from: 2026-01-01\n---\n\n# myarea-core Scope Overview\n\n[ADRs](adrs/index.md)\n',
980
+ '.xdrs/myarea-core/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
981
+ '.xdrs/myarea-core/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
982
+ '.xdrs/shared-standards-core/index.md': '---\nscope-type: core\nname: shared-standards-core\ndescription: Shared standards for all areas.\napply-to: All scopes\nvalid-from: 2026-01-01\n---\n\n# shared-standards-core Scope Overview\n\n[ADRs](adrs/index.md)\n',
983
+ '.xdrs/shared-standards-core/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
984
+ '.xdrs/shared-standards-core/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
985
+ });
986
+
987
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
988
+
989
+ expect(result.errors.join('\n')).not.toContain('Scope index frontmatter follows');
990
+ });
991
+
992
+ test('accepts follows as a single core scope name string in scope index frontmatter', () => {
993
+ const workspaceRoot = createWorkspace('valid-scope-follows-string', {
994
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)', '[myarea-core](myarea-core/index.md)']),
995
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nfollows: myarea-core\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
996
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
997
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
998
+ '.xdrs/myarea-core/index.md': '---\nscope-type: core\nname: myarea-core\ndescription: Meta-governance for myarea.\napply-to: All myarea scopes\nvalid-from: 2026-01-01\n---\n\n# myarea-core Scope Overview\n\n[ADRs](adrs/index.md)\n',
999
+ '.xdrs/myarea-core/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
1000
+ '.xdrs/myarea-core/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
1001
+ });
1002
+
1003
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
1004
+
1005
+ expect(result.errors.join('\n')).not.toContain('Scope index frontmatter follows');
1006
+ });
1007
+
1008
+ test('reports invalid follows value in scope index frontmatter', () => {
1009
+ const workspaceRoot = createWorkspace('invalid-scope-follows', {
1010
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
1011
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nfollows: 123\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
1012
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
1013
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
1014
+ });
1015
+
1016
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
1017
+
1018
+ expect(result.errors.join('\n')).toContain('Scope index frontmatter follows must be a core scope name or list of core scope names');
1019
+ });
1020
+
1021
+ test('reports follows scope that does not exist in workspace', () => {
1022
+ const workspaceRoot = createWorkspace('follows-missing-scope', {
1023
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
1024
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nfollows: nonexistent-core\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
1025
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
1026
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
1027
+ });
1028
+
1029
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
1030
+
1031
+ expect(result.errors.join('\n')).toContain('follows references scope "nonexistent-core" which does not exist in the workspace');
1032
+ });
1033
+
1034
+ test('accepts follows scope that exists in workspace', () => {
1035
+ const workspaceRoot = createWorkspace('follows-existing-scope', {
1036
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)', '[myarea-core](myarea-core/index.md)']),
1037
+ '.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nfollows: myarea-core\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
1038
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
1039
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
1040
+ '.xdrs/myarea-core/index.md': '---\nscope-type: core\nname: myarea-core\ndescription: Meta-governance for myarea.\napply-to: All myarea scopes\nvalid-from: 2026-01-01\n---\n\n# myarea-core Scope Overview\n\n[ADRs](adrs/index.md)\n',
1041
+ '.xdrs/myarea-core/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
1042
+ '.xdrs/myarea-core/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
1043
+ });
1044
+
1045
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
1046
+
1047
+ expect(result.errors.join('\n')).not.toContain('follows references scope');
1048
+ });
1049
+
651
1050
  test('reports orphan asset files not referenced by any document', () => {
652
1051
  const workspaceRoot = createWorkspace('orphan-asset', {
653
1052
  '.xdrs/index.md': rootIndex(),
@@ -707,7 +1106,7 @@ test('reports orphan asset in articles .assets directory', () => {
707
1106
  test('reports unexpected directories inside a skill package', () => {
708
1107
  const workspaceRoot = createWorkspace('unexpected-skill-package-directory', {
709
1108
  '.xdrs/index.md': rootIndex(),
710
- '.xdrs/_local/index.md': '# _local Scope Overview\n\nOverview.\n\n[ADRs](adrs/index.md)\n',
1109
+ '.xdrs/_local/index.md': localScopeIndex(),
711
1110
  '.xdrs/_local/adrs/index.md': localAdrIndex([
712
1111
  '- [001-check-links](principles/skills/001-check-links/SKILL.md) - Check local links'
713
1112
  ]),
@@ -724,7 +1123,7 @@ test('reports unexpected directories inside a skill package', () => {
724
1123
  test('allows nested directories in .assets', () => {
725
1124
  const workspaceRoot = createWorkspace('asset-subdir-allowed', {
726
1125
  '.xdrs/index.md': rootIndex(),
727
- '.xdrs/_local/index.md': '# _local Scope Overview\n\nOverview.\n\n[ADRs](adrs/index.md)\n',
1126
+ '.xdrs/_local/index.md': localScopeIndex(),
728
1127
  '.xdrs/_local/adrs/index.md': localAdrIndex([
729
1128
  '- [001-main](principles/001-main.md) - Main decision'
730
1129
  ]),
@@ -753,6 +1152,44 @@ test('allows nested directories in .assets', () => {
753
1152
  expect(result.errors.join('\n')).not.toContain('.assets directory must be flat');
754
1153
  });
755
1154
 
1155
+ function localScopeIndex() {
1156
+ return [
1157
+ '---',
1158
+ 'scope-type: _local',
1159
+ 'name: _local',
1160
+ 'description: Local scope for tests.',
1161
+ 'apply-to: Test workspace only',
1162
+ 'valid-from: 2026-01-01',
1163
+ '---',
1164
+ '',
1165
+ '# _local Scope Overview',
1166
+ '',
1167
+ 'Local scope for tests.',
1168
+ '',
1169
+ '[ADRs](adrs/index.md)',
1170
+ ''
1171
+ ].join('\n');
1172
+ }
1173
+
1174
+ function teamScopeIndex() {
1175
+ return [
1176
+ '---',
1177
+ 'scope-type: domain',
1178
+ 'name: myteam',
1179
+ 'description: Team scope for tests.',
1180
+ 'apply-to: Test team only',
1181
+ 'valid-from: 2026-01-01',
1182
+ '---',
1183
+ '',
1184
+ '# myteam Scope Overview',
1185
+ '',
1186
+ 'Team scope for tests.',
1187
+ '',
1188
+ '[ADRs](adrs/index.md)',
1189
+ ''
1190
+ ].join('\n');
1191
+ }
1192
+
756
1193
  function teamAdrIndex(entries) {
757
1194
  return [
758
1195
  '# myteam ADR Index',
@@ -896,7 +1333,7 @@ test('accepts a custom root folder name as the XDRS root when it contains index.
896
1333
  ].join('\n');
897
1334
  const workspaceRoot = createWorkspace('custom-root-folder', {
898
1335
  'my-decisions/index.md': rootIndex(),
899
- 'my-decisions/_local/index.md': '# _local Scope Overview\n\nOverview.\n\n[ADRs](adrs/index.md)\n',
1336
+ 'my-decisions/_local/index.md': localScopeIndex(),
900
1337
  'my-decisions/_local/adrs/index.md': localAdrIndex([
901
1338
  '- [001-main](principles/001-main.md) - Main decision'
902
1339
  ]),
@@ -931,7 +1368,7 @@ test('falls back to .xdrs subdirectory when given path has no index.md', () => {
931
1368
  ].join('\n');
932
1369
  const workspaceRoot = createWorkspace('fallback-to-xdrs', {
933
1370
  '.xdrs/index.md': rootIndex(),
934
- '.xdrs/_local/index.md': '# _local Scope Overview\n\nOverview.\n\n[ADRs](adrs/index.md)\n',
1371
+ '.xdrs/_local/index.md': localScopeIndex(),
935
1372
  '.xdrs/_local/adrs/index.md': localAdrIndex([
936
1373
  '- [001-main](principles/001-main.md) - Main decision'
937
1374
  ]),
@@ -1266,7 +1703,7 @@ test('passes when research ## Introduction contains Question: line', () => {
1266
1703
  test('passes for valid slide file in .assets with marp frontmatter and backlink', () => {
1267
1704
  const workspaceRoot = createWorkspace('marp-valid', {
1268
1705
  '.xdrs/index.md': rootIndex(),
1269
- '.xdrs/_local/index.md': '# _local Scope Overview\n\n## Content\n\nLocal scope.\n\n- [ADRs](adrs/index.md)\n',
1706
+ '.xdrs/_local/index.md': localScopeIndex(),
1270
1707
  '.xdrs/_local/adrs/index.md': localAdrIndex([
1271
1708
  '- [001-main](principles/001-main.md) - Main decision'
1272
1709
  ]),
@@ -1409,7 +1846,7 @@ test('does not lint non-slide files in .assets as slides', () => {
1409
1846
  test('passes for slide file in article .assets with backlink', () => {
1410
1847
  const workspaceRoot = createWorkspace('marp-article-valid', {
1411
1848
  '.xdrs/index.md': rootIndex(),
1412
- '.xdrs/_local/index.md': '# _local Scope Overview\n\n## Content\n\nLocal scope.\n\n- [ADRs](adrs/index.md)\n',
1849
+ '.xdrs/_local/index.md': localScopeIndex(),
1413
1850
  '.xdrs/_local/adrs/index.md': localAdrIndex([
1414
1851
  '- [001-guide](principles/articles/001-guide.md) - Guide'
1415
1852
  ]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xdrs-core",
3
- "version": "0.31.2",
3
+ "version": "0.32.0",
4
4
  "description": "A framework to structure, compile and distribute Architectural (ADR), Business (BDR), and Engineering (EDR) decision records contents so that AI agents and humans can reliably find and use them with hierarchical scopes and controlled rollout in the format of distributable versioned packages.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  "jest": "^29.7.0"
31
31
  },
32
32
  "dependencies": {
33
- "filedist": "^0.37.0",
33
+ "filedist": "^0.39.0",
34
34
  "ignore": "^7.0.5",
35
35
  "minimatch": "^10.2.5"
36
36
  },