xdrs-core 0.15.3 → 0.16.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.
|
@@ -57,7 +57,7 @@ Collectively, these are referred to as XDRs.
|
|
|
57
57
|
- **Scopes:**
|
|
58
58
|
- Short name that defines a group or a package of xdrs
|
|
59
59
|
- examples: `business-x`, `business-y`, `team-43`, `_core`
|
|
60
|
-
- `_local` is a reserved scope for XDRs created locally to a specific project or repository. XDRs in `_local` must not be shared with or propagated to other contexts. This scope must always be placed in the lowest position in `.xdrs/index.md` so that its decisions override or extend any decisions from all higher-positioned scopes. Shared `.xdrs/index.md` files MUST NOT link `_local` canonical type indexes because `_local` stays workspace-local and is not distributed with shared packages. Readers, tools, and agents SHOULD still try to discover existing workspace-local `_local` canonical indexes by default, even when the shared root index does not link them.
|
|
60
|
+
- `_local` is a reserved scope for XDRs created locally to a specific project or repository. XDRs in `_local` must not be shared with or propagated to other contexts. This scope must always be placed in the lowest position in `.xdrs/index.md` so that its decisions override or extend any decisions from all higher-positioned scopes. Shared `.xdrs/index.md` files MUST NOT link `_local` canonical type indexes because `_local` stays workspace-local and is not distributed with shared packages. Readers, tools, and agents SHOULD still try to discover existing workspace-local `_local` canonical indexes by default, even when the shared root index does not link them. Documents in non-`_local` scopes MUST NEVER link to any document inside the `_local` scope, because `_local` is workspace-only and such links would break in any consumer workspace. Documents inside `_local` MAY link to other documents inside `_local`.
|
|
61
61
|
- **Types:** `adrs`, `bdrs`, `edrs`
|
|
62
62
|
- there can exist sufixes to the standard scope names (e.g: `business-x-mobileapp`, `business-y-servicedesk`)
|
|
63
63
|
- **Subjects:** MUST be one of the following depending on the type of the XDR. Use the subject to indicate the main concern of the decision.
|
|
@@ -121,7 +121,7 @@ Prefer tables, bullets, or ASCII art for simple comparisons. Use external figure
|
|
|
121
121
|
|
|
122
122
|
## Considered Options
|
|
123
123
|
|
|
124
|
-
- Related research:
|
|
124
|
+
- Related research: `001-research-and-decision-lifecycle` (workspace-local research)
|
|
125
125
|
|
|
126
126
|
* (REJECTED) **Inline long-form analysis inside the XDR** - Put all research and decision text in one file.
|
|
127
127
|
* Reason: Makes XDRs too long, mixes evidence with the adopted rule set, and hurts fast retrieval by humans and AI agents.
|
package/.xdrs/index.md
CHANGED
|
@@ -19,4 +19,4 @@ Decisions about how XDRs work
|
|
|
19
19
|
|
|
20
20
|
### _local (reserved)
|
|
21
21
|
|
|
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. Keep `_local` canonical indexes in the workspace tree only; do not link them from this shared index. Readers and tools should still try to discover existing `_local` indexes in the current workspace by default.
|
|
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. Keep `_local` canonical indexes in the workspace tree only; do not link them from this shared index. Readers and tools should still try to discover existing `_local` indexes in the current workspace by default. Documents in non-`_local` scopes must never link into `_local`; only `_local` documents may link to other `_local` documents.
|
package/lib/lint.js
CHANGED
|
@@ -248,7 +248,6 @@ function lintXdrFile(xdrsRoot, scopeName, typeName, filePath, xdrNumbers, errors
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
const number = match[1];
|
|
251
|
-
const shortTitle = match[2];
|
|
252
251
|
const previous = xdrNumbers.get(number);
|
|
253
252
|
if (previous) {
|
|
254
253
|
errors.push(`Duplicate XDR number ${number} in ${scopeName}/${typeName}: ${toDisplayPath(previous)} and ${toDisplayPath(filePath)}`);
|
|
@@ -267,9 +266,35 @@ function lintXdrFile(xdrsRoot, scopeName, typeName, filePath, xdrNumbers, errors
|
|
|
267
266
|
errors.push(`XDR title must start with "${expectedHeader}": ${toDisplayPath(filePath)}`);
|
|
268
267
|
}
|
|
269
268
|
|
|
270
|
-
const expectedName =
|
|
269
|
+
const expectedName = extractExpectedXdrNameFromHeading(firstLine)
|
|
270
|
+
|| `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${match[2]}`;
|
|
271
271
|
lintXdrFrontmatter(content, expectedName, filePath, errors);
|
|
272
|
-
lintDocumentLinks(filePath, errors);
|
|
272
|
+
lintDocumentLinks(filePath, xdrsRoot, scopeName, errors);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function extractExpectedXdrNameFromHeading(headingLine) {
|
|
276
|
+
const match = headingLine.match(/^#\s+([a-z0-9_]+-(?:adr|bdr|edr)-\d{3,}):\s+(.+?)\s*$/);
|
|
277
|
+
if (!match) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const identifier = match[1];
|
|
282
|
+
const titleSlug = slugifyTitle(match[2]);
|
|
283
|
+
if (!titleSlug) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return `${identifier}-${titleSlug}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function slugifyTitle(title) {
|
|
291
|
+
return title
|
|
292
|
+
.normalize('NFKD')
|
|
293
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
294
|
+
.toLowerCase()
|
|
295
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
296
|
+
.replace(/^-+|-+$/g, '')
|
|
297
|
+
.replace(/--+/g, '-');
|
|
273
298
|
}
|
|
274
299
|
|
|
275
300
|
function lintXdrFrontmatter(content, expectedName, filePath, errors) {
|
|
@@ -359,7 +384,7 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
|
|
|
359
384
|
}
|
|
360
385
|
}
|
|
361
386
|
|
|
362
|
-
lintDocumentLinks(skillFilePath, errors);
|
|
387
|
+
lintDocumentLinks(skillFilePath, xdrsRoot, scopeName, errors);
|
|
363
388
|
}
|
|
364
389
|
|
|
365
390
|
return artifacts;
|
|
@@ -412,7 +437,7 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
|
|
|
412
437
|
errors.push(`Article title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
|
|
413
438
|
}
|
|
414
439
|
|
|
415
|
-
lintDocumentLinks(entryPath, errors);
|
|
440
|
+
lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors);
|
|
416
441
|
}
|
|
417
442
|
|
|
418
443
|
return artifacts;
|
|
@@ -465,7 +490,7 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
|
|
|
465
490
|
errors.push(`Research title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
|
|
466
491
|
}
|
|
467
492
|
|
|
468
|
-
lintDocumentLinks(entryPath, errors);
|
|
493
|
+
lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors);
|
|
469
494
|
}
|
|
470
495
|
|
|
471
496
|
return artifacts;
|
|
@@ -519,7 +544,7 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
|
|
|
519
544
|
}
|
|
520
545
|
|
|
521
546
|
lintPlanExpectedEndDate(content, entryPath, errors);
|
|
522
|
-
lintDocumentLinks(entryPath, errors);
|
|
547
|
+
lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors);
|
|
523
548
|
}
|
|
524
549
|
|
|
525
550
|
return artifacts;
|
|
@@ -549,6 +574,8 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors) {
|
|
|
549
574
|
const content = fs.readFileSync(indexPath, 'utf8');
|
|
550
575
|
const localLinks = parseLocalLinks(content, path.dirname(indexPath));
|
|
551
576
|
const linkedSet = new Set();
|
|
577
|
+
const scopeName = path.relative(xdrsRoot, indexPath).split(path.sep)[0];
|
|
578
|
+
const localScopePath = normalizePath(path.join(xdrsRoot, '_local'));
|
|
552
579
|
|
|
553
580
|
for (const linkPath of localLinks) {
|
|
554
581
|
if (!fs.existsSync(linkPath)) {
|
|
@@ -556,6 +583,10 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors) {
|
|
|
556
583
|
continue;
|
|
557
584
|
}
|
|
558
585
|
|
|
586
|
+
if (scopeName !== '_local' && (isPathInside(localScopePath, linkPath) || normalizePath(linkPath) === localScopePath)) {
|
|
587
|
+
errors.push(`Non-_local document must not link into _local scope: ${displayPath(indexPath, linkPath)}`);
|
|
588
|
+
}
|
|
589
|
+
|
|
559
590
|
linkedSet.add(normalizePath(linkPath));
|
|
560
591
|
}
|
|
561
592
|
|
|
@@ -566,11 +597,12 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors) {
|
|
|
566
597
|
}
|
|
567
598
|
}
|
|
568
599
|
|
|
569
|
-
function lintDocumentLinks(documentPath, errors) {
|
|
600
|
+
function lintDocumentLinks(documentPath, xdrsRoot, scopeName, errors) {
|
|
570
601
|
const lines = fs.readFileSync(documentPath, 'utf8').split(/\r?\n/);
|
|
571
602
|
const ignoredLines = findIgnoredMarkdownLines(lines);
|
|
572
603
|
const documentDir = path.dirname(documentPath);
|
|
573
604
|
const resourceDir = path.join(documentDir, RESOURCE_DIR_NAME);
|
|
605
|
+
const localScopePath = normalizePath(path.join(xdrsRoot, '_local'));
|
|
574
606
|
|
|
575
607
|
for (let index = 0; index < lines.length; index += 1) {
|
|
576
608
|
if (ignoredLines[index]) {
|
|
@@ -589,6 +621,10 @@ function lintDocumentLinks(documentPath, errors) {
|
|
|
589
621
|
continue;
|
|
590
622
|
}
|
|
591
623
|
|
|
624
|
+
if (scopeName !== '_local' && (isPathInside(localScopePath, link.resolvedPath) || normalizePath(link.resolvedPath) === localScopePath)) {
|
|
625
|
+
errors.push(`Non-_local document must not link into _local scope in ${toDisplayPath(documentPath)}:${index + 1}: ${link.rawTarget}`);
|
|
626
|
+
}
|
|
627
|
+
|
|
592
628
|
if (isResourceLink && !isPathInside(resourceDir, link.resolvedPath)) {
|
|
593
629
|
errors.push(`Asset links in ${toDisplayPath(documentPath)} must point to ${toDisplayPath(resourceDir)}: ${link.rawTarget}`);
|
|
594
630
|
}
|
package/lib/lint.test.js
CHANGED
|
@@ -82,6 +82,140 @@ test('skips read-only files by default and checks them when ignoreReadOnly is fa
|
|
|
82
82
|
expect(allResult.errors.join('\n')).toContain('Broken local link in');
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
test('derives expected frontmatter name from the markdown heading title', () => {
|
|
86
|
+
const workspaceRoot = createWorkspace('heading-name-match', {
|
|
87
|
+
'.xdrs/index.md': rootIndex(),
|
|
88
|
+
'.xdrs/_local/adrs/index.md': localAdrIndex([
|
|
89
|
+
'- [002-scope-guidelines](principles/002-xdr-scope-guidelines.md) - Scope guidelines'
|
|
90
|
+
]),
|
|
91
|
+
'.xdrs/_local/adrs/principles/002-xdr-scope-guidelines.md': [
|
|
92
|
+
'---',
|
|
93
|
+
'name: _local-adr-002-xdr-scope-guidelines-for-agentme',
|
|
94
|
+
'description: Test XDR document',
|
|
95
|
+
'---',
|
|
96
|
+
'',
|
|
97
|
+
'# _local-adr-002: XDR scope guidelines for agentme',
|
|
98
|
+
'',
|
|
99
|
+
'## Context and Problem Statement',
|
|
100
|
+
'',
|
|
101
|
+
'Test body.',
|
|
102
|
+
''
|
|
103
|
+
].join('\n'),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const result = lintWorkspace(workspaceRoot, { ignoreReadOnly: false });
|
|
107
|
+
|
|
108
|
+
expect(result.errors.join('\n')).not.toContain('XDR frontmatter name must be');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('reports non-_local XDR linking to _local scope document', () => {
|
|
112
|
+
const workspaceRoot = createWorkspace('non-local-links-to-local-xdr', {
|
|
113
|
+
'.xdrs/index.md': rootIndex(),
|
|
114
|
+
'.xdrs/_local/adrs/index.md': localAdrIndex([
|
|
115
|
+
'- [001-main](principles/001-main.md) - Main decision'
|
|
116
|
+
]),
|
|
117
|
+
'.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Local decision.'),
|
|
118
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex([
|
|
119
|
+
'- [001-team](principles/001-team.md) - Team decision'
|
|
120
|
+
]),
|
|
121
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument(
|
|
122
|
+
'See [local doc](../../../_local/adrs/principles/001-main.md).'
|
|
123
|
+
),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result = lintWorkspace(workspaceRoot, { ignoreReadOnly: false });
|
|
127
|
+
|
|
128
|
+
expect(result.errors.join('\n')).toContain('Non-_local document must not link into _local scope');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('allows _local XDR linking to another _local scope document', () => {
|
|
132
|
+
const workspaceRoot = createWorkspace('local-links-to-local', {
|
|
133
|
+
'.xdrs/index.md': rootIndex(),
|
|
134
|
+
'.xdrs/_local/adrs/index.md': localAdrIndex([
|
|
135
|
+
'- [001-main](principles/001-main.md) - Main decision',
|
|
136
|
+
'- [002-second](principles/002-second.md) - Second decision'
|
|
137
|
+
]),
|
|
138
|
+
'.xdrs/_local/adrs/principles/001-main.md': [
|
|
139
|
+
'---',
|
|
140
|
+
'name: _local-adr-001-main',
|
|
141
|
+
'description: Test XDR document',
|
|
142
|
+
'---',
|
|
143
|
+
'',
|
|
144
|
+
'# _local-adr-001: Main decision',
|
|
145
|
+
'',
|
|
146
|
+
'## Context and Problem Statement',
|
|
147
|
+
'',
|
|
148
|
+
'See [second](002-second.md).',
|
|
149
|
+
''
|
|
150
|
+
].join('\n'),
|
|
151
|
+
'.xdrs/_local/adrs/principles/002-second.md': [
|
|
152
|
+
'---',
|
|
153
|
+
'name: _local-adr-002-second',
|
|
154
|
+
'description: Second test XDR document',
|
|
155
|
+
'---',
|
|
156
|
+
'',
|
|
157
|
+
'# _local-adr-002: Second decision',
|
|
158
|
+
'',
|
|
159
|
+
'## Context and Problem Statement',
|
|
160
|
+
'',
|
|
161
|
+
'Second body.',
|
|
162
|
+
''
|
|
163
|
+
].join('\n'),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const result = lintWorkspace(workspaceRoot, { ignoreReadOnly: false });
|
|
167
|
+
|
|
168
|
+
expect(result.errors.join('\n')).not.toContain('Non-_local document must not link into _local scope');
|
|
169
|
+
expect(result.errors.join('\n')).not.toContain('Broken local link');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('reports non-_local canonical index linking to _local scope document', () => {
|
|
173
|
+
const workspaceRoot = createWorkspace('non-local-type-index-links-to-local', {
|
|
174
|
+
'.xdrs/index.md': rootIndex(),
|
|
175
|
+
'.xdrs/_local/adrs/index.md': localAdrIndex([
|
|
176
|
+
'- [001-main](principles/001-main.md) - Main decision'
|
|
177
|
+
]),
|
|
178
|
+
'.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Local decision.'),
|
|
179
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex([
|
|
180
|
+
'- [_local 001](../../_local/adrs/principles/001-main.md) - Cross-scope link'
|
|
181
|
+
]),
|
|
182
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const result = lintWorkspace(workspaceRoot, { ignoreReadOnly: false });
|
|
186
|
+
|
|
187
|
+
expect(result.errors.join('\n')).toContain('Non-_local document must not link into _local scope');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
function teamAdrIndex(entries) {
|
|
191
|
+
return [
|
|
192
|
+
'# myteam ADR Index',
|
|
193
|
+
'',
|
|
194
|
+
'Team ADRs for tests.',
|
|
195
|
+
'',
|
|
196
|
+
'## principles',
|
|
197
|
+
'',
|
|
198
|
+
...entries,
|
|
199
|
+
''
|
|
200
|
+
].join('\n');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function teamXdrDocument(body) {
|
|
204
|
+
return [
|
|
205
|
+
'---',
|
|
206
|
+
'name: myteam-adr-001-team',
|
|
207
|
+
'description: Team test XDR document',
|
|
208
|
+
'---',
|
|
209
|
+
'',
|
|
210
|
+
'# myteam-adr-001: Team decision',
|
|
211
|
+
'',
|
|
212
|
+
'## Context and Problem Statement',
|
|
213
|
+
'',
|
|
214
|
+
body,
|
|
215
|
+
''
|
|
216
|
+
].join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
85
219
|
function createWorkspace(name, files) {
|
|
86
220
|
const workspaceRoot = path.join(tmpRoot, name);
|
|
87
221
|
fs.mkdirSync(workspaceRoot, { recursive: true });
|
package/package.json
CHANGED