xdrs-core 0.33.0 → 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.
- package/.xdrs/_core/adrs/index.md +6 -1
- package/.xdrs/_core/adrs/principles/001-xdrs-core.md +13 -11
- package/.xdrs/_core/adrs/principles/003-skill-standards.md +1 -1
- package/.xdrs/_core/adrs/principles/004-article-standards.md +1 -1
- package/.xdrs/_core/adrs/principles/006-research-standards.md +1 -1
- package/.xdrs/_core/adrs/principles/007-plan-standards.md +1 -1
- package/.xdrs/_core/adrs/principles/010-scope-governance.md +142 -0
- package/.xdrs/_core/adrs/principles/011-core-scope-type.md +69 -0
- package/.xdrs/_core/adrs/principles/012-reference-scope-type.md +59 -0
- package/.xdrs/_core/adrs/principles/013-platform-scope-type.md +47 -0
- package/.xdrs/_core/adrs/principles/014-standard-scope-type.md +48 -0
- package/.xdrs/_core/adrs/principles/015-local-scope-type.md +41 -0
- package/.xdrs/_core/adrs/principles/skills/001-review/SKILL.md +5 -1
- package/.xdrs/_core/adrs/principles/skills/002-write-policy/SKILL.md +2 -0
- package/.xdrs/_core/index.md +5 -3
- package/.xdrs/index.md +1 -1
- package/lib/lint.js +174 -26
- package/lib/lint.test.js +527 -33
- package/package.json +1 -1
- package/.xdrs/_core/adrs/principles/010-core-scope-naming.md +0 -71
package/lib/lint.js
CHANGED
|
@@ -18,7 +18,6 @@ const ALLOWED_SUBJECTS = {
|
|
|
18
18
|
|
|
19
19
|
const TYPE_NAMES = new Set(Object.keys(TYPE_TO_ID));
|
|
20
20
|
const RESERVED_SCOPES = new Set(['_core', '_local']);
|
|
21
|
-
const SCOPE_TYPE_NAMES = new Set(['core', 'reference', 'platform', 'domain', '_local']);
|
|
22
21
|
const NUMBERED_FILE_RE = /^(\d{3,})-([a-z0-9-]+)\.md$/;
|
|
23
22
|
const NUMBERED_DIR_RE = /^(\d{3,})-([a-z0-9-]+)$/;
|
|
24
23
|
const REQUIRED_ROOT_INDEX_TEXT = 'XDRS scopes listed last override the ones listed first';
|
|
@@ -100,6 +99,42 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
100
99
|
const rootEntries = safeReadDir(xdrsRoot, errors, 'read XDRS root directory');
|
|
101
100
|
const scopeEntries = rootEntries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'));
|
|
102
101
|
|
|
102
|
+
// Pass 1: collect all core-type scopes by reading index.md directly (bypasses external-skip)
|
|
103
|
+
const coreScopes = [];
|
|
104
|
+
for (const scopeEntry of scopeEntries) {
|
|
105
|
+
const scopeIndexPath = path.join(xdrsRoot, scopeEntry.name, 'index.md');
|
|
106
|
+
if (!existsFile(scopeIndexPath)) continue;
|
|
107
|
+
const content = fs.readFileSync(scopeIndexPath, 'utf8');
|
|
108
|
+
const scopeTypeMatch = content.match(/^scope-type:\s*(.+)$/m);
|
|
109
|
+
if (scopeTypeMatch && scopeTypeMatch[1].trim() === 'core') {
|
|
110
|
+
coreScopes.push(path.join(xdrsRoot, scopeEntry.name));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Pass 2: collect known scope-type names from NNN-{scope-type}-scope-type.md files in core scopes
|
|
115
|
+
const knownScopeTypes = new Map(); // scopeTypeName -> filePath
|
|
116
|
+
for (const coreScopePath of coreScopes) {
|
|
117
|
+
for (const typeName of Object.keys(TYPE_TO_ID)) {
|
|
118
|
+
const principlesDir = path.join(coreScopePath, typeName, 'principles');
|
|
119
|
+
if (!existsDirectory(principlesDir)) continue;
|
|
120
|
+
let principlesEntries;
|
|
121
|
+
try { principlesEntries = fs.readdirSync(principlesDir, { withFileTypes: true }); } catch { continue; }
|
|
122
|
+
for (const entry of principlesEntries) {
|
|
123
|
+
if (!entry.isFile()) continue;
|
|
124
|
+
const m = entry.name.match(NUMBERED_FILE_RE);
|
|
125
|
+
if (!m) continue;
|
|
126
|
+
const shortTitle = m[2];
|
|
127
|
+
if (!shortTitle.endsWith('-scope-type')) continue;
|
|
128
|
+
const rawTypeName = shortTitle.slice(0, -'-scope-type'.length);
|
|
129
|
+
// Special-case: 'local' maps to '_local' (underscore not allowed in NUMBERED_FILE_RE)
|
|
130
|
+
const scopeTypeName = rawTypeName === 'local' ? '_local' : rawTypeName;
|
|
131
|
+
if (!knownScopeTypes.has(scopeTypeName)) {
|
|
132
|
+
knownScopeTypes.set(scopeTypeName, path.join(principlesDir, entry.name));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
103
138
|
for (const entry of rootEntries) {
|
|
104
139
|
if (entry.isFile() && entry.name !== 'index.md') {
|
|
105
140
|
errors.push(`Unexpected file at .xdrs root: ${entry.name}`);
|
|
@@ -107,7 +142,7 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
107
142
|
}
|
|
108
143
|
|
|
109
144
|
for (const scopeEntry of scopeEntries) {
|
|
110
|
-
lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, actualTypeIndexes, ignoreExternal, effectiveExternalScopes);
|
|
145
|
+
lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, actualTypeIndexes, ignoreExternal, effectiveExternalScopes, knownScopeTypes);
|
|
111
146
|
}
|
|
112
147
|
|
|
113
148
|
const rootIndexPath = path.join(xdrsRoot, 'index.md');
|
|
@@ -183,7 +218,23 @@ function lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope,
|
|
|
183
218
|
}
|
|
184
219
|
}
|
|
185
220
|
|
|
186
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Parse a scope-type policy file for a NN-parent-scope-type rule and return the parent type name.
|
|
223
|
+
* Returns null if no parent is declared or the file cannot be read.
|
|
224
|
+
*/
|
|
225
|
+
function extractParentScopeType(policyFilePath) {
|
|
226
|
+
let content;
|
|
227
|
+
try { content = fs.readFileSync(policyFilePath, 'utf8'); } catch { return null; }
|
|
228
|
+
// Find a rule heading like "#### NN-parent-scope-type"
|
|
229
|
+
const headingMatch = content.match(/^#{4}\s+\d+-parent-scope-type\s*$/m);
|
|
230
|
+
if (!headingMatch) return null;
|
|
231
|
+
// Extract text after the heading until the next heading or end of file
|
|
232
|
+
const afterHeading = content.slice(content.indexOf(headingMatch[0]) + headingMatch[0].length);
|
|
233
|
+
const bodyMatch = afterHeading.match(/`([a-z0-9-]+)`/);
|
|
234
|
+
return bodyMatch ? bodyMatch[1] : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes = new Map()) {
|
|
187
238
|
const content = fs.readFileSync(scopeIndexPath, 'utf8');
|
|
188
239
|
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
189
240
|
if (!fmMatch) {
|
|
@@ -205,10 +256,35 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot)
|
|
|
205
256
|
errors.push(`Scope index frontmatter must include a scope-type field: ${toDisplayPath(scopeIndexPath)}`);
|
|
206
257
|
return;
|
|
207
258
|
}
|
|
208
|
-
if (
|
|
209
|
-
errors.push(`Scope
|
|
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)}`);
|
|
210
261
|
return;
|
|
211
262
|
}
|
|
263
|
+
if (!knownScopeTypes.has(scopeType) && scopeType !== '_local') {
|
|
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)}`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Validate inheritance chain: ensure all ancestors exist in knownScopeTypes
|
|
268
|
+
if (scopeType !== '_local') {
|
|
269
|
+
const visited = new Set([scopeType]);
|
|
270
|
+
let currentType = scopeType;
|
|
271
|
+
while (currentType) {
|
|
272
|
+
const policyPath = knownScopeTypes.get(currentType);
|
|
273
|
+
if (!policyPath) break;
|
|
274
|
+
const parentType = extractParentScopeType(policyPath);
|
|
275
|
+
if (!parentType) break;
|
|
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
|
+
}
|
|
280
|
+
if (!knownScopeTypes.has(parentType)) {
|
|
281
|
+
errors.push(`Scope type "${currentType}" declares parent "${parentType}" but no ${parentType}-scope-type policy exists in any core-type scope: ${toDisplayPath(scopeIndexPath)}`);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
visited.add(parentType);
|
|
285
|
+
currentType = parentType;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
212
288
|
if (scopeName === '_core' && scopeType !== 'core') {
|
|
213
289
|
errors.push(`Scope "_core" must have scope-type "core": ${toDisplayPath(scopeIndexPath)}`);
|
|
214
290
|
}
|
|
@@ -236,8 +312,8 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot)
|
|
|
236
312
|
|
|
237
313
|
if (!fm.description) {
|
|
238
314
|
errors.push(`Scope index frontmatter must include a non-empty description field: ${toDisplayPath(scopeIndexPath)}`);
|
|
239
|
-
} else if (fm.descriptionText && fm.descriptionText
|
|
240
|
-
errors.push(`Scope index frontmatter description must be
|
|
315
|
+
} else if (fm.descriptionText && countWords(fm.descriptionText) > 40) {
|
|
316
|
+
errors.push(`Scope index frontmatter description must be 40 words or fewer: ${toDisplayPath(scopeIndexPath)}`);
|
|
241
317
|
}
|
|
242
318
|
|
|
243
319
|
if (!fm.appliedTo) {
|
|
@@ -246,8 +322,8 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot)
|
|
|
246
322
|
const words = countWords(fm.appliedTo);
|
|
247
323
|
if (words === 0) {
|
|
248
324
|
errors.push(`Scope index frontmatter apply-to must not be empty: ${toDisplayPath(scopeIndexPath)}`);
|
|
249
|
-
} else if (words
|
|
250
|
-
errors.push(`Scope index frontmatter apply-to must be
|
|
325
|
+
} else if (words > 30) {
|
|
326
|
+
errors.push(`Scope index frontmatter apply-to must be 30 words or fewer: ${toDisplayPath(scopeIndexPath)}`);
|
|
251
327
|
}
|
|
252
328
|
}
|
|
253
329
|
|
|
@@ -257,12 +333,14 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot)
|
|
|
257
333
|
errors.push(`Scope index frontmatter valid-from must be a valid ISO date YYYY-MM-DD: ${toDisplayPath(scopeIndexPath)}`);
|
|
258
334
|
}
|
|
259
335
|
|
|
336
|
+
let followsEntries = [];
|
|
260
337
|
const followsLineMatch = block.match(/^follows:[ \t]*(.*)/m);
|
|
261
338
|
if (followsLineMatch !== null) {
|
|
262
339
|
const followsValue = followsLineMatch[1].trim();
|
|
263
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_-]*)+$/;
|
|
264
342
|
if (followsValue) {
|
|
265
|
-
if (!scopeNamePattern.test(followsValue)) {
|
|
343
|
+
if (!scopeNamePattern.test(followsValue) && !commaSeparatedPattern.test(followsValue)) {
|
|
266
344
|
errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)}`);
|
|
267
345
|
}
|
|
268
346
|
} else {
|
|
@@ -288,19 +366,46 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot)
|
|
|
288
366
|
}
|
|
289
367
|
}
|
|
290
368
|
if (xdrsRoot) {
|
|
291
|
-
const entries = followsValue
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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();
|
|
301
385
|
for (const entry of entries) {
|
|
302
|
-
if (
|
|
303
|
-
errors.push(`Scope index frontmatter follows
|
|
386
|
+
if (seenFollows.has(entry)) {
|
|
387
|
+
errors.push(`Scope index frontmatter follows has duplicate entry "${entry}": ${toDisplayPath(scopeIndexPath)}`);
|
|
388
|
+
}
|
|
389
|
+
seenFollows.add(entry);
|
|
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
|
+
}
|
|
399
|
+
const followedIndexPath = path.join(xdrsRoot, entry, 'index.md');
|
|
400
|
+
if (!existsFile(followedIndexPath)) {
|
|
401
|
+
errors.push(`Scope index frontmatter follows references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)}`);
|
|
402
|
+
} else {
|
|
403
|
+
const followedContent = fs.readFileSync(followedIndexPath, 'utf8');
|
|
404
|
+
const followedTypeMatch = followedContent.match(/^scope-type:\s*(.+)$/m);
|
|
405
|
+
if (!followedTypeMatch || followedTypeMatch[1].trim() !== 'core') {
|
|
406
|
+
errors.push(`Scope index frontmatter follows references scope "${entry}" which is not a core-type scope: ${toDisplayPath(scopeIndexPath)}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
304
409
|
}
|
|
305
410
|
}
|
|
306
411
|
}
|
|
@@ -347,16 +452,31 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot)
|
|
|
347
452
|
}
|
|
348
453
|
return items;
|
|
349
454
|
})();
|
|
455
|
+
const seenRelated = new Set();
|
|
350
456
|
for (const entry of entries) {
|
|
351
|
-
if (
|
|
352
|
-
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
|
+
}
|
|
353
473
|
}
|
|
354
474
|
}
|
|
355
475
|
}
|
|
356
476
|
}
|
|
357
477
|
}
|
|
358
478
|
|
|
359
|
-
function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, ignoreExternal, externalScopes) {
|
|
479
|
+
function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, ignoreExternal, externalScopes, knownScopeTypes = new Map()) {
|
|
360
480
|
const scopePath = path.join(xdrsRoot, scopeName);
|
|
361
481
|
|
|
362
482
|
if (ignoreExternal && externalScopes.has(scopeName)) {
|
|
@@ -396,7 +516,35 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, igno
|
|
|
396
516
|
errors.push(`Missing required scope index: ${toDisplayPath(scopeIndexPath)}`);
|
|
397
517
|
} else {
|
|
398
518
|
lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes);
|
|
399
|
-
lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot);
|
|
519
|
+
lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes);
|
|
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}`);
|
|
400
548
|
}
|
|
401
549
|
}
|
|
402
550
|
|