xdrs-core 0.52.2 → 0.52.3

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.
@@ -50,7 +50,7 @@ ADR and EDR share the same six technical subject names (see [`_core-adr-policy-0
50
50
  `[xdrs-root]/[scope]/[type]/[subject]/[number]-[short-title].md`
51
51
  where `[xdrs-root]` is the XDRS root folder (default: `.xdrs/`).
52
52
  - MUST ignore symlinks paths. MUST NOT create or update documents inside symlinked folders.
53
- - **Files listed in `.filedist.lock` are external XDRs.** A file whose path appears in the workspace root `.filedist.lock` file was distributed from an external source repository. It MUST NOT be modified locally. To change it, submit the change to the source repository and re-extract the updated package. The `.filedist.lock` format is one entry per line: `<relative-path>|<package>|<version>`. A scope is considered external when any of its files appear in `.filedist.lock`, and tools (such as `xdrs-core lint`) will skip external scopes by default. The `.filedist.lock` file can also be used to detect which files changed when bumping an external scope to a newer version: compare the version field in `.filedist.lock` entries before and after the upgrade and diff the affected paths to understand what decisions were added, updated, or removed.
53
+ - **Files listed in `.filedist.lock` are external XDRs.** A file whose path appears in the workspace root `.filedist.lock` file was distributed from an external source repository. It MUST NOT be modified locally. To change it, submit the change to the source repository and re-extract the updated package. The `.filedist.lock` format is one entry per line: `<relative-path>|<package>|<version>`. A scope is considered external when any of its files appear in `.filedist.lock`. The `.filedist.lock` file can also be used to detect which files changed when bumping an external scope to a newer version: compare the version field in `.filedist.lock` entries before and after the upgrade and diff the affected paths to understand what decisions were added, updated, or removed.
54
54
  - Optional supporting artifacts under the same subject:
55
55
  - `[xdrs-root]/[scope]/[type]/[subject]/researches/[number]-[short-title].md`
56
56
  - `[xdrs-root]/[scope]/[type]/[subject]/skills/[skill-name]/SKILL.md`
@@ -91,8 +91,8 @@ ADR and EDR share the same six technical subject names (see [`_core-adr-policy-0
91
91
  - `apply-to` (required): Declares in which contexts — teams, systems, codebases, or environments — the decisions in this scope are relevant. Max 30 words.
92
92
  - `valid-from` (required): ISO date (YYYY-MM-DD) from which this scope became active.
93
93
  - `metadata` (optional): Arbitrary key-value map for additional scope metadata.
94
- - `follows` (optional): Core scope names whose Policies apply as mandatory governance conventions to this scope, beyond `_core`. Last-listed takes precedence on conflicts (e.g., `follows: myarea-core, shared-standards`). All referenced scopes MUST exist in the workspace; if any is absent, READ/WRITE/REVIEW operations MUST fail.
95
- - `extends` (optional): Scope names whose **policy documents** (decision records only — not skills, articles, research, or initiatives) are inherited by this scope as if they were authored here. Any scope type except `_local` and `_core` may be referenced. Last-listed extended scope takes precedence on conflicts; the extending scope's own policies always take highest precedence. Depth-first transitive resolution applies: if A extends [B, C] and B extends [D], the effective precedence order is D < B < C < A. `extends:` and `follows:` MUST reference disjoint scope sets. All referenced scopes MUST exist in the workspace; if any is absent, READ/WRITE/REVIEW operations MUST fail. When an extending scope's policy overrides an inherited policy on the same topic, a `## Conflicts` section MUST document the override. Extended scopes are still linked in the root index; tag them `extends-only` to apply them only via `extends:` (see `_core-adr-policy-022.01-root-index-completeness`). `extends:` takes precedence over root index ordering for conflict resolution between the scopes involved; root index ordering is the fallback for all other scope pairs. See `_core-adr-policy-010` Section D (rules 27–35) and Section E (rule 36) for full normative rules.
94
+ - `follows` (optional): Core scope names whose Policies apply as mandatory governance conventions to this scope, beyond `_core`. Last-listed takes precedence on conflicts (e.g., `follows: myarea-core, shared-standards`). A missing scope MUST be fixed in local scopes and makes external scopes READ-ONLY (see `_core-adr-policy-022.06-read-only-dependencies`).
95
+ - `extends` (optional): Scope names whose **policy documents** (decision records only — not skills, articles, research, or initiatives) are inherited by this scope as if they were authored here. Any scope type except `_local` and `_core` may be referenced. Last-listed extended scope takes precedence on conflicts; the extending scope's own policies always take highest precedence. Depth-first transitive resolution applies: if A extends [B, C] and B extends [D], the effective precedence order is D < B < C < A. `extends:` and `follows:` MUST reference disjoint scope sets. A missing scope MUST be fixed in local scopes and makes external scopes READ-ONLY (see `_core-adr-policy-022.06-read-only-dependencies`). When an extending scope's policy overrides an inherited policy on the same topic, a `## Conflicts` section MUST document the override. Extended scopes are still linked in the root index; tag them `extends-only` to apply them only via `extends:` (see `_core-adr-policy-022.01-root-index-completeness`). `extends:` takes precedence over root index ordering for conflict resolution between the scopes involved; root index ordering is the fallback for all other scope pairs. See `_core-adr-policy-010` Section D (rules 27–35) and Section E (rule 36) for full normative rules.
96
96
  - `extends-only` (optional): See `_core-adr-policy-022.07-extends-only-field`.
97
97
  - `license` (optional): SPDX identifier for the scope's content license.
98
98
  - **Subjects:** The subject folder MUST be chosen per [`_core-adr-policy-016`](016-policy-subjects.md), which defines allowed subjects, full descriptions, examples, and disambiguation tiebreakers.
@@ -81,9 +81,9 @@ A scope-type definition policy MAY declare a parent scope type by including a ru
81
81
 
82
82
  #### 10-def-valid-iff-policy-exists
83
83
 
84
- A `scope-type` value in a scope `index.md` is valid if and only if a policy file whose name ends with `{scope-type}-scope-type` exists in the `principles` subject of any `core`-type scope in the workspace. Tools (such as `xdrs-core lint`) MUST enforce this for every element when `scope-type` is an array.
84
+ A `scope-type` value in a scope `index.md` is valid if and only if a policy file whose name ends with `{scope-type}-scope-type` exists in the `principles` subject of any `core`-type scope in the workspace. When `scope-type` is an array, this applies to every element.
85
85
 
86
- When a scope declares a `scope-type` but any corresponding `{scope-type}-scope-type.md` policy is absent from the workspace or defined only in a `disabled` scope, the scope MUST be treated as READ-ONLY (see `_core-adr-policy-022.06-read-only-dependencies`). Missing types are a lint error for local scopes.
86
+ When a scope declares a `scope-type` but any corresponding `{scope-type}-scope-type.md` policy is absent from the workspace or defined only in a `disabled` scope, the scope MUST be treated as READ-ONLY (see `_core-adr-policy-022.06-read-only-dependencies`). Missing or disabled-only types MUST be fixed in local scopes.
87
87
 
88
88
  #### 11-def-scope-type-must-be-structured
89
89
 
@@ -103,7 +103,7 @@ The word `core` as a hyphen-delimited segment in a policy filename title is rese
103
103
 
104
104
  #### 14-local-placement
105
105
 
106
- Local meta-policy files MUST be placed in the `principles` subject of any type folder within the scope they govern (e.g., `my-team/adrs/principles/001-core.md`). They apply implicitly to that scope — no declaration in `index.md` is required. Local meta-policies MUST NOT be placed in any other subject folder; a file whose title starts with `core` in a non-`principles` subject is a lint error.
106
+ Local meta-policy files MUST be placed in the `principles` subject of any type folder within the scope they govern (e.g., `my-team/adrs/principles/001-core.md`). They apply implicitly to that scope — no declaration in `index.md` is required. Local meta-policies MUST NOT be placed in any other subject folder; a file whose title starts with `core` in a non-`principles` subject is invalid.
107
107
 
108
108
  #### 15-local-primary-required
109
109
 
@@ -180,7 +180,7 @@ When a policy (scope-type definition, local meta-policy, or companion) overrides
180
180
  - The policy file where the original rule is defined.
181
181
  - A short explanation of why the override is required for this scope or type.
182
182
 
183
- A `## Conflicts` section MAY also document cross-scope-type incompatibilities for informational purposes. Tools and agents MUST detect undeclared conflicts at review time and surface them as errors. Semantic conflict detection is the responsibility of the authoring agent, not the lint tool.
183
+ A `## Conflicts` section MAY also document cross-scope-type incompatibilities for informational purposes. Tools and agents MUST detect undeclared conflicts at review time and surface them as errors. Semantic conflict detection is the responsibility of the authoring agent.
184
184
 
185
185
  ## References
186
186
 
@@ -213,7 +213,7 @@ A scope `extends:` declaration MUST NOT reference the declaring scope itself. To
213
213
 
214
214
  #### 32-extends-mandatory-presence
215
215
 
216
- Every scope listed in a scope's `extends:` field MUST exist in the workspace with an accessible `index.md`. If any referenced scope is absent or its `index.md` cannot be read, all READ, WRITE, and REVIEW operations on the extending scope MUST fail immediately. Tools MUST surface a clear message naming the missing scope and citing code `_core-adr-policy-010.32-extends-mandatory-presence`.
216
+ Every scope listed in a scope's `extends:` field MUST exist in the workspace with an accessible `index.md`. If any referenced scope is absent or its `index.md` cannot be read, the extending scope MUST be fixed when local and is READ-ONLY when external (`_core-adr-policy-022.06-read-only-dependencies`).
217
217
 
218
218
  #### 33-extends-content-precedence
219
219
 
@@ -73,7 +73,7 @@ Distributing `-core` scopes alongside their companion consumable scope to downst
73
73
 
74
74
  #### 10-no-type-combination
75
75
 
76
- A scope is core-type when its resolved scope-type chain, including parent types (see `_core-adr-policy-010.09-def-parent-scope-type`), contains `core`. A core-type scope MUST declare exactly one scope type; combining a core-type with any other type (e.g., `scope-type: core, standard`) is a lint error. Core-type scopes hold meta governance only, so mixing them with consumable types would blur rules 04 and 05.
76
+ A scope is core-type when its resolved scope-type chain, including parent types (see `_core-adr-policy-010.09-def-parent-scope-type`), contains `core`. A core-type scope MUST declare exactly one scope type; combining a core-type with any other type (e.g., `scope-type: core, standard`) is invalid. Core-type scopes hold meta governance only, so mixing them with consumable types would blur rules 04 and 05.
77
77
 
78
78
  ## References
79
79
 
@@ -9,21 +9,21 @@ valid-from: 2026-07-20
9
9
 
10
10
  ## Context and Problem Statement
11
11
 
12
- Packages install scope folders into a workspace, but nothing guaranteed that every installed scope was listed in the root `index.md`. Scopes that were meant to be reused only through `extends:` were hidden from the root index, so agents could not discover them, and consumers had no way to switch off a scope they installed but did not want to apply. When a scope depended on another scope that was absent, the result was either a hard lint error or silent misbehaviour.
12
+ Packages install scope folders into a workspace, but nothing guaranteed that every installed scope was listed in the root `index.md`. Scopes that were meant to be reused only through `extends:` were hidden from the root index, so agents could not discover them, and consumers had no way to switch off a scope they installed but did not want to apply. When a scope depended on another scope that was absent, the result was either a hard failure or silent misbehaviour.
13
13
 
14
14
  How does a consumer control which installed scopes apply globally, which apply only through `extends:`, and which are ignored, while keeping every scope discoverable?
15
15
 
16
16
  ## Decision Outcome
17
17
 
18
- **Every installed scope is listed once in the root index; the consumer controls activation with an inline activation tag next to the scope link, and the scope author declares intent with an `extends-only` frontmatter field that lint checks for coherence.**
18
+ **Every installed scope is listed once in the root index; the consumer controls activation with an inline activation tag next to the scope link, and the scope author declares intent with an `extends-only` frontmatter field that MUST be coherent with the tag.**
19
19
 
20
- Policies of listed scopes apply globally by default. An activation tag narrows a scope to `extends-only` or turns it off with `disabled`. Agents rely only on the tags. Scopes whose dependencies are missing, disabled, or invalid become read-only.
20
+ Policies of listed scopes apply globally by default. An activation tag narrows a scope to `extends-only` or turns it off with `disabled`. Agents rely only on the tags. Scopes whose dependencies are missing, disabled, or invalid become read-only; local scopes MUST NOT stay in that state.
21
21
 
22
22
  ### Details
23
23
 
24
24
  #### 01-root-index-completeness
25
25
 
26
- Every scope folder under the XDRS root that contains an `index.md`, including scopes installed from external packages, MUST be linked exactly once from the root `index.md`. The `_local` scope MUST NOT be linked (see `_core-adr-policy-001`). A scope folder with no link, or a scope linked more than once, is a lint error. Links that resolve to the same scope index (e.g. `./x/index.md` and `x/index.md`) count as the same link. Links inside fenced code blocks are ignored.
26
+ Every scope folder under the XDRS root that contains an `index.md`, including scopes installed from external packages, MUST be linked exactly once from the root `index.md`. The `_local` scope MUST NOT be linked (see `_core-adr-policy-001`). A scope folder with no link, or a scope linked more than once, is invalid. Links that resolve to the same scope index (e.g. `./x/index.md` and `x/index.md`) count as the same link. Links inside fenced code blocks are ignored.
27
27
 
28
28
  #### 02-global-applicability
29
29
 
@@ -31,23 +31,26 @@ Policies of a listed scope without an activation tag MUST be applied globally, n
31
31
 
32
32
  #### 03-activation-tag-syntax
33
33
 
34
- An activation tag is an inline code span placed immediately after a scope link on the same line, optionally separated by spaces or tabs, for example a link to `payments-ref/index.md` followed by `` `extends-only` ``. Allowed values are `extends-only` and `disabled`, lowercase only. A scope link line MUST NOT contain any other inline code: an unknown value, a second tag, or inline code placed elsewhere on the line is a lint error. A tag on the next line is not a tag. `_core` MUST NOT be tagged.
34
+ An activation tag is an inline code span placed immediately after a scope link on the same line, optionally separated by spaces or tabs, for example a link to `payments-ref/index.md` followed by `` `extends-only` ``. Allowed values are `extends-only` and `disabled`, lowercase only. A scope link line MUST NOT contain any other inline code: an unknown value, a second tag, or inline code placed elsewhere on the line is invalid. A tag on the next line is not a tag. `_core` MUST NOT be tagged.
35
35
 
36
36
  #### 04-extends-only
37
37
 
38
- Policies of a scope tagged `extends-only` MUST be applied only through the `extends:` chain of an active scope (untagged, including read-only scopes, or `_local`). Reach is transitive: if A extends B and B extends C, both B and C apply to A. The root index position of a tagged scope never matters for conflict resolution; `extends:` precedence applies (see `_core-adr-policy-010.33-extends-content-precedence`). Skills, articles, research, and initiatives of the scope remain usable. Tools SHOULD warn when no active scope reaches an `extends-only` scope; reach only through a `disabled` scope does not count.
38
+ Policies of a scope tagged `extends-only` MUST be applied only through the `extends:` chain of an active scope (untagged, including read-only scopes, or `_local`). Reach is transitive: if A extends B and B extends C, both B and C apply to A. The root index position of a tagged scope never matters for conflict resolution; `extends:` precedence applies (see `_core-adr-policy-010.33-extends-content-precedence`). Skills, articles, research, and initiatives of the scope remain usable. Every `extends-only` scope MUST be reached by at least one active scope; reach only through a `disabled` scope does not count. Fix it by adding the scope to the `extends:` of an active scope or by tagging it `disabled`.
39
39
 
40
40
  #### 05-disabled
41
41
 
42
- All content of a scope tagged `disabled` MUST be ignored by agents: its policies, skills, articles, research, and initiatives are not applied, and nothing is inherited from it through `extends:` or `follows:`. The scope is still linted and MAY be edited. Skills of a disabled scope that are symlinked into `.agents/skills` (see `_core-adr-policy-018`) stay loaded by tools and MUST be removed by the consumer.
42
+ All content of a scope tagged `disabled` MUST be ignored by agents: its policies, skills, articles, research, and initiatives are not applied, and nothing is inherited from it through `extends:` or `follows:`. The scope is still validated and MAY be edited; a disabled local scope follows rule 06 like any other local scope. Skills of a disabled scope that are symlinked into `.agents/skills` (see `_core-adr-policy-018`) stay loaded by tools and MUST be removed by the consumer.
43
43
 
44
44
  #### 06-read-only-dependencies
45
45
 
46
- A scope MUST be treated as READ-ONLY when a scope it follows or extends, or the scope that defines one of its scope types (including parent types), is missing, disabled, or invalid, or when it is an external scope and its own `index.md` is invalid. Content in a READ-ONLY scope MUST NOT be added, changed, or removed. For local scopes, missing dependencies and invalid indexes remain lint errors (see `_core-adr-policy-010.32-extends-mandatory-presence`); a disabled dependency only makes the scope read-only. Tools MUST report each cause and how to fix it, citing this rule.
46
+ A scope MUST be treated as READ-ONLY when a scope it follows or extends, or the scope that defines one of its scope types (including parent types), is missing, disabled, or invalid, or when it is an external scope and its own `index.md` is invalid. Content in a READ-ONLY scope MUST NOT be added, changed, or removed.
47
+
48
+ - **Local scope** (not tracked in `.filedist.lock`, including `_local` and disabled scopes): a read-only local scope is frozen and cannot be maintained, so each cause is invalid and MUST be fixed.
49
+ - **External scope** (tracked in `.filedist.lock`): the scope is READ-ONLY; agents MUST tell the user when they skip a change because of it, naming the cause and fix.
47
50
 
48
51
  #### 07-extends-only-field
49
52
 
50
- A scope author MAY declare `extends-only: true` or `extends-only: false` in the scope index frontmatter. Only unquoted lowercase `true` and `false` are valid. An invalid value is a lint error in a local scope; in an external scope it makes the scope read-only and the value is treated as absent. `extends-only` (field `true` or tag) on `_core`, `_local`, or a core-type scope is a lint error. Coherence: when the field is `true`, the root index link MUST carry the tag `extends-only` or `disabled`; when the field is `false` or absent, the consumer MAY add either tag (narrowing), and MUST NOT widen a scope beyond what the author declared. The field is a lint aid only.
53
+ A scope author MAY declare `extends-only: true` or `extends-only: false` in the scope index frontmatter. Only unquoted lowercase `true` and `false` are valid. An invalid value in a local scope MUST be fixed; in an external scope it makes the scope READ-ONLY and the value is treated as absent. `extends-only` (field `true` or tag) MUST NOT be used on `_core`, `_local`, or a core-type scope. Coherence: when the field is `true`, the root index link MUST carry the tag `extends-only` or `disabled`; when the field is `false` or absent, the consumer MAY add either tag (narrowing), and MUST NOT widen a scope beyond what the author declared. Agents MUST NOT use the field for activation (rule 08).
51
54
 
52
55
  #### 08-agent-resolution
53
56
 
@@ -9,4 +9,3 @@ This module is shared across all XDRS writing skills. Run these steps after all
9
9
  npx -y xdrs-core@latest lint
10
10
  ```
11
11
  2. Fix all reported errors before considering the task complete.
12
- 3. Review warnings; fix straightforward ones and note intentional deviations explicitly.
@@ -138,7 +138,7 @@ After writing each policy:
138
138
  ### Phase 6: Lint
139
139
 
140
140
  1. Run `npx xdrs-core lint` from the workspace root (or `make lint` if a `Makefile` is present with a `lint` target).
141
- 2. Review all reported errors and warnings.
141
+ 2. Review all reported errors.
142
142
  3. Fix only mechanical and structural errors (missing required frontmatter fields, naming violations, index link mismatches). Do NOT invent content to satisfy content-level findings.
143
143
  4. Re-run lint after fixes until it passes or only unfixable findings remain.
144
144
 
package/lib/lint.js CHANGED
@@ -99,18 +99,6 @@ function runLintCli(args) {
99
99
  const targetPath = pathArgs[0] || '.';
100
100
  const result = lintWorkspace(targetPath, { ignoreExternal: !all });
101
101
 
102
- if (result.warnings.length > 0) {
103
- for (const warning of result.warnings) {
104
- console.warn(`- [warning] ${warning}`);
105
- }
106
- }
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
-
114
102
  if (result.errors.length === 0) {
115
103
  console.log(`Lint passed for ${toDisplayPath(result.xdrsRoot)}`);
116
104
  return 0;
@@ -147,12 +135,10 @@ function lintWorkspace(targetPath, options = {}) {
147
135
  const resolvedTarget = path.resolve(targetPath);
148
136
  const xdrsRoot = resolveXdrsRoot(resolvedTarget);
149
137
  const errors = [];
150
- const warnings = [];
151
- const readOnlyScopes = new Map(); // scopeName -> [cause, ...] per _core-adr-policy-022.06
152
138
 
153
139
  if (!existsDirectory(xdrsRoot)) {
154
140
  errors.push(`Missing XDRS root directory: ${toDisplayPath(xdrsRoot)} [_core-adr-policy-001]`);
155
- return { xdrsRoot, errors, warnings, readOnlyScopes };
141
+ return { xdrsRoot, errors };
156
142
  }
157
143
 
158
144
  const repoRoot = path.dirname(xdrsRoot);
@@ -247,13 +233,13 @@ function lintWorkspace(targetPath, options = {}) {
247
233
  }
248
234
  }
249
235
 
250
- collectReadOnlyScopes(scopeMeta, activation, externalScopes, knownScopeTypes, scopeTypeDefiningScopes, readOnlyScopes);
236
+ lintLocalReadOnlyDependencies(xdrsRoot, scopeMeta, activation, externalScopes, knownScopeTypes, scopeTypeDefiningScopes, errors);
251
237
 
252
238
  // Populated during lintScopeIndexFrontmatter: scopeName -> [extendedScopeNames...]
253
239
  const extendsRelationships = new Map();
254
240
 
255
241
  for (const scopeEntry of scopeEntries) {
256
- lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, warnings, actualTypeIndexes, ignoreExternal, effectiveExternalScopes, knownScopeTypes, extendsRelationships, externalScopes);
242
+ lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, actualTypeIndexes, ignoreExternal, effectiveExternalScopes, knownScopeTypes, extendsRelationships, externalScopes);
257
243
  }
258
244
 
259
245
  // Detect cycles in extends: chains (DFS)
@@ -283,13 +269,13 @@ function lintWorkspace(targetPath, options = {}) {
283
269
  if (!existsFile(rootIndexPath)) {
284
270
  errors.push('Missing required root index: .xdrs/index.md [_core-adr-policy-001]');
285
271
  } else {
286
- lintRootIndex(rootIndexPath, xdrsRoot, errors, warnings, effectiveExternalScopes, { activation, scopeMeta, knownScopeTypes });
272
+ lintRootIndex(rootIndexPath, xdrsRoot, errors, effectiveExternalScopes, { activation, scopeMeta, knownScopeTypes });
287
273
  }
288
274
 
289
- return { xdrsRoot, errors, warnings, readOnlyScopes };
275
+ return { xdrsRoot, errors };
290
276
  }
291
277
 
292
- function lintRootIndex(rootIndexPath, xdrsRoot, errors, warnings, externalScopes, ctx) {
278
+ function lintRootIndex(rootIndexPath, xdrsRoot, errors, externalScopes, ctx) {
293
279
  const content = fs.readFileSync(rootIndexPath, 'utf8');
294
280
  const repoRoot = path.dirname(xdrsRoot);
295
281
 
@@ -360,7 +346,7 @@ function lintRootIndex(rootIndexPath, xdrsRoot, errors, warnings, externalScopes
360
346
  }
361
347
  for (const [scopeName, entry] of activation) {
362
348
  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]`);
349
+ errors.push(`Scope "${scopeName}" is tagged \`extends-only\` but no active scope extends it, so its policies never apply; add it to the extends: of an active scope or tag it \`disabled\`: ${display} [_core-adr-policy-022.04-extends-only]`);
364
350
  }
