xdrs-core 0.15.4 → 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
@@ -269,7 +269,7 @@ function lintXdrFile(xdrsRoot, scopeName, typeName, filePath, xdrNumbers, errors
269
269
  const expectedName = extractExpectedXdrNameFromHeading(firstLine)
270
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
273
  }
274
274
 
275
275
  function extractExpectedXdrNameFromHeading(headingLine) {
@@ -384,7 +384,7 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
384
384
  }
385
385
  }
386
386
 
387
- lintDocumentLinks(skillFilePath, errors);
387
+ lintDocumentLinks(skillFilePath, xdrsRoot, scopeName, errors);
388
388
  }
389
389
 
390
390
  return artifacts;
@@ -437,7 +437,7 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
437
437
  errors.push(`Article title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
438
438
  }
439
439
 
440
- lintDocumentLinks(entryPath, errors);
440
+ lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors);
441
441
  }
442
442
 
443
443
  return artifacts;
@@ -490,7 +490,7 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
490
490
  errors.push(`Research title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
491
491
  }
492
492
 
493
- lintDocumentLinks(entryPath, errors);
493
+ lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors);
494
494
  }
495
495
 
496
496
  return artifacts;
@@ -544,7 +544,7 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
544
544
  }
545
545
 
546
546
  lintPlanExpectedEndDate(content, entryPath, errors);
547
- lintDocumentLinks(entryPath, errors);
547
+ lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors);
548
548
  }
549
549
 
550
550
  return artifacts;
@@ -574,6 +574,8 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors) {
574
574
  const content = fs.readFileSync(indexPath, 'utf8');
575
575
  const localLinks = parseLocalLinks(content, path.dirname(indexPath));
576
576
  const linkedSet = new Set();
577
+ const scopeName = path.relative(xdrsRoot, indexPath).split(path.sep)[0];
578
+ const localScopePath = normalizePath(path.join(xdrsRoot, '_local'));
577
579
 
578
580
  for (const linkPath of localLinks) {
579
581
  if (!fs.existsSync(linkPath)) {
@@ -581,6 +583,10 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors) {
581
583
  continue;
582
584
  }
583
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
+
584
590
  linkedSet.add(normalizePath(linkPath));
585
591
  }
586
592
 
@@ -591,11 +597,12 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors) {
591
597
  }
592
598
  }
593
599
 
594
- function lintDocumentLinks(documentPath, errors) {
600
+ function lintDocumentLinks(documentPath, xdrsRoot, scopeName, errors) {
595
601
  const lines = fs.readFileSync(documentPath, 'utf8').split(/\r?\n/);
596
602
  const ignoredLines = findIgnoredMarkdownLines(lines);
597
603
  const documentDir = path.dirname(documentPath);
598
604
  const resourceDir = path.join(documentDir, RESOURCE_DIR_NAME);
605
+ const localScopePath = normalizePath(path.join(xdrsRoot, '_local'));
599
606
 
600
607
  for (let index = 0; index < lines.length; index += 1) {
601
608
  if (ignoredLines[index]) {
@@ -614,6 +621,10 @@ function lintDocumentLinks(documentPath, errors) {
614
621
  continue;
615
622
  }
616
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
+
617
628
  if (isResourceLink && !isPathInside(resourceDir, link.resolvedPath)) {
618
629
  errors.push(`Asset links in ${toDisplayPath(documentPath)} must point to ${toDisplayPath(resourceDir)}: ${link.rawTarget}`);
619
630
  }
package/lib/lint.test.js CHANGED
@@ -108,6 +108,114 @@ test('derives expected frontmatter name from the markdown heading title', () =>
108
108
  expect(result.errors.join('\n')).not.toContain('XDR frontmatter name must be');
109
109
  });
110
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
+
111
219
  function createWorkspace(name, files) {
112
220
  const workspaceRoot = path.join(tmpRoot, name);
113
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.4",
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",