xdrs-core 0.34.2 → 0.35.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.
|
@@ -80,7 +80,7 @@ Policies can be of different kinds, depending on the nature of the decision:
|
|
|
80
80
|
- `apply-to` (required): Declares in which contexts — teams, systems, codebases, or environments — the decisions in this scope are relevant. Max 30 words.
|
|
81
81
|
- `valid-from` (required): ISO date (YYYY-MM-DD) from which this scope became active.
|
|
82
82
|
- `metadata` (optional): Arbitrary key-value map for additional scope metadata.
|
|
83
|
-
- `follows` (optional): Core scope names whose Policies apply as mandatory conventions to this scope, beyond `_core`. Last-listed takes precedence on conflicts (e.g., `follows:
|
|
83
|
+
- `follows` (optional): Core scope names whose Policies apply as mandatory conventions to this scope, beyond `_core`. Last-listed takes precedence on conflicts (e.g., `follows: myarea-core, shared-standards`).
|
|
84
84
|
- `related-scopes` (optional): Scope names of parent, sibling, or child scopes. Use when structural links help verify policy correctness across related scopes.
|
|
85
85
|
- **Subjects:** MUST be one of the following depending on the type of the Policy. Use the subject to indicate the main concern of the decision.
|
|
86
86
|
- **ADR subjects**
|
|
@@ -57,7 +57,7 @@ Content that MUST NOT be placed in a `core`-type scope:
|
|
|
57
57
|
|
|
58
58
|
#### 06-follows-required
|
|
59
59
|
|
|
60
|
-
A scope that is governed by a `core` scope MUST declare this relationship explicitly using `follows:
|
|
60
|
+
A scope that is governed by a `core` scope MUST declare this relationship explicitly using `follows: {core-scope-name}` in its `index.md` frontmatter.
|
|
61
61
|
|
|
62
62
|
#### 07-distribution
|
|
63
63
|
|
|
@@ -30,7 +30,7 @@ Performs a structured review of code changes or files against the Policies in th
|
|
|
30
30
|
- Check `valid-from:` first. If a date is present and has not yet been reached, the decision SHOULD be adopted for new implementations but is not enforced during reviews.
|
|
31
31
|
- Check `apply-to:` second. Keep only Policies whose stated scope fits the files, systems, or workflows under review.
|
|
32
32
|
- Check the decision text itself last for additional boundaries or exceptions that metadata does not encode.
|
|
33
|
-
2. **Meta-policy scopes:** For each scope containing files under review, read its `index.md` frontmatter and check for a `follows` field. `_core` Policies always apply to all scopes. If a scope declares `follows:` with additional core scope names (e.g., `follows:
|
|
33
|
+
2. **Meta-policy scopes:** For each scope containing files under review, read its `index.md` frontmatter and check for a `follows` field. `_core` Policies always apply to all scopes. If a scope declares `follows:` with additional core scope names (e.g., `follows: myarea-core, shared-standards`), verify that each listed scope directory exists in the workspace (e.g., `.xdrs/[scope-name]/index.md`). If any listed scope is missing, STOP immediately — do not proceed with the review — and tell the user: "Scope `[scope-name]` is listed in `follows` but not found in the workspace. Install it before proceeding." Once all `follows` scopes are confirmed present, load their Policies and apply them as mandatory governance. Last-listed scope in `follows` takes precedence when the same topic is addressed by multiple scopes.
|
|
34
34
|
- **Scope-type standards:** Read the scope's `scope-type` from its `index.md`. Search the `[type]/principles/` directories of all `core`-type scopes in the workspace for a file whose name ends with `{scope-type}-scope-type.md`. If found, apply its rules as mandatory conventions. If the scope-type policy contains a rule titled `NN-parent-scope-type`, extract the parent type name (backtick-quoted identifier in the rule body) and repeat for the parent; continue until no more parents are declared. Detect and stop on cycles. See `_core-adr-policy-010` rules 15 and 17 for the full application model.
|
|
35
35
|
- **Scope-local standards:** Search the scope's own `[type]/principles/` directories for a policy file whose name ends with `{scope-name}-core.md`. If found, apply its rules as mandatory conventions; scope-local standards take precedence over scope-type standards on any conflict. See `_core-adr-policy-010` rules 16 and 17.
|
|
36
36
|
- **Scope-type standards:** Read the scope's `scope-type` from its `index.md`. Search the `[type]/principles/` directories of all `core`-type scopes in the workspace for a file whose name ends with `{scope-type}-scope-type.md`. If found, apply its rules as mandatory conventions. If the scope-type policy contains a rule titled `NN-parent-scope-type`, extract the parent type name (backtick-quoted identifier in the rule body) and repeat for the parent; continue until no more parents are declared. Detect and stop on cycles. See `_core-adr-policy-010` rules 15 and 17 for the full application model.
|
package/lib/lint.js
CHANGED
|
@@ -256,6 +256,10 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
256
256
|
errors.push(`Scope index frontmatter must include a scope-type field: ${toDisplayPath(scopeIndexPath)}`);
|
|
257
257
|
return;
|
|
258
258
|
}
|
|
259
|
+
if (scopeType.startsWith('_') && scopeType !== '_local') {
|
|
260
|
+
errors.push(`Scope type "${scopeType}" uses a reserved "_" prefix; only "_local" is a valid underscore-prefixed scope type: ${toDisplayPath(scopeIndexPath)}`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
259
263
|
if (!knownScopeTypes.has(scopeType) && scopeType !== '_local') {
|
|
260
264
|
errors.push(`Scope index scope-type "${scopeType}" has no corresponding ${scopeType}-scope-type policy in the principles subject of any core-type scope: ${toDisplayPath(scopeIndexPath)}`);
|
|
261
265
|
return;
|
|
@@ -269,7 +273,10 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
269
273
|
if (!policyPath) break;
|
|
270
274
|
const parentType = extractParentScopeType(policyPath);
|
|
271
275
|
if (!parentType) break;
|
|
272
|
-
if (visited.has(parentType))
|
|
276
|
+
if (visited.has(parentType)) {
|
|
277
|
+
errors.push(`Scope type "${currentType}" declares parent "${parentType}" which creates a cycle in the scope-type inheritance chain: ${toDisplayPath(scopeIndexPath)}`);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
273
280
|
if (!knownScopeTypes.has(parentType)) {
|
|
274
281
|
errors.push(`Scope type "${currentType}" declares parent "${parentType}" but no ${parentType}-scope-type policy exists in any core-type scope: ${toDisplayPath(scopeIndexPath)}`);
|
|
275
282
|
break;
|
|
@@ -326,12 +333,14 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
326
333
|
errors.push(`Scope index frontmatter valid-from must be a valid ISO date YYYY-MM-DD: ${toDisplayPath(scopeIndexPath)}`);
|
|
327
334
|
}
|
|
328
335
|
|
|
336
|
+
let followsEntries = [];
|
|
329
337
|
const followsLineMatch = block.match(/^follows:[ \t]*(.*)/m);
|
|
330
338
|
if (followsLineMatch !== null) {
|
|
331
339
|
const followsValue = followsLineMatch[1].trim();
|
|
332
340
|
const scopeNamePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
|
|
341
|
+
const commaSeparatedPattern = /^[a-zA-Z_][a-zA-Z0-9_-]*(\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
|
|
333
342
|
if (followsValue) {
|
|
334
|
-
if (!scopeNamePattern.test(followsValue)) {
|
|
343
|
+
if (!scopeNamePattern.test(followsValue) && !commaSeparatedPattern.test(followsValue)) {
|
|
335
344
|
errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)}`);
|
|
336
345
|
}
|
|
337
346
|
} else {
|
|
@@ -357,18 +366,36 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
357
366
|
}
|
|
358
367
|
}
|
|
359
368
|
if (xdrsRoot) {
|
|
360
|
-
const entries = followsValue
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
369
|
+
const entries = followsValue
|
|
370
|
+
? (commaSeparatedPattern.test(followsValue)
|
|
371
|
+
? followsValue.split(',').map((s) => s.trim())
|
|
372
|
+
: [followsValue])
|
|
373
|
+
: (() => {
|
|
374
|
+
const items = [];
|
|
375
|
+
const followsStart = block.indexOf(followsLineMatch[0]) + followsLineMatch[0].length;
|
|
376
|
+
for (const line of block.slice(followsStart).split('\n')) {
|
|
377
|
+
const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
|
|
378
|
+
if (itemMatch && itemMatch[1]) items.push(itemMatch[1].trim());
|
|
379
|
+
else if (line.trim() && !/^\s/.test(line)) break;
|
|
380
|
+
}
|
|
381
|
+
return items;
|
|
382
|
+
})();
|
|
383
|
+
followsEntries = entries;
|
|
384
|
+
const seenFollows = new Set();
|
|
370
385
|
for (const entry of entries) {
|
|
386
|
+
if (seenFollows.has(entry)) {
|
|
387
|
+
errors.push(`Scope index frontmatter follows has duplicate entry "${entry}": ${toDisplayPath(scopeIndexPath)}`);
|
|
388
|
+
}
|
|
389
|
+
seenFollows.add(entry);
|
|
371
390
|
if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(entry)) {
|
|
391
|
+
if (entry === scopeName) {
|
|
392
|
+
errors.push(`Scope index frontmatter follows must not reference the scope itself: ${toDisplayPath(scopeIndexPath)}`);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (entry === '_core') {
|
|
396
|
+
errors.push(`Scope index frontmatter follows must not reference "_core" as it is always applied implicitly: ${toDisplayPath(scopeIndexPath)}`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
372
399
|
const followedIndexPath = path.join(xdrsRoot, entry, 'index.md');
|
|
373
400
|
if (!existsFile(followedIndexPath)) {
|
|
374
401
|
errors.push(`Scope index frontmatter follows references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)}`);
|
|
@@ -425,9 +452,24 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
425
452
|
}
|
|
426
453
|
return items;
|
|
427
454
|
})();
|
|
455
|
+
const seenRelated = new Set();
|
|
428
456
|
for (const entry of entries) {
|
|
429
|
-
if (
|
|
430
|
-
errors.push(`Scope index frontmatter related-scopes
|
|
457
|
+
if (seenRelated.has(entry)) {
|
|
458
|
+
errors.push(`Scope index frontmatter related-scopes has duplicate entry "${entry}": ${toDisplayPath(scopeIndexPath)}`);
|
|
459
|
+
}
|
|
460
|
+
seenRelated.add(entry);
|
|
461
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(entry)) {
|
|
462
|
+
if (entry === scopeName) {
|
|
463
|
+
errors.push(`Scope index frontmatter related-scopes must not reference the scope itself: ${toDisplayPath(scopeIndexPath)}`);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (followsEntries.includes(entry)) {
|
|
467
|
+
errors.push(`Scope index frontmatter related-scopes must not repeat scope "${entry}" already declared in follows: ${toDisplayPath(scopeIndexPath)}`);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (!existsFile(path.join(xdrsRoot, entry, 'index.md'))) {
|
|
471
|
+
errors.push(`Scope index frontmatter related-scopes references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)}`);
|
|
472
|
+
}
|
|
431
473
|
}
|
|
432
474
|
}
|
|
433
475
|
}
|
|
@@ -476,6 +518,34 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, igno
|
|
|
476
518
|
lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes);
|
|
477
519
|
lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes);
|
|
478
520
|
}
|
|
521
|
+
lintScopeLocalStandards(xdrsRoot, scopeName, errors);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function lintScopeLocalStandards(xdrsRoot, scopeName, errors) {
|
|
525
|
+
const scopePath = path.join(xdrsRoot, scopeName);
|
|
526
|
+
const corePolicySuffix = `${scopeName}-core`;
|
|
527
|
+
const foundPolicies = [];
|
|
528
|
+
for (const typeName of Object.keys(TYPE_TO_ID)) {
|
|
529
|
+
const principlesDir = path.join(scopePath, typeName, 'principles');
|
|
530
|
+
if (!existsDirectory(principlesDir)) continue;
|
|
531
|
+
let principlesEntries;
|
|
532
|
+
try { principlesEntries = fs.readdirSync(principlesDir, { withFileTypes: true }); } catch { continue; }
|
|
533
|
+
for (const entry of principlesEntries) {
|
|
534
|
+
if (!entry.isFile()) continue;
|
|
535
|
+
if (!NUMBERED_FILE_RE.test(entry.name)) continue;
|
|
536
|
+
const filePath = path.join(principlesDir, entry.name);
|
|
537
|
+
let content;
|
|
538
|
+
try { content = fs.readFileSync(filePath, 'utf8'); } catch { continue; }
|
|
539
|
+
const fm = extractFrontmatter(content);
|
|
540
|
+
if (fm.name && (fm.name === corePolicySuffix || fm.name.endsWith(`-${corePolicySuffix}`))) {
|
|
541
|
+
foundPolicies.push(filePath);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (foundPolicies.length > 1) {
|
|
546
|
+
const paths = foundPolicies.map((p) => toDisplayPath(p)).join(', ');
|
|
547
|
+
errors.push(`Scope "${scopeName}" has more than one scope-local standards policy (name ending with "${corePolicySuffix}"): ${paths}`);
|
|
548
|
+
}
|
|
479
549
|
}
|
|
480
550
|
|
|
481
551
|
function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeIndexes, externalScopes = new Set()) {
|
package/lib/lint.test.js
CHANGED
|
@@ -943,6 +943,205 @@ test('reports error when parent scope-type in chain does not exist', () => {
|
|
|
943
943
|
expect(result.errors.join('\n')).toContain('declares parent "nonexistent-parent" but no nonexistent-parent-scope-type policy exists');
|
|
944
944
|
});
|
|
945
945
|
|
|
946
|
+
test('reports cycle in scope-type inheritance chain', () => {
|
|
947
|
+
const workspaceRoot = createWorkspace('scope-type-inheritance-cycle', {
|
|
948
|
+
'.xdrs/index.md': rootIndex(['[my-custom](my-custom/index.md)', '[_core](_core/index.md)']),
|
|
949
|
+
'.xdrs/my-custom/index.md': '---\nscope-type: child\nname: my-custom\ndescription: Custom scope.\napply-to: All\nvalid-from: 2026-01-01\n---\n\n# my-custom Scope Overview\n\nCustom.\n\n[ADRs](adrs/index.md)\n',
|
|
950
|
+
'.xdrs/my-custom/adrs/index.md': teamAdrIndex([]),
|
|
951
|
+
'.xdrs/_core/index.md': '---\nscope-type: core\nname: _core\ndescription: Core scope.\napply-to: All\nvalid-from: 2026-01-01\n---\n\n# _core\n\n[ADRs](adrs/index.md)\n',
|
|
952
|
+
'.xdrs/_core/adrs/index.md': [
|
|
953
|
+
'# _core ADR Index', '', 'Core ADRs.', '', '## principles', '',
|
|
954
|
+
'- [001-core-scope-type](principles/001-core-scope-type.md) - core type',
|
|
955
|
+
'- [002-parent-scope-type](principles/002-parent-scope-type.md) - parent type',
|
|
956
|
+
'- [003-child-scope-type](principles/003-child-scope-type.md) - child type',
|
|
957
|
+
''
|
|
958
|
+
].join('\n'),
|
|
959
|
+
'.xdrs/_core/adrs/principles/001-core-scope-type.md': '---\nname: _core-adr-policy-001-core-scope-type\ndescription: core stub\napply-to: All\nvalid-from: 2026-01-01\n---\n\n## Context and Problem Statement\n\nStub.\n\n## Decision Outcome\n\nStub.\n',
|
|
960
|
+
'.xdrs/_core/adrs/principles/002-parent-scope-type.md': '---\nname: _core-adr-policy-002-parent-scope-type\ndescription: parent type that cycles back to child\napply-to: All\nvalid-from: 2026-01-01\n---\n\n## Context and Problem Statement\n\nParent.\n\n## Decision Outcome\n\nParent.\n\n#### 01-parent-scope-type\n\nInstances inherit from the `child` scope type.\n',
|
|
961
|
+
'.xdrs/_core/adrs/principles/003-child-scope-type.md': '---\nname: _core-adr-policy-003-child-scope-type\ndescription: child type that points to parent\napply-to: All\nvalid-from: 2026-01-01\n---\n\n## Context and Problem Statement\n\nChild.\n\n## Decision Outcome\n\nChild.\n\n#### 01-parent-scope-type\n\nInstances inherit from the `parent` scope type.\n',
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
965
|
+
|
|
966
|
+
expect(result.errors.join('\n')).toContain('creates a cycle in the scope-type inheritance chain');
|
|
967
|
+
expect(result.errors.join('\n')).toContain('my-custom/index.md');
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
test('reports scope-type with reserved underscore prefix', () => {
|
|
971
|
+
const workspaceRoot = createWorkspace('scope-type-underscore-prefix', {
|
|
972
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
|
|
973
|
+
'.xdrs/myteam/index.md': '---\nscope-type: _custom\nname: myteam\ndescription: Team scope for tests.\napply-to: Test team only\nvalid-from: 2026-01-01\n---\n\n# myteam Scope Overview\n\nTeam scope for tests.\n\n[ADRs](adrs/index.md)\n',
|
|
974
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex([
|
|
975
|
+
'- [001-team](principles/001-team.md) - Team decision'
|
|
976
|
+
]),
|
|
977
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
978
|
+
...coreWithScopeTypes([]),
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
982
|
+
|
|
983
|
+
expect(result.errors.join('\n')).toContain('uses a reserved "_" prefix');
|
|
984
|
+
expect(result.errors.join('\n')).toContain('myteam/index.md');
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
test('does not report underscore prefix error for valid _local scope type', () => {
|
|
988
|
+
const workspaceRoot = createWorkspace('scope-type-local-no-prefix-error', {
|
|
989
|
+
'.xdrs/index.md': rootIndex(),
|
|
990
|
+
'.xdrs/_local/index.md': localScopeIndex(),
|
|
991
|
+
'.xdrs/_local/adrs/index.md': localAdrIndex([
|
|
992
|
+
'- [001-main](principles/001-main.md) - Main decision'
|
|
993
|
+
]),
|
|
994
|
+
'.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Test body.'),
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
const result = lintWorkspace(workspaceRoot);
|
|
998
|
+
|
|
999
|
+
expect(result.errors.join('\n')).not.toContain('uses a reserved "_" prefix');
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test('reports follows that references the scope itself', () => {
|
|
1003
|
+
const workspaceRoot = createWorkspace('follows-self-reference', {
|
|
1004
|
+
'.xdrs/index.md': rootIndex(['[myarea-core](myarea-core/index.md)']),
|
|
1005
|
+
'.xdrs/myarea-core/index.md': '---\nscope-type: core\nname: myarea-core\ndescription: Meta-governance for myarea.\napply-to: All myarea scopes\nvalid-from: 2026-01-01\nfollows: myarea-core\n---\n\n# myarea-core Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1006
|
+
'.xdrs/myarea-core/adrs/index.md': teamAdrIndex([
|
|
1007
|
+
'- [001-team](principles/001-team.md) - Team decision'
|
|
1008
|
+
]),
|
|
1009
|
+
'.xdrs/myarea-core/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1013
|
+
|
|
1014
|
+
expect(result.errors.join('\n')).toContain('follows must not reference the scope itself');
|
|
1015
|
+
expect(result.errors.join('\n')).toContain('myarea-core/index.md');
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test('reports follows that references _core', () => {
|
|
1019
|
+
const workspaceRoot = createWorkspace('follows-core-reference', {
|
|
1020
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
|
|
1021
|
+
'.xdrs/myteam/index.md': '---\nscope-type: standard\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nfollows: _core\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1022
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex([
|
|
1023
|
+
'- [001-team](principles/001-team.md) - Team decision'
|
|
1024
|
+
]),
|
|
1025
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1029
|
+
|
|
1030
|
+
expect(result.errors.join('\n')).toContain('follows must not reference "_core" as it is always applied implicitly');
|
|
1031
|
+
expect(result.errors.join('\n')).toContain('myteam/index.md');
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
test('reports follows list that includes _core', () => {
|
|
1035
|
+
const workspaceRoot = createWorkspace('follows-list-includes-core', {
|
|
1036
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)', '[myarea-core](myarea-core/index.md)']),
|
|
1037
|
+
'.xdrs/myteam/index.md': '---\nscope-type: standard\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nfollows:\n - myarea-core\n - _core\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1038
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex([
|
|
1039
|
+
'- [001-team](principles/001-team.md) - Team decision'
|
|
1040
|
+
]),
|
|
1041
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1042
|
+
'.xdrs/myarea-core/index.md': '---\nscope-type: core\nname: myarea-core\ndescription: Meta-governance for myarea.\napply-to: All myarea scopes\nvalid-from: 2026-01-01\n---\n\n# myarea-core Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1043
|
+
'.xdrs/myarea-core/adrs/index.md': teamAdrIndex([
|
|
1044
|
+
'- [001-team](principles/001-team.md) - Team decision'
|
|
1045
|
+
]),
|
|
1046
|
+
'.xdrs/myarea-core/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1050
|
+
|
|
1051
|
+
expect(result.errors.join('\n')).toContain('follows must not reference "_core" as it is always applied implicitly');
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
test('reports duplicate scope-local standards policies in same scope', () => {
|
|
1055
|
+
const workspaceRoot = createWorkspace('duplicate-scope-local-standards', {
|
|
1056
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
|
|
1057
|
+
'.xdrs/myteam/index.md': teamScopeIndex(),
|
|
1058
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex([
|
|
1059
|
+
'- [001-team](principles/001-team.md) - Team decision',
|
|
1060
|
+
'- [002-myteam-core](principles/002-myteam-core.md) - Local standards',
|
|
1061
|
+
'- [003-myteam-core-dup](principles/003-myteam-core-dup.md) - Duplicate local standards',
|
|
1062
|
+
]),
|
|
1063
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1064
|
+
'.xdrs/myteam/adrs/principles/002-myteam-core.md': [
|
|
1065
|
+
'---',
|
|
1066
|
+
'name: myteam-adr-policy-002-myteam-core',
|
|
1067
|
+
'description: Local standards for myteam.',
|
|
1068
|
+
'apply-to: myteam scope',
|
|
1069
|
+
'valid-from: 2026-01-01',
|
|
1070
|
+
'---',
|
|
1071
|
+
'',
|
|
1072
|
+
'# myteam-adr-policy-002: myteam local standards',
|
|
1073
|
+
'',
|
|
1074
|
+
'## Context and Problem Statement',
|
|
1075
|
+
'',
|
|
1076
|
+
'Defines local authoring standards.',
|
|
1077
|
+
'',
|
|
1078
|
+
'## Decision Outcome',
|
|
1079
|
+
'',
|
|
1080
|
+
'Standard outcome.',
|
|
1081
|
+
''
|
|
1082
|
+
].join('\n'),
|
|
1083
|
+
'.xdrs/myteam/adrs/principles/003-myteam-core-dup.md': [
|
|
1084
|
+
'---',
|
|
1085
|
+
'name: myteam-adr-policy-003-myteam-core',
|
|
1086
|
+
'description: Duplicate local standards for myteam.',
|
|
1087
|
+
'apply-to: myteam scope',
|
|
1088
|
+
'valid-from: 2026-01-01',
|
|
1089
|
+
'---',
|
|
1090
|
+
'',
|
|
1091
|
+
'# myteam-adr-policy-003: myteam local standards duplicate',
|
|
1092
|
+
'',
|
|
1093
|
+
'## Context and Problem Statement',
|
|
1094
|
+
'',
|
|
1095
|
+
'Duplicate defines local authoring standards.',
|
|
1096
|
+
'',
|
|
1097
|
+
'## Decision Outcome',
|
|
1098
|
+
'',
|
|
1099
|
+
'Duplicate outcome.',
|
|
1100
|
+
''
|
|
1101
|
+
].join('\n'),
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1105
|
+
|
|
1106
|
+
expect(result.errors.join('\n')).toContain('has more than one scope-local standards policy');
|
|
1107
|
+
expect(result.errors.join('\n')).toContain('myteam-core');
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
test('accepts single scope-local standards policy in a scope', () => {
|
|
1111
|
+
const workspaceRoot = createWorkspace('single-scope-local-standards', {
|
|
1112
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
|
|
1113
|
+
'.xdrs/myteam/index.md': teamScopeIndex(),
|
|
1114
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex([
|
|
1115
|
+
'- [001-team](principles/001-team.md) - Team decision',
|
|
1116
|
+
'- [002-myteam-core](principles/002-myteam-core.md) - Local standards',
|
|
1117
|
+
]),
|
|
1118
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1119
|
+
'.xdrs/myteam/adrs/principles/002-myteam-core.md': [
|
|
1120
|
+
'---',
|
|
1121
|
+
'name: myteam-adr-policy-002-myteam-core',
|
|
1122
|
+
'description: Local standards for myteam.',
|
|
1123
|
+
'apply-to: myteam scope',
|
|
1124
|
+
'valid-from: 2026-01-01',
|
|
1125
|
+
'---',
|
|
1126
|
+
'',
|
|
1127
|
+
'# myteam-adr-policy-002: myteam local standards',
|
|
1128
|
+
'',
|
|
1129
|
+
'## Context and Problem Statement',
|
|
1130
|
+
'',
|
|
1131
|
+
'Defines local authoring standards.',
|
|
1132
|
+
'',
|
|
1133
|
+
'## Decision Outcome',
|
|
1134
|
+
'',
|
|
1135
|
+
'Standard outcome.',
|
|
1136
|
+
''
|
|
1137
|
+
].join('\n'),
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1141
|
+
|
|
1142
|
+
expect(result.errors.join('\n')).not.toContain('has more than one scope-local standards policy');
|
|
1143
|
+
});
|
|
1144
|
+
|
|
946
1145
|
test('reports missing name field in scope index frontmatter', () => {
|
|
947
1146
|
const workspaceRoot = createWorkspace('scope-index-missing-name', {
|
|
948
1147
|
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
|
|
@@ -1082,6 +1281,25 @@ test('accepts follows as a single core scope name string in scope index frontmat
|
|
|
1082
1281
|
expect(result.errors.join('\n')).not.toContain('Scope index frontmatter follows');
|
|
1083
1282
|
});
|
|
1084
1283
|
|
|
1284
|
+
test('accepts follows as a comma-separated list of core scope names in scope index frontmatter', () => {
|
|
1285
|
+
const workspaceRoot = createWorkspace('valid-scope-follows-comma', {
|
|
1286
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)', '[myarea-core](myarea-core/index.md)', '[shared-standards-core](shared-standards-core/index.md)']),
|
|
1287
|
+
'.xdrs/myteam/index.md': '---\nscope-type: standard\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nfollows: myarea-core, shared-standards-core\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1288
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1289
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1290
|
+
'.xdrs/myarea-core/index.md': '---\nscope-type: core\nname: myarea-core\ndescription: Meta-governance for myarea.\napply-to: All myarea scopes\nvalid-from: 2026-01-01\n---\n\n# myarea-core Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1291
|
+
'.xdrs/myarea-core/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1292
|
+
'.xdrs/myarea-core/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1293
|
+
'.xdrs/shared-standards-core/index.md': '---\nscope-type: core\nname: shared-standards-core\ndescription: Shared standards for all areas.\napply-to: All scopes\nvalid-from: 2026-01-01\n---\n\n# shared-standards-core Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1294
|
+
'.xdrs/shared-standards-core/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1295
|
+
'.xdrs/shared-standards-core/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1299
|
+
|
|
1300
|
+
expect(result.errors.join('\n')).not.toContain('Scope index frontmatter follows');
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1085
1303
|
test('reports invalid follows value in scope index frontmatter', () => {
|
|
1086
1304
|
const workspaceRoot = createWorkspace('invalid-scope-follows', {
|
|
1087
1305
|
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
|
|
@@ -1243,6 +1461,83 @@ test('accepts related-scopes scope that exists in workspace', () => {
|
|
|
1243
1461
|
expect(result.errors.join('\n')).not.toContain('related-scopes references scope "sibling-team"');
|
|
1244
1462
|
});
|
|
1245
1463
|
|
|
1464
|
+
test('reports duplicate entries in follows list', () => {
|
|
1465
|
+
const workspaceRoot = createWorkspace('follows-duplicate-entries', {
|
|
1466
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)', '[myarea-core](myarea-core/index.md)']),
|
|
1467
|
+
'.xdrs/myteam/index.md': '---\nscope-type: standard\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nfollows:\n - myarea-core\n - myarea-core\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1468
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1469
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1470
|
+
'.xdrs/myarea-core/index.md': '---\nscope-type: core\nname: myarea-core\ndescription: Meta-governance for myarea.\napply-to: All myarea scopes\nvalid-from: 2026-01-01\n---\n\n# myarea-core Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1471
|
+
'.xdrs/myarea-core/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1472
|
+
'.xdrs/myarea-core/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1476
|
+
|
|
1477
|
+
expect(result.errors.join('\n')).toContain('follows has duplicate entry "myarea-core"');
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
test('reports duplicate entries in follows comma-separated list', () => {
|
|
1481
|
+
const workspaceRoot = createWorkspace('follows-comma-duplicate-entries', {
|
|
1482
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)', '[myarea-core](myarea-core/index.md)']),
|
|
1483
|
+
'.xdrs/myteam/index.md': '---\nscope-type: standard\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nfollows: myarea-core, myarea-core\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1484
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1485
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1486
|
+
'.xdrs/myarea-core/index.md': '---\nscope-type: core\nname: myarea-core\ndescription: Meta-governance for myarea.\napply-to: All myarea scopes\nvalid-from: 2026-01-01\n---\n\n# myarea-core Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1487
|
+
'.xdrs/myarea-core/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1488
|
+
'.xdrs/myarea-core/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1492
|
+
|
|
1493
|
+
expect(result.errors.join('\n')).toContain('follows has duplicate entry "myarea-core"');
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
test('reports related-scopes that references the scope itself', () => {
|
|
1497
|
+
const workspaceRoot = createWorkspace('related-scopes-self-reference', {
|
|
1498
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
|
|
1499
|
+
'.xdrs/myteam/index.md': '---\nscope-type: standard\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nrelated-scopes: myteam\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1500
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1501
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1505
|
+
|
|
1506
|
+
expect(result.errors.join('\n')).toContain('related-scopes must not reference the scope itself');
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
test('reports duplicate entries in related-scopes list', () => {
|
|
1510
|
+
const workspaceRoot = createWorkspace('related-scopes-duplicate-entries', {
|
|
1511
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)', '[sibling-team](sibling-team/index.md)']),
|
|
1512
|
+
'.xdrs/myteam/index.md': '---\nscope-type: standard\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nrelated-scopes:\n - sibling-team\n - sibling-team\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1513
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1514
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1515
|
+
'.xdrs/sibling-team/index.md': '---\nscope-type: standard\nname: sibling-team\ndescription: Sibling team.\napply-to: Sibling team\nvalid-from: 2026-01-01\n---\n\n# sibling-team Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1516
|
+
'.xdrs/sibling-team/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1517
|
+
'.xdrs/sibling-team/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1521
|
+
|
|
1522
|
+
expect(result.errors.join('\n')).toContain('related-scopes has duplicate entry "sibling-team"');
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
test('reports related-scopes entry that is already declared in follows', () => {
|
|
1526
|
+
const workspaceRoot = createWorkspace('related-scopes-follows-overlap', {
|
|
1527
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)', '[myarea-core](myarea-core/index.md)']),
|
|
1528
|
+
'.xdrs/myteam/index.md': '---\nscope-type: standard\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nfollows: myarea-core\nrelated-scopes: myarea-core\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1529
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1530
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1531
|
+
'.xdrs/myarea-core/index.md': '---\nscope-type: core\nname: myarea-core\ndescription: Meta-governance for myarea.\napply-to: All myarea scopes\nvalid-from: 2026-01-01\n---\n\n# myarea-core Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1532
|
+
'.xdrs/myarea-core/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1533
|
+
'.xdrs/myarea-core/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1537
|
+
|
|
1538
|
+
expect(result.errors.join('\n')).toContain('related-scopes must not repeat scope "myarea-core" already declared in follows');
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1246
1541
|
test('reports orphan asset files not referenced by any document', () => {
|
|
1247
1542
|
const workspaceRoot = createWorkspace('orphan-asset', {
|
|
1248
1543
|
'.xdrs/index.md': rootIndex(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xdrs-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "A framework to structure, compile and distribute Architectural (ADR), Business (BDR), and Engineering (EDR) decision records contents so that AI agents and humans can reliably find and use them with hierarchical scopes and controlled rollout in the format of distributable versioned packages.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|