xdrs-core 0.51.0 → 0.52.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 +1 -0
- package/.xdrs/_core/adrs/principles/001-xdrs-standards.md +6 -5
- package/.xdrs/_core/adrs/principles/010-scope-governance.md +11 -10
- package/.xdrs/_core/adrs/principles/011-core-scope-type.md +5 -1
- package/.xdrs/_core/adrs/principles/019-compiled-scope-type.md +1 -1
- package/.xdrs/_core/adrs/principles/022-scope-activation.md +69 -0
- package/.xdrs/_core/adrs/principles/skills/.assets/prerequisites-gate.md +1 -0
- package/.xdrs/_core/adrs/principles/skills/.assets/scope-placement.md +1 -1
- package/.xdrs/_core/adrs/principles/skills/compile-scope/SKILL.md +5 -0
- package/.xdrs/_core/adrs/principles/skills/review/SKILL.md +1 -0
- package/.xdrs/_core/adrs/principles/skills/write-article/SKILL.md +1 -1
- package/.xdrs/_core/adrs/principles/skills/write-initiative/SKILL.md +1 -1
- package/.xdrs/_core/adrs/principles/skills/write-policy/SKILL.md +9 -9
- package/.xdrs/_core/adrs/principles/skills/write-research/SKILL.md +1 -1
- package/.xdrs/_core/index.md +1 -1
- package/.xdrs/index.md +2 -0
- package/AGENTS.md +1 -1
- package/README.md +19 -0
- package/lib/lint.js +340 -154
- package/lib/lint.test.js +425 -24
- package/lib/testPrompt.js +0 -2
- package/package.json +1 -1
package/lib/lint.js
CHANGED
|
@@ -23,6 +23,9 @@ const NUMBERED_FILE_RE = /^(\d{3,})-([a-z0-9-]+)\.md$/;
|
|
|
23
23
|
// kebab-case; digits are allowed anywhere in the name, including as a prefix.
|
|
24
24
|
const SKILL_DIR_RE = /^[a-z0-9-]+$/;
|
|
25
25
|
const REQUIRED_ROOT_INDEX_TEXT = 'XDRS scopes listed last override the ones listed first';
|
|
26
|
+
// Activation tags and legend per _core-adr-policy-022 rules 03 and 09
|
|
27
|
+
const ACTIVATION_TAGS = new Set(['extends-only', 'disabled']);
|
|
28
|
+
const ACTIVATION_LEGEND_TEXT = 'Scopes tagged `disabled` MUST be ignored; `extends-only` scopes apply only via extends (see _core-adr-policy-022)';
|
|
26
29
|
const SUBJECT_ARTIFACT_DIRS = new Set(['skills', 'articles', 'researches', 'initiatives']);
|
|
27
30
|
const RESOURCE_DIR_NAME = '.assets';
|
|
28
31
|
const SOURCE_SNAPSHOT_DIR_NAME = 'sources';
|
|
@@ -31,7 +34,7 @@ const SKILL_PACKAGE_OPTIONAL_DIRS = new Set(['scripts', 'references', RESOURCE_D
|
|
|
31
34
|
|
|
32
35
|
const POLICY_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'freeze-reference']);
|
|
33
36
|
const SKILL_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'license', 'metadata']);
|
|
34
|
-
const SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS = new Set(['scope-type', 'name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'follows', 'extends']);
|
|
37
|
+
const SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS = new Set(['scope-type', 'name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'follows', 'extends', 'extends-only']);
|
|
35
38
|
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
|
|
36
39
|
const SLIDE_FILE_RE = /^.+-slides(?:-[a-z0-9-]+)?\.md$/;
|
|
37
40
|
const SLIDE_MAX_NAME_LENGTH = 64;
|
|
@@ -102,13 +105,14 @@ function runLintCli(args) {
|
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
for (const [scopeName, causes] of result.readOnlyScopes) {
|
|
109
|
+
for (const cause of causes) {
|
|
110
|
+
console.log(`- READ-ONLY scope "${scopeName}": ${cause}; no changes must be made to this scope [_core-adr-policy-022.06-read-only-dependencies]`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
105
114
|
if (result.errors.length === 0) {
|
|
106
115
|
console.log(`Lint passed for ${toDisplayPath(result.xdrsRoot)}`);
|
|
107
|
-
if (result.readOnlyScopes.size > 0) {
|
|
108
|
-
for (const scopeName of result.readOnlyScopes) {
|
|
109
|
-
console.log(`- READ-ONLY scope "${scopeName}": one or more follows -core scopes are not present in this workspace; no changes must be made to this scope [_core-adr-policy-011.08-unavailable-core-read-only]`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
116
|
return 0;
|
|
113
117
|
}
|
|
114
118
|
|
|
@@ -144,7 +148,7 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
144
148
|
const xdrsRoot = resolveXdrsRoot(resolvedTarget);
|
|
145
149
|
const errors = [];
|
|
146
150
|
const warnings = [];
|
|
147
|
-
const readOnlyScopes = new
|
|
151
|
+
const readOnlyScopes = new Map(); // scopeName -> [cause, ...] per _core-adr-policy-022.06
|
|
148
152
|
|
|
149
153
|
if (!existsDirectory(xdrsRoot)) {
|
|
150
154
|
errors.push(`Missing XDRS root directory: ${toDisplayPath(xdrsRoot)} [_core-adr-policy-001]`);
|
|
@@ -160,7 +164,13 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
160
164
|
const rootEntries = safeReadDir(xdrsRoot, errors, 'read XDRS root directory');
|
|
161
165
|
const scopeEntries = rootEntries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'));
|
|
162
166
|
|
|
163
|
-
|
|
167
|
+
const rootIndexPath = path.join(xdrsRoot, 'index.md');
|
|
168
|
+
const activation = existsFile(rootIndexPath) ? parseRootIndexActivation(rootIndexPath, xdrsRoot, errors) : new Map();
|
|
169
|
+
|
|
170
|
+
// Pass 1: collect known scope-type names from NNN-{scope-type}-scope-type.md files in any scope,
|
|
171
|
+
// plus the follows:/extends:/extends-only fields of every scope index (including external scopes)
|
|
172
|
+
const scopeMeta = new Map(); // scopeName -> readScopeMeta() result (null when index.md is missing)
|
|
173
|
+
const scopeTypeDefiningScopes = new Map(); // scopeTypeName -> Set(scopeName)
|
|
164
174
|
const knownScopeTypes = new Map(); // scopeTypeName -> filePath (first primary found, for downstream validation)
|
|
165
175
|
const scopeTypePrimaryKeys = new Set(); // "scopeName|typeName|scopeTypeName" for companion co-location checks
|
|
166
176
|
const scopeTypePrimaryByScope = new Map(); // "scopeName|scopeTypeName" -> [typeName, ...] for duplicate detection
|
|
@@ -168,6 +178,7 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
168
178
|
for (const scopeEntry of scopeEntries) {
|
|
169
179
|
const scopeName = scopeEntry.name;
|
|
170
180
|
const scopePath = path.join(xdrsRoot, scopeName);
|
|
181
|
+
scopeMeta.set(scopeName, readScopeMeta(path.join(scopePath, 'index.md')));
|
|
171
182
|
for (const typeName of Object.keys(TYPE_TO_ID)) {
|
|
172
183
|
const principlesDir = path.join(scopePath, typeName, 'principles');
|
|
173
184
|
if (!existsDirectory(principlesDir)) continue;
|
|
@@ -187,13 +198,17 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
187
198
|
if (!knownScopeTypes.has(scopeTypeName)) {
|
|
188
199
|
knownScopeTypes.set(scopeTypeName, filePath);
|
|
189
200
|
}
|
|
201
|
+
if (!scopeTypeDefiningScopes.has(scopeTypeName)) {
|
|
202
|
+
scopeTypeDefiningScopes.set(scopeTypeName, new Set());
|
|
203
|
+
}
|
|
204
|
+
scopeTypeDefiningScopes.get(scopeTypeName).add(scopeName);
|
|
190
205
|
scopeTypePrimaryKeys.add(`${scopeName}|${typeName}|${scopeTypeName}`);
|
|
191
206
|
const scopeKey = `${scopeName}|${scopeTypeName}`;
|
|
192
207
|
if (!scopeTypePrimaryByScope.has(scopeKey)) {
|
|
193
208
|
scopeTypePrimaryByScope.set(scopeKey, []);
|
|
194
209
|
}
|
|
195
210
|
scopeTypePrimaryByScope.get(scopeKey).push(typeName);
|
|
196
|
-
validateStructuredFormat(filePath, errors, '_core-adr-policy-010.
|
|
211
|
+
validateStructuredFormat(filePath, errors, '_core-adr-policy-010.11-def-scope-type-must-be-structured');
|
|
197
212
|
} else {
|
|
198
213
|
// Check if it's a companion file: title contains '-scope-type-' followed by a qualifier
|
|
199
214
|
const stIdx = shortTitle.indexOf('-scope-type-');
|
|
@@ -202,7 +217,7 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
202
217
|
// Special-case: 'local' maps to '_local'
|
|
203
218
|
const scopeTypeName = rawScopeTypeName === 'local' ? '_local' : rawScopeTypeName;
|
|
204
219
|
scopeTypeCompanions.push({ scopeTypeName, scopeName, typeName, filePath });
|
|
205
|
-
validateStructuredFormat(filePath, errors, '_core-adr-policy-010.
|
|
220
|
+
validateStructuredFormat(filePath, errors, '_core-adr-policy-010.11-def-scope-type-must-be-structured');
|
|
206
221
|
}
|
|
207
222
|
}
|
|
208
223
|
}
|
|
@@ -222,7 +237,7 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
222
237
|
// Validate: each companion must have a corresponding primary in the same scope and type folder
|
|
223
238
|
for (const { scopeTypeName, scopeName, typeName, filePath } of scopeTypeCompanions) {
|
|
224
239
|
if (!scopeTypePrimaryKeys.has(`${scopeName}|${typeName}|${scopeTypeName}`)) {
|
|
225
|
-
errors.push(`Companion scope-type file has no corresponding primary "${scopeTypeName}-scope-type" policy in the same scope "${scopeName}" and type folder "${typeName}": ${toDisplayPath(filePath)} [_core-adr-policy-010.
|
|
240
|
+
errors.push(`Companion scope-type file has no corresponding primary "${scopeTypeName}-scope-type" policy in the same scope "${scopeName}" and type folder "${typeName}": ${toDisplayPath(filePath)} [_core-adr-policy-010.03-def-companion-files]`);
|
|
226
241
|
}
|
|
227
242
|
}
|
|
228
243
|
|
|
@@ -232,11 +247,13 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
232
247
|
}
|
|
233
248
|
}
|
|
234
249
|
|
|
250
|
+
collectReadOnlyScopes(scopeMeta, activation, externalScopes, knownScopeTypes, scopeTypeDefiningScopes, readOnlyScopes);
|
|
251
|
+
|
|
235
252
|
// Populated during lintScopeIndexFrontmatter: scopeName -> [extendedScopeNames...]
|
|
236
253
|
const extendsRelationships = new Map();
|
|
237
254
|
|
|
238
255
|
for (const scopeEntry of scopeEntries) {
|
|
239
|
-
lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, warnings, actualTypeIndexes, ignoreExternal, effectiveExternalScopes, knownScopeTypes,
|
|
256
|
+
lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, warnings, actualTypeIndexes, ignoreExternal, effectiveExternalScopes, knownScopeTypes, extendsRelationships, externalScopes);
|
|
240
257
|
}
|
|
241
258
|
|
|
242
259
|
// Detect cycles in extends: chains (DFS)
|
|
@@ -263,17 +280,16 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
263
280
|
detectExtendsCycles(scope, []);
|
|
264
281
|
}
|
|
265
282
|
|
|
266
|
-
const rootIndexPath = path.join(xdrsRoot, 'index.md');
|
|
267
283
|
if (!existsFile(rootIndexPath)) {
|
|
268
284
|
errors.push('Missing required root index: .xdrs/index.md [_core-adr-policy-001]');
|
|
269
285
|
} else {
|
|
270
|
-
lintRootIndex(rootIndexPath, xdrsRoot,
|
|
286
|
+
lintRootIndex(rootIndexPath, xdrsRoot, errors, warnings, effectiveExternalScopes, { activation, scopeMeta, knownScopeTypes });
|
|
271
287
|
}
|
|
272
288
|
|
|
273
289
|
return { xdrsRoot, errors, warnings, readOnlyScopes };
|
|
274
290
|
}
|
|
275
291
|
|
|
276
|
-
function lintRootIndex(rootIndexPath, xdrsRoot,
|
|
292
|
+
function lintRootIndex(rootIndexPath, xdrsRoot, errors, warnings, externalScopes, ctx) {
|
|
277
293
|
const content = fs.readFileSync(rootIndexPath, 'utf8');
|
|
278
294
|
const repoRoot = path.dirname(xdrsRoot);
|
|
279
295
|
|
|
@@ -292,8 +308,6 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, warni
|
|
|
292
308
|
}
|
|
293
309
|
}
|
|
294
310
|
|
|
295
|
-
const linkedScopeIndexes = links.filter((linkPath) => isScopeIndex(linkPath, xdrsRoot));
|
|
296
|
-
const linkedSet = new Set(linkedScopeIndexes.map(normalizePath));
|
|
297
311
|
const localScopePath = normalizePath(path.join(xdrsRoot, '_local'));
|
|
298
312
|
|
|
299
313
|
for (const linkPath of links) {
|
|
@@ -302,38 +316,207 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, warni
|
|
|
302
316
|
}
|
|
303
317
|
}
|
|
304
318
|
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
319
|
+
const { activation, scopeMeta, knownScopeTypes } = ctx;
|
|
320
|
+
const display = toDisplayPath(rootIndexPath);
|
|
321
|
+
const tagOf = (scopeName) => (activation.get(scopeName) || {}).tag || null;
|
|
322
|
+
|
|
323
|
+
// Every scope folder with an index.md (including external scopes), except _local, is linked
|
|
324
|
+
for (const [scopeName, meta] of scopeMeta) {
|
|
325
|
+
if (!meta || scopeName === '_local') continue;
|
|
326
|
+
const fieldTrue = meta.extendsOnly === 'true';
|
|
327
|
+
const suggestedLine = `[View scope ${scopeName}](${scopeName}/index.md)${fieldTrue ? ' `extends-only`' : ''}`;
|
|
328
|
+
const entry = activation.get(scopeName);
|
|
329
|
+
if (!entry) {
|
|
330
|
+
errors.push(`Root index is missing scope index link: ${toDisplayPath(path.join(xdrsRoot, scopeName, 'index.md'))}; add the line: ${suggestedLine} [_core-adr-policy-022.01-root-index-completeness]`);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (fieldTrue && !entry.tag) {
|
|
334
|
+
errors.push(`Scope "${scopeName}" declares extends-only: true, so its root index link on line ${entry.line} must carry an activation tag; change it to: ${suggestedLine} (or tag it \`disabled\`): ${display} [_core-adr-policy-022.07-extends-only-field]`);
|
|
335
|
+
}
|
|
336
|
+
if (entry.tag === 'extends-only' && scopeName !== '_core' && isCoreTypeScope(meta.scopeTypes, knownScopeTypes)) {
|
|
337
|
+
errors.push(`Core-type scope "${scopeName}" must not be tagged \`extends-only\` (line ${entry.line}): ${display} [_core-adr-policy-022.07-extends-only-field]`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const hasTags = [...activation.values()].some((entry) => entry.tag);
|
|
342
|
+
if (hasTags && !content.includes(ACTIVATION_LEGEND_TEXT)) {
|
|
343
|
+
errors.push(`Root index uses activation tags but is missing the legend line: ${ACTIVATION_LEGEND_TEXT}: ${display} [_core-adr-policy-022.09-root-index-ownership]`);
|
|
309
344
|
}
|
|
310
345
|
|
|
311
|
-
//
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
346
|
+
// extends-only scopes must be reached (transitively) through the extends: chain of an active scope
|
|
347
|
+
const reached = new Set();
|
|
348
|
+
const visit = (scopeName) => {
|
|
349
|
+
for (const target of (scopeMeta.get(scopeName) || {}).extends || []) {
|
|
350
|
+
if (reached.has(target) || !scopeMeta.get(target) || tagOf(target) === 'disabled') continue;
|
|
351
|
+
reached.add(target);
|
|
352
|
+
visit(target);
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
for (const [scopeName, meta] of scopeMeta) {
|
|
356
|
+
if (!meta) continue;
|
|
357
|
+
if (scopeName === '_local' || (activation.has(scopeName) && !tagOf(scopeName))) {
|
|
358
|
+
visit(scopeName);
|
|
317
359
|
}
|
|
318
360
|
}
|
|
361
|
+
for (const [scopeName, entry] of activation) {
|
|
362
|
+
if (entry.tag === 'extends-only' && !reached.has(scopeName)) {
|
|
363
|
+
warnings.push(`Scope "${scopeName}" is tagged \`extends-only\` but no active scope extends it, so its policies never apply: ${display} [_core-adr-policy-022.04-extends-only]`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
319
367
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
368
|
+
// Parses root index scope links and their activation tags (_core-adr-policy-022 rules 01 and 03).
|
|
369
|
+
// Returns Map scopeName -> { tag, line }. Links inside fenced code blocks are ignored.
|
|
370
|
+
function parseRootIndexActivation(rootIndexPath, xdrsRoot, errors) {
|
|
371
|
+
const activation = new Map();
|
|
372
|
+
const content = fs.readFileSync(rootIndexPath, 'utf8');
|
|
373
|
+
const repoRoot = path.dirname(xdrsRoot);
|
|
374
|
+
const baseDir = path.dirname(rootIndexPath);
|
|
375
|
+
const display = toDisplayPath(rootIndexPath);
|
|
376
|
+
const lines = content.split(/\r?\n/);
|
|
377
|
+
const ignoredLines = findIgnoredMarkdownLines(lines);
|
|
378
|
+
|
|
379
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
380
|
+
if (ignoredLines[index]) continue;
|
|
381
|
+
const line = lines[index];
|
|
382
|
+
const lineNumber = index + 1;
|
|
383
|
+
const allowedTagOffsets = new Set();
|
|
384
|
+
let hasScopeLink = false;
|
|
385
|
+
|
|
386
|
+
for (const match of line.matchAll(/!?\[[^\]]+\]\(([^)]+)\)/g)) {
|
|
387
|
+
const target = normalizeLocalLinkTarget(match[1].trim());
|
|
388
|
+
if (!target) continue;
|
|
389
|
+
const linkPath = target.startsWith('/') ? path.join(repoRoot, target) : path.resolve(baseDir, target);
|
|
390
|
+
if (!isScopeIndex(linkPath, xdrsRoot)) continue;
|
|
391
|
+
hasScopeLink = true;
|
|
392
|
+
const scopeName = relativeFrom(xdrsRoot, linkPath).split(path.sep)[0];
|
|
393
|
+
const linkEnd = match.index + match[0].length;
|
|
394
|
+
const tagMatch = line.slice(linkEnd).match(/^[ \t]*`([^`]*)`/);
|
|
395
|
+
let tag = null;
|
|
396
|
+
if (tagMatch && ACTIVATION_TAGS.has(tagMatch[1])) {
|
|
397
|
+
tag = tagMatch[1];
|
|
398
|
+
allowedTagOffsets.add(linkEnd + tagMatch[0].length - tagMatch[1].length - 2);
|
|
399
|
+
}
|
|
400
|
+
if (scopeName === '_local') continue; // reported by lintRootIndex
|
|
401
|
+
if (tag && scopeName === '_core') {
|
|
402
|
+
errors.push(`Scope "_core" must not carry an activation tag (line ${lineNumber}): ${display} [_core-adr-policy-022.03-activation-tag-syntax]`);
|
|
403
|
+
}
|
|
404
|
+
const existing = activation.get(scopeName);
|
|
405
|
+
if (existing) {
|
|
406
|
+
errors.push(`Root index links scope "${scopeName}" more than once (lines ${existing.line} and ${lineNumber}); keep a single link: ${display} [_core-adr-policy-022.01-root-index-completeness]`);
|
|
407
|
+
} else {
|
|
408
|
+
activation.set(scopeName, { tag, line: lineNumber });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!hasScopeLink) continue;
|
|
413
|
+
for (const span of line.matchAll(/`[^`]*`/g)) {
|
|
414
|
+
if (!allowedTagOffsets.has(span.index)) {
|
|
415
|
+
errors.push(`Root index line ${lineNumber} has invalid inline code ${span[0]}: a scope link line may only carry one activation tag (\`extends-only\` or \`disabled\`, lowercase) right after the link: ${display} [_core-adr-policy-022.03-activation-tag-syntax]`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return activation;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Reads the fields of a scope index needed before per-scope lint runs (including external scopes).
|
|
424
|
+
// Returns null when the scope has no readable index.md.
|
|
425
|
+
function readScopeMeta(scopeIndexPath) {
|
|
426
|
+
if (!existsFile(scopeIndexPath)) return null;
|
|
427
|
+
let content;
|
|
428
|
+
try { content = fs.readFileSync(scopeIndexPath, 'utf8'); } catch { return null; }
|
|
429
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
430
|
+
const block = fmMatch ? fmMatch[1] : '';
|
|
431
|
+
const listField = (key) => {
|
|
432
|
+
const lineMatch = block.match(new RegExp(`^${key}:[ \\t]*(.*)`, 'm'));
|
|
433
|
+
return lineMatch ? deriveScopeNameListEntries(block, lineMatch, lineMatch[1].trim()) : [];
|
|
434
|
+
};
|
|
435
|
+
const scopeTypeMatch = block.match(/^scope-type:[ \t]*(.+)$/m);
|
|
436
|
+
const extendsOnlyMatch = block.match(/^extends-only:[ \t]*(.*)$/m);
|
|
437
|
+
return {
|
|
438
|
+
hasFrontmatter: Boolean(fmMatch),
|
|
439
|
+
scopeTypes: scopeTypeMatch ? parseScopeTypeField(scopeTypeMatch[1].trim()) : [],
|
|
440
|
+
follows: listField('follows'),
|
|
441
|
+
extends: listField('extends'),
|
|
442
|
+
extendsOnly: extendsOnlyMatch ? extendsOnlyMatch[1].trim() : null,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Computes read-only scopes and their causes per _core-adr-policy-022.06. Missing dependencies of
|
|
447
|
+
// local scopes are reported as lint errors elsewhere; here they only make external scopes read-only.
|
|
448
|
+
function collectReadOnlyScopes(scopeMeta, activation, externalScopes, knownScopeTypes, scopeTypeDefiningScopes, readOnlyScopes) {
|
|
449
|
+
const isDisabled = (scopeName) => (activation.get(scopeName) || {}).tag === 'disabled';
|
|
450
|
+
for (const [scopeName, meta] of scopeMeta) {
|
|
451
|
+
if (!meta) continue;
|
|
452
|
+
const isExternal = externalScopes.has(scopeName);
|
|
453
|
+
const causes = [];
|
|
454
|
+
if (isExternal && !meta.hasFrontmatter) {
|
|
455
|
+
causes.push('its scope index has no frontmatter; fix it in the source package');
|
|
456
|
+
}
|
|
457
|
+
if (isExternal && meta.extendsOnly !== null && meta.extendsOnly !== 'true' && meta.extendsOnly !== 'false') {
|
|
458
|
+
causes.push(`its scope index has an invalid extends-only value "${meta.extendsOnly}" (treated as absent); fix it in the source package`);
|
|
459
|
+
}
|
|
460
|
+
if (isExternal && meta.hasFrontmatter && meta.scopeTypes.length === 0) {
|
|
461
|
+
causes.push('its scope index has no scope-type; fix it in the source package');
|
|
462
|
+
}
|
|
463
|
+
for (const [field, targets] of [['follows', meta.follows], ['extends', meta.extends]]) {
|
|
464
|
+
for (const target of targets) {
|
|
465
|
+
if (target === scopeName || RESERVED_SCOPES.has(target)) continue;
|
|
466
|
+
const targetMeta = scopeMeta.get(target);
|
|
467
|
+
if (!targetMeta) {
|
|
468
|
+
if (isExternal) causes.push(`${field}: scope "${target}" is not present in this workspace; install it`);
|
|
469
|
+
} else if (isDisabled(target)) {
|
|
470
|
+
causes.push(`${field}: scope "${target}" is tagged \`disabled\` in the root index; enable it or remove the ${field}: reference`);
|
|
471
|
+
} else if (!targetMeta.hasFrontmatter) {
|
|
472
|
+
causes.push(`${field}: scope "${target}" has an invalid scope index; fix it`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
for (const scopeType of resolveScopeTypeChain(meta.scopeTypes, knownScopeTypes)) {
|
|
477
|
+
if (scopeType === '_local') continue;
|
|
478
|
+
if (!knownScopeTypes.has(scopeType)) {
|
|
479
|
+
if (isExternal) causes.push(`scope type "${scopeType}" has no ${scopeType}-scope-type policy in this workspace; install the scope that defines it`);
|
|
327
480
|
continue;
|
|
328
481
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
482
|
+
const definers = [...scopeTypeDefiningScopes.get(scopeType)].filter((s) => s !== scopeName);
|
|
483
|
+
if (definers.length > 0 && definers.every(isDisabled)) {
|
|
484
|
+
causes.push(`scope type "${scopeType}" is defined only in disabled scope(s) ${definers.map((s) => `"${s}"`).join(', ')}; enable one of them`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (causes.length > 0) {
|
|
488
|
+
readOnlyScopes.set(scopeName, causes);
|
|
333
489
|
}
|
|
334
490
|
}
|
|
335
491
|
}
|
|
336
492
|
|
|
493
|
+
// Parses a scope-type value in either "a, b" or "[a, b]" form into its elements.
|
|
494
|
+
function parseScopeTypeField(raw) {
|
|
495
|
+
const inner = raw.startsWith('[') && raw.endsWith(']') ? raw.slice(1, -1) : raw;
|
|
496
|
+
return inner.split(',').map((s) => s.trim()).filter(Boolean);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Resolves the flat scope-type chain (declared types plus all parent types, deduplicated) using
|
|
500
|
+
// _core-adr-policy-010.09 parent declarations. Types without a known policy end their chain.
|
|
501
|
+
function resolveScopeTypeChain(scopeTypes, knownScopeTypes) {
|
|
502
|
+
const chain = [];
|
|
503
|
+
const seen = new Set();
|
|
504
|
+
for (const scopeType of scopeTypes) {
|
|
505
|
+
let current = scopeType;
|
|
506
|
+
while (current && !seen.has(current)) {
|
|
507
|
+
seen.add(current);
|
|
508
|
+
chain.push(current);
|
|
509
|
+
const policyPath = knownScopeTypes.get(current);
|
|
510
|
+
current = policyPath ? extractParentScopeType(policyPath) : null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return chain;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function isCoreTypeScope(scopeTypes, knownScopeTypes) {
|
|
517
|
+
return resolveScopeTypeChain(scopeTypes, knownScopeTypes).includes('core');
|
|
518
|
+
}
|
|
519
|
+
|
|
337
520
|
function lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes = new Set()) {
|
|
338
521
|
const content = fs.readFileSync(scopeIndexPath, 'utf8');
|
|
339
522
|
const repoRoot = path.dirname(xdrsRoot);
|
|
@@ -370,7 +553,59 @@ function extractParentScopeType(policyFilePath) {
|
|
|
370
553
|
return bodyMatch ? bodyMatch[1] : null;
|
|
371
554
|
}
|
|
372
555
|
|
|
373
|
-
|
|
556
|
+
// Parse a scope-type frontmatter value into its individual elements. Supports a single
|
|
557
|
+
// value or a comma-separated list (e.g. "compiled, internal-docs"), matching the grammar
|
|
558
|
+
// defined in _core-adr-policy-010 rules 06 and 07.
|
|
559
|
+
function parseScopeTypeList(raw) {
|
|
560
|
+
const commaSeparatedScopeTypePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
|
|
561
|
+
return commaSeparatedScopeTypePattern.test(raw) ? raw.split(',').map((s) => s.trim()) : [raw];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Shared by the follows:/extends: frontmatter fields, which both accept a scope name, a
|
|
565
|
+
// comma-separated list of scope names, or an indented YAML list of scope names.
|
|
566
|
+
const SCOPE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
|
|
567
|
+
const SCOPE_NAME_LIST_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
|
|
568
|
+
|
|
569
|
+
// Parses the indented "- item" list entries following a scalar-less field line (e.g. a
|
|
570
|
+
// `follows:` key with no inline value). Returns raw trimmed strings in order, including empty
|
|
571
|
+
// ones for bare "-" markers so callers can distinguish "no items" from "blank item".
|
|
572
|
+
function parseYamlListItems(block, lineMatch) {
|
|
573
|
+
const items = [];
|
|
574
|
+
const start = block.indexOf(lineMatch[0]) + lineMatch[0].length;
|
|
575
|
+
for (const line of block.slice(start).split('\n')) {
|
|
576
|
+
const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
|
|
577
|
+
if (itemMatch) {
|
|
578
|
+
items.push((itemMatch[1] || '').trim());
|
|
579
|
+
} else if (line.trim() && !/^\s/.test(line)) {
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return items;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Validates the raw shape of a follows:/extends: frontmatter field (inline scalar, inline
|
|
587
|
+
// comma-separated list, or indented YAML list). Returns 'empty' when the field has no usable
|
|
588
|
+
// value, 'item' when a list entry is blank or not a valid scope name, or null when valid.
|
|
589
|
+
function validateScopeNameListFormat(block, lineMatch, inlineValue) {
|
|
590
|
+
if (inlineValue) {
|
|
591
|
+
return (SCOPE_NAME_PATTERN.test(inlineValue) || SCOPE_NAME_LIST_PATTERN.test(inlineValue)) ? null : 'empty';
|
|
592
|
+
}
|
|
593
|
+
const items = parseYamlListItems(block, lineMatch);
|
|
594
|
+
if (items.length === 0) return 'empty';
|
|
595
|
+
return items.some((item) => !item || !SCOPE_NAME_PATTERN.test(item)) ? 'item' : null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Derives the final array of entry names declared in a follows:/extends: frontmatter field.
|
|
599
|
+
function deriveScopeNameListEntries(block, lineMatch, inlineValue) {
|
|
600
|
+
if (inlineValue) {
|
|
601
|
+
if (SCOPE_NAME_LIST_PATTERN.test(inlineValue)) return inlineValue.split(',').map((s) => s.trim());
|
|
602
|
+
return SCOPE_NAME_PATTERN.test(inlineValue) ? [inlineValue] : [];
|
|
603
|
+
}
|
|
604
|
+
return parseYamlListItems(block, lineMatch).filter(Boolean);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes = new Map(), extendsRelationships = new Map(), externalScopes = new Set()) {
|
|
608
|
+
const isExternal = externalScopes.has(scopeName);
|
|
374
609
|
const content = fs.readFileSync(scopeIndexPath, 'utf8');
|
|
375
610
|
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
376
611
|
if (!fmMatch) {
|
|
@@ -389,18 +624,15 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
389
624
|
const scopeTypeMatch = block.match(/^scope-type:\s*(.+)$/m);
|
|
390
625
|
const scopeTypeRaw = scopeTypeMatch ? scopeTypeMatch[1].trim() : null;
|
|
391
626
|
if (!scopeTypeRaw) {
|
|
392
|
-
|
|
627
|
+
// External scopes with no scope-type become read-only instead (_core-adr-policy-022.06)
|
|
628
|
+
if (!isExternal) {
|
|
629
|
+
errors.push(`Scope index frontmatter must include a scope-type field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.10-def-valid-iff-policy-exists]`);
|
|
630
|
+
}
|
|
393
631
|
return;
|
|
394
632
|
}
|
|
395
633
|
|
|
396
634
|
// Parse scope-type: supports single string or comma-separated list (e.g. "compiled, internal-docs")
|
|
397
|
-
const
|
|
398
|
-
let scopeTypes;
|
|
399
|
-
if (commaSeparatedScopeTypePattern.test(scopeTypeRaw)) {
|
|
400
|
-
scopeTypes = scopeTypeRaw.split(',').map((s) => s.trim());
|
|
401
|
-
} else {
|
|
402
|
-
scopeTypes = [scopeTypeRaw];
|
|
403
|
-
}
|
|
635
|
+
const scopeTypes = parseScopeTypeList(scopeTypeRaw);
|
|
404
636
|
|
|
405
637
|
// Validate each element: no duplicates, valid chars, no reserved prefix
|
|
406
638
|
const seenTypes = new Set();
|
|
@@ -408,17 +640,17 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
408
640
|
const SCOPE_TYPE_ELEMENT_RE = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
|
|
409
641
|
for (const scopeType of scopeTypes) {
|
|
410
642
|
if (!SCOPE_TYPE_ELEMENT_RE.test(scopeType)) {
|
|
411
|
-
errors.push(`Scope type element "${scopeType}" contains invalid characters (must match [a-zA-Z_][a-zA-Z0-9_-]*): ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.
|
|
643
|
+
errors.push(`Scope type element "${scopeType}" contains invalid characters (must match [a-zA-Z_][a-zA-Z0-9_-]*): ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.08-def-no-underscore-prefix]`);
|
|
412
644
|
hasElementError = true;
|
|
413
645
|
continue;
|
|
414
646
|
}
|
|
415
647
|
if (scopeType.startsWith('_') && scopeType !== '_local') {
|
|
416
|
-
errors.push(`Scope type "${scopeType}" uses a reserved "_" prefix; only "_local" is a valid underscore-prefixed scope type: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.
|
|
648
|
+
errors.push(`Scope type "${scopeType}" uses a reserved "_" prefix; only "_local" is a valid underscore-prefixed scope type: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.08-def-no-underscore-prefix]`);
|
|
417
649
|
hasElementError = true;
|
|
418
650
|
continue;
|
|
419
651
|
}
|
|
420
652
|
if (seenTypes.has(scopeType)) {
|
|
421
|
-
errors.push(`Scope type list contains duplicate value "${scopeType}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.
|
|
653
|
+
errors.push(`Scope type list contains duplicate value "${scopeType}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.07-def-multi-type-validation]`);
|
|
422
654
|
hasElementError = true;
|
|
423
655
|
continue;
|
|
424
656
|
}
|
|
@@ -434,7 +666,9 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
434
666
|
for (const st of scopeTypes) {
|
|
435
667
|
if (st === '_local') continue;
|
|
436
668
|
if (!knownScopeTypes.has(st)) {
|
|
437
|
-
|
|
669
|
+
if (!isExternal) {
|
|
670
|
+
errors.push(`Scope index scope-type "${st}" has no corresponding ${st}-scope-type policy in the principles of any scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.10-def-valid-iff-policy-exists]`);
|
|
671
|
+
}
|
|
438
672
|
continue;
|
|
439
673
|
}
|
|
440
674
|
// Validate parent chain
|
|
@@ -445,11 +679,13 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
445
679
|
const parentType = extractParentScopeType(policyPath);
|
|
446
680
|
if (!parentType) break;
|
|
447
681
|
if (globalVisited.has(parentType)) {
|
|
448
|
-
errors.push(`Scope type "${currentType}" declares parent "${parentType}" which creates a cycle in the scope-type inheritance chain: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.
|
|
682
|
+
errors.push(`Scope type "${currentType}" declares parent "${parentType}" which creates a cycle in the scope-type inheritance chain: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.09-def-parent-scope-type]`);
|
|
449
683
|
break;
|
|
450
684
|
}
|
|
451
685
|
if (!knownScopeTypes.has(parentType)) {
|
|
452
|
-
|
|
686
|
+
if (!isExternal) {
|
|
687
|
+
errors.push(`Scope type "${currentType}" declares parent "${parentType}" but no ${parentType}-scope-type policy exists in any scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.09-def-parent-scope-type]`);
|
|
688
|
+
}
|
|
453
689
|
break;
|
|
454
690
|
}
|
|
455
691
|
globalVisited.add(parentType);
|
|
@@ -462,7 +698,11 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
462
698
|
if (scopeName === '_local' && scopeType !== '_local') {
|
|
463
699
|
errors.push(`Scope "_local" must have scope-type "_local": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-015.01-scope-type-name]`);
|
|
464
700
|
}
|
|
465
|
-
|
|
701
|
+
const isCoreType = isCoreTypeScope(scopeTypes, knownScopeTypes);
|
|
702
|
+
if (isCoreType && scopeTypes.length > 1) {
|
|
703
|
+
errors.push(`Core-type scope must declare exactly one scope type (found "${scopeTypes.join(', ')}"): ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-011.10-no-type-combination]`);
|
|
704
|
+
}
|
|
705
|
+
if (isCoreType && !scopeName.includes('core')) {
|
|
466
706
|
errors.push(`Scope with type "core" must have "core" in its name: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-011.03-naming-convention]`);
|
|
467
707
|
}
|
|
468
708
|
if (scopeTypes.includes('_local') && scopeName !== '_local') {
|
|
@@ -475,6 +715,19 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
475
715
|
errors.push(`Scope with type "platform" must follow the naming pattern {domain}-plat-{name} (e.g. "cloud-plat-aws"): ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-013.03-naming-convention]`);
|
|
476
716
|
}
|
|
477
717
|
|
|
718
|
+
const extendsOnlyMatch = block.match(/^extends-only:[ \t]*(.*)$/m);
|
|
719
|
+
if (extendsOnlyMatch) {
|
|
720
|
+
const extendsOnlyValue = extendsOnlyMatch[1].trim();
|
|
721
|
+
if (extendsOnlyValue !== 'true' && extendsOnlyValue !== 'false') {
|
|
722
|
+
// External scopes with an invalid value become read-only instead (_core-adr-policy-022.06)
|
|
723
|
+
if (!isExternal) {
|
|
724
|
+
errors.push(`Scope index frontmatter extends-only must be unquoted lowercase true or false (found "${extendsOnlyValue}"): ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-022.07-extends-only-field]`);
|
|
725
|
+
}
|
|
726
|
+
} else if (extendsOnlyValue === 'true' && (RESERVED_SCOPES.has(scopeName) || isCoreType)) {
|
|
727
|
+
errors.push(`Scope index frontmatter extends-only: true is not allowed on _core, _local or core-type scopes: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-022.07-extends-only-field]`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
478
731
|
if (!fm.name) {
|
|
479
732
|
errors.push(`Scope index frontmatter must include a non-empty name field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
480
733
|
} else if (fm.name !== scopeName) {
|
|
@@ -508,49 +761,14 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
508
761
|
const followsLineMatch = block.match(/^follows:[ \t]*(.*)/m);
|
|
509
762
|
if (followsLineMatch !== null) {
|
|
510
763
|
const followsValue = followsLineMatch[1].trim();
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
}
|
|
517
|
-
} else {
|
|
518
|
-
const followsStart = block.indexOf(followsLineMatch[0]) + followsLineMatch[0].length;
|
|
519
|
-
const listItems = [];
|
|
520
|
-
let hasInvalidEntry = false;
|
|
521
|
-
for (const line of block.slice(followsStart).split('\n')) {
|
|
522
|
-
const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
|
|
523
|
-
if (itemMatch) {
|
|
524
|
-
const item = (itemMatch[1] || '').trim();
|
|
525
|
-
if (!item || !scopeNamePattern.test(item)) {
|
|
526
|
-
errors.push(`Scope index frontmatter follows entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
527
|
-
hasInvalidEntry = true;
|
|
528
|
-
break;
|
|
529
|
-
}
|
|
530
|
-
listItems.push(item);
|
|
531
|
-
} else if (line.trim() && !/^\s/.test(line)) {
|
|
532
|
-
break;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
if (!hasInvalidEntry && listItems.length === 0) {
|
|
536
|
-
errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
537
|
-
}
|
|
764
|
+
const followsFormatError = validateScopeNameListFormat(block, followsLineMatch, followsValue);
|
|
765
|
+
if (followsFormatError === 'item') {
|
|
766
|
+
errors.push(`Scope index frontmatter follows entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
767
|
+
} else if (followsFormatError === 'empty') {
|
|
768
|
+
errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
538
769
|
}
|
|
539
770
|
if (xdrsRoot) {
|
|
540
|
-
const entries = followsValue
|
|
541
|
-
? (commaSeparatedPattern.test(followsValue)
|
|
542
|
-
? followsValue.split(',').map((s) => s.trim())
|
|
543
|
-
: [followsValue])
|
|
544
|
-
: (() => {
|
|
545
|
-
const items = [];
|
|
546
|
-
const followsStart = block.indexOf(followsLineMatch[0]) + followsLineMatch[0].length;
|
|
547
|
-
for (const line of block.slice(followsStart).split('\n')) {
|
|
548
|
-
const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
|
|
549
|
-
if (itemMatch && itemMatch[1]) items.push(itemMatch[1].trim());
|
|
550
|
-
else if (line.trim() && !/^\s/.test(line)) break;
|
|
551
|
-
}
|
|
552
|
-
return items;
|
|
553
|
-
})();
|
|
771
|
+
const entries = deriveScopeNameListEntries(block, followsLineMatch, followsValue);
|
|
554
772
|
followsEntries = entries;
|
|
555
773
|
const seenFollows = new Set();
|
|
556
774
|
for (const entry of entries) {
|
|
@@ -558,7 +776,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
558
776
|
errors.push(`Scope index frontmatter follows has duplicate entry "${entry}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
559
777
|
}
|
|
560
778
|
seenFollows.add(entry);
|
|
561
|
-
if (
|
|
779
|
+
if (SCOPE_NAME_PATTERN.test(entry)) {
|
|
562
780
|
if (entry === scopeName) {
|
|
563
781
|
errors.push(`Scope index frontmatter follows must not reference the scope itself: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
564
782
|
continue;
|
|
@@ -569,11 +787,16 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
569
787
|
}
|
|
570
788
|
const followedIndexPath = path.join(xdrsRoot, entry, 'index.md');
|
|
571
789
|
if (!existsFile(followedIndexPath)) {
|
|
572
|
-
|
|
790
|
+
// Missing -core is expected for an external companion scope: it becomes read-only
|
|
791
|
+
// (collected by collectReadOnlyScopes per _core-adr-policy-022.06), not a lint error.
|
|
792
|
+
if (!isExternal) {
|
|
793
|
+
errors.push(`Scope index frontmatter follows references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
794
|
+
}
|
|
573
795
|
} else {
|
|
574
796
|
const followedContent = fs.readFileSync(followedIndexPath, 'utf8');
|
|
575
797
|
const followedTypeMatch = followedContent.match(/^scope-type:\s*(.+)$/m);
|
|
576
|
-
|
|
798
|
+
const followedTypes = followedTypeMatch ? parseScopeTypeField(followedTypeMatch[1].trim()) : [];
|
|
799
|
+
if (!isCoreTypeScope(followedTypes, knownScopeTypes)) {
|
|
577
800
|
errors.push(`Scope index frontmatter follows references scope "${entry}" which is not a core-type scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
578
801
|
}
|
|
579
802
|
}
|
|
@@ -587,48 +810,13 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
587
810
|
const extendsLineMatch = block.match(/^extends:[ \t]*(.*)/m);
|
|
588
811
|
if (extendsLineMatch !== null) {
|
|
589
812
|
const extendsValue = extendsLineMatch[1].trim();
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
}
|
|
596
|
-
} else {
|
|
597
|
-
const extendsStart = block.indexOf(extendsLineMatch[0]) + extendsLineMatch[0].length;
|
|
598
|
-
const listItems = [];
|
|
599
|
-
let hasInvalidEntry = false;
|
|
600
|
-
for (const line of block.slice(extendsStart).split('\n')) {
|
|
601
|
-
const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
|
|
602
|
-
if (itemMatch) {
|
|
603
|
-
const item = (itemMatch[1] || '').trim();
|
|
604
|
-
if (!item || !scopeNamePattern.test(item)) {
|
|
605
|
-
errors.push(`Scope index frontmatter extends entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.27-extends-declaration]`);
|
|
606
|
-
hasInvalidEntry = true;
|
|
607
|
-
break;
|
|
608
|
-
}
|
|
609
|
-
listItems.push(item);
|
|
610
|
-
} else if (line.trim() && !/^\s/.test(line)) {
|
|
611
|
-
break;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
if (!hasInvalidEntry && listItems.length === 0) {
|
|
615
|
-
errors.push(`Scope index frontmatter extends must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.27-extends-declaration]`);
|
|
616
|
-
}
|
|
813
|
+
const extendsFormatError = validateScopeNameListFormat(block, extendsLineMatch, extendsValue);
|
|
814
|
+
if (extendsFormatError === 'item') {
|
|
815
|
+
errors.push(`Scope index frontmatter extends entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.27-extends-declaration]`);
|
|
816
|
+
} else if (extendsFormatError === 'empty') {
|
|
817
|
+
errors.push(`Scope index frontmatter extends must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.27-extends-declaration]`);
|
|
617
818
|
}
|
|
618
|
-
const rawEntries = extendsValue
|
|
619
|
-
? (commaSeparatedPattern.test(extendsValue)
|
|
620
|
-
? extendsValue.split(',').map((s) => s.trim())
|
|
621
|
-
: (scopeNamePattern.test(extendsValue) ? [extendsValue] : []))
|
|
622
|
-
: (() => {
|
|
623
|
-
const items = [];
|
|
624
|
-
const extendsStart = block.indexOf(extendsLineMatch[0]) + extendsLineMatch[0].length;
|
|
625
|
-
for (const line of block.slice(extendsStart).split('\n')) {
|
|
626
|
-
const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
|
|
627
|
-
if (itemMatch && itemMatch[1]) items.push(itemMatch[1].trim());
|
|
628
|
-
else if (line.trim() && !/^\s/.test(line)) break;
|
|
629
|
-
}
|
|
630
|
-
return items;
|
|
631
|
-
})();
|
|
819
|
+
const rawEntries = deriveScopeNameListEntries(block, extendsLineMatch, extendsValue);
|
|
632
820
|
const seenExtends = new Set();
|
|
633
821
|
for (const entry of rawEntries) {
|
|
634
822
|
if (seenExtends.has(entry)) {
|
|
@@ -636,7 +824,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
636
824
|
continue;
|
|
637
825
|
}
|
|
638
826
|
seenExtends.add(entry);
|
|
639
|
-
if (!
|
|
827
|
+
if (!SCOPE_NAME_PATTERN.test(entry)) continue;
|
|
640
828
|
if (entry === scopeName) {
|
|
641
829
|
errors.push(`Scope index frontmatter extends must not reference the scope itself: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.30-extends-no-self]`);
|
|
642
830
|
continue;
|
|
@@ -657,7 +845,9 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
657
845
|
continue;
|
|
658
846
|
}
|
|
659
847
|
if (!existsFile(path.join(xdrsRoot, entry, 'index.md'))) {
|
|
660
|
-
|
|
848
|
+
if (!isExternal) {
|
|
849
|
+
errors.push(`Scope index frontmatter extends references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.32-extends-mandatory-presence]`);
|
|
850
|
+
}
|
|
661
851
|
}
|
|
662
852
|
}
|
|
663
853
|
if (extendsEntries.length > 0) {
|
|
@@ -668,7 +858,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
668
858
|
}
|
|
669
859
|
|
|
670
860
|
|
|
671
|
-
function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeIndexes, ignoreExternal, externalScopes, knownScopeTypes = new Map(),
|
|
861
|
+
function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeIndexes, ignoreExternal, externalScopes, knownScopeTypes = new Map(), extendsRelationships = new Map(), trackedExternalScopes = new Set()) {
|
|
672
862
|
const scopePath = path.join(xdrsRoot, scopeName);
|
|
673
863
|
|
|
674
864
|
if (ignoreExternal && externalScopes.has(scopeName)) {
|
|
@@ -690,12 +880,8 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeInd
|
|
|
690
880
|
const idxContent = fs.readFileSync(scopeIndexPath, 'utf8');
|
|
691
881
|
const stMatch = idxContent.match(/^scope-type:\s*(.+)$/m);
|
|
692
882
|
if (stMatch) {
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
const stList = stRaw.startsWith('[') && stRaw.endsWith(']')
|
|
696
|
-
? stRaw.slice(1, -1).split(',').map((s) => s.trim())
|
|
697
|
-
: stRaw.split(',').map((s) => s.trim());
|
|
698
|
-
isCoreTypeNonExempt = stList.includes('core');
|
|
883
|
+
const stList = parseScopeTypeField(stMatch[1].trim());
|
|
884
|
+
isCoreTypeNonExempt = isCoreTypeScope(stList, knownScopeTypes);
|
|
699
885
|
isCompiledScope = stList.includes('compiled');
|
|
700
886
|
}
|
|
701
887
|
} catch { /* ignore read errors; will be caught later */ }
|
|
@@ -731,7 +917,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeInd
|
|
|
731
917
|
errors.push(`Missing required scope index: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
732
918
|
} else {
|
|
733
919
|
lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes);
|
|
734
|
-
lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes,
|
|
920
|
+
lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes, extendsRelationships, trackedExternalScopes);
|
|
735
921
|
}
|
|
736
922
|
lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope);
|
|
737
923
|
}
|
|
@@ -769,7 +955,7 @@ function lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope = fa
|
|
|
769
955
|
// Error: companion exists without primary in same type folder
|
|
770
956
|
for (const companion of companionsInFolder) {
|
|
771
957
|
if (!primaryInFolder) {
|
|
772
|
-
errors.push(`Local meta-policy companion "${path.basename(companion.filePath)}" requires a primary "NNN-core.md" in the same type folder "${typeName}": ${toDisplayPath(companion.filePath)} [_core-adr-policy-010.
|
|
958
|
+
errors.push(`Local meta-policy companion "${path.basename(companion.filePath)}" requires a primary "NNN-core.md" in the same type folder "${typeName}": ${toDisplayPath(companion.filePath)} [_core-adr-policy-010.15-local-primary-required]`);
|
|
773
959
|
}
|
|
774
960
|
}
|
|
775
961
|
// For compiled scopes: primary meta-policy must include ## Sources, ## Selectors, and ## Sync Settings
|
|
@@ -832,12 +1018,12 @@ function lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope = fa
|
|
|
832
1018
|
const existing = seenQualifiers.get(qualifier);
|
|
833
1019
|
if (existing) {
|
|
834
1020
|
const label = qualifier === '' ? 'primary (no qualifier)' : `qualifier "${qualifier}"`;
|
|
835
|
-
errors.push(`Local meta-policy ${label} appears in multiple type folders in scope "${scopeName}": ${toDisplayPath(existing)} and ${toDisplayPath(filePath)} [_core-adr-policy-010.
|
|
1021
|
+
errors.push(`Local meta-policy ${label} appears in multiple type folders in scope "${scopeName}": ${toDisplayPath(existing)} and ${toDisplayPath(filePath)} [_core-adr-policy-010.16-local-unique-qualifier]`);
|
|
836
1022
|
} else {
|
|
837
1023
|
seenQualifiers.set(qualifier, filePath);
|
|
838
1024
|
}
|
|
839
1025
|
// Validate structured format for all local meta-policy files
|
|
840
|
-
validateStructuredFormat(filePath, errors, '_core-adr-policy-010.
|
|
1026
|
+
validateStructuredFormat(filePath, errors, '_core-adr-policy-010.19-local-must-be-structured');
|
|
841
1027
|
}
|
|
842
1028
|
}
|
|
843
1029
|
}
|
|
@@ -963,7 +1149,7 @@ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePat
|
|
|
963
1149
|
// Enforce 'core' word reservation: only allowed as first word of filename title in principles/
|
|
964
1150
|
const titleParts = match[2].split('-');
|
|
965
1151
|
if (titleParts.includes('core') && !(subjectName === 'principles' && titleParts[0] === 'core')) {
|
|
966
|
-
const msg = `Policy filename must not use "core" as a name word unless it is a local meta-policy in "principles/" starting with "core" (e.g., "NNN-core.md" or "NNN-core-{qualifier}.md"): ${toDisplayPath(filePath)} [_core-adr-policy-010.
|
|
1152
|
+
const msg = `Policy filename must not use "core" as a name word unless it is a local meta-policy in "principles/" starting with "core" (e.g., "NNN-core.md" or "NNN-core-{qualifier}.md"): ${toDisplayPath(filePath)} [_core-adr-policy-010.13-local-naming]`;
|
|
967
1153
|
if (freezeReference) { warnings.push(`${msg} [exempt: freeze-reference]`); } else { errors.push(msg); }
|
|
968
1154
|
}
|
|
969
1155
|
|
|
@@ -971,7 +1157,7 @@ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePat
|
|
|
971
1157
|
|| `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${match[2]}`;
|
|
972
1158
|
lintXdrsElementFrontmatter(content, expectedName, filePath, errors, warnings, freezeReference);
|
|
973
1159
|
if (enforceStructured) {
|
|
974
|
-
validateStructuredFormatContent(content, filePath, errors, '_core-adr-policy-010.
|
|
1160
|
+
validateStructuredFormatContent(content, filePath, errors, '_core-adr-policy-010.12-def-core-scope-policies-must-be-structured');
|
|
975
1161
|
} else if (isCompiledScope) {
|
|
976
1162
|
validateStructuredFormatContent(content, filePath, errors, '_core-adr-policy-019.04-structured-format-required');
|
|
977
1163
|
} else {
|