xdrs-core 0.50.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 +38 -6
- 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 +79 -41
- 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 +26 -2
- package/lib/lint.js +446 -158
- package/lib/lint.test.js +837 -25
- package/lib/testPrompt.js +0 -2
- package/package.json +1 -1
package/lib/lint.js
CHANGED
|
@@ -23,13 +23,18 @@ 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';
|
|
31
|
+
const SOURCE_SNAPSHOT_DIR_NAME = 'sources';
|
|
32
|
+
const SOURCE_TRACKING_FILE_NAME = 'source.md';
|
|
28
33
|
const SKILL_PACKAGE_OPTIONAL_DIRS = new Set(['scripts', 'references', RESOURCE_DIR_NAME, 'dist']);
|
|
29
34
|
|
|
30
35
|
const POLICY_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'freeze-reference']);
|
|
31
36
|
const SKILL_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'license', 'metadata']);
|
|
32
|
-
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']);
|
|
33
38
|
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
|
|
34
39
|
const SLIDE_FILE_RE = /^.+-slides(?:-[a-z0-9-]+)?\.md$/;
|
|
35
40
|
const SLIDE_MAX_NAME_LENGTH = 64;
|
|
@@ -100,13 +105,14 @@ function runLintCli(args) {
|
|
|
100
105
|
}
|
|
101
106
|
}
|
|
102
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
|
+
|
|
103
114
|
if (result.errors.length === 0) {
|
|
104
115
|
console.log(`Lint passed for ${toDisplayPath(result.xdrsRoot)}`);
|
|
105
|
-
if (result.readOnlyScopes.size > 0) {
|
|
106
|
-
for (const scopeName of result.readOnlyScopes) {
|
|
107
|
-
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]`);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
116
|
return 0;
|
|
111
117
|
}
|
|
112
118
|
|
|
@@ -142,7 +148,7 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
142
148
|
const xdrsRoot = resolveXdrsRoot(resolvedTarget);
|
|
143
149
|
const errors = [];
|
|
144
150
|
const warnings = [];
|
|
145
|
-
const readOnlyScopes = new
|
|
151
|
+
const readOnlyScopes = new Map(); // scopeName -> [cause, ...] per _core-adr-policy-022.06
|
|
146
152
|
|
|
147
153
|
if (!existsDirectory(xdrsRoot)) {
|
|
148
154
|
errors.push(`Missing XDRS root directory: ${toDisplayPath(xdrsRoot)} [_core-adr-policy-001]`);
|
|
@@ -158,7 +164,13 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
158
164
|
const rootEntries = safeReadDir(xdrsRoot, errors, 'read XDRS root directory');
|
|
159
165
|
const scopeEntries = rootEntries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'));
|
|
160
166
|
|
|
161
|
-
|
|
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)
|
|
162
174
|
const knownScopeTypes = new Map(); // scopeTypeName -> filePath (first primary found, for downstream validation)
|
|
163
175
|
const scopeTypePrimaryKeys = new Set(); // "scopeName|typeName|scopeTypeName" for companion co-location checks
|
|
164
176
|
const scopeTypePrimaryByScope = new Map(); // "scopeName|scopeTypeName" -> [typeName, ...] for duplicate detection
|
|
@@ -166,6 +178,7 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
166
178
|
for (const scopeEntry of scopeEntries) {
|
|
167
179
|
const scopeName = scopeEntry.name;
|
|
168
180
|
const scopePath = path.join(xdrsRoot, scopeName);
|
|
181
|
+
scopeMeta.set(scopeName, readScopeMeta(path.join(scopePath, 'index.md')));
|
|
169
182
|
for (const typeName of Object.keys(TYPE_TO_ID)) {
|
|
170
183
|
const principlesDir = path.join(scopePath, typeName, 'principles');
|
|
171
184
|
if (!existsDirectory(principlesDir)) continue;
|
|
@@ -185,13 +198,17 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
185
198
|
if (!knownScopeTypes.has(scopeTypeName)) {
|
|
186
199
|
knownScopeTypes.set(scopeTypeName, filePath);
|
|
187
200
|
}
|
|
201
|
+
if (!scopeTypeDefiningScopes.has(scopeTypeName)) {
|
|
202
|
+
scopeTypeDefiningScopes.set(scopeTypeName, new Set());
|
|
203
|
+
}
|
|
204
|
+
scopeTypeDefiningScopes.get(scopeTypeName).add(scopeName);
|
|
188
205
|
scopeTypePrimaryKeys.add(`${scopeName}|${typeName}|${scopeTypeName}`);
|
|
189
206
|
const scopeKey = `${scopeName}|${scopeTypeName}`;
|
|
190
207
|
if (!scopeTypePrimaryByScope.has(scopeKey)) {
|
|
191
208
|
scopeTypePrimaryByScope.set(scopeKey, []);
|
|
192
209
|
}
|
|
193
210
|
scopeTypePrimaryByScope.get(scopeKey).push(typeName);
|
|
194
|
-
validateStructuredFormat(filePath, errors, '_core-adr-policy-010.
|
|
211
|
+
validateStructuredFormat(filePath, errors, '_core-adr-policy-010.11-def-scope-type-must-be-structured');
|
|
195
212
|
} else {
|
|
196
213
|
// Check if it's a companion file: title contains '-scope-type-' followed by a qualifier
|
|
197
214
|
const stIdx = shortTitle.indexOf('-scope-type-');
|
|
@@ -200,7 +217,7 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
200
217
|
// Special-case: 'local' maps to '_local'
|
|
201
218
|
const scopeTypeName = rawScopeTypeName === 'local' ? '_local' : rawScopeTypeName;
|
|
202
219
|
scopeTypeCompanions.push({ scopeTypeName, scopeName, typeName, filePath });
|
|
203
|
-
validateStructuredFormat(filePath, errors, '_core-adr-policy-010.
|
|
220
|
+
validateStructuredFormat(filePath, errors, '_core-adr-policy-010.11-def-scope-type-must-be-structured');
|
|
204
221
|
}
|
|
205
222
|
}
|
|
206
223
|
}
|
|
@@ -220,7 +237,7 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
220
237
|
// Validate: each companion must have a corresponding primary in the same scope and type folder
|
|
221
238
|
for (const { scopeTypeName, scopeName, typeName, filePath } of scopeTypeCompanions) {
|
|
222
239
|
if (!scopeTypePrimaryKeys.has(`${scopeName}|${typeName}|${scopeTypeName}`)) {
|
|
223
|
-
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]`);
|
|
224
241
|
}
|
|
225
242
|
}
|
|
226
243
|
|
|
@@ -230,11 +247,13 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
230
247
|
}
|
|
231
248
|
}
|
|
232
249
|
|
|
250
|
+
collectReadOnlyScopes(scopeMeta, activation, externalScopes, knownScopeTypes, scopeTypeDefiningScopes, readOnlyScopes);
|
|
251
|
+
|
|
233
252
|
// Populated during lintScopeIndexFrontmatter: scopeName -> [extendedScopeNames...]
|
|
234
253
|
const extendsRelationships = new Map();
|
|
235
254
|
|
|
236
255
|
for (const scopeEntry of scopeEntries) {
|
|
237
|
-
lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, warnings, actualTypeIndexes, ignoreExternal, effectiveExternalScopes, knownScopeTypes,
|
|
256
|
+
lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, warnings, actualTypeIndexes, ignoreExternal, effectiveExternalScopes, knownScopeTypes, extendsRelationships, externalScopes);
|
|
238
257
|
}
|
|
239
258
|
|
|
240
259
|
// Detect cycles in extends: chains (DFS)
|
|
@@ -261,17 +280,16 @@ function lintWorkspace(targetPath, options = {}) {
|
|
|
261
280
|
detectExtendsCycles(scope, []);
|
|
262
281
|
}
|
|
263
282
|
|
|
264
|
-
const rootIndexPath = path.join(xdrsRoot, 'index.md');
|
|
265
283
|
if (!existsFile(rootIndexPath)) {
|
|
266
284
|
errors.push('Missing required root index: .xdrs/index.md [_core-adr-policy-001]');
|
|
267
285
|
} else {
|
|
268
|
-
lintRootIndex(rootIndexPath, xdrsRoot,
|
|
286
|
+
lintRootIndex(rootIndexPath, xdrsRoot, errors, warnings, effectiveExternalScopes, { activation, scopeMeta, knownScopeTypes });
|
|
269
287
|
}
|
|
270
288
|
|
|
271
289
|
return { xdrsRoot, errors, warnings, readOnlyScopes };
|
|
272
290
|
}
|
|
273
291
|
|
|
274
|
-
function lintRootIndex(rootIndexPath, xdrsRoot,
|
|
292
|
+
function lintRootIndex(rootIndexPath, xdrsRoot, errors, warnings, externalScopes, ctx) {
|
|
275
293
|
const content = fs.readFileSync(rootIndexPath, 'utf8');
|
|
276
294
|
const repoRoot = path.dirname(xdrsRoot);
|
|
277
295
|
|
|
@@ -290,8 +308,6 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, warni
|
|
|
290
308
|
}
|
|
291
309
|
}
|
|
292
310
|
|
|
293
|
-
const linkedScopeIndexes = links.filter((linkPath) => isScopeIndex(linkPath, xdrsRoot));
|
|
294
|
-
const linkedSet = new Set(linkedScopeIndexes.map(normalizePath));
|
|
295
311
|
const localScopePath = normalizePath(path.join(xdrsRoot, '_local'));
|
|
296
312
|
|
|
297
313
|
for (const linkPath of links) {
|
|
@@ -300,38 +316,207 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, warni
|
|
|
300
316
|
}
|
|
301
317
|
}
|
|
302
318
|
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
}
|
|
307
339
|
}
|
|
308
340
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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]`);
|
|
344
|
+
}
|
|
345
|
+
|
|
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);
|
|
359
|
+
}
|
|
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
|
+
}
|
|
367
|
+
|
|
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
|
+
}
|
|
315
417
|
}
|
|
316
418
|
}
|
|
317
419
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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`);
|
|
325
480
|
continue;
|
|
326
481
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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);
|
|
331
489
|
}
|
|
332
490
|
}
|
|
333
491
|
}
|
|
334
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
|
+
|
|
335
520
|
function lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes = new Set()) {
|
|
336
521
|
const content = fs.readFileSync(scopeIndexPath, 'utf8');
|
|
337
522
|
const repoRoot = path.dirname(xdrsRoot);
|
|
@@ -368,7 +553,59 @@ function extractParentScopeType(policyFilePath) {
|
|
|
368
553
|
return bodyMatch ? bodyMatch[1] : null;
|
|
369
554
|
}
|
|
370
555
|
|
|
371
|
-
|
|
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);
|
|
372
609
|
const content = fs.readFileSync(scopeIndexPath, 'utf8');
|
|
373
610
|
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
374
611
|
if (!fmMatch) {
|
|
@@ -387,18 +624,15 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
387
624
|
const scopeTypeMatch = block.match(/^scope-type:\s*(.+)$/m);
|
|
388
625
|
const scopeTypeRaw = scopeTypeMatch ? scopeTypeMatch[1].trim() : null;
|
|
389
626
|
if (!scopeTypeRaw) {
|
|
390
|
-
|
|
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
|
+
}
|
|
391
631
|
return;
|
|
392
632
|
}
|
|
393
633
|
|
|
394
634
|
// Parse scope-type: supports single string or comma-separated list (e.g. "compiled, internal-docs")
|
|
395
|
-
const
|
|
396
|
-
let scopeTypes;
|
|
397
|
-
if (commaSeparatedScopeTypePattern.test(scopeTypeRaw)) {
|
|
398
|
-
scopeTypes = scopeTypeRaw.split(',').map((s) => s.trim());
|
|
399
|
-
} else {
|
|
400
|
-
scopeTypes = [scopeTypeRaw];
|
|
401
|
-
}
|
|
635
|
+
const scopeTypes = parseScopeTypeList(scopeTypeRaw);
|
|
402
636
|
|
|
403
637
|
// Validate each element: no duplicates, valid chars, no reserved prefix
|
|
404
638
|
const seenTypes = new Set();
|
|
@@ -406,17 +640,17 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
406
640
|
const SCOPE_TYPE_ELEMENT_RE = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
|
|
407
641
|
for (const scopeType of scopeTypes) {
|
|
408
642
|
if (!SCOPE_TYPE_ELEMENT_RE.test(scopeType)) {
|
|
409
|
-
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]`);
|
|
410
644
|
hasElementError = true;
|
|
411
645
|
continue;
|
|
412
646
|
}
|
|
413
647
|
if (scopeType.startsWith('_') && scopeType !== '_local') {
|
|
414
|
-
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]`);
|
|
415
649
|
hasElementError = true;
|
|
416
650
|
continue;
|
|
417
651
|
}
|
|
418
652
|
if (seenTypes.has(scopeType)) {
|
|
419
|
-
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]`);
|
|
420
654
|
hasElementError = true;
|
|
421
655
|
continue;
|
|
422
656
|
}
|
|
@@ -432,7 +666,9 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
432
666
|
for (const st of scopeTypes) {
|
|
433
667
|
if (st === '_local') continue;
|
|
434
668
|
if (!knownScopeTypes.has(st)) {
|
|
435
|
-
|
|
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
|
+
}
|
|
436
672
|
continue;
|
|
437
673
|
}
|
|
438
674
|
// Validate parent chain
|
|
@@ -443,11 +679,13 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
443
679
|
const parentType = extractParentScopeType(policyPath);
|
|
444
680
|
if (!parentType) break;
|
|
445
681
|
if (globalVisited.has(parentType)) {
|
|
446
|
-
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]`);
|
|
447
683
|
break;
|
|
448
684
|
}
|
|
449
685
|
if (!knownScopeTypes.has(parentType)) {
|
|
450
|
-
|
|
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
|
+
}
|
|
451
689
|
break;
|
|
452
690
|
}
|
|
453
691
|
globalVisited.add(parentType);
|
|
@@ -460,7 +698,11 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
460
698
|
if (scopeName === '_local' && scopeType !== '_local') {
|
|
461
699
|
errors.push(`Scope "_local" must have scope-type "_local": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-015.01-scope-type-name]`);
|
|
462
700
|
}
|
|
463
|
-
|
|
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')) {
|
|
464
706
|
errors.push(`Scope with type "core" must have "core" in its name: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-011.03-naming-convention]`);
|
|
465
707
|
}
|
|
466
708
|
if (scopeTypes.includes('_local') && scopeName !== '_local') {
|
|
@@ -473,6 +715,19 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
473
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]`);
|
|
474
716
|
}
|
|
475
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
|
+
|
|
476
731
|
if (!fm.name) {
|
|
477
732
|
errors.push(`Scope index frontmatter must include a non-empty name field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
478
733
|
} else if (fm.name !== scopeName) {
|
|
@@ -506,49 +761,14 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
506
761
|
const followsLineMatch = block.match(/^follows:[ \t]*(.*)/m);
|
|
507
762
|
if (followsLineMatch !== null) {
|
|
508
763
|
const followsValue = followsLineMatch[1].trim();
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
} else {
|
|
516
|
-
const followsStart = block.indexOf(followsLineMatch[0]) + followsLineMatch[0].length;
|
|
517
|
-
const listItems = [];
|
|
518
|
-
let hasInvalidEntry = false;
|
|
519
|
-
for (const line of block.slice(followsStart).split('\n')) {
|
|
520
|
-
const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
|
|
521
|
-
if (itemMatch) {
|
|
522
|
-
const item = (itemMatch[1] || '').trim();
|
|
523
|
-
if (!item || !scopeNamePattern.test(item)) {
|
|
524
|
-
errors.push(`Scope index frontmatter follows entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
525
|
-
hasInvalidEntry = true;
|
|
526
|
-
break;
|
|
527
|
-
}
|
|
528
|
-
listItems.push(item);
|
|
529
|
-
} else if (line.trim() && !/^\s/.test(line)) {
|
|
530
|
-
break;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
if (!hasInvalidEntry && listItems.length === 0) {
|
|
534
|
-
errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
535
|
-
}
|
|
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]`);
|
|
536
769
|
}
|
|
537
770
|
if (xdrsRoot) {
|
|
538
|
-
const entries = followsValue
|
|
539
|
-
? (commaSeparatedPattern.test(followsValue)
|
|
540
|
-
? followsValue.split(',').map((s) => s.trim())
|
|
541
|
-
: [followsValue])
|
|
542
|
-
: (() => {
|
|
543
|
-
const items = [];
|
|
544
|
-
const followsStart = block.indexOf(followsLineMatch[0]) + followsLineMatch[0].length;
|
|
545
|
-
for (const line of block.slice(followsStart).split('\n')) {
|
|
546
|
-
const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
|
|
547
|
-
if (itemMatch && itemMatch[1]) items.push(itemMatch[1].trim());
|
|
548
|
-
else if (line.trim() && !/^\s/.test(line)) break;
|
|
549
|
-
}
|
|
550
|
-
return items;
|
|
551
|
-
})();
|
|
771
|
+
const entries = deriveScopeNameListEntries(block, followsLineMatch, followsValue);
|
|
552
772
|
followsEntries = entries;
|
|
553
773
|
const seenFollows = new Set();
|
|
554
774
|
for (const entry of entries) {
|
|
@@ -556,7 +776,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
556
776
|
errors.push(`Scope index frontmatter follows has duplicate entry "${entry}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
557
777
|
}
|
|
558
778
|
seenFollows.add(entry);
|
|
559
|
-
if (
|
|
779
|
+
if (SCOPE_NAME_PATTERN.test(entry)) {
|
|
560
780
|
if (entry === scopeName) {
|
|
561
781
|
errors.push(`Scope index frontmatter follows must not reference the scope itself: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
562
782
|
continue;
|
|
@@ -567,11 +787,16 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
567
787
|
}
|
|
568
788
|
const followedIndexPath = path.join(xdrsRoot, entry, 'index.md');
|
|
569
789
|
if (!existsFile(followedIndexPath)) {
|
|
570
|
-
|
|
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
|
+
}
|
|
571
795
|
} else {
|
|
572
796
|
const followedContent = fs.readFileSync(followedIndexPath, 'utf8');
|
|
573
797
|
const followedTypeMatch = followedContent.match(/^scope-type:\s*(.+)$/m);
|
|
574
|
-
|
|
798
|
+
const followedTypes = followedTypeMatch ? parseScopeTypeField(followedTypeMatch[1].trim()) : [];
|
|
799
|
+
if (!isCoreTypeScope(followedTypes, knownScopeTypes)) {
|
|
575
800
|
errors.push(`Scope index frontmatter follows references scope "${entry}" which is not a core-type scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
576
801
|
}
|
|
577
802
|
}
|
|
@@ -585,48 +810,13 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
585
810
|
const extendsLineMatch = block.match(/^extends:[ \t]*(.*)/m);
|
|
586
811
|
if (extendsLineMatch !== null) {
|
|
587
812
|
const extendsValue = extendsLineMatch[1].trim();
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
594
|
-
} else {
|
|
595
|
-
const extendsStart = block.indexOf(extendsLineMatch[0]) + extendsLineMatch[0].length;
|
|
596
|
-
const listItems = [];
|
|
597
|
-
let hasInvalidEntry = false;
|
|
598
|
-
for (const line of block.slice(extendsStart).split('\n')) {
|
|
599
|
-
const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
|
|
600
|
-
if (itemMatch) {
|
|
601
|
-
const item = (itemMatch[1] || '').trim();
|
|
602
|
-
if (!item || !scopeNamePattern.test(item)) {
|
|
603
|
-
errors.push(`Scope index frontmatter extends entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.27-extends-declaration]`);
|
|
604
|
-
hasInvalidEntry = true;
|
|
605
|
-
break;
|
|
606
|
-
}
|
|
607
|
-
listItems.push(item);
|
|
608
|
-
} else if (line.trim() && !/^\s/.test(line)) {
|
|
609
|
-
break;
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
if (!hasInvalidEntry && listItems.length === 0) {
|
|
613
|
-
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]`);
|
|
614
|
-
}
|
|
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]`);
|
|
615
818
|
}
|
|
616
|
-
const rawEntries = extendsValue
|
|
617
|
-
? (commaSeparatedPattern.test(extendsValue)
|
|
618
|
-
? extendsValue.split(',').map((s) => s.trim())
|
|
619
|
-
: (scopeNamePattern.test(extendsValue) ? [extendsValue] : []))
|
|
620
|
-
: (() => {
|
|
621
|
-
const items = [];
|
|
622
|
-
const extendsStart = block.indexOf(extendsLineMatch[0]) + extendsLineMatch[0].length;
|
|
623
|
-
for (const line of block.slice(extendsStart).split('\n')) {
|
|
624
|
-
const itemMatch = line.match(/^\s+-\s*(\S.*)?$/);
|
|
625
|
-
if (itemMatch && itemMatch[1]) items.push(itemMatch[1].trim());
|
|
626
|
-
else if (line.trim() && !/^\s/.test(line)) break;
|
|
627
|
-
}
|
|
628
|
-
return items;
|
|
629
|
-
})();
|
|
819
|
+
const rawEntries = deriveScopeNameListEntries(block, extendsLineMatch, extendsValue);
|
|
630
820
|
const seenExtends = new Set();
|
|
631
821
|
for (const entry of rawEntries) {
|
|
632
822
|
if (seenExtends.has(entry)) {
|
|
@@ -634,7 +824,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
634
824
|
continue;
|
|
635
825
|
}
|
|
636
826
|
seenExtends.add(entry);
|
|
637
|
-
if (!
|
|
827
|
+
if (!SCOPE_NAME_PATTERN.test(entry)) continue;
|
|
638
828
|
if (entry === scopeName) {
|
|
639
829
|
errors.push(`Scope index frontmatter extends must not reference the scope itself: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.30-extends-no-self]`);
|
|
640
830
|
continue;
|
|
@@ -655,7 +845,9 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
655
845
|
continue;
|
|
656
846
|
}
|
|
657
847
|
if (!existsFile(path.join(xdrsRoot, entry, 'index.md'))) {
|
|
658
|
-
|
|
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
|
+
}
|
|
659
851
|
}
|
|
660
852
|
}
|
|
661
853
|
if (extendsEntries.length > 0) {
|
|
@@ -666,7 +858,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
|
|
|
666
858
|
}
|
|
667
859
|
|
|
668
860
|
|
|
669
|
-
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()) {
|
|
670
862
|
const scopePath = path.join(xdrsRoot, scopeName);
|
|
671
863
|
|
|
672
864
|
if (ignoreExternal && externalScopes.has(scopeName)) {
|
|
@@ -688,12 +880,8 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeInd
|
|
|
688
880
|
const idxContent = fs.readFileSync(scopeIndexPath, 'utf8');
|
|
689
881
|
const stMatch = idxContent.match(/^scope-type:\s*(.+)$/m);
|
|
690
882
|
if (stMatch) {
|
|
691
|
-
const
|
|
692
|
-
|
|
693
|
-
const stList = stRaw.startsWith('[') && stRaw.endsWith(']')
|
|
694
|
-
? stRaw.slice(1, -1).split(',').map((s) => s.trim())
|
|
695
|
-
: stRaw.split(',').map((s) => s.trim());
|
|
696
|
-
isCoreTypeNonExempt = stList.includes('core');
|
|
883
|
+
const stList = parseScopeTypeField(stMatch[1].trim());
|
|
884
|
+
isCoreTypeNonExempt = isCoreTypeScope(stList, knownScopeTypes);
|
|
697
885
|
isCompiledScope = stList.includes('compiled');
|
|
698
886
|
}
|
|
699
887
|
} catch { /* ignore read errors; will be caught later */ }
|
|
@@ -729,7 +917,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, warnings, actualTypeInd
|
|
|
729
917
|
errors.push(`Missing required scope index: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
|
|
730
918
|
} else {
|
|
731
919
|
lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes);
|
|
732
|
-
lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes,
|
|
920
|
+
lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes, extendsRelationships, trackedExternalScopes);
|
|
733
921
|
}
|
|
734
922
|
lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope);
|
|
735
923
|
}
|
|
@@ -767,22 +955,61 @@ function lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope = fa
|
|
|
767
955
|
// Error: companion exists without primary in same type folder
|
|
768
956
|
for (const companion of companionsInFolder) {
|
|
769
957
|
if (!primaryInFolder) {
|
|
770
|
-
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]`);
|
|
771
959
|
}
|
|
772
960
|
}
|
|
773
|
-
// For compiled scopes: primary meta-policy must include ## Sources and ##
|
|
961
|
+
// For compiled scopes: primary meta-policy must include ## Sources, ## Selectors, and ## Sync Settings
|
|
774
962
|
if (isCompiledScope && primaryInFolder) {
|
|
775
963
|
let metaContent;
|
|
776
964
|
try { metaContent = fs.readFileSync(primaryInFolder.filePath, 'utf8'); } catch { /* will be caught elsewhere */ }
|
|
777
965
|
if (metaContent !== undefined) {
|
|
778
966
|
const metaLines = metaContent.split(/\r?\n/);
|
|
779
967
|
const metaIgnored = findIgnoredMarkdownLines(metaLines);
|
|
780
|
-
|
|
968
|
+
const metaHeadingLines = findHeadingLines(metaLines, metaIgnored);
|
|
969
|
+
|
|
970
|
+
const sourcesHeadingIdx = findHeadingLine(metaLines, metaIgnored, '## Sources');
|
|
971
|
+
if (sourcesHeadingIdx === -1) {
|
|
781
972
|
errors.push(`Compiled scope meta-policy must include a "## Sources" section: ${toDisplayPath(primaryInFolder.filePath)} [_core-adr-policy-019.07-compilation-meta-policy]`);
|
|
973
|
+
} else {
|
|
974
|
+
const [sourcesStart, sourcesEnd] = sectionBodyRange(metaLines, metaHeadingLines, sourcesHeadingIdx);
|
|
975
|
+
for (let i = sourcesStart; i < sourcesEnd; i += 1) {
|
|
976
|
+
if (metaIgnored[i]) continue;
|
|
977
|
+
const trimmed = metaLines[i].trim();
|
|
978
|
+
if (!trimmed.startsWith('-')) continue;
|
|
979
|
+
if (!/^-\s+\[(web|git|local)\]\s+[a-z0-9-]+:\s+.+$/.test(trimmed)) {
|
|
980
|
+
errors.push(`Compiled scope "## Sources" bullet must include a NAME slug in the format "- [web|git|local] name: value": ${toDisplayPath(primaryInFolder.filePath)}:${i + 1} [_core-adr-policy-019.07-compilation-meta-policy]`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
782
983
|
}
|
|
984
|
+
|
|
783
985
|
if (findHeadingLine(metaLines, metaIgnored, '## Selectors') === -1) {
|
|
784
986
|
errors.push(`Compiled scope meta-policy must include a "## Selectors" section: ${toDisplayPath(primaryInFolder.filePath)} [_core-adr-policy-019.07-compilation-meta-policy]`);
|
|
785
987
|
}
|
|
988
|
+
|
|
989
|
+
const syncHeadingIdx = findHeadingLine(metaLines, metaIgnored, '## Sync Settings');
|
|
990
|
+
if (syncHeadingIdx === -1) {
|
|
991
|
+
errors.push(`Compiled scope meta-policy must include a "## Sync Settings" section: ${toDisplayPath(primaryInFolder.filePath)} [_core-adr-policy-019.07-compilation-meta-policy]`);
|
|
992
|
+
} else {
|
|
993
|
+
const [syncStart, syncEnd] = sectionBodyRange(metaLines, metaHeadingLines, syncHeadingIdx);
|
|
994
|
+
let hasValidResyncPeriod = false;
|
|
995
|
+
for (let i = syncStart; i < syncEnd; i += 1) {
|
|
996
|
+
if (metaIgnored[i]) continue;
|
|
997
|
+
const trimmed = metaLines[i].trim();
|
|
998
|
+
if (!trimmed.startsWith('-')) continue;
|
|
999
|
+
const periodMatch = trimmed.match(/^-\s+Source re-sync period days:\s*(\d+)\s*$/);
|
|
1000
|
+
if (periodMatch) {
|
|
1001
|
+
hasValidResyncPeriod = Number(periodMatch[1]) > 0;
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
const storageMatch = trimmed.match(/^-\s+Source storage:\s*(.+)$/);
|
|
1005
|
+
if (storageMatch && storageMatch[1].trim() !== 'temporary') {
|
|
1006
|
+
errors.push(`Compiled scope "## Sync Settings" "Source storage" value must be "temporary" if present: ${toDisplayPath(primaryInFolder.filePath)}:${i + 1} [_core-adr-policy-019.07-compilation-meta-policy]`);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (!hasValidResyncPeriod) {
|
|
1010
|
+
errors.push(`Compiled scope "## Sync Settings" section must include a "Source re-sync period days: N" bullet with a positive integer: ${toDisplayPath(primaryInFolder.filePath)} [_core-adr-policy-019.07-compilation-meta-policy]`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
786
1013
|
}
|
|
787
1014
|
}
|
|
788
1015
|
// Check qualifier uniqueness across all type folders
|
|
@@ -791,12 +1018,12 @@ function lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope = fa
|
|
|
791
1018
|
const existing = seenQualifiers.get(qualifier);
|
|
792
1019
|
if (existing) {
|
|
793
1020
|
const label = qualifier === '' ? 'primary (no qualifier)' : `qualifier "${qualifier}"`;
|
|
794
|
-
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]`);
|
|
795
1022
|
} else {
|
|
796
1023
|
seenQualifiers.set(qualifier, filePath);
|
|
797
1024
|
}
|
|
798
1025
|
// Validate structured format for all local meta-policy files
|
|
799
|
-
validateStructuredFormat(filePath, errors, '_core-adr-policy-010.
|
|
1026
|
+
validateStructuredFormat(filePath, errors, '_core-adr-policy-010.19-local-must-be-structured');
|
|
800
1027
|
}
|
|
801
1028
|
}
|
|
802
1029
|
}
|
|
@@ -884,7 +1111,7 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNu
|
|
|
884
1111
|
|
|
885
1112
|
const subjectAssetsDir = path.join(subjectPath, RESOURCE_DIR_NAME);
|
|
886
1113
|
const xdrsDocsInSubject = artifacts.filter((p) => path.dirname(p) === subjectPath);
|
|
887
|
-
lintOrphanAssets(subjectAssetsDir, xdrsDocsInSubject, xdrsRoot, errors);
|
|
1114
|
+
lintOrphanAssets(subjectAssetsDir, xdrsDocsInSubject, xdrsRoot, errors, isCompiledScope);
|
|
888
1115
|
|
|
889
1116
|
return artifacts;
|
|
890
1117
|
}
|
|
@@ -922,7 +1149,7 @@ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePat
|
|
|
922
1149
|
// Enforce 'core' word reservation: only allowed as first word of filename title in principles/
|
|
923
1150
|
const titleParts = match[2].split('-');
|
|
924
1151
|
if (titleParts.includes('core') && !(subjectName === 'principles' && titleParts[0] === 'core')) {
|
|
925
|
-
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]`;
|
|
926
1153
|
if (freezeReference) { warnings.push(`${msg} [exempt: freeze-reference]`); } else { errors.push(msg); }
|
|
927
1154
|
}
|
|
928
1155
|
|
|
@@ -930,7 +1157,7 @@ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, subjectName, filePat
|
|
|
930
1157
|
|| `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${match[2]}`;
|
|
931
1158
|
lintXdrsElementFrontmatter(content, expectedName, filePath, errors, warnings, freezeReference);
|
|
932
1159
|
if (enforceStructured) {
|
|
933
|
-
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');
|
|
934
1161
|
} else if (isCompiledScope) {
|
|
935
1162
|
validateStructuredFormatContent(content, filePath, errors, '_core-adr-policy-019.04-structured-format-required');
|
|
936
1163
|
} else {
|
|
@@ -1833,11 +2060,16 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors, externalScopes =
|
|
|
1833
2060
|
}
|
|
1834
2061
|
}
|
|
1835
2062
|
|
|
1836
|
-
function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
|
|
2063
|
+
function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors, isCompiledScope = false) {
|
|
1837
2064
|
if (!existsDirectory(assetsDir)) {
|
|
1838
2065
|
return;
|
|
1839
2066
|
}
|
|
1840
2067
|
|
|
2068
|
+
const sourcesDir = path.join(assetsDir, SOURCE_SNAPSHOT_DIR_NAME);
|
|
2069
|
+
if (isCompiledScope) {
|
|
2070
|
+
lintSourceSnapshotTracking(sourcesDir, errors);
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1841
2073
|
const assetTree = collectAssetTree(assetsDir, errors);
|
|
1842
2074
|
if (assetTree.files.length === 0 && assetTree.directories.length === 0) {
|
|
1843
2075
|
return;
|
|
@@ -1859,6 +2091,9 @@ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
|
|
|
1859
2091
|
}
|
|
1860
2092
|
|
|
1861
2093
|
for (const assetPath of assetTree.files) {
|
|
2094
|
+
if (isCompiledScope && isPathInside(sourcesDir, assetPath)) {
|
|
2095
|
+
continue;
|
|
2096
|
+
}
|
|
1862
2097
|
if (!referencedAssets.has(assetPath)) {
|
|
1863
2098
|
errors.push(`Orphan asset file not referenced by any document: ${toDisplayPath(assetPath)} [_core-adr-policy-001]`);
|
|
1864
2099
|
}
|
|
@@ -1866,6 +2101,9 @@ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
|
|
|
1866
2101
|
|
|
1867
2102
|
// Lint slide/presentation files inside .assets
|
|
1868
2103
|
for (const assetPath of assetTree.files) {
|
|
2104
|
+
if (isCompiledScope && isPathInside(sourcesDir, assetPath)) {
|
|
2105
|
+
continue;
|
|
2106
|
+
}
|
|
1869
2107
|
const fileName = path.basename(assetPath);
|
|
1870
2108
|
if (SLIDE_FILE_RE.test(fileName)) {
|
|
1871
2109
|
lintSlideFile(assetPath, documentPaths, xdrsRoot, errors);
|
|
@@ -1873,6 +2111,47 @@ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
|
|
|
1873
2111
|
}
|
|
1874
2112
|
}
|
|
1875
2113
|
|
|
2114
|
+
function lintSourceSnapshotTracking(sourcesDir, errors) {
|
|
2115
|
+
if (!existsDirectory(sourcesDir)) {
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
const entries = safeReadDir(sourcesDir, errors, `read source snapshot directory ${toDisplayPath(sourcesDir)}`);
|
|
2120
|
+
for (const entry of entries) {
|
|
2121
|
+
if (!entry.isDirectory()) {
|
|
2122
|
+
continue;
|
|
2123
|
+
}
|
|
2124
|
+
const trackingFilePath = path.join(sourcesDir, entry.name, SOURCE_TRACKING_FILE_NAME);
|
|
2125
|
+
if (!existsFile(trackingFilePath)) {
|
|
2126
|
+
errors.push(`Compiled source snapshot folder must contain a "${SOURCE_TRACKING_FILE_NAME}" tracking file: ${toDisplayPath(trackingFilePath)} [_core-adr-policy-019.12-source-storage-tracking]`);
|
|
2127
|
+
continue;
|
|
2128
|
+
}
|
|
2129
|
+
validateSourceTrackingFile(trackingFilePath, errors);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
function validateSourceTrackingFile(filePath, errors) {
|
|
2134
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
2135
|
+
const lines = content.split(/\r?\n/);
|
|
2136
|
+
|
|
2137
|
+
if (firstNonEmptyLine(content) !== '# Source') {
|
|
2138
|
+
errors.push(`Source tracking file must start with a "# Source" heading: ${toDisplayPath(filePath)} [_core-adr-policy-019.12-source-storage-tracking]`);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
const timestampRe = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
|
2142
|
+
for (const field of ['last-fetch-timestamp', 'last-compilation-timestamp']) {
|
|
2143
|
+
const fieldLine = lines.find((line) => line.trim().startsWith(`${field}:`));
|
|
2144
|
+
if (!fieldLine) {
|
|
2145
|
+
errors.push(`Source tracking file must include a "${field}:" line: ${toDisplayPath(filePath)} [_core-adr-policy-019.12-source-storage-tracking]`);
|
|
2146
|
+
continue;
|
|
2147
|
+
}
|
|
2148
|
+
const value = fieldLine.trim().slice(`${field}:`.length).trim();
|
|
2149
|
+
if (!timestampRe.test(value)) {
|
|
2150
|
+
errors.push(`Source tracking file "${field}" must match "YYYY-MM-DD HH:MM:SS": ${toDisplayPath(filePath)} [_core-adr-policy-019.12-source-storage-tracking]`);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
|
|
1876
2155
|
function lintSlideFile(filePath, documentPaths, xdrsRoot, errors) {
|
|
1877
2156
|
const fileName = path.basename(filePath);
|
|
1878
2157
|
|
|
@@ -1983,6 +2262,10 @@ function lintDocumentLinks(documentPath, xdrsRoot, scopeName, errors, externalSc
|
|
|
1983
2262
|
if (isResourceLink && !isPathInside(resourceDir, link.resolvedPath)) {
|
|
1984
2263
|
errors.push(`Asset links in ${toDisplayPath(documentPath)} must point to ${toDisplayPath(resourceDir)}: ${link.rawTarget} [_core-adr-policy-001]`);
|
|
1985
2264
|
}
|
|
2265
|
+
|
|
2266
|
+
if (isSourceSnapshotPath(link.resolvedPath)) {
|
|
2267
|
+
errors.push(`Document must not link directly to compiled-source snapshot content, which is not authoritative (see _core-adr-policy-019 rule 14) in ${toDisplayPath(documentPath)}:${index + 1}: ${link.rawTarget} [_core-adr-policy-019.14-source-not-authoritative]`);
|
|
2268
|
+
}
|
|
1986
2269
|
}
|
|
1987
2270
|
}
|
|
1988
2271
|
}
|
|
@@ -2069,6 +2352,11 @@ function shouldValidateResourceLink(rawTarget) {
|
|
|
2069
2352
|
|| IMAGE_EXTENSIONS.has(extension);
|
|
2070
2353
|
}
|
|
2071
2354
|
|
|
2355
|
+
function isSourceSnapshotPath(resolvedPath) {
|
|
2356
|
+
const normalized = normalizePath(resolvedPath).split(path.sep).join('/');
|
|
2357
|
+
return normalized.includes(`/${RESOURCE_DIR_NAME}/${SOURCE_SNAPSHOT_DIR_NAME}/`);
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2072
2360
|
function extractDescriptionText(block) {
|
|
2073
2361
|
const lines = block.split(/\r?\n/);
|
|
2074
2362
|
for (let i = 0; i < lines.length; i++) {
|