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: [001-research-and-decision-lifecycle](../../../_local/adrs/principles/researches/001-research-and-decision-lifecycle.md)
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 = `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${shortTitle}`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xdrs-core",
3
- "version": "0.15.3",
3
+ "version": "0.16.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",