365
351
  }
366
352
  }
@@ -443,30 +429,19 @@ function readScopeMeta(scopeIndexPath) {
443
429
  };
444
430
  }
445
431
 
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) {
432
+ // Reports local scopes that would be READ-ONLY per _core-adr-policy-022.06 because of a disabled or
433
+ // invalid dependency. Missing dependencies are reported by lintScopeIndexFrontmatter; external scopes are never reported.
434
+ function lintLocalReadOnlyDependencies(xdrsRoot, scopeMeta, activation, externalScopes, knownScopeTypes, scopeTypeDefiningScopes, errors) {
449
435
  const isDisabled = (scopeName) => (activation.get(scopeName) || {}).tag === 'disabled';
450
436
  for (const [scopeName, meta] of scopeMeta) {
451
- if (!meta) continue;
452
- const isExternal = externalScopes.has(scopeName);
437
+ if (!meta || externalScopes.has(scopeName)) continue;
453
438
  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
439
  for (const [field, targets] of [['follows', meta.follows], ['extends', meta.extends]]) {
464
440
  for (const target of targets) {
465
441
  if (target === scopeName || RESERVED_SCOPES.has(target)) continue;
466
442
  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)) {
443
+ if (!targetMeta) continue;
444
+ if (isDisabled(target)) {
470
445
  causes.push(`${field}: scope "${target}" is tagged \`disabled\` in the root index; enable it or remove the ${field}: reference`);
471
446
  } else if (!targetMeta.hasFrontmatter) {
472
447
  causes.push(`${field}: scope "${target}" has an invalid scope index; fix it`);
@@ -474,18 +449,15 @@ function collectReadOnlyScopes(scopeMeta, activation, externalScopes, knownScope
474
449
  }
475
450
  }
476
451
  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`);
480
- continue;
481
- }
452
+ if (scopeType === '_local' || !knownScopeTypes.has(scopeType)) continue;
482
453
  const definers = [...scopeTypeDefiningScopes.get(scopeType)].filter((s) => s !== scopeName);
483
454
  if (definers.length > 0 && definers.every(isDisabled)) {
484
455
  causes.push(`scope type "${scopeType}" is defined only in disabled scope(s) ${definers.map((s) => `"${s}"`).join(', ')}; enable one of them`);
485
456
  }
486
457
  }
487
- if (causes.length > 0) {
488
- readOnlyScopes.set(scopeName, causes);
458
+ const display = toDisplayPath(path.join(xdrsRoot, scopeName, 'index.md'));
459
+ for (const cause of causes) {
460
+ errors.push(`Local scope "${scopeName}" is frozen (read-only) because ${cause}: ${display} [_core-adr-policy-022.06-read-only-dependencies]`);
489
461
  }
490
462
  }
491
463
  }
@@ -787,8 +759,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
787
759
  }
788
760
  const followedIndexPath = path.join(xdrsRoot, entry, 'index.md');
789
761
  if (!existsFile(followedIndexPath)) {
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.
762
+ // A missing dependency of an external scope makes it READ-ONLY (_core-adr-policy-022.06); lint does not report it.
792
763
  if (!isExternal) {
793
764
  errors.push(`Scope index frontmatter follows references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
794
765
  }
@@ -858,7 +829,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
858
829
  }
859
830
 
860
831
 
861
- function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeIndexes, ignoreExternal, externalScopes, knownScopeTypes = new Map(), extendsRelationships = new Map(), trackedExternalScopes = new Set()) {
832
+ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, ignoreExternal, externalScopes, knownScopeTypes = new Map(), extendsRelationships = new Map(), trackedExternalScopes = new Set()) {
862
833
  const scopePath = path.join(xdrsRoot, scopeName);
863
834
 
864
835
  if (ignoreExternal && externalScopes.has(scopeName)) {
@@ -897,7 +868,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeInd
897
868
  errors.push(`Unexpected directory under scope ${scopeName}: ${toDisplayPath(entryPath)} [_core-adr-policy-001]`);
898
869
  continue;
899
870
  }
900
- lintTypeDirectory(xdrsRoot, scopeName, entry.name, errors, warnings, actualTypeIndexes, externalScopes, isCoreTypeNonExempt, isCompiledScope);
871
+ lintTypeDirectory(xdrsRoot, scopeName, entry.name, errors, actualTypeIndexes, externalScopes, isCoreTypeNonExempt, isCompiledScope);
901
872
  const typeIndexPath = path.join(entryPath, 'index.md');
902
873
  if (existsFile(typeIndexPath)) {
903
874
  typeIndexesInScope.push(typeIndexPath);
@@ -1028,7 +999,7 @@ function lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope = fa
1028
999
  }
1029
1000
  }
1030
1001
 
1031
- function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, warnings, actualTypeIndexes, externalScopes = new Set(), enforceStructured = false, isCompiledScope = false) {
1002
+ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeIndexes, externalScopes = new Set(), enforceStructured = false, isCompiledScope = false) {
1032
1003
  const typePath = path.join(xdrsRoot, scopeName, typeName);
1033
1004
  const indexPath = path.join(typePath, 'index.md');
1034
1005
  const xdrsNumbers = new Map();
@@ -1055,11 +1026,10 @@ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, warnings, actu
1055
1026
  errors.push(`Invalid subject folder for ${typeName}: ${toDisplayPath(entryPath)} [_core-adr-policy-001]`);
1056
1027
  continue;
1057
1028
  }
