xdrs-core 0.6.0 → 0.7.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.
@@ -11,6 +11,7 @@ Foundational standards, principles, and guidelines.
11
11
  - [_core-adr-001](principles/001-xdr-standards.md) - **XDR standards** (includes XDR template)
12
12
  - [_core-adr-003](principles/003-skill-standards.md) - **Skill standards**
13
13
  - [_core-adr-004](principles/004-article-standards.md) - **Article standards**
14
+ - [_core-adr-005](principles/005-semantic-versioning-for-xdr-packages.md) - **Semantic versioning for XDR packages**
14
15
 
15
16
  ## Skills
16
17
 
@@ -0,0 +1,45 @@
1
+ # _core-adr-005: Semantic versioning for XDR packages
2
+
3
+ ## Context and Problem Statement
4
+
5
+ Teams consume XDR packages as reusable guidance, constraints, skills, and articles. If package versions do not reflect the real impact of a release, upgrades become risky and teams lose trust in reuse.
6
+
7
+ Question: How should semantic versioning be used when publishing or versioning a package containing XDRs, skills, and articles?
8
+
9
+ ## Decision Outcome
10
+
11
+ **semantic versioning aligned with decision impact**
12
+
13
+ XDR packages must use semantic versioning so the package version communicates the expected upgrade impact on consuming teams and projects.
14
+
15
+ ### Implementation Details
16
+
17
+ - Package versions MUST follow `MAJOR.MINOR.PATCH`.
18
+ - The published package version MUST represent the impact of the package as a whole, not only of a single changed file.
19
+ - If a release contains changes of different severities, the highest-impact change MUST define the version bump.
20
+ - When uncertainty exists between two levels, the safer and more explicit version bump SHOULD be chosen.
21
+
22
+ **MAJOR**
23
+ - Use a major bump for breaking changes.
24
+ - Use a major bump when an existing XDR changes meaning in a way that can require consuming teams to revisit architecture, governance, operations, or implementation decisions.
25
+ - Use a major bump when impactful concepts are introduced or changed in a way that materially alters how the package should be adopted or interpreted.
26
+ - Typical cases: removed or renamed XDRs that affect references, changed mandatory rules, changed conflict/override behavior, or changed guidance that invalidates previously compliant usage.
27
+
28
+ **MINOR**
29
+ - Use a minor bump for backward-compatible additions and new capabilities.
30
+ - Use a minor bump for new XDRs that do not break existing guidance.
31
+ - Use a minor bump for new or updated articles and skill changes that extend the package without requiring consumers to undo previous adoption work.
32
+ - Typical cases: new features, new optional guidance, new articles, expanded skills, or additive non-breaking decision coverage.
33
+
34
+ **PATCH**
35
+ - Use a patch bump for low-risk fixes and simple improvements.
36
+ - Use a patch bump for corrections that preserve the existing meaning and upgrade expectations of the package.
37
+ - Typical cases: typo fixes, broken links, wording clarifications, examples, simple additions, formatting fixes, and small consistency improvements.
38
+
39
+ - Teams publishing XDR packages SHOULD treat the version number as an upgrade contract.
40
+ - Consumers SHOULD be able to assume that patch upgrades are low risk, minor upgrades are additive, and major upgrades may require review or migration work.
41
+ - Release notes SHOULD explain the reason for the chosen bump, especially for major releases.
42
+
43
+ ## References
44
+
45
+ - https://semver.org/
@@ -0,0 +1,11 @@
1
+ # local ADRs Index
2
+
3
+ This index covers project-local architectural documentation for this repository. These documents
4
+ are not meant to be distributed as reusable scope packages unless they are promoted into a named
5
+ shared scope.
6
+
7
+ ## Articles
8
+
9
+ Synthetic views combining XDRs, repository examples, and packaging guidance.
10
+
11
+ - [_local-article-001](principles/articles/001-create-your-own-xdrs-extension-package.md) - **Create your own xdrs-core extension package** (package layout, filedist sets, publishing, versioning, and consumer overrides)
@@ -0,0 +1,123 @@
1
+ # _local-article-001: Create your own xdrs-core extension package
2
+
3
+ ## Overview
4
+
5
+ This article explains how to turn your own XDR scope into a distributable npm package. It is for
6
+ teams that want to publish their own DRs, skills, and articles while staying compatible with the
7
+ xdrs-core structure and extraction flow.
8
+
9
+ ## Content
10
+
11
+ ### Start with a real shared scope, not `_local`
12
+
13
+ Use `_local` only for project-only records that must stay inside one repository. Shared packages
14
+ should publish a named scope such as `acme-platform` or `team-ml`, because scopes are the unit of
15
+ ownership, distribution, and override ordering in XDRs. The root structure and precedence rules are
16
+ defined in [_core-adr-001](../../../../_core/adrs/principles/001-xdr-standards.md).
17
+
18
+ ### Package the whole scope as a normal npm package
19
+
20
+ The package shape used by this repository is the simplest reference implementation: the published
21
+ artifact is a regular npm package with a `files` whitelist, a thin CLI entrypoint, and `filedist`
22
+ configuration embedded in [package.json](../../../../../package.json). The important parts are:
23
+
24
+ - include the shipped XDR tree, agent instruction files, and CLI files in `files`
25
+ - expose `bin/filedist.js` as the package `bin` so consumers run the package through the same
26
+ `extract` and `check` interface
27
+ - define one `filedist.sets` entry for managed files and another for editable local overrides
28
+
29
+ For a minimal consumer flow, see [example/package.json](../../../../../example/package.json) and
30
+ [example/Makefile](../../../../../example/Makefile).
31
+
32
+ ### Separate managed files from local overrides
33
+
34
+ This repository's `filedist` config in [package.json](../../../../../package.json) shows the key
35
+ pattern:
36
+
37
+ - managed set: ships `AGENTS.md` and `.xdrs/**`, while excluding `.xdrs/index.md`
38
+ - keep-existing set: ships `.xdrs/index.md` and `AGENTS.local.md` with `managed=false` and
39
+ `keepExisting=true`
40
+
41
+ That split matters because consumers need some files to stay under package control and others to
42
+ remain editable in their own repository. The current example verifies exactly that behavior by
43
+ re-extracting into [example/output](../../../../../example/output) and asserting that local edits to
44
+ `.xdrs/index.md` survive `extract --keep-existing` while managed files are still checked for drift in
45
+ [example/Makefile](../../../../../example/Makefile).
46
+
47
+ ### Keep DRs, skills, and articles together
48
+
49
+ Your reusable package should place DRs, skills, and articles under the same scope folder so they
50
+ ship together:
51
+
52
+ ```text
53
+ .xdrs/
54
+ my-scope/
55
+ adrs/
56
+ index.md
57
+ principles/
58
+ 001-my-decision.md
59
+ skills/
60
+ 001-my-skill/SKILL.md
61
+ articles/
62
+ 001-my-overview.md
63
+ ```
64
+
65
+ The co-location rule for skills comes from [_core-adr-003](../../../../_core/adrs/principles/003-skill-standards.md),
66
+ and article placement rules come from [_core-adr-004](../../../../_core/adrs/principles/004-article-standards.md).
67
+ When you publish the scope folder, those documents travel together and stay version-aligned.
68
+
69
+ ### Expose skills to Copilot-compatible tooling
70
+
71
+ This repository's managed `filedist` set creates symlinks from `.xdrs/**/skills/*` into
72
+ `.github/skills`, configured in [package.json](../../../../../package.json). That means your skills
73
+ remain authored next to the XDRs they implement, but consumers also get the discovery path expected
74
+ by GitHub Copilot and similar tooling.
75
+
76
+ If your package targets non-Copilot agents too, keep the source of truth in `.xdrs/[scope]/.../skills/`
77
+ and treat `.github/skills` as an exposure mechanism rather than the canonical location.
78
+
79
+ ### Verify with a consumer example before publishing
80
+
81
+ The [example](../../../../../example) folder is the best reference in this repository for the
82
+ consumer side of the workflow:
83
+
84
+ 1. depend on the locally packed tarball from `dist/`
85
+ 2. run `pnpm exec xdrs-core extract --output ./output`
86
+ 3. verify the expected files exist
87
+ 4. re-run extraction to confirm keep-existing behavior
88
+ 5. run `check` and `lint` against the extracted tree
89
+
90
+ That same pattern should exist in your extension package repository. A runnable example catches bad
91
+ selectors, missing files, broken indexes, and skill exposure problems before you publish.
92
+
93
+ ### Publish and version the package as an upgrade contract
94
+
95
+ The release flow in [Makefile](../../../../../Makefile) packs the project and publishes it with npm,
96
+ including prerelease tag handling. Your extension package can follow the same shape: `pnpm pack` for
97
+ local verification, then `npm publish` to your public or internal registry.
98
+
99
+ Version the package with semantic versioning according to the impact on consumers, not only on the
100
+ changed file. [_core-adr-005](../../../../_core/adrs/principles/005-semantic-versioning-for-xdr-packages.md)
101
+ defines the practical rule: breaking guidance or changed mandatory behavior is `MAJOR`, additive
102
+ guidance such as new DRs, skills, or articles is usually `MINOR`, and low-risk corrections are
103
+ `PATCH`.
104
+
105
+ ### Use agentme as the fuller packaged example
106
+
107
+ This repository shows the baseline xdrs-core packaging model. For a fuller distribution package that
108
+ combines reusable XDR scopes with additional agent workflow files and presets, use
109
+ [flaviostutz/agentme](https://github.com/flaviostutz/agentme) as the reference. Its README shows the
110
+ same extraction model applied to a richer package: install the dependency, run `extract`, review the
111
+ generated output, and re-run `check` when upgrading.
112
+
113
+ ## References
114
+
115
+ - [_core-adr-001](../../../../_core/adrs/principles/001-xdr-standards.md) - Scope structure, precedence, and distribution model
116
+ - [_core-adr-003](../../../../_core/adrs/principles/003-skill-standards.md) - Skill co-location and discovery rules
117
+ - [_core-adr-004](../../../../_core/adrs/principles/004-article-standards.md) - Article placement and template rules
118
+ - [_core-adr-005](../../../../_core/adrs/principles/005-semantic-versioning-for-xdr-packages.md) - Versioning policy for published XDR packages
119
+ - [package.json](../../../../../package.json) - Reference `files`, `bin`, symlink, and `filedist` set layout
120
+ - [Makefile](../../../../../Makefile) - Reference pack and publish flow
121
+ - [example/package.json](../../../../../example/package.json) - Minimal consumer dependency setup
122
+ - [example/Makefile](../../../../../example/Makefile) - Extraction, keep-existing, check, and lint verification flow
123
+ - [agentme](https://github.com/flaviostutz/agentme) - Full distribution package example built on top of xdrs-core
package/.xdrs/index.md CHANGED
@@ -21,8 +21,4 @@ Decisions about how XDRs work
21
21
 
22
22
  Project-local XDRs that must not be shared with other contexts. Always keep this scope last so its decisions override or extend all scopes listed above. Add specific `_local` ADR/BDR/EDR index links here when present.
23
23
 
24
- [View _local BDRs Index](_local/bdrs/index.md)
25
-
26
- [View _local ADRs Index](_local/adrs/index.md)
27
-
28
- [View _local EDRs Index](_local/edrs/index.md)
24
+ [View local ADRs Index](_local/adrs/index.md)
package/README.md CHANGED
@@ -32,6 +32,36 @@ Every XDR package contains three types of documents:
32
32
 
33
33
  > Create an ADR about our decision on using Python for AI related projects. For high volume projects (expected >1000 t/s), an exception can be made on using Golang.
34
34
 
35
+ ## Examples
36
+
37
+ - [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.
38
+ - [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.
39
+ - For a fuller real-world package built on the same distribution model, see [flaviostutz/agentme](https://github.com/flaviostutz/agentme).
40
+
41
+ ## CLI
42
+
43
+ The published package exposes the `xdrs-core` CLI.
44
+
45
+ - Bootstrap or extract managed XDR files with the existing `filedist`-backed commands such as `npx -y xdrs-core extract` and `npx -y xdrs-core check`.
46
+ - Lint an XDR tree with `npx -y xdrs-core lint .`.
47
+
48
+ The `lint` command reads `./.xdrs/**` from the given workspace path and checks common consistency rules, including:
49
+
50
+ - allowed scope, type, and subject folder structure
51
+ - XDR numbering uniqueness per `scope/type`
52
+ - skill numbering uniqueness per `scope/type/subject/skills`
53
+ - article numbering uniqueness per `scope/type/subject/articles`
54
+ - canonical index presence and link consistency
55
+ - root index coverage for all discovered canonical indexes
56
+
57
+ Examples:
58
+
59
+ ```bash
60
+ npx -y xdrs-core lint .
61
+ npx -y xdrs-core lint ./some-project
62
+ pnpm exec xdrs-core lint .
63
+ ```
64
+
35
65
  ## Requirements
36
66
 
37
67
  ### Multi-scope support
@@ -94,6 +124,7 @@ Document types:
94
124
  See [.xdrs/index.md](.xdrs/index.md) for the full list of active decision records.
95
125
 
96
126
  For a deeper overview of XDRs — objective, structure, guidelines, extension, and usage — see the [XDRs Overview article](.xdrs/_core/adrs/principles/articles/001-xdrs-overview.md).
127
+ For packaging guidance on publishing your own reusable scope with DRs, skills, and articles, see the [Create your own xdrs-core extension package article](.xdrs/_local/adrs/principles/articles/001-create-your-own-xdrs-extension-package.md), then compare [examples/basic-usage](examples/basic-usage) and [examples/mydevkit](examples/mydevkit).
97
128
 
98
129
  ## Flow: Decision -> Distribution -> Usage
99
130
 
package/bin/filedist.js CHANGED
@@ -1,3 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- require('filedist').binpkg(__dirname, process.argv.slice(2));
3
+
4
+ const { runLintCli } = require('../lib/lint');
5
+
6
+ const args = process.argv.slice(2);
7
+
8
+ if (args[0] === 'lint') {
9
+ process.exitCode = runLintCli(args.slice(1));
10
+ } else {
11
+ require('filedist').binpkg(__dirname, args);
12
+ }
package/lib/lint.js ADDED
@@ -0,0 +1,462 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const TYPE_TO_ID = {
8
+ adrs: 'adr',
9
+ bdrs: 'bdr',
10
+ edrs: 'edr'
11
+ };
12
+
13
+ const ALLOWED_SUBJECTS = {
14
+ adrs: new Set(['principles', 'application', 'data', 'integration', 'platform', 'controls', 'operations']),
15
+ bdrs: new Set(['principles', 'marketing', 'product', 'controls', 'operations', 'organization', 'finance', 'sustainability']),
16
+ edrs: new Set(['principles', 'application', 'infra', 'ai', 'observability', 'devops', 'governance'])
17
+ };
18
+
19
+ const TYPE_NAMES = new Set(Object.keys(TYPE_TO_ID));
20
+ const RESERVED_SCOPES = new Set(['_core', '_local']);
21
+ const NUMBERED_FILE_RE = /^(\d{3,})-([a-z0-9-]+)\.md$/;
22
+ const NUMBERED_DIR_RE = /^(\d{3,})-([a-z0-9-]+)$/;
23
+ const REQUIRED_ROOT_INDEX_TEXT = 'XDRs in scopes listed last override the ones listed first';
24
+
25
+ function runLintCli(args) {
26
+ if (args.includes('--help') || args.includes('-h')) {
27
+ printHelp();
28
+ return 0;
29
+ }
30
+
31
+ const targetPath = args[0] || '.';
32
+ const result = lintWorkspace(targetPath);
33
+
34
+ if (result.errors.length === 0) {
35
+ console.log(`Lint passed for ${toDisplayPath(result.xdrsRoot)}`);
36
+ return 0;
37
+ }
38
+
39
+ console.error(`Lint failed for ${toDisplayPath(result.xdrsRoot)}`);
40
+ for (const error of result.errors) {
41
+ console.error(`- ${error}`);
42
+ }
43
+
44
+ return 1;
45
+ }
46
+
47
+ function printHelp() {
48
+ console.log('Usage: xdrs-core lint [path]\n');
49
+ console.log('Lint the XDR tree rooted at [path]/.xdrs or at [path] when [path] already points to .xdrs.');
50
+ console.log('All other commands continue to be delegated to the bundled filedist CLI.');
51
+ }
52
+
53
+ function lintWorkspace(targetPath) {
54
+ const resolvedTarget = path.resolve(targetPath);
55
+ const xdrsRoot = path.basename(resolvedTarget) === '.xdrs'
56
+ ? resolvedTarget
57
+ : path.join(resolvedTarget, '.xdrs');
58
+ const errors = [];
59
+
60
+ if (!existsDirectory(xdrsRoot)) {
61
+ errors.push(`Missing XDR root directory: ${toDisplayPath(xdrsRoot)}`);
62
+ return { xdrsRoot, errors };
63
+ }
64
+
65
+ const actualTypeIndexes = [];
66
+ const rootEntries = safeReadDir(xdrsRoot, errors, 'read XDR root directory');
67
+ const scopeEntries = rootEntries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'));
68
+
69
+ for (const entry of rootEntries) {
70
+ if (entry.isFile() && entry.name !== 'index.md') {
71
+ errors.push(`Unexpected file at .xdrs root: ${entry.name}`);
72
+ }
73
+ }
74
+
75
+ for (const scopeEntry of scopeEntries) {
76
+ lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, actualTypeIndexes);
77
+ }
78
+
79
+ const rootIndexPath = path.join(xdrsRoot, 'index.md');
80
+ if (!existsFile(rootIndexPath)) {
81
+ errors.push('Missing required root index: .xdrs/index.md');
82
+ } else {
83
+ lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors);
84
+ }
85
+
86
+ return { xdrsRoot, errors };
87
+ }
88
+
89
+ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors) {
90
+ const content = fs.readFileSync(rootIndexPath, 'utf8');
91
+
92
+ if (!content.includes(REQUIRED_ROOT_INDEX_TEXT)) {
93
+ errors.push(`Root index is missing required override text: ${toDisplayPath(rootIndexPath)}`);
94
+ }
95
+
96
+ const localLinks = parseLocalLinks(content, path.dirname(rootIndexPath));
97
+ for (const linkPath of localLinks) {
98
+ if (!fs.existsSync(linkPath)) {
99
+ errors.push(`Broken link in root index: ${displayPath(rootIndexPath, linkPath)}`);
100
+ }
101
+ }
102
+
103
+ const linkedTypeIndexes = localLinks.filter((linkPath) => isCanonicalTypeIndex(linkPath, xdrsRoot));
104
+ const linkedSet = new Set(linkedTypeIndexes.map(normalizePath));
105
+
106
+ for (const indexPath of actualTypeIndexes) {
107
+ if (!linkedSet.has(normalizePath(indexPath))) {
108
+ errors.push(`Root index is missing canonical index link: ${toDisplayPath(indexPath)}`);
109
+ }
110
+ }
111
+
112
+ let seenLocal = false;
113
+ for (const indexPath of linkedTypeIndexes) {
114
+ const scopeName = path.basename(path.dirname(path.dirname(indexPath)));
115
+ if (scopeName === '_local') {
116
+ seenLocal = true;
117
+ continue;
118
+ }
119
+ if (seenLocal) {
120
+ errors.push('Root index must keep all _local scope links after every non-_local scope link');
121
+ break;
122
+ }
123
+ }
124
+ }
125
+
126
+ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes) {
127
+ const scopePath = path.join(xdrsRoot, scopeName);
128
+
129
+ if (!isValidScopeName(scopeName)) {
130
+ errors.push(`Invalid scope name: ${toDisplayPath(scopePath)}`);
131
+ }
132
+
133
+ const entries = safeReadDir(scopePath, errors, `read scope directory ${scopeName}`);
134
+ for (const entry of entries) {
135
+ const entryPath = path.join(scopePath, entry.name);
136
+ if (entry.isDirectory()) {
137
+ if (!TYPE_NAMES.has(entry.name)) {
138
+ errors.push(`Unexpected directory under scope ${scopeName}: ${toDisplayPath(entryPath)}`);
139
+ continue;
140
+ }
141
+ lintTypeDirectory(xdrsRoot, scopeName, entry.name, errors, actualTypeIndexes);
142
+ continue;
143
+ }
144
+
145
+ errors.push(`Unexpected file under scope ${scopeName}: ${toDisplayPath(entryPath)}`);
146
+ }
147
+ }
148
+
149
+ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeIndexes) {
150
+ const typePath = path.join(xdrsRoot, scopeName, typeName);
151
+ const indexPath = path.join(typePath, 'index.md');
152
+ const xdrNumbers = new Map();
153
+ const artifacts = [];
154
+
155
+ if (!existsFile(indexPath)) {
156
+ errors.push(`Missing canonical index: ${toDisplayPath(indexPath)}`);
157
+ } else {
158
+ actualTypeIndexes.push(indexPath);
159
+ }
160
+
161
+ const entries = safeReadDir(typePath, errors, `read type directory ${scopeName}/${typeName}`);
162
+ for (const entry of entries) {
163
+ const entryPath = path.join(typePath, entry.name);
164
+ if (entry.isFile()) {
165
+ if (entry.name !== 'index.md') {
166
+ errors.push(`Unexpected file under ${scopeName}/${typeName}: ${toDisplayPath(entryPath)}`);
167
+ }
168
+ continue;
169
+ }
170
+
171
+ if (!ALLOWED_SUBJECTS[typeName].has(entry.name)) {
172
+ errors.push(`Invalid subject folder for ${typeName}: ${toDisplayPath(entryPath)}`);
173
+ continue;
174
+ }
175
+
176
+ artifacts.push(...lintSubjectDirectory(xdrsRoot, scopeName, typeName, entry.name, xdrNumbers, errors));
177
+ }
178
+
179
+ if (existsFile(indexPath)) {
180
+ lintTypeIndex(indexPath, xdrsRoot, artifacts, errors);
181
+ }
182
+ }
183
+
184
+ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNumbers, errors) {
185
+ const subjectPath = path.join(xdrsRoot, scopeName, typeName, subjectName);
186
+ const artifacts = [];
187
+ const entries = safeReadDir(subjectPath, errors, `read subject directory ${scopeName}/${typeName}/${subjectName}`);
188
+
189
+ for (const entry of entries) {
190
+ const entryPath = path.join(subjectPath, entry.name);
191
+
192
+ if (entry.isDirectory()) {
193
+ if (entry.name === 'skills') {
194
+ artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
195
+ continue;
196
+ }
197
+ if (entry.name === 'articles') {
198
+ artifacts.push(...lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
199
+ continue;
200
+ }
201
+
202
+ errors.push(`Unexpected directory under ${scopeName}/${typeName}/${subjectName}: ${toDisplayPath(entryPath)}`);
203
+ continue;
204
+ }
205
+
206
+ if (!NUMBERED_FILE_RE.test(entry.name)) {
207
+ errors.push(`Invalid XDR file name: ${toDisplayPath(entryPath)}`);
208
+ continue;
209
+ }
210
+
211
+ artifacts.push(entryPath);
212
+ lintXdrFile(xdrsRoot, scopeName, typeName, entryPath, xdrNumbers, errors);
213
+ }
214
+
215
+ return artifacts;
216
+ }
217
+
218
+ function lintXdrFile(xdrsRoot, scopeName, typeName, filePath, xdrNumbers, errors) {
219
+ const baseName = path.basename(filePath);
220
+ const match = baseName.match(NUMBERED_FILE_RE);
221
+ if (!match) {
222
+ return;
223
+ }
224
+
225
+ const number = match[1];
226
+ const previous = xdrNumbers.get(number);
227
+ if (previous) {
228
+ errors.push(`Duplicate XDR number ${number} in ${scopeName}/${typeName}: ${toDisplayPath(previous)} and ${toDisplayPath(filePath)}`);
229
+ } else {
230
+ xdrNumbers.set(number, filePath);
231
+ }
232
+
233
+ if (baseName !== baseName.toLowerCase()) {
234
+ errors.push(`XDR file name must be lowercase: ${toDisplayPath(filePath)}`);
235
+ }
236
+
237
+ const content = fs.readFileSync(filePath, 'utf8');
238
+ const expectedHeader = `# ${scopeName}-${TYPE_TO_ID[typeName]}-${number}:`;
239
+ const firstLine = firstNonEmptyLine(content);
240
+ if (!firstLine.startsWith(expectedHeader)) {
241
+ errors.push(`XDR title must start with "${expectedHeader}": ${toDisplayPath(filePath)}`);
242
+ }
243
+ }
244
+
245
+ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors) {
246
+ const artifacts = [];
247
+ const skillNumbers = new Map();
248
+ const entries = safeReadDir(skillsPath, errors, `read skills directory ${scopeName}/${typeName}/${subjectName}/skills`);
249
+
250
+ for (const entry of entries) {
251
+ const entryPath = path.join(skillsPath, entry.name);
252
+ if (!entry.isDirectory()) {
253
+ errors.push(`Unexpected file in skills directory: ${toDisplayPath(entryPath)}`);
254
+ continue;
255
+ }
256
+
257
+ const match = entry.name.match(NUMBERED_DIR_RE);
258
+ if (!match) {
259
+ errors.push(`Invalid skill package name: ${toDisplayPath(entryPath)}`);
260
+ continue;
261
+ }
262
+
263
+ const number = match[1];
264
+ const previous = skillNumbers.get(number);
265
+ if (previous) {
266
+ errors.push(`Duplicate skill number ${number} in ${scopeName}/${typeName}/${subjectName}/skills: ${toDisplayPath(previous)} and ${toDisplayPath(entryPath)}`);
267
+ } else {
268
+ skillNumbers.set(number, entryPath);
269
+ }
270
+
271
+ if (entry.name !== entry.name.toLowerCase()) {
272
+ errors.push(`Skill package name must be lowercase: ${toDisplayPath(entryPath)}`);
273
+ }
274
+
275
+ const skillFilePath = path.join(entryPath, 'SKILL.md');
276
+ artifacts.push(skillFilePath);
277
+
278
+ if (!existsFile(skillFilePath)) {
279
+ errors.push(`Missing SKILL.md in skill package: ${toDisplayPath(entryPath)}`);
280
+ continue;
281
+ }
282
+
283
+ const skillContent = fs.readFileSync(skillFilePath, 'utf8');
284
+ const frontmatterName = extractFrontmatterName(skillContent);
285
+ if (!frontmatterName) {
286
+ errors.push(`SKILL.md is missing a frontmatter name field: ${toDisplayPath(skillFilePath)}`);
287
+ } else if (frontmatterName !== entry.name) {
288
+ errors.push(`Skill frontmatter name must match package directory "${entry.name}": ${toDisplayPath(skillFilePath)}`);
289
+ }
290
+ }
291
+
292
+ return artifacts;
293
+ }
294
+
295
+ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, articlesPath, errors) {
296
+ const artifacts = [];
297
+ const articleNumbers = new Map();
298
+ const entries = safeReadDir(articlesPath, errors, `read articles directory ${scopeName}/${typeName}/${subjectName}/articles`);
299
+
300
+ for (const entry of entries) {
301
+ const entryPath = path.join(articlesPath, entry.name);
302
+ if (!entry.isFile()) {
303
+ errors.push(`Unexpected directory in articles folder: ${toDisplayPath(entryPath)}`);
304
+ continue;
305
+ }
306
+
307
+ const match = entry.name.match(NUMBERED_FILE_RE);
308
+ if (!match) {
309
+ errors.push(`Invalid article file name: ${toDisplayPath(entryPath)}`);
310
+ continue;
311
+ }
312
+
313
+ artifacts.push(entryPath);
314
+
315
+ const number = match[1];
316
+ const previous = articleNumbers.get(number);
317
+ if (previous) {
318
+ errors.push(`Duplicate article number ${number} in ${scopeName}/${typeName}/${subjectName}/articles: ${toDisplayPath(previous)} and ${toDisplayPath(entryPath)}`);
319
+ } else {
320
+ articleNumbers.set(number, entryPath);
321
+ }
322
+
323
+ if (entry.name !== entry.name.toLowerCase()) {
324
+ errors.push(`Article file name must be lowercase: ${toDisplayPath(entryPath)}`);
325
+ }
326
+
327
+ const content = fs.readFileSync(entryPath, 'utf8');
328
+ const expectedHeader = `# ${scopeName}-article-${number}:`;
329
+ const firstLine = firstNonEmptyLine(content);
330
+ if (!firstLine.startsWith(expectedHeader)) {
331
+ errors.push(`Article title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
332
+ }
333
+ }
334
+
335
+ return artifacts;
336
+ }
337
+
338
+ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors) {
339
+ const content = fs.readFileSync(indexPath, 'utf8');
340
+ const localLinks = parseLocalLinks(content, path.dirname(indexPath));
341
+ const linkedSet = new Set();
342
+
343
+ for (const linkPath of localLinks) {
344
+ if (!fs.existsSync(linkPath)) {
345
+ errors.push(`Broken link in canonical index ${toDisplayPath(indexPath)}: ${displayPath(indexPath, linkPath)}`);
346
+ continue;
347
+ }
348
+
349
+ linkedSet.add(normalizePath(linkPath));
350
+ }
351
+
352
+ for (const artifactPath of artifacts) {
353
+ if (!linkedSet.has(normalizePath(artifactPath))) {
354
+ errors.push(`Canonical index ${toDisplayPath(indexPath)} is missing an entry for ${toDisplayPath(artifactPath)}`);
355
+ }
356
+ }
357
+ }
358
+
359
+ function parseLocalLinks(markdown, baseDir) {
360
+ const links = [];
361
+ const linkRe = /\[[^\]]+\]\(([^)]+)\)/g;
362
+ let match = linkRe.exec(markdown);
363
+ while (match) {
364
+ const rawTarget = match[1].trim();
365
+ if (isLocalLink(rawTarget)) {
366
+ const targetWithoutAnchor = rawTarget.split('#')[0];
367
+ links.push(path.resolve(baseDir, targetWithoutAnchor));
368
+ }
369
+ match = linkRe.exec(markdown);
370
+ }
371
+ return links;
372
+ }
373
+
374
+ function isLocalLink(target) {
375
+ return target !== ''
376
+ && !target.startsWith('#')
377
+ && !target.startsWith('http://')
378
+ && !target.startsWith('https://')
379
+ && !target.startsWith('mailto:');
380
+ }
381
+
382
+ function isCanonicalTypeIndex(filePath, xdrsRoot) {
383
+ const relative = relativeFrom(xdrsRoot, filePath).split(path.sep);
384
+ return relative.length === 3 && TYPE_NAMES.has(relative[1]) && relative[2] === 'index.md';
385
+ }
386
+
387
+ function extractFrontmatterName(content) {
388
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
389
+ if (!frontmatterMatch) {
390
+ return null;
391
+ }
392
+
393
+ const nameMatch = frontmatterMatch[1].match(/^name:\s*(.+)$/m);
394
+ return nameMatch ? nameMatch[1].trim() : null;
395
+ }
396
+
397
+ function firstNonEmptyLine(content) {
398
+ const lines = content.split(/\r?\n/);
399
+ for (const line of lines) {
400
+ if (line.trim() !== '') {
401
+ return line.trim();
402
+ }
403
+ }
404
+ return '';
405
+ }
406
+
407
+ function safeReadDir(dirPath, errors, operation) {
408
+ try {
409
+ return fs.readdirSync(dirPath, { withFileTypes: true });
410
+ } catch (error) {
411
+ errors.push(`Failed to ${operation}: ${toDisplayPath(dirPath)} (${error.message})`);
412
+ return [];
413
+ }
414
+ }
415
+
416
+ function isValidScopeName(scopeName) {
417
+ if (RESERVED_SCOPES.has(scopeName)) {
418
+ return true;
419
+ }
420
+ return /^[a-z0-9][a-z0-9-]*$/.test(scopeName);
421
+ }
422
+
423
+ function existsDirectory(dirPath) {
424
+ try {
425
+ return fs.statSync(dirPath).isDirectory();
426
+ } catch {
427
+ return false;
428
+ }
429
+ }
430
+
431
+ function existsFile(filePath) {
432
+ try {
433
+ return fs.statSync(filePath).isFile();
434
+ } catch {
435
+ return false;
436
+ }
437
+ }
438
+
439
+ function displayPath(indexPath, targetPath) {
440
+ return `${toDisplayPath(indexPath)} -> ${toDisplayPath(targetPath)}`;
441
+ }
442
+
443
+ function toDisplayPath(targetPath) {
444
+ return relativeFrom(process.cwd(), targetPath);
445
+ }
446
+
447
+ function relativeFrom(basePath, targetPath) {
448
+ return path.relative(basePath, targetPath) || '.';
449
+ }
450
+
451
+ function normalizePath(filePath) {
452
+ return path.normalize(filePath);
453
+ }
454
+
455
+ module.exports = {
456
+ runLintCli,
457
+ lintWorkspace
458
+ };
459
+
460
+ if (require.main === module) {
461
+ process.exitCode = runLintCli(process.argv.slice(2));
462
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xdrs-core",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A standard way to organize Decision Records (XDRs) across scopes, subjects, and teams so that AI agents can reliably query and follow them.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,10 +18,11 @@
18
18
  "package.json",
19
19
  "AGENTS.md",
20
20
  "AGENTS.local.md",
21
- "bin/filedist.js"
21
+ "bin/filedist.js",
22
+ "lib/**/*.js"
22
23
  ],
23
24
  "dependencies": {
24
- "filedist": "^0.24.0"
25
+ "filedist": "^0.26.0"
25
26
  },
26
27
  "filedist": {
27
28
  "sets": [