xdrs-core 0.52.2 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ After preparation those elements can be downloaded anywhere and used to compose
12
12
 
13
13
  Policies capture Architectural (ADR), Business (BDR), and Engineering (EDR) decisions. As organizations grow, hundreds of decisions accumulate across teams, levels, and domains. Without a consistent structure, AI agents cannot efficiently locate the right decisions for a given context, and humans cannot maintain or evolve them sustainably.
14
14
 
15
- This project defines a standard for organizing XDRS that satisfies the following requirements.
15
+ This project defines a standard for organizing XDRS that addresses this problem (see [Features](#features)).
16
16
 
17
17
  ## Overview
18
18
 
@@ -35,6 +35,20 @@ The XDRS framework separates these concerns into different document types, each
35
35
  - **Articles** — Synthetic explanatory texts that combine information from multiple Policies, Research documents, and Skills around a specific topic or audience. They never replace Policies as source of truth.
36
36
  - **Initiatives** — Ephemeral execution documents that describe a problem, proposed solution, and the approach and activities needed to solve it. Initiatives have a clear start and end and must be deleted after full implementation. Lasting outputs are captured as Policies, Skills, Articles, or other artifacts.
37
37
 
38
+ ```mermaid
39
+ flowchart LR
40
+ DR["Raw Decision Record<br/>ADR / BDR / EDR"]
41
+ DR -- why --> R[Research]
42
+ DR -- what --> P["Policy<br/>source of truth"]
43
+ DR -- how --> S[Skill]
44
+ DR -- when --> I["Initiative<br/>ephemeral"]
45
+ DR -- view --> A[Article]
46
+ R -. backs .-> P
47
+ S -. complies with .-> P
48
+ I -. implements .-> P
49
+ A -. explains .-> P
50
+ ```
51
+
38
52
  The compilation process of a raw Decision Record is to distribute it into those different documents and create links between them. You can also use the framework standalone, generating these elements individually directly during the writing process without starting from a raw Decision Record.
39
53
 
40
54
  ## Getting started
@@ -43,9 +57,11 @@ The compilation process of a raw Decision Record is to distribute it into those
43
57
 
44
58
  2. On the workspace root folder, run `npx xdrs-core`
45
59
 
46
- The basic xdrs tooling should be installed in your workspace along with:
47
- - AGENTS.md pointing to the Policies structure
48
- - XDRS related skills and prompts for Copilot
60
+ This installs:
61
+ - `AGENTS.md` instructing agents to consult Policies before every request
62
+ - `.xdrs/_core/` with the framework Policies, skills and articles
63
+ - `.xdrs/index.md` root index, installed unmanaged so you own it and list your scopes there
64
+ - `.agents/skills/review` and `.agents/skills/write-xdrs-doc` symlinks so agents discover the main skills
49
65
 
50
66
  3. Run a prompt such as:
51
67
 
@@ -53,10 +69,28 @@ The compilation process of a raw Decision Record is to distribute it into those
53
69
 
54
70
  > Compile ADR 043-python-package-manager into the XDRS structure
55
71
 
72
+ > Review my changes against our Policies
73
+
74
+ 4. Validate the result with `npx xdrs-core lint .`
75
+
76
+ ### Bundled skills
77
+
78
+ | Skill | Purpose |
79
+ |---|---|
80
+ | `write-xdrs-doc` | Router: infers the document type from your request and delegates to the right skill below |
81
+ | `write-policy`, `write-skill`, `write-article`, `write-research`, `write-initiative` | Author each XDRS element type |
82
+ | `write-presentation` | Create Marp slide decks backing XDRS documents |
83
+ | `review` | Review code or documents against applicable Policies and report violations |
84
+ | `compile-scope` | Fetch and compile a `compiled`-type scope from external sources |
85
+
86
+ See [.xdrs/_core/adrs/index.md](.xdrs/_core/adrs/index.md) for the full list of `_core` Policies, skills and articles.
87
+
56
88
  ## Examples
57
89
 
58
90
  - [examples/basic-usage](examples/basic-usage) shows the minimal consumer flow for installing the packaged `xdrs-core` tarball, extracting files, checking drift, and linting the resulting tree.
59
91
  - [examples/mydevkit](examples/mydevkit) shows a reusable extension package that uses `.filedistrc` as its package config source, composes `xdrs-core`, and ships its own named scope.
92
+ - [examples/typed-scope](examples/typed-scope) shows `core`, `reference`, `platform` and `_local` scopes, a custom `business-area` scope type, and `follows`.
93
+ - [examples/load-test](examples/load-test) generates a large synthetic XDRS tree to exercise the linter at scale.
60
94
  - For a fuller real-world package built on the same distribution model, see [flaviostutz/agentme](https://github.com/flaviostutz/agentme).
61
95
 
62
96
 
@@ -66,7 +100,7 @@ The compilation process of a raw Decision Record is to distribute it into those
66
100
 
67
101
  Different teams at different organizational levels make decisions that apply to different audiences. XDRS are organized by scope (e.g. `_core`, `business-x`, `business-y-mobileapp`) so that each team owns its own decision space. Scopes can extend or override policies from broader scopes, with explicit precedence rules: scopes listed later in an index override those listed earlier.
68
102
 
69
- ### Scope types and `follows`
103
+ ### Scope types
70
104
 
71
105
  Every scope declares a `scope-type` in its `index.md` frontmatter. The six built-in types are `core`, `reference`, `platform`, `compiled`, `standard`, and `_local`, each with different governance rules. Custom types (e.g. `business-area`) can be introduced by adding a `{type}-scope-type` policy to any `core`-type scope.
72
106
 
@@ -79,11 +113,7 @@ Every scope declares a `scope-type` in its `index.md` frontmatter. The six built
79
113
  | `standard` | Business areas, products, teams, or any general-purpose scope. Default type when nothing else fits. | Any valid scope name (e.g. `checkout`, `mobile-app`) | 4th |
80
114
  | `_local` | Workspace-local overrides. Reserved exclusively for the `_local` scope. Never distributed. | `_local` (reserved) | last |
81
115
 
82
- **`core` scopes** carry meta-policies — authoring standards, scope-type definitions, and governance conventions. `_core` is the built-in foundation; teams add their own `core` scope (e.g. `myorg-core`) to extend it with organisation-level standards. A `core`-type scope MUST NOT contain consumable policies intended for other teams to follow.
83
-
84
- **`reference` scopes** hold read-only material to adopt or map against: industry frameworks, regulatory requirements, vendor best-practice patterns, business procedure standards. Content describes how something should be done — not what is currently running.
85
-
86
- **`platform` scopes** document what already exists and is ready to use. A scope is of type `platform` when its content describes a live operational capability — what is available, how to access it, and what constraints apply.
116
+ `_core` is the built-in `core` scope; teams can add their own (e.g. `myorg-core`) with organisation-level authoring standards.
87
117
 
88
118
  **`compiled` scopes** hold policies compiled directly from external authoritative sources. All content must trace back to the source; no invented content is allowed. A `compiled` scope requires a local meta-policy with source URLs and compilation configuration, and uses the `compile-scope` skill to fetch, compile, and keep content up to date. The `compiled` type may be combined with another type (e.g. `scope-type: compiled, reference`) when the combined type's governance also applies.
89
119
 
@@ -102,9 +132,7 @@ Include A01–A10 risk entries only.
102
132
 
103
133
  By default, fetched and selected source content persists under `.assets/sources/[name]/` so later compilations can re-sync incrementally instead of always refetching; set `Source storage: temporary` in `## Sync Settings` to discard the bulk content after each run instead (only the per-source tracking file persists in that mode). Either way, `.assets/sources/` content is never authoritative on its own — only the compiled policies are.
104
134
 
105
- **`standard` scopes** are the default. Use them for team, product, or business-area scopes that do not fit the other types.
106
-
107
- **`_local` scopes** hold workspace-specific content such as local tooling decisions, transition overrides, or temporary decisions. This content is never packaged or distributed.
135
+ ### Scope relationships: `follows` and `extends`
108
136
 
109
137
  The `follows` field in a scope's `index.md` links it to one or more `core` scopes whose standards it must respect (beyond `_core`). This models organisational hierarchies: a team scope can follow a business-area `core` scope, which in turn follows `_core`.
110
138
 
@@ -118,11 +146,37 @@ description: Checkout team decisions
118
146
  ---
119
147
  ```
120
148
 
121
- The recommended ordering in `.xdrs/index.md` is: `core → reference → platform → compiled/standard → _local`. Scopes listed later override earlier ones on the same topic.
149
+ The `extends` field makes a scope inherit all **policy documents** (not skills, articles, research or initiatives) of other scopes, as if authored locally. Use it to adopt a `reference` scope and override only what differs. When scopes are related by `extends`, the chain decides conflicts instead of root index ordering. A scope cannot list the same scope in both `follows` and `extends`, cannot extend `_core`/`_local` or itself, and cannot form cycles.
150
+
151
+ ```yaml
152
+ # .xdrs/payments/index.md
153
+ ---
154
+ scope-type: standard
155
+ name: payments
156
+ extends: ecomm-ref-payments # inherit its policies, override locally when needed
157
+ ---
158
+ ```
159
+
160
+ A scope can also hold its own authoring rules in a local meta-policy (`[type]/principles/NNN-core.md`) without creating a separate `-core` scope. See [_core-adr-policy-010](.xdrs/_core/adrs/principles/010-scope-governance.md) for the full precedence chain.
161
+
162
+ ```mermaid
163
+ flowchart LR
164
+ ecore["ecomm-core<br/>core"]
165
+ ref["ecomm-ref-payments<br/>reference, extends-only"]
166
+ checkout["checkout<br/>standard"]
167
+ payments["payments<br/>standard"]
168
+ local["_local<br/>workspace only"]
169
+
170
+ checkout -- "follows: respects authoring rules" --> ecore
171
+ payments -- follows --> ecore
172
+ payments -- "extends: inherits policies" --> ref
173
+ local -- overrides --> checkout
174
+ local -- overrides --> payments
175
+ ```
122
176
 
123
- Custom scope types can be introduced by adding a `{type}-scope-type` policy to any `core`-type scope. The `checkout` scope in [examples/typed-scope](examples/typed-scope) uses a custom `business-area` type defined in `ecomm-core`.
177
+ `_core` applies implicitly to every scope, so it is never listed in `follows` or `extends`.
124
178
 
125
- See [examples/typed-scope](examples/typed-scope) for a full working example covering all five scope types and a custom `business-area` type.
179
+ The `checkout` scope in [examples/typed-scope](examples/typed-scope) uses a custom `business-area` type defined in `ecomm-core` and declares `follows: ecomm-core`.
126
180
 
127
181
  ### Scope activation
128
182
 
@@ -158,13 +212,27 @@ XDRS packages are versioned and distributed via the npm registry. This allows te
158
212
 
159
213
  The folder layout, file naming, and document format are designed so that AI agents can efficiently work with hundreds of decisions:
160
214
 
161
- - Each Policy is a small, focused Markdown file (target under 1300 words), covering a set of rules or statements.
215
+ - Each Policy is a small, focused Markdown file (target under 1300 words, hard limit 2600), covering a set of rules or statements.
216
+ - Policies that need external citation can expose individually numbered rules (e.g. `_core-adr-policy-010.28-extends-disjoint`), see [_core-adr-policy-008](.xdrs/_core/adrs/principles/008-policy-structured-standards.md).
162
217
  - The canonical index per scope and type lists all XDRS elements with short descriptions, enabling agents to identify relevant records without reading every file.
163
218
  - The root index at `.xdrs/index.md` provides a single entry point for discovery.
164
219
  - Policy metadata gives agents a first-pass filter: check `valid-from` for the convergence date, then check `apply-to`, and finally the decision text itself to confirm the decision should be used in the current context. All documents present in the collection are considered active.
165
220
  - Decisions cross-reference each other by Policy ID rather than duplicating content, keeping individual files concise.
166
221
  - Subject folders reduce the search space when a query maps to a known domain.
167
222
 
223
+ How an agent finds the Policies that apply to a request:
224
+
225
+ ```mermaid
226
+ flowchart LR
227
+ Q([Request]) --> AG[AGENTS.md]
228
+ AG --> RI[".xdrs/index.md<br/>scope order and activation"]
229
+ RI --> TI["scope type indexes<br/>one line per document"]
230
+ TI --> F{"apply-to and<br/>valid-from match?"}
231
+ F -- yes --> P["Read Policy<br/>and linked Skills"]
232
+ F -- no --> X[Skip]
233
+ P --> W([Act and verify])
234
+ ```
235
+
168
236
  ### Multi-agent framework support
169
237
 
170
238
  Policies and skills must be usable by any type of AI agent, not only coding agents (e.g. GitHub Copilot, Cursor, Cline). General-purpose agent frameworks such as LangGraph, CrewAI, AutoGen, and similar orchestration runtimes must be able to consume Policies without relying on IDE-specific tooling or conventions.
@@ -197,15 +265,13 @@ This is especially important for BDRs: because business rules govern decisions t
197
265
  .assets/
198
266
  ```
199
267
 
200
- Document types:
268
+ Types:
201
269
 
202
270
  - **ADR** - Architectural Decision Record: architectural and technical decisions
203
271
  - **BDR** - Business Decision Record: business process and strategy decisions
204
272
  - **EDR** - Engineering Decision Record: engineering workflow and tooling decisions
205
- - **Research** - Exploratory support material used while evaluating or updating a decision. Research captures constraints, findings, options, and proposal tradeoffs, but it is not the source of truth.
206
- - **Skills** - Step-by-step procedural guides that can be followed by humans, AI agents, or both. Must comply with Policies, but add the execution detail they lack. Skills are not mandatory by themselves unless referenced by a Policy or another policy artifact. A skill may start as a fully manual human procedure and evolve incrementally toward partial or full AI automation without being restructured. Co-located with the Policies they implement inside `skills/` sub-directories.
207
- - **Articles** - Synthetic views that explain concepts or combine information from multiple Policies, Research documents, and Skills into a coherent text for a specific topic or audience. Articles are not the source of truth; Policies take precedence when there is a conflict. Useful as navigational indexes that link related artifacts around a specific aspect.
208
- - **Initiatives** - Ephemeral execution documents that describe a problem, proposed solution, and the approach and activities needed to solve it. Initiatives have a clear start and end and must be deleted after full implementation. Lasting outputs are captured as Policies, Skills, Articles, or other artifacts. Co-located with Policies inside `initiatives/` sub-directories.
273
+
274
+ Each type has a fixed set of allowed subjects (e.g. `principles`, `application`, `data` for ADRs), see [_core-adr-policy-016](.xdrs/_core/adrs/principles/016-policy-subjects.md). Element types are described in [XDRS elements](#xdrs-elements).
209
275
 
210
276
  See [.xdrs/index.md](.xdrs/index.md) for the full list of active policies.
211
277
 
@@ -232,38 +298,37 @@ A project that wants to follow a scope's decisions adds the corresponding npm pa
232
298
 
233
299
  Multiple scope packages can be combined in the same workspace by listing them as separate dependencies. Scope precedence (defined in `.xdrs/index.md`) determines which decisions take effect when scopes overlap.
234
300
 
235
- ```
236
- [Scope repo] --> npm publish --> [npm registry] --> npm install --> [Project workspace]
237
- .xdrs/[scope]/ versioned pkg .xdrs/[scope]/
238
- adrs/ bdrs/ edrs/ adrs/ bdrs/ edrs/
239
- [subject]/ [subject]/
240
- *.md *.md
241
- researches/ researches/
242
- skills/ skills/
243
- articles/ articles/
301
+ ```mermaid
302
+ flowchart LR
303
+ subgraph owners[Scope owner repositories]
304
+ A[".xdrs/ecomm-core/"]
305
+ B[".xdrs/checkout/"]
306
+ end
307
+ A -- pack and publish --> NPM[("npm registry<br/>versioned packages")]
308
+ B -- pack and publish --> NPM
309
+ NPM -- "install (filedist)" --> WS
310
+ subgraph WS[Project workspace]
311
+ M[".xdrs/_core, ecomm-core, checkout<br/>managed, pinned in .filedist.lock"]
312
+ X[".xdrs/index.md<br/>owned by the project"]
313
+ L[".xdrs/_local<br/>never published"]
314
+ end
315
+ WS -- "xdrs-core lint / check" --> CI([CI])
244
316
  ```
245
317
 
246
318
  ## CLI
247
319
 
248
320
  The published package exposes the `xdrs-core` CLI.
249
321
 
250
- - Bootstrap or extract managed XDRS files with the existing `filedist`-backed commands such as `npx -y xdrs-core extract` and `npx -y xdrs-core check`.
251
- - Lint a Policy tree with `npx -y xdrs-core lint .`. By default, scopes whose files are listed in the workspace root `.filedist.lock` file are treated as external and skipped; use `--all` to include them.
322
+ - `npx -y xdrs-core` (or `install`) installs or updates the managed XDRS files; `npx -y xdrs-core check` fails if managed files drifted from the package. Both are delegated to [filedist](https://github.com/flaviostutz/filedist) (run `--help` for all options).
323
+ - `npx -y xdrs-core lint [path]` validates the XDRS tree. Scopes listed in the workspace `.filedist.lock` are treated as external and skipped; use `--all` to include them.
252
324
 
253
- The `lint` command reads `./.xdrs/**` from the given workspace path and checks common consistency rules, including:
325
+ The `lint` command reads `.xdrs/**` (or `[path]` directly when it contains an `index.md`) and reports errors citing the violated Policy rule. It checks:
254
326
 
255
- - allowed scope, type, and subject folder structure
256
- - Policy numbering uniqueness per `scope/type`
257
- - article numbering uniqueness per `scope/type/subject/articles`
258
- - research numbering uniqueness per `scope/type/subject/researches`
259
- - initiative numbering uniqueness per `scope/type/subject/initiatives`
260
- - initiative `Expected end date:` field presence and ISO date format
261
- - canonical index presence and link consistency
262
- - root index coverage for all discovered canonical indexes
263
- - root index completeness: every scope folder linked exactly once, activation tags (`extends-only`, `disabled`) valid, coherent with the scope's `extends-only` field, and explained by the legend line when used
264
- - Policy metadata section placement and `valid-from` / `apply-to` field format
265
- - local markdown links between Policy documents, skills, articles, researches, and initiatives (excluding fenced code blocks)
266
- - local image and `.assets/` links resolving inside the sibling `.assets/` folder for each document
327
+ - **Structure**: allowed scope, type and subject folders; canonical indexes present and linking every document; root index linking every scope exactly once with valid activation tags
328
+ - **Numbering**: unique numbers per element type, subject-based numbering ranges ([_core-adr-policy-017](.xdrs/_core/adrs/principles/017-policy-numbering-ranges.md))
329
+ - **Document format**: Policy metadata (`valid-from`, `apply-to`), structured rule syntax, initiative `Expected end date`, skill `README.md`, word limits per element type
330
+ - **Scope governance**: scope-type definitions and naming conventions, `follows`/`extends` targets, cycles and conflicts, local meta-policies, `compiled` scope meta-policy
331
+ - **Links**: local markdown links and `.assets/` references resolve (fenced code blocks are ignored)
267
332
 
268
333
  Examples:
269
334
 
@@ -279,30 +344,61 @@ The package also exposes a reusable behavior-test library for Jest or any other
279
344
 
280
345
  Main exports:
281
346
 
282
- - `testPrompt(config, inputPrompt, judgePrompt)` runs the task prompt, evaluates the result in a fresh judge session, and returns an empty string on success or a markdown bullet list on failure.
283
- - `runPromptTest(config, inputPrompt, judgePrompt)` returns the structured result object when you need access to captured output and the agent-reported changed file list.
284
- - `copilotCmd(workspaceRoot)` returns a ready-to-use `promptCmd` template for the Copilot CLI in headless mode (`--autopilot`, full tool/url permissions, and `--no-ask-user`). The library uses that same command template for both the task and judge phases. If `workspaceRoot` is omitted it defaults to the current git repository root.
285
- - `config.workspaceRoot`, when set, is the authoritative workspace under test. If omitted, the library uses the current git repository root.
347
+ - `async testPrompt(config, inputPrompt, judgePrompt, id?, verbose?)` runs the task prompt, evaluates the result with a judge prompt, and resolves to an empty string on success or a markdown bullet list of findings on failure. Passing results are cached (see below).
348
+ - `async runPromptTest(config, inputPrompt, judgePrompt, verbose?)` resolves to the structured result (`passed`, `findings`, `taskOutput`, `agentReportedChanges`, `contextFiles`, `workspace`), without caching.
349
+ - `copilotCmd(workspaceRoot?)` returns a partial config for the Copilot CLI in headless mode (`promptCmd`, `promptCmdContinueFlag`, `workspaceFilter`, `workspaceExclude`). Spread it into your config. `workspaceRoot` defaults to the current git repository root.
350
+
351
+ Main `config` fields:
352
+
353
+ | Field | Description |
354
+ |---|---|
355
+ | `promptCmd` | Command as string array or JSON array string; must include a `{PROMPT}` placeholder |
356
+ | `promptCmdContinueFlag` | Flag inserted before the prompt to continue the previous session (e.g. `--continue`) |
357
+ | `workspaceRoot` | Workspace under test (default: current git repository root) |
358
+ | `workspaceMode` | `copy` (default, runs in a temp copy honoring `.gitignore`, without git metadata) or `in-place` |
359
+ | `workspaceFilter` / `workspaceExclude` | Globs selecting which files are copied |
360
+ | `taskTimeoutMs` / `judgeTimeoutMs` | Timeouts per phase |
361
+ | `env` | Extra environment variables for the command |
362
+ | `reportFile` | Cache file of passing tests (default: `<test-file>.report` next to the calling test); `null` disables caching |
363
+ | `checkOnly` | Do not run prompts; fail unless a matching passing entry exists in `reportFile` (useful in CI) |
364
+ | `model` | Model name, included in the cache hash |
286
365
 
287
366
  Execution model:
288
367
 
289
- - phase 1 runs the task prompt and captures final output text plus the files the agent says it changed
290
- - phase 2 runs an independent judge prompt in a fresh invocation of `promptCmd` against the original task prompt, task output, the agent-reported changed file list, and the current workspace state
291
- - the judge trusts that reported file list as the authoritative change report and reads file contents from the workspace directly when needed
292
- - when `workspaceMode: 'copy'` is used, the temporary workspace honors nested `.gitignore` rules and skips git metadata files during the copy
293
-
294
- `promptCmd` accepts either a string array or a JSON array string and must include a `{PROMPT}` placeholder.
368
+ 1. **Task**: runs the task prompt and captures the final output
369
+ 2. **Files**: continues the same session to collect the files the agent read and changed
370
+ 3. **Judge**: continues the same session and evaluates the task output, changed files and workspace state against the judge prompt
371
+
372
+ A passing result is stored in `reportFile` with a hash of the model, prompts and the files the agent read. Later runs are skipped while that hash is unchanged, so tests re-run only when relevant context changes.
373
+
374
+ ```mermaid
375
+ sequenceDiagram
376
+ participant T as Test
377
+ participant L as testPrompt
378
+ participant A as Agent CLI
379
+ T->>L: testPrompt(config, task, judge)
380
+ alt hash matches reportFile entry
381
+ L-->>T: empty string (cached pass)
382
+ else run
383
+ L->>A: 1. task prompt (temp workspace copy)
384
+ L->>A: 2. continue: list files read and changed
385
+ L->>A: 3. continue: judge prompt
386
+ A-->>L: pass and findings
387
+ L->>L: store hash when passed
388
+ L-->>T: empty string or findings markdown
389
+ end
390
+ ```
295
391
 
296
392
  Example with Jest:
297
393
 
298
394
  ```js
299
395
  const { copilotCmd, testPrompt } = require('xdrs-core');
300
396
 
301
- test('creates hello.md', () => {
302
- const err = testPrompt(
397
+ test('creates hello.md', async () => {
398
+ const err = await testPrompt(
303
399
  {
400
+ ...copilotCmd(process.cwd()),
304
401
  workspaceRoot: process.cwd(),
305
- promptCmd: copilotCmd(process.cwd()),
306
402
  workspaceMode: 'copy'
307
403
  },
308
404
  "Create a nice markdown file at hello.md saying 'hello!'",
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
  }