1058
- // All policies are frozen: emit a warning and still process for content-level checks.
1059
- warnings.push(`Invalid subject folder for ${typeName}: ${toDisplayPath(entryPath)} [_core-adr-policy-001] [exempt: freeze-reference]`);
1029
+ // All policies are frozen (_core-adr-policy-002.01): skip the check, keep content-level checks.
1060
1030
  }
1061
1031
 
1062
- artifacts.push(...lintSubjectDirectory(xdrsRoot, scopeName, typeName, entry.name, xdrsNumbers, errors, warnings, externalScopes, enforceStructured, isCompiledScope));
1032
+ artifacts.push(...lintSubjectDirectory(xdrsRoot, scopeName, typeName, entry.name, xdrsNumbers, errors, externalScopes, enforceStructured, isCompiledScope));
1063
1033
  }
1064
1034
 
1065
1035
  if (existsFile(indexPath)) {
@@ -1067,7 +1037,7 @@ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, warnings, actu
1067
1037
  }
1068
1038
  }
1069
1039
 
1070
- function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNumbers, errors, warnings, externalScopes = new Set(), enforceStructured = false, isCompiledScope = false) {
1040
+ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNumbers, errors, externalScopes = new Set(), enforceStructured = false, isCompiledScope = false) {
1071
1041
  const subjectPath = path.join(xdrsRoot, scopeName, typeName, subjectName);
1072
1042
  const artifacts = [];
1073
1043
  const entries = safeReadDir(subjectPath, errors, `read subject directory ${scopeName}/${typeName}/${subjectName}`);
@@ -1106,7 +1076,7 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNu
1106
1076
  }
