xdrs-core 0.32.0 → 0.33.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.
- package/.xdrs/_core/adrs/principles/001-xdrs-core.md +2 -1
- package/lib/lint.js +50 -1
- package/lib/lint.test.js +77 -0
- package/package.json +1 -1
|
@@ -80,7 +80,8 @@ 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):
|
|
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
|
+
- `related-scopes` (optional): Scope names of parent, sibling, or child scopes. Use when structural links help verify policy correctness across related scopes.
|
|
84
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.
|
|
85
86
|
- **ADR subjects**
|
|
86
87
|
- `principles`: Cross-cutting architecture and policy foundations.
|
package/lib/lint.js
CHANGED
|
@@ -28,7 +28,7 @@ const SKILL_PACKAGE_OPTIONAL_DIRS = new Set(['scripts', 'references', RESOURCE_D
|
|
|
28
28
|
|
|
29
29
|
const POLICY_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'apply-to', 'valid-from', 'license', 'metadata']);
|
|
30
30
|
const SKILL_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'license', 'metadata', 'compatibility', 'allowed-tools']);
|
|
31
|
-
const SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS = new Set(['scope-type', 'name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'follows']);
|
|
31
|
+
const SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS = new Set(['scope-type', 'name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'follows', 'related-scopes']);
|
|
32
32
|
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
|
|
33
33
|
const SLIDE_FILE_RE = /^.+-slides(?:-[a-z0-9-]+)?\.md$/;
|
|
34
34
|
const SLIDE_MAX_NAME_LENGTH = 64;
|
|
@@ -305,6 +305,55 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot)
|
|
|
305
305
|
}
|
|
306
306
|
}
|
|
307
307
|
}
|
|
308
|
+
|
|
309
|
+
const relatedScopesLineMatch = block.match(/^related-scopes:[ \t]*(.*)/m);
|
|
310
|
+
if (relatedScopesLineMatch !== null) {
|
|
311
|
+
const relatedScopesValue = relatedScopesLineMatch[1].trim();
|
|
312
|
+
const scopeNamePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
|
|
313
|
+
if (relatedScopesValue) {
|
|
314
|
+
if (!scopeNamePattern.test(relatedScopesValue)) {
|
|
315
|
+
errors.push(`Scope index frontmatter related-scopes must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)}`);
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
const relatedScopesStart = block.indexOf(relatedScopesLineMatch[0]) + relatedScopesLineMatch[0].length;
|
|
319
|
+
const listItems = [];
|
|
320
|
+
let hasInvalidEntry = false;
|
|
321
|
+
for (const line of block.slice(relatedScopesStart).split('\n')) {
|
|
322
|
+
const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
|
|
323
|
+
if (itemMatch) {
|
|
324
|
+
const item = (itemMatch[1] || '').trim();
|
|
325
|
+
if (!item || !scopeNamePattern.test(item)) {
|
|
326
|
+
errors.push(`Scope index frontmatter related-scopes entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)}`);
|
|
327
|
+
hasInvalidEntry = true;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
listItems.push(item);
|
|
331
|
+
} else if (line.trim() && !/^\s/.test(line)) {
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (!hasInvalidEntry && listItems.length === 0) {
|
|
336
|
+
errors.push(`Scope index frontmatter related-scopes must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (xdrsRoot) {
|
|
340
|
+
const entries = relatedScopesValue ? [relatedScopesValue] : (() => {
|
|
341
|
+
const items = [];
|
|
342
|
+
const relatedScopesStart = block.indexOf(relatedScopesLineMatch[0]) + relatedScopesLineMatch[0].length;
|
|
343
|
+
for (const line of block.slice(relatedScopesStart).split('\n')) {
|
|
344
|
+
const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
|
|
345
|
+
if (itemMatch && itemMatch[1]) items.push(itemMatch[1].trim());
|
|
346
|
+
else if (line.trim() && !/^\s/.test(line)) break;
|
|
347
|
+
}
|
|
348
|
+
return items;
|
|
349
|
+
})();
|
|
350
|
+
for (const entry of entries) {
|
|
351
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(entry) && !existsFile(path.join(xdrsRoot, entry, 'index.md'))) {
|
|
352
|
+
errors.push(`Scope index frontmatter related-scopes references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
308
357
|
}
|
|
309
358
|
|
|
310
359
|
function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, ignoreExternal, externalScopes) {
|
package/lib/lint.test.js
CHANGED
|
@@ -1047,6 +1047,83 @@ test('accepts follows scope that exists in workspace', () => {
|
|
|
1047
1047
|
expect(result.errors.join('\n')).not.toContain('follows references scope');
|
|
1048
1048
|
});
|
|
1049
1049
|
|
|
1050
|
+
test('accepts related-scopes as a list of scope names in scope index frontmatter', () => {
|
|
1051
|
+
const workspaceRoot = createWorkspace('valid-scope-related-scopes-list', {
|
|
1052
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)', '[sibling-team](sibling-team/index.md)', '[parent-domain](parent-domain/index.md)']),
|
|
1053
|
+
'.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nrelated-scopes:\n - sibling-team\n - parent-domain\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1054
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1055
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1056
|
+
'.xdrs/sibling-team/index.md': '---\nscope-type: domain\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',
|
|
1057
|
+
'.xdrs/sibling-team/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1058
|
+
'.xdrs/sibling-team/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1059
|
+
'.xdrs/parent-domain/index.md': '---\nscope-type: domain\nname: parent-domain\ndescription: Parent domain.\napply-to: Parent domain\nvalid-from: 2026-01-01\n---\n\n# parent-domain Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1060
|
+
'.xdrs/parent-domain/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1061
|
+
'.xdrs/parent-domain/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1065
|
+
|
|
1066
|
+
expect(result.errors.join('\n')).not.toContain('Scope index frontmatter related-scopes');
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
test('accepts related-scopes as a single scope name string in scope index frontmatter', () => {
|
|
1070
|
+
const workspaceRoot = createWorkspace('valid-scope-related-scopes-string', {
|
|
1071
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)', '[sibling-team](sibling-team/index.md)']),
|
|
1072
|
+
'.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nrelated-scopes: sibling-team\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1073
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1074
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1075
|
+
'.xdrs/sibling-team/index.md': '---\nscope-type: domain\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',
|
|
1076
|
+
'.xdrs/sibling-team/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1077
|
+
'.xdrs/sibling-team/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1081
|
+
|
|
1082
|
+
expect(result.errors.join('\n')).not.toContain('Scope index frontmatter related-scopes');
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
test('reports invalid related-scopes value in scope index frontmatter', () => {
|
|
1086
|
+
const workspaceRoot = createWorkspace('invalid-scope-related-scopes', {
|
|
1087
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
|
|
1088
|
+
'.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nrelated-scopes: 123\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1089
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1090
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1094
|
+
|
|
1095
|
+
expect(result.errors.join('\n')).toContain('Scope index frontmatter related-scopes must be a scope name or list of scope names');
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
test('reports related-scopes scope that does not exist in workspace', () => {
|
|
1099
|
+
const workspaceRoot = createWorkspace('related-scopes-missing-scope', {
|
|
1100
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)']),
|
|
1101
|
+
'.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nrelated-scopes: nonexistent-scope\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1102
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1103
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1107
|
+
|
|
1108
|
+
expect(result.errors.join('\n')).toContain('related-scopes references scope "nonexistent-scope" which does not exist in the workspace');
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
test('accepts related-scopes scope that exists in workspace', () => {
|
|
1112
|
+
const workspaceRoot = createWorkspace('related-scopes-existing-scope', {
|
|
1113
|
+
'.xdrs/index.md': rootIndex(['[myteam](myteam/index.md)', '[sibling-team](sibling-team/index.md)']),
|
|
1114
|
+
'.xdrs/myteam/index.md': '---\nscope-type: domain\nname: myteam\ndescription: Team.\napply-to: Test team\nvalid-from: 2026-01-01\nrelated-scopes: sibling-team\n---\n\n# myteam Scope Overview\n\n[ADRs](adrs/index.md)\n',
|
|
1115
|
+
'.xdrs/myteam/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1116
|
+
'.xdrs/myteam/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1117
|
+
'.xdrs/sibling-team/index.md': '---\nscope-type: domain\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',
|
|
1118
|
+
'.xdrs/sibling-team/adrs/index.md': teamAdrIndex(['- [001-team](principles/001-team.md) - Team decision']),
|
|
1119
|
+
'.xdrs/sibling-team/adrs/principles/001-team.md': teamXdrDocument('Team decision.'),
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
const result = lintWorkspace(workspaceRoot, { ignoreExternal: false });
|
|
1123
|
+
|
|
1124
|
+
expect(result.errors.join('\n')).not.toContain('related-scopes references scope "sibling-team"');
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1050
1127
|
test('reports orphan asset files not referenced by any document', () => {
|
|
1051
1128
|
const workspaceRoot = createWorkspace('orphan-asset', {
|
|
1052
1129
|
'.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.33.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",
|