xdrs-core 0.6.0 → 0.7.1
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/
|
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
24
|
|
|
26
|
-
[View _local ADRs Index](_local/adrs/index.md)
|
|
27
|
-
|
|
28
|
-
[View _local EDRs Index](_local/edrs/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](docs/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
|
-
|
|
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.
|
|
3
|
+
"version": "0.7.1",
|
|
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.
|
|
25
|
+
"filedist": "^0.26.0"
|
|
25
26
|
},
|
|
26
27
|
"filedist": {
|
|
27
28
|
"sets": [
|
|
@@ -29,10 +30,7 @@
|
|
|
29
30
|
"selector": {
|
|
30
31
|
"files": [
|
|
31
32
|
"AGENTS.md",
|
|
32
|
-
".xdrs/**"
|
|
33
|
-
],
|
|
34
|
-
"exclude": [
|
|
35
|
-
".xdrs/index.md"
|
|
33
|
+
".xdrs/_core/**"
|
|
36
34
|
]
|
|
37
35
|
},
|
|
38
36
|
"output": {
|