1107
1077
 
1108
1078
  artifacts.push(entryPath);
1109
- lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, entryPath, xdrsNumbers, errors, warnings, externalScopes, enforceStructured, isCompiledScope);
1079
+ lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, entryPath, xdrsNumbers, errors, externalScopes, enforceStructured, isCompiledScope);
1110
1080
  }
1111
1081
 
1112
1082
  const subjectAssetsDir = path.join(subjectPath, RESOURCE_DIR_NAME);
@@ -1116,7 +1086,7 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNu
1116
1086
  return artifacts;
1117
1087
  }
1118
1088
 
1119
- function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePath, xdrsNumbers, errors, warnings, externalScopes = new Set(), enforceStructured = false, isCompiledScope = false) {
1089
+ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePath, xdrsNumbers, errors, externalScopes = new Set(), enforceStructured = false, isCompiledScope = false) {
1120
1090
  const baseName = path.basename(filePath);
1121
1091
  const match = baseName.match(NUMBERED_FILE_RE);
1122
1092
  if (!match) {
@@ -1136,26 +1106,23 @@ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePat
1136
1106
  }
1137
1107
 
1138
1108
  const content = fs.readFileSync(filePath, 'utf8');
1139
- lintPolicyNumberRange(number, subjectName, content, filePath, errors, warnings);
1109
+ lintPolicyNumberRange(number, subjectName, content, filePath, errors);
1140
1110
  const { freezeReference } = extractFrontmatter(content);
1141
1111
  const expectedHeader = `# ${scopeName}-${TYPE_TO_ID[typeName]}-${number}:`;
1142
1112
  const firstLine = firstNonEmptyLine(stripFrontmatter(content));
1143
1113
  if (!freezeReference && !firstLine.startsWith(expectedHeader)) {
1144
1114
  errors.push(`Policy title must start with "${expectedHeader}": ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
1145
- } else if (freezeReference && !firstLine.startsWith(expectedHeader)) {
1146
- warnings.push(`Policy title must start with "${expectedHeader}": ${toDisplayPath(filePath)} [_core-adr-policy-002] [exempt: freeze-reference]`);
1147
1115
  }
1148
1116
 
1149
1117
  // Enforce 'core' word reservation: only allowed as first word of filename title in principles/
1150
1118
  const titleParts = match[2].split('-');
1151
- if (titleParts.includes('core') && !(subjectName === 'principles' && titleParts[0] === 'core')) {
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]`;
1153
- if (freezeReference) { warnings.push(`${msg} [exempt: freeze-reference]`); } else { errors.push(msg); }
1119
+ if (!freezeReference && titleParts.includes('core') && !(subjectName === 'principles' && titleParts[0] === 'core')) {
1120
+ errors.push(`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]`);
1154
1121
  }
1155
1122
 
1156
1123
  const expectedName = extractExpectedXdrsNameFromHeading(firstLine)
1157
1124
  || `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${match[2]}`;
1158
- lintXdrsElementFrontmatter(content, expectedName, filePath, errors, warnings, freezeReference);
1125
+ lintXdrsElementFrontmatter(content, expectedName, filePath, errors, freezeReference);
1159
1126
  if (enforceStructured) {
1160
1127
  validateStructuredFormatContent(content, filePath, errors, '_core-adr-policy-010.12-def-core-scope-policies-must-be-structured');
1161
1128
  } else if (isCompiledScope) {
@@ -1198,7 +1165,7 @@ function slugifyTitle(title) {
1198
1165
  .replace(/--+/g, '-');
1199
1166
  }
1200
1167
 
1201
- function lintXdrsElementFrontmatter(content, expectedName, filePath, errors, warnings, freezeReference = false) {
1168
+ function lintXdrsElementFrontmatter(content, expectedName, filePath, errors, freezeReference = false) {
1202
1169
  const fm = extractFrontmatter(content);
1203
1170
  if (!fm.present) {
1204
1171
  errors.push(`Policy must start with a YAML frontmatter block: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
@@ -1207,9 +1174,8 @@ function lintXdrsElementFrontmatter(content, expectedName, filePath, errors, war
1207
1174
  if (!fm.name) {
1208
1175
  errors.push(`Policy frontmatter must include a non-empty name field: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
1209
1176
  } else {
1210
- if (fm.name !== expectedName) {
1211
- const msg = `Policy frontmatter name must be "${expectedName}": ${toDisplayPath(filePath)} [_core-adr-policy-002]`;
1212
- if (freezeReference) { warnings.push(`${msg} [exempt: freeze-reference]`); } else { errors.push(msg); }
1177
+ if (fm.name !== expectedName && !freezeReference) {
1178
+ errors.push(`Policy frontmatter name must be "${expectedName}": ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
1213
1179
  }
1214
1180
  if (fm.name.endsWith('-')) {
1215
1181
  errors.push(`Policy frontmatter name must not end with a hyphen: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
@@ -1915,19 +1881,17 @@ function hasAnyNonFrozenPolicy(dirPath) {
1915
1881
  return false;
1916
1882
  }
1917
1883
 
1918
- function lintPolicyNumberRange(number, subjectName, content, filePath, errors, warnings) {
1919
- const fm = extractFrontmatter(content);
1884
+ function lintPolicyNumberRange(number, subjectName, content, filePath, errors) {
1885
+ if (extractFrontmatter(content).freezeReference) return;
1920
1886
  const num = parseInt(number, 10);
1921
1887
  if (num >= RESERVED_NUMBER_RANGE_MIN && num <= RESERVED_NUMBER_RANGE_MAX) {
1922
- const msg = `Policy number ${number} is in the reserved range (801–900) which MUST NOT be assigned: ${toDisplayPath(filePath)} [_core-adr-policy-017.05-reserved-gap-must-not-be-assigned]`;
1923
- if (fm.freezeReference) { warnings.push(`${msg} [exempt: freeze-reference]`); } else { errors.push(msg); }
1888
+ errors.push(`Policy number ${number} is in the reserved range (801–900) which MUST NOT be assigned: ${toDisplayPath(filePath)} [_core-adr-policy-017.05-reserved-gap-must-not-be-assigned]`);
1924
1889
  return;
1925
1890
  }
1926
1891
  if (num < OVERFLOW_RANGE_MIN) {
1927
1892
  const range = SUBJECT_NUMBER_RANGES[subjectName];
1928
1893
  if (range && (num < range[0] || num > range[1])) {
1929
- const msg = `Policy number ${number} is outside the ${range[0]}–${range[1]} block reserved for subject "${subjectName}": ${toDisplayPath(filePath)} [_core-adr-policy-017.01-numbering-blocks-per-subject]`;
1930
- if (fm.freezeReference) { warnings.push(`${msg} [exempt: freeze-reference]`); } else { errors.push(msg); }
1894
+ errors.push(`Policy number ${number} is outside the ${range[0]}–${range[1]} block reserved for subject "${subjectName}": ${toDisplayPath(filePath)} [_core-adr-policy-017.01-numbering-blocks-per-subject]`);
1931
1895
  }
1932
1896
  }
1933
1897
  }
package/lib/lint.test.js CHANGED
@@ -2245,22 +2245,35 @@ test('reports error when follows references a scope not in workspace', () => {
2245
2245
  const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
2246
2246
 
2247
2247
  expect(result.errors.join('\n')).toContain('follows references scope "nonexistent-core" which does not exist in the workspace');
2248
- expect(result.readOnlyScopes.has('myteam')).toBe(false);
2249
2248
  });
2250
2249
 
2251
- test('marks scope read-only instead of erroring when follows references a missing scope for an external scope', () => {
2252
- const workspaceRoot = createWorkspace('follows-missing-scope-external', {
2250
+ test.each([[true], [false]])('does not report a missing follows target of an external scope (ignoreExternal=%s) [022.06]', (ignoreExternal) => {
2251
+ const workspaceRoot = createWorkspace(`follows-missing-scope-external-${ignoreExternal}`, {
2253
2252
  '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
2254
2253
  '.xdrs/myteam/index.md': '---\nscope-type: standard\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',
2255
2254
  '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
2256
2255
  '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
2257
- '.filedist.lock': '.xdrs/myteam/adrs/principles/001-team.md|some-package|1.0.0\n',
2256
+ '.filedist.lock': '.xdrs/myteam/index.md|some-package|1.0.0\n.xdrs/myteam/adrs/index.md|some-package|1.0.0\n.xdrs/myteam/adrs/principles/001-team.md|some-package|1.0.0\n',
2257
+ });
2258
+
2259
+ const result = lintWorkspace(workspaceRoot, { ignoreExternal });
2260
+
2261
+ expect(result.errors.join('\n')).not.toContain('nonexistent-core');
2262
+ expect(result.errors.join('\n')).not.toContain('022.06');
2263
+ });
2264
+
2265
+ test('does not report a missing extends target of an external scope [022.06]', () => {
2266
+ const workspaceRoot = createWorkspace('extends-missing-scope-external', {
2267
+ '.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
2268
+ '.xdrs/myteam/index.md': '---\nscope-type: standard\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nextends: ghost-lib\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
2269
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
2270
+ '.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
2271
+ '.filedist.lock': '.xdrs/myteam/index.md|some-package|1.0.0\n',
2258
2272
  });
2259
2273
 
2260
2274
  const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
2261
2275
 
2262
- expect(result.errors.join('\n')).not.toContain('follows references scope "nonexistent-core" which does not exist in the workspace');
2263
- expect(result.readOnlyScopes.has('myteam')).toBe(true);
2276
+ expect(result.errors.join('\n')).not.toContain('ghost-lib');
2264
2277
  });
2265
2278
 
2266
2279
  test('accepts follows scope that exists in workspace', () => {
@@ -2525,7 +2538,6 @@ test('does not warn when extends-referenced scope also appears in root index', (
2525
2538
  const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
2526
2539
 
2527
2540
  expect(result.errors.join('\n')).not.toContain('[_core-adr-policy-010.35-extends-index]');
2528
- expect(result.warnings.join('\n')).not.toContain('[_core-adr-policy-010.35-extends-index]');
2529
2541
  expect(result.errors.join('\n')).not.toContain('_core-adr-policy-022');
2530
2542
  });
2531
2543
 
@@ -5563,7 +5575,6 @@ function scopeFiles(name, extraFields = '', scopeType = 'standard') {
5563
5575
  }
5564
5576
 
5565
5577
  const activationErrors = (result) => result.errors.filter((e) => e.includes('_core-adr-policy-022')).join('\n');
5566
- const activationWarnings = (result) => result.warnings.filter((e) => e.includes('_core-adr-policy-022')).join('\n');
5567
5578
 
5568
5579
  test('passes activation checks when only _core is installed and linked (smoke)', () => {
5569
5580
  const workspaceRoot = createWorkspace('activation-smoke', {
@@ -5572,7 +5583,7 @@ test('passes activation checks when only _core is installed and linked (smoke)',
5572
5583
  });
5573
5584
  const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
5574
5585
  expect(activationErrors(result)).toBe('');
5575
- expect(result.readOnlyScopes.size).toBe(0);
5586
+ expect(Object.keys(result).sort()).toEqual(['errors', 'xdrsRoot']);
5576
5587
  });
5577
5588
 
5578
5589
  test('reports unlisted scope without type indexes with a paste-ready line [022.01]', () => {
@@ -5691,7 +5702,6 @@ test('accepts consumer narrowing: extends-only: false with an extends-only tag [
5691
5702
  });
5692
5703
  const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
5693
5704
  expect(activationErrors(result)).toBe('');
5694
- expect(activationWarnings(result)).toBe('');
5695
5705
  });
5696
5706
 
5697
5707
  test.each([['quoted', '"true"'], ['capitalised', 'True']])('reports %s extends-only value in a local scope [022.07]', (_label, value) => {
@@ -5703,7 +5713,7 @@ test.each([['quoted', '"true"'], ['capitalised', 'True']])('reports %s extends-o
5703
5713
  expect(activationErrors(result)).toContain('extends-only must be unquoted lowercase true or false');
5704
5714
  });
5705
5715
 
5706
- test('marks external scope with invalid extends-only value read-only, treated as absent [022.06]', () => {
5716
+ test('does not report an invalid extends-only value in an external scope [022.07]', () => {
5707
5717
  const workspaceRoot = createWorkspace('activation-field-invalid-external', {
5708
5718
  '.xdrs/index.md': rootIndex(['[_core](_core/index.md)', '[View scope lib](lib/index.md)']),
5709
5719
  ...scopeFiles('lib', 'extends-only: yes\n'),
@@ -5711,7 +5721,6 @@ test('marks external scope with invalid extends-only value read-only, treated as
5711
5721
  });
5712
5722
  const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
5713
5723
  expect(activationErrors(result)).toBe('');
5714
- expect(result.readOnlyScopes.get('lib').join('\n')).toContain('invalid extends-only value "yes"');
5715
5724
  });
5716
5725
 
5717
5726
  test('reports extends-only on core-type and _local scopes [022.07]', () => {
@@ -5728,17 +5737,18 @@ test('reports extends-only on core-type and _local scopes [022.07]', () => {
5728
5737
  expect(errors).toContain('_local/index.md [_core-adr-policy-022.07-extends-only-field]');
5729
5738
  });
5730
5739
 
5731
- test('warns when no active scope extends an extends-only scope [022.04]', () => {
5740
+ test('reports an error when no active scope extends an extends-only scope [022.04]', () => {
5732
5741
  const workspaceRoot = createWorkspace('activation-unused-extends-only', {
5733
5742
  '.xdrs/index.md': rootIndex(['[_core](_core/index.md)', '[View scope lib](lib/index.md) `extends-only`', ACTIVATION_LEGEND]),
5734
5743
  ...scopeFiles('lib'),
5735
5744
  });
5736
5745
  const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
5737
- expect(activationErrors(result)).toBe('');
5738
- expect(activationWarnings(result)).toContain('Scope "lib" is tagged `extends-only` but no active scope extends it');
5746
+ expect(activationErrors(result)).toContain('Scope "lib" is tagged `extends-only` but no active scope extends it');
5747
+ expect(activationErrors(result)).toContain('add it to the extends: of an active scope or tag it `disabled`');
5748
+ expect(activationErrors(result)).toContain('[_core-adr-policy-022.04-extends-only]');
5739
5749
  });
5740
5750
 
5741
- test('warns when an extends-only scope is reached only through a disabled scope [022.04]', () => {
5751
+ test('reports an extends-only scope reached only through a disabled scope, and the frozen extender [022.04, 022.06]', () => {
5742
5752
  const workspaceRoot = createWorkspace('activation-reach-through-disabled', {
5743
5753
  '.xdrs/index.md': rootIndex(['[_core](_core/index.md)', '[View scope lib](lib/index.md) `extends-only`', '[View scope mid](mid/index.md) `disabled`', '[View scope myteam](myteam/index.md)', ACTIVATION_LEGEND]),
5744
5754
  ...scopeFiles('lib'),
@@ -5746,11 +5756,11 @@ test('warns when an extends-only scope is reached only through a disabled scope
5746
5756
  ...scopeFiles('myteam', 'extends: mid\n'),
5747
5757
  });
5748
5758
  const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
5749
- expect(activationWarnings(result)).toContain('Scope "lib" is tagged `extends-only`');
5750
- expect(result.readOnlyScopes.get('myteam').join('\n')).toContain('extends: scope "mid" is tagged `disabled`');
5759
+ expect(activationErrors(result)).toContain('Scope "lib" is tagged `extends-only`');
5760
+ expect(activationErrors(result)).toContain('Local scope "myteam" is frozen (read-only) because extends: scope "mid" is tagged `disabled`');
5751
5761
  });
5752
5762
 
5753
- test('makes a scope following a disabled scope read-only while it still reaches extends-only scopes [022.04, 022.06]', () => {
5763
+ test('reports a local scope following a disabled scope as frozen while it still reaches extends-only scopes [022.04, 022.06]', () => {
5754
5764
  const workspaceRoot = createWorkspace('activation-read-only-reaches', {
5755
5765
  '.xdrs/index.md': rootIndex(['[_core](_core/index.md)', '[View scope area-core](area-core/index.md) `disabled`', '[View scope lib](lib/index.md) `extends-only`', '[View scope myteam](myteam/index.md)', ACTIVATION_LEGEND]),
5756
5766
  ...scopeFiles('area-core', '', 'core'),
@@ -5758,22 +5768,24 @@ test('makes a scope following a disabled scope read-only while it still reaches
5758
5768
  ...scopeFiles('myteam', 'follows: area-core\nextends: lib\n'),
5759
5769
  });
5760
5770
  const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
5761
- expect(activationWarnings(result)).toBe('');
5762
- expect(result.readOnlyScopes.get('myteam').join('\n')).toContain('follows: scope "area-core" is tagged `disabled` in the root index; enable it or remove the follows: reference');
5771
+ const errors = activationErrors(result);
5772
+ expect(errors).not.toContain('022.04');
5773
+ expect(errors).toContain('Local scope "myteam" is frozen (read-only) because follows: scope "area-core" is tagged `disabled` in the root index; enable it or remove the follows: reference: ');
5774
+ expect(errors).toContain('myteam/index.md [_core-adr-policy-022.06-read-only-dependencies]');
5763
5775
  });
5764
5776
 
5765
- test('makes an external scope extending a disabled scope read-only [022.06]', () => {
5777
+ test('does not report an external scope extending a disabled scope [022.06]', () => {
5766
5778
  const workspaceRoot = createWorkspace('activation-external-extends-disabled', {
5767
5779
  '.xdrs/index.md': rootIndex(['[_core](_core/index.md)', '[View scope lib](lib/index.md) `disabled`', '[View scope vendor](vendor/index.md)', ACTIVATION_LEGEND]),
5768
5780
  ...scopeFiles('lib'),
5769
5781
  ...scopeFiles('vendor', 'extends: lib\n'),
5770
5782
  '.filedist.lock': '.xdrs/vendor/index.md|some-package|1.0.0\n',
5771
5783
  });
5772
- const result = lintWorkspace(workspaceRoot);
5773
- expect(result.readOnlyScopes.has('vendor')).toBe(true);
5784
+ expect(activationErrors(lintWorkspace(workspaceRoot))).toBe('');
5785
+ expect(activationErrors(lintWorkspace(workspaceRoot, { ignoreExternal: false }))).toBe('');
5774
5786
  });
5775
5787
 
5776
- test('makes _local extending a disabled scope read-only [022.06]', () => {
5788
+ test('reports _local extending a disabled scope as frozen [022.06]', () => {
5777
5789
  const workspaceRoot = createWorkspace('activation-local-extends-disabled', {
5778
5790
  '.xdrs/index.md': rootIndex(['[_core](_core/index.md)', '[View scope lib](lib/index.md) `disabled`', ACTIVATION_LEGEND]),
5779
5791
  ...scopeFiles('lib'),
@@ -5781,10 +5793,10 @@ test('makes _local extending a disabled scope read-only [022.06]', () => {
5781
5793
  ...coreWithScopeTypes(['standard', '_local']),
5782
5794
  });
5783
5795
  const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
5784
- expect(result.readOnlyScopes.has('_local')).toBe(true);
5796
+ expect(activationErrors(result)).toContain('Local scope "_local" is frozen (read-only)');
5785
5797
  });
5786
5798
 
5787
- test('makes a scope whose type is defined only in a disabled scope read-only [022.06]', () => {
5799
+ test('reports a local scope whose type is defined only in a disabled scope as frozen [022.06]', () => {
5788
5800
  const workspaceRoot = createWorkspace('activation-type-in-disabled-scope', {
5789
5801
  '.xdrs/index.md': rootIndex(['[_core](_core/index.md)', '[View scope widgets-core](widgets-core/index.md) `disabled`', '[View scope shop](shop/index.md)', ACTIVATION_LEGEND]),
5790
5802
  '.xdrs/widgets-core/index.md': scopeIndexWith('widgets-core', '', 'core'),
@@ -5795,10 +5807,22 @@ test('makes a scope whose type is defined only in a disabled scope read-only [02
5795
5807
  });
5796
5808
  const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
5797
5809
  expect(result.errors.join('\n')).not.toContain('has no corresponding widget-scope-type policy');
5798
- expect(result.readOnlyScopes.get('shop').join('\n')).toContain('scope type "widget" is defined only in disabled scope(s) "widgets-core"');
5810
+ expect(activationErrors(result)).toContain('Local scope "shop" is frozen (read-only) because scope type "widget" is defined only in disabled scope(s) "widgets-core"');
5811
+ });
5812
+
5813
+ test('reports missing dependencies of a disabled local scope as errors [022.05]', () => {
5814
+ const workspaceRoot = createWorkspace('activation-disabled-local-missing-deps', {
5815
+ '.xdrs/index.md': rootIndex(['[_core](_core/index.md)', '[View scope myteam](myteam/index.md) `disabled`', ACTIVATION_LEGEND]),
5816
+ ...scopeFiles('myteam', 'follows: ghost-core\nextends: ghost-lib\n', 'phantom'),
5817
+ ...coreWithScopeTypes(['standard']),
5818
+ });
5819
+ const errors = lintWorkspace(workspaceRoot, { ignoreExternal: false }).errors.join('\n');
5820
+ expect(errors).toContain('follows references scope "ghost-core" which does not exist in the workspace');
5821
+ expect(errors).toContain('ghost-lib');
5822
+ expect(errors).toContain('phantom');
5799
5823
  });
5800
5824
 
5801
- test('prints one read-only line per cause citing 022.06', () => {
5825
+ test('prints frozen local scopes as errors and never prints warning or READ-ONLY lines [022.06]', () => {
5802
5826
  const workspaceRoot = createWorkspace('activation-cli-read-only', {
5803
5827
  '.xdrs/index.md': rootIndex(['[View scope _core](_core/index.md)', '[View scope area-core](area-core/index.md) `disabled`', '[View scope lib](lib/index.md) `disabled`', '[View scope myteam](myteam/index.md)', ACTIVATION_LEGEND]),
5804
5828
  ...scopeFiles('area-core', '', 'core'),
@@ -5806,22 +5830,40 @@ test('prints one read-only line per cause citing 022.06', () => {
5806
5830
  ...scopeFiles('myteam', 'follows: area-core\nextends: lib\n'),
5807
5831
  });
5808
5832
  const { runLintCli } = require('./lint');
5809
- const logs = [];
5810
- const logSpy = jest.spyOn(console, 'log').mockImplementation((msg) => logs.push(msg));
5811
- const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
5812
- const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
5833
+ const out = [];
5834
+ const errOut = [];
5835
+ const logSpy = jest.spyOn(console, 'log').mockImplementation((msg) => out.push(msg));
5836
+ const errSpy = jest.spyOn(console, 'error').mockImplementation((msg) => errOut.push(msg));
5837
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation((msg) => out.push(msg));
5838
+ let code;
5813
5839
  try {
5814
- runLintCli(['--all', workspaceRoot]);
5840
+ code = runLintCli(['--all', workspaceRoot]);
5815
5841
  } finally {
5816
5842
  logSpy.mockRestore();
5817
5843
  errSpy.mockRestore();
5818
5844
  warnSpy.mockRestore();
5819
5845
  }
5820
- const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
5821
- expect(result.readOnlyScopes.get('myteam')).toHaveLength(2);
5822
- const readOnlyLines = logs.filter((l) => l.includes('READ-ONLY scope "myteam"'));
5823
- expect(readOnlyLines).toHaveLength(2);
5824
- expect(readOnlyLines.every((l) => l.includes('[_core-adr-policy-022.06-read-only-dependencies]'))).toBe(true);
5846
+ expect(code).toBe(1);
5847
+ const frozenLines = errOut.filter((l) => l.includes('Local scope "myteam" is frozen (read-only)'));
5848
+ expect(frozenLines).toHaveLength(2);
5849
+ expect(frozenLines.every((l) => l.includes('[_core-adr-policy-022.06-read-only-dependencies]'))).toBe(true);
5850
+ expect([...out, ...errOut].join('\n')).not.toMatch(/\[warning\]|READ-ONLY/);
5851
+ });
5852
+
5853
+ test('silently skips freeze-reference exempt checks [002.01]', () => {
5854
+ const frozen = (name) => teamXdrDocument('Frozen decision.')
5855
+ .replace(/^---\n/, `---\nfreeze-reference: true\n`)
5856
+ .replace(/^name: .*$/m, `name: ${name}`);
5857
+ const workspaceRoot = createWorkspace('freeze-reference-silent', {
5858
+ '.xdrs/index.md': rootIndex(['[_core](_core/index.md)', '[View scope myteam](myteam/index.md)']),
5859
+ '.xdrs/myteam/index.md': scopeIndexWith('myteam'),
5860
+ '.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [850-frozen](principles/850-frozen.md) - Frozen']),
5861
+ '.xdrs/myteam/adrs/principles/850-frozen.md': frozen('wrong-name'),
5862
+ });
5863
+ const errors = lintWorkspace(workspaceRoot, { ignoreExternal: false }).errors.join('\n');
5864
+ expect(errors).not.toContain('017.05');
5865
+ expect(errors).not.toContain('Policy frontmatter name must be');
5866
+ expect(errors).not.toContain('Policy title must start with');
5825
5867
  });
5826
5868
 
5827
5869
  test('dry-run workspace: disabled core, extends-only ref, unlisted legacy scope', () => {
@@ -5834,10 +5876,10 @@ test('dry-run workspace: disabled core, extends-only ref, unlisted legacy scope'
5834
5876
  });
5835
5877
  const disabledIndexBefore = fs.readFileSync(path.join(workspaceRoot, '.xdrs/ecomm-core/index.md'), 'utf8');
5836
5878
  const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
5837
- expect(result.readOnlyScopes.get('checkout')).toHaveLength(1);
5838
- expect(activationWarnings(result)).toBe('');
5839
- expect(activationErrors(result)).toContain('[View scope legacy](legacy/index.md)');
5840
- expect(activationErrors(result).split('\n')).toHaveLength(1);
5879
+ const errors = activationErrors(result).split('\n');
5880
+ expect(errors).toHaveLength(2);
5881
+ expect(errors.join('\n')).toContain('[View scope legacy](legacy/index.md)');
5882
+ expect(errors.join('\n')).toContain('Local scope "checkout" is frozen (read-only) because follows: scope "ecomm-core" is tagged `disabled`');
5841
5883
  expect(fs.readFileSync(path.join(workspaceRoot, '.xdrs/ecomm-core/index.md'), 'utf8')).toBe(disabledIndexBefore);
5842
5884
  });
5843
5885
 
@@ -5872,7 +5914,7 @@ test('treats a type whose parent chain reaches core as core-type (naming, follow
5872
5914
  expect(errors).toContain('_core-adr-policy-010.12-def-core-scope-policies-must-be-structured');
5873
5915
  });
5874
5916
 
5875
- test('reports missing scope-type for local scopes and marks external ones read-only [010.10, 022.06]', () => {
5917
+ test('reports missing scope-type for local scopes only [010.10, 022.06]', () => {
5876
5918
  const noType = (name) => `---\nname: ${name}\ndescription: Scope ${name}.\napply-to: Test teams\nvalid-from: 2026-01-01\n---\n\n# ${name}\n`;
5877
5919
  const workspaceRoot = createWorkspace('scope-type-missing-local-external', {
5878
5920
  '.xdrs/index.md': rootIndex(['[_core](_core/index.md)', '[View scope localone](localone/index.md)', '[View scope vendor](vendor/index.md)']),
@@ -5885,10 +5927,10 @@ test('reports missing scope-type for local scopes and marks external ones read-o
5885
5927
  const missingTypeErrors = result.errors.filter((e) => e.includes('must include a scope-type field'));
5886
5928
  expect(missingTypeErrors.join('\n')).toContain('localone/index.md');
5887
5929
  expect(missingTypeErrors.join('\n')).not.toContain('vendor/index.md');
5888
- expect(result.readOnlyScopes.get('vendor').join('\n')).toContain('no scope-type');
5930
+ expect(result.errors.join('\n')).not.toContain('022.06');
5889
5931
  });
5890
5932
 
5891
- test('reports missing parent type for local scopes and marks external ones read-only [010.09, 022.06]', () => {
5933
+ test('reports missing parent type for local scopes only [010.09, 022.06]', () => {
5892
5934
  const workspaceRoot = createWorkspace('scope-type-missing-parent', {
5893
5935
  '.xdrs/index.md': rootIndex(['[_core](_core/index.md)', '[View scope localone](localone/index.md)', '[View scope vendor](vendor/index.md)']),
5894
5936
  ...scopeFiles('localone', '', 'widget'),
@@ -5900,7 +5942,7 @@ test('reports missing parent type for local scopes and marks external ones read-
5900
5942
  const parentErrors = result.errors.filter((e) => e.includes('declares parent "ghost"'));
5901
5943
  expect(parentErrors).toHaveLength(1);
5902
5944
  expect(parentErrors[0]).toContain('localone/index.md');
5903
- expect(result.readOnlyScopes.get('vendor').join('\n')).toContain('scope type "ghost"');
5945
+ expect(result.errors.join('\n')).not.toMatch(/vendor\/index\.md.*ghost/);
5904
5946
  });
5905
5947
 
5906
5948
  test('accepts compiled combined with a non-core type [011.10]', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xdrs-core",
3
- "version": "0.52.2",
3
+ "version": "0.52.3",
4
4
  "description": "XDRS (eXtensible Decision Record System): 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",