xdrs-core 0.37.2 → 0.38.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/lib/lint.js CHANGED
@@ -37,6 +37,26 @@ const ARTICLE_MAX_WORDS = 8000;
37
37
  const RESEARCH_MAX_WORDS = 5000;
38
38
  const SKILL_MAX_WORDS = 6500;
39
39
 
40
+ const NORMATIVE_KEYWORDS_RE = /\bMUST NOT\b|\bMUST\b|\bSHOULD NOT\b|\bSHOULD\b|\bMAY\b|\bREQUIRED\b|\bOPTIONAL\b/;
41
+
42
+ const RESEARCH_SECTION_LIMITS = {
43
+ '## Abstract': 200,
44
+ '## Introduction': 700,
45
+ '## Methods': 1200,
46
+ '## Results': 1800,
47
+ '## Discussion': 1000,
48
+ '## Conclusion': 400,
49
+ };
50
+
51
+ const DOC_TYPE_POLICY_REF = {
52
+ Policy: '[_core-adr-policy-002]',
53
+ Skill: '[_core-adr-policy-003]',
54
+ Article: '[_core-adr-policy-004]',
55
+ Research: '[_core-adr-policy-006]',
56
+ Plan: '[_core-adr-policy-007]',
57
+ Slide: '[_core-adr-policy-009]',
58
+ };
59
+
40
60
  function runLintCli(args) {
41
61
  if (args.includes('--help') || args.includes('-h')) {
42
62
  printHelp();
@@ -50,6 +70,11 @@ function runLintCli(args) {
50
70
 
51
71
  if (result.errors.length === 0) {
52
72
  console.log(`Lint passed for ${toDisplayPath(result.xdrsRoot)}`);
73
+ if (result.readOnlyScopes.size > 0) {
74
+ for (const scopeName of result.readOnlyScopes) {
75
+ 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]`);
76
+ }
77
+ }
53
78
  return 0;
54
79
  }
55
80
 
@@ -84,10 +109,11 @@ function lintWorkspace(targetPath, options = {}) {
84
109
  const resolvedTarget = path.resolve(targetPath);
85
110
  const xdrsRoot = resolveXdrsRoot(resolvedTarget);
86
111
  const errors = [];
112
+ const readOnlyScopes = new Set();
87
113
 
88
114
  if (!existsDirectory(xdrsRoot)) {
89
- errors.push(`Missing XDRS root directory: ${toDisplayPath(xdrsRoot)}`);
90
- return { xdrsRoot, errors };
115
+ errors.push(`Missing XDRS root directory: ${toDisplayPath(xdrsRoot)} [_core-adr-policy-001]`);
116
+ return { xdrsRoot, errors, readOnlyScopes };
91
117
  }
92
118
 
93
119
  const repoRoot = path.dirname(xdrsRoot);
@@ -126,22 +152,22 @@ function lintWorkspace(targetPath, options = {}) {
126
152
 
127
153
  for (const entry of rootEntries) {
128
154
  if (entry.isFile() && entry.name !== 'index.md') {
129
- errors.push(`Unexpected file at .xdrs root: ${entry.name}`);
155
+ errors.push(`Unexpected file at .xdrs root: ${entry.name} [_core-adr-policy-001]`);
130
156
  }
131
157
  }
132
158
 
133
159
  for (const scopeEntry of scopeEntries) {
134
- lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, actualTypeIndexes, ignoreExternal, effectiveExternalScopes, knownScopeTypes);
160
+ lintScopeDirectory(xdrsRoot, scopeEntry.name, errors, actualTypeIndexes, ignoreExternal, effectiveExternalScopes, knownScopeTypes, readOnlyScopes);
135
161
  }
136
162
 
137
163
  const rootIndexPath = path.join(xdrsRoot, 'index.md');
138
164
  if (!existsFile(rootIndexPath)) {
139
- errors.push('Missing required root index: .xdrs/index.md');
165
+ errors.push('Missing required root index: .xdrs/index.md [_core-adr-policy-001]');
140
166
  } else {
141
167
  lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, effectiveExternalScopes);
142
168
  }
143
169
 
144
- return { xdrsRoot, errors };
170
+ return { xdrsRoot, errors, readOnlyScopes };
145
171
  }
146
172
 
147
173
  function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, externalScopes = new Set()) {
@@ -149,14 +175,17 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, exter
149
175
  const repoRoot = path.dirname(xdrsRoot);
150
176
 
151
177
  if (!content.includes(REQUIRED_ROOT_INDEX_TEXT)) {
152
- errors.push(`Root index is missing required override text: ${toDisplayPath(rootIndexPath)}`);
178
+ errors.push(`Root index is missing required override text: ${toDisplayPath(rootIndexPath)} [_core-adr-policy-001]`);
153
179
  }
154
180
 
155
181
  const links = parseLocalLinks(content, path.dirname(rootIndexPath), repoRoot);
156
182
  for (const linkPath of links) {
157
183
  if (!fs.existsSync(linkPath)) {
158
184
  if (isExternalScopeLink(linkPath, xdrsRoot, externalScopes)) continue;
159
- errors.push(`Broken link in root index: ${displayPath(rootIndexPath, linkPath)}`);
185
+ errors.push(`Broken link in root index: ${displayPath(rootIndexPath, linkPath)} [_core-adr-policy-001]`);
186
+ }
187
+ if (isCanonicalTypeIndex(linkPath, xdrsRoot)) {
188
+ errors.push(`Root index must not link directly to type indexes: ${displayPath(rootIndexPath, linkPath)} [_core-adr-policy-001]`);
160
189
  }
161
190
  }
162
191
 
@@ -166,7 +195,7 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, exter
166
195
 
167
196
  for (const linkPath of links) {
168
197
  if (isPathInside(localScopePath, linkPath) || normalizePath(linkPath) === localScopePath) {
169
- errors.push(`Root index must not link into _local scope: ${displayPath(rootIndexPath, linkPath)}`);
198
+ errors.push(`Root index must not link into _local scope: ${displayPath(rootIndexPath, linkPath)} [_core-adr-policy-001]`);
170
199
  }
171
200
  }
172
201
 
@@ -182,7 +211,7 @@ function lintRootIndex(rootIndexPath, xdrsRoot, actualTypeIndexes, errors, exter
182
211
  for (const scopeName of scopesWithTypeIndexes) {
183
212
  const scopeIndexPath = normalizePath(path.join(xdrsRoot, scopeName, 'index.md'));
184
213
  if (!linkedSet.has(scopeIndexPath)) {
185
- errors.push(`Root index is missing scope index link: ${toDisplayPath(path.join(xdrsRoot, scopeName, 'index.md'))}`);
214
+ errors.push(`Root index is missing scope index link: ${toDisplayPath(path.join(xdrsRoot, scopeName, 'index.md'))} [_core-adr-policy-001]`);
186
215
  }
187
216
  }
188
217
  }
@@ -196,13 +225,13 @@ function lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope,
196
225
  for (const linkPath of links) {
197
226
  if (!fs.existsSync(linkPath)) {
198
227
  if (isExternalScopeLink(linkPath, xdrsRoot, externalScopes)) continue;
199
- errors.push(`Broken link in scope index: ${displayPath(scopeIndexPath, linkPath)}`);
228
+ errors.push(`Broken link in scope index: ${displayPath(scopeIndexPath, linkPath)} [_core-adr-policy-001]`);
200
229
  }
201
230
  }
202
231
 
203
232
  for (const typeIndexPath of typeIndexesInScope) {
204
233
  if (!linkedSet.has(normalizePath(typeIndexPath))) {
205
- errors.push(`Scope index ${toDisplayPath(scopeIndexPath)} is missing link to type index: ${toDisplayPath(typeIndexPath)}`);
234
+ errors.push(`Scope index ${toDisplayPath(scopeIndexPath)} is missing link to type index: ${toDisplayPath(typeIndexPath)} [_core-adr-policy-001]`);
206
235
  }
207
236
  }
208
237
  }
@@ -223,11 +252,11 @@ function extractParentScopeType(policyFilePath) {
223
252
  return bodyMatch ? bodyMatch[1] : null;
224
253
  }
225
254
 
226
- function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes = new Map()) {
255
+ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes = new Map(), readOnlyScopes = new Set()) {
227
256
  const content = fs.readFileSync(scopeIndexPath, 'utf8');
228
257
  const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
229
258
  if (!fmMatch) {
230
- errors.push(`Scope index must start with a YAML frontmatter block: ${toDisplayPath(scopeIndexPath)}`);
259
+ errors.push(`Scope index must start with a YAML frontmatter block: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
231
260
  return;
232
261
  }
233
262
  const block = fmMatch[1];
@@ -235,22 +264,22 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
235
264
 
236
265
  for (const key of fm.topLevelKeys) {
237
266
  if (!SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS.has(key)) {
238
- errors.push(`Scope index frontmatter has unknown field "${key}": ${toDisplayPath(scopeIndexPath)}`);
267
+ errors.push(`Scope index frontmatter has unknown field "${key}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
239
268
  }
240
269
  }
241
270
 
242
271
  const scopeTypeMatch = block.match(/^scope-type:\s*(.+)$/m);
243
272
  const scopeType = scopeTypeMatch ? scopeTypeMatch[1].trim() : null;
244
273
  if (!scopeType) {
245
- errors.push(`Scope index frontmatter must include a scope-type field: ${toDisplayPath(scopeIndexPath)}`);
274
+ errors.push(`Scope index frontmatter must include a scope-type field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.08-def-valid-iff-policy-exists]`);
246
275
  return;
247
276
  }
248
277
  if (scopeType.startsWith('_') && scopeType !== '_local') {
249
- errors.push(`Scope type "${scopeType}" uses a reserved "_" prefix; only "_local" is a valid underscore-prefixed scope type: ${toDisplayPath(scopeIndexPath)}`);
278
+ errors.push(`Scope type "${scopeType}" uses a reserved "_" prefix; only "_local" is a valid underscore-prefixed scope type: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.06-def-no-underscore-prefix]`);
250
279
  return;
251
280
  }
252
281
  if (!knownScopeTypes.has(scopeType) && scopeType !== '_local') {
253
- errors.push(`Scope index scope-type "${scopeType}" has no corresponding ${scopeType}-scope-type policy in the principles of any scope: ${toDisplayPath(scopeIndexPath)}`);
282
+ errors.push(`Scope index scope-type "${scopeType}" has no corresponding ${scopeType}-scope-type policy in the principles of any scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.08-def-valid-iff-policy-exists]`);
254
283
  return;
255
284
  }
256
285
  // Validate inheritance chain: ensure all ancestors exist in knownScopeTypes
@@ -263,11 +292,11 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
263
292
  const parentType = extractParentScopeType(policyPath);
264
293
  if (!parentType) break;
265
294
  if (visited.has(parentType)) {
266
- errors.push(`Scope type "${currentType}" declares parent "${parentType}" which creates a cycle in the scope-type inheritance chain: ${toDisplayPath(scopeIndexPath)}`);
295
+ errors.push(`Scope type "${currentType}" declares parent "${parentType}" which creates a cycle in the scope-type inheritance chain: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.07-def-parent-scope-type]`);
267
296
  break;
268
297
  }
269
298
  if (!knownScopeTypes.has(parentType)) {
270
- errors.push(`Scope type "${currentType}" declares parent "${parentType}" but no ${parentType}-scope-type policy exists in any scope: ${toDisplayPath(scopeIndexPath)}`);
299
+ errors.push(`Scope type "${currentType}" declares parent "${parentType}" but no ${parentType}-scope-type policy exists in any scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-010.07-def-parent-scope-type]`);
271
300
  break;
272
301
  }
273
302
  visited.add(parentType);
@@ -275,51 +304,51 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
275
304
  }
276
305
  }
277
306
  if (scopeName === '_core' && scopeType !== 'core') {
278
- errors.push(`Scope "_core" must have scope-type "core": ${toDisplayPath(scopeIndexPath)}`);
307
+ errors.push(`Scope "_core" must have scope-type "core": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-011.01-scope-type-name]`);
279
308
  }
280
309
  if (scopeName === '_local' && scopeType !== '_local') {
281
- errors.push(`Scope "_local" must have scope-type "_local": ${toDisplayPath(scopeIndexPath)}`);
310
+ errors.push(`Scope "_local" must have scope-type "_local": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-015.01-scope-type-name]`);
282
311
  }
283
312
  if (scopeType === 'core' && !scopeName.includes('core')) {
284
- errors.push(`Scope with type "core" must have "core" in its name: ${toDisplayPath(scopeIndexPath)}`);
313
+ errors.push(`Scope with type "core" must have "core" in its name: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-011.03-naming-convention]`);
285
314
  }
286
315
  if (scopeType === '_local' && scopeName !== '_local') {
287
- errors.push(`Scope type "_local" is reserved for the "_local" scope: ${toDisplayPath(scopeIndexPath)}`);
316
+ errors.push(`Scope type "_local" is reserved for the "_local" scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-015.03-exclusive-reservation]`);
288
317
  }
289
318
  if (scopeType === 'reference' && !scopeName.includes('-ref-')) {
290
- errors.push(`Scope with type "reference" must follow the naming pattern {domain}-ref-{name} (e.g. "security-ref-baseline"): ${toDisplayPath(scopeIndexPath)}`);
319
+ errors.push(`Scope with type "reference" must follow the naming pattern {domain}-ref-{name} (e.g. "security-ref-baseline"): ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-012.03-naming-convention]`);
291
320
  }
292
321
  if (scopeType === 'platform' && !scopeName.includes('-plat-')) {
293
- errors.push(`Scope with type "platform" must follow the naming pattern {domain}-plat-{name} (e.g. "cloud-plat-aws"): ${toDisplayPath(scopeIndexPath)}`);
322
+ 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]`);
294
323
  }
295
324
 
296
325
  if (!fm.name) {
297
- errors.push(`Scope index frontmatter must include a non-empty name field: ${toDisplayPath(scopeIndexPath)}`);
326
+ errors.push(`Scope index frontmatter must include a non-empty name field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
298
327
  } else if (fm.name !== scopeName) {
299
- errors.push(`Scope index frontmatter name must match scope directory name "${scopeName}": ${toDisplayPath(scopeIndexPath)}`);
328
+ errors.push(`Scope index frontmatter name must match scope directory name "${scopeName}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
300
329
  }
301
330
 
302
331
  if (!fm.description) {
303
- errors.push(`Scope index frontmatter must include a non-empty description field: ${toDisplayPath(scopeIndexPath)}`);
332
+ errors.push(`Scope index frontmatter must include a non-empty description field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
304
333
  } else if (fm.descriptionText && countWords(fm.descriptionText) > 40) {
305
- errors.push(`Scope index frontmatter description must be 40 words or fewer: ${toDisplayPath(scopeIndexPath)}`);
334
+ errors.push(`Scope index frontmatter description must be 40 words or fewer: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
306
335
  }
307
336
 
308
337
  if (!fm.appliedTo) {
309
- errors.push(`Scope index frontmatter must include an apply-to field: ${toDisplayPath(scopeIndexPath)}`);
338
+ errors.push(`Scope index frontmatter must include an apply-to field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
310
339
  } else {
311
340
  const words = countWords(fm.appliedTo);
312
341
  if (words === 0) {
313
- errors.push(`Scope index frontmatter apply-to must not be empty: ${toDisplayPath(scopeIndexPath)}`);
342
+ errors.push(`Scope index frontmatter apply-to must not be empty: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
314
343
  } else if (words > 30) {
315
- errors.push(`Scope index frontmatter apply-to must be 30 words or fewer: ${toDisplayPath(scopeIndexPath)}`);
344
+ errors.push(`Scope index frontmatter apply-to must be 30 words or fewer: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
316
345
  }
317
346
  }
318
347
 
319
348
  if (!fm.validFrom) {
320
- errors.push(`Scope index frontmatter must include a valid-from field: ${toDisplayPath(scopeIndexPath)}`);
349
+ errors.push(`Scope index frontmatter must include a valid-from field: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
321
350
  } else if (!isIsoDate(fm.validFrom)) {
322
- errors.push(`Scope index frontmatter valid-from must be a valid ISO date YYYY-MM-DD: ${toDisplayPath(scopeIndexPath)}`);
351
+ errors.push(`Scope index frontmatter valid-from must be a valid ISO date YYYY-MM-DD: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
323
352
  }
324
353
 
325
354
  let followsEntries = [];
@@ -330,7 +359,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
330
359
  const commaSeparatedPattern = /^[a-zA-Z_][a-zA-Z0-9_-]*(\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)+$/;
331
360
  if (followsValue) {
332
361
  if (!scopeNamePattern.test(followsValue) && !commaSeparatedPattern.test(followsValue)) {
333
- errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)}`);
362
+ errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
334
363
  }
335
364
  } else {
336
365
  const followsStart = block.indexOf(followsLineMatch[0]) + followsLineMatch[0].length;
@@ -341,7 +370,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
341
370
  if (itemMatch) {
342
371
  const item = (itemMatch[1] || '').trim();
343
372
  if (!item || !scopeNamePattern.test(item)) {
344
- errors.push(`Scope index frontmatter follows entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)}`);
373
+ errors.push(`Scope index frontmatter follows entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
345
374
  hasInvalidEntry = true;
346
375
  break;
347
376
  }
@@ -351,7 +380,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
351
380
  }
352
381
  }
353
382
  if (!hasInvalidEntry && listItems.length === 0) {
354
- errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)}`);
383
+ errors.push(`Scope index frontmatter follows must be a core scope name or list of core scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
355
384
  }
356
385
  }
357
386
  if (xdrsRoot) {
@@ -373,26 +402,26 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
373
402
  const seenFollows = new Set();
374
403
  for (const entry of entries) {
375
404
  if (seenFollows.has(entry)) {
376
- errors.push(`Scope index frontmatter follows has duplicate entry "${entry}": ${toDisplayPath(scopeIndexPath)}`);
405
+ errors.push(`Scope index frontmatter follows has duplicate entry "${entry}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
377
406
  }
378
407
  seenFollows.add(entry);
379
408
  if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(entry)) {
380
409
  if (entry === scopeName) {
381
- errors.push(`Scope index frontmatter follows must not reference the scope itself: ${toDisplayPath(scopeIndexPath)}`);
410
+ errors.push(`Scope index frontmatter follows must not reference the scope itself: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
382
411
  continue;
383
412
  }
384
413
  if (entry === '_core') {
385
- errors.push(`Scope index frontmatter follows must not reference "_core" as it is always applied implicitly: ${toDisplayPath(scopeIndexPath)}`);
414
+ errors.push(`Scope index frontmatter follows must not reference "_core" as it is always applied implicitly: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
386
415
  continue;
387
416
  }
388
417
  const followedIndexPath = path.join(xdrsRoot, entry, 'index.md');
389
418
  if (!existsFile(followedIndexPath)) {
390
- errors.push(`Scope index frontmatter follows references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)}`);
419
+ readOnlyScopes.add(scopeName);
391
420
  } else {
392
421
  const followedContent = fs.readFileSync(followedIndexPath, 'utf8');
393
422
  const followedTypeMatch = followedContent.match(/^scope-type:\s*(.+)$/m);
394
423
  if (!followedTypeMatch || followedTypeMatch[1].trim() !== 'core') {
395
- errors.push(`Scope index frontmatter follows references scope "${entry}" which is not a core-type scope: ${toDisplayPath(scopeIndexPath)}`);
424
+ errors.push(`Scope index frontmatter follows references scope "${entry}" which is not a core-type scope: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
396
425
  }
397
426
  }
398
427
  }
@@ -406,7 +435,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
406
435
  const scopeNamePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
407
436
  if (relatedScopesValue) {
408
437
  if (!scopeNamePattern.test(relatedScopesValue)) {
409
- errors.push(`Scope index frontmatter related-scopes must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)}`);
438
+ errors.push(`Scope index frontmatter related-scopes must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
410
439
  }
411
440
  } else {
412
441
  const relatedScopesStart = block.indexOf(relatedScopesLineMatch[0]) + relatedScopesLineMatch[0].length;
@@ -417,7 +446,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
417
446
  if (itemMatch) {
418
447
  const item = (itemMatch[1] || '').trim();
419
448
  if (!item || !scopeNamePattern.test(item)) {
420
- errors.push(`Scope index frontmatter related-scopes entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)}`);
449
+ errors.push(`Scope index frontmatter related-scopes entries must be non-empty scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
421
450
  hasInvalidEntry = true;
422
451
  break;
423
452
  }
@@ -427,7 +456,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
427
456
  }
428
457
  }
429
458
  if (!hasInvalidEntry && listItems.length === 0) {
430
- errors.push(`Scope index frontmatter related-scopes must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)}`);
459
+ errors.push(`Scope index frontmatter related-scopes must be a scope name or list of scope names: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
431
460
  }
432
461
  }
433
462
  if (xdrsRoot) {
@@ -444,20 +473,20 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
444
473
  const seenRelated = new Set();
445
474
  for (const entry of entries) {
446
475
  if (seenRelated.has(entry)) {
447
- errors.push(`Scope index frontmatter related-scopes has duplicate entry "${entry}": ${toDisplayPath(scopeIndexPath)}`);
476
+ errors.push(`Scope index frontmatter related-scopes has duplicate entry "${entry}": ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
448
477
  }
449
478
  seenRelated.add(entry);
450
479
  if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(entry)) {
451
480
  if (entry === scopeName) {
452
- errors.push(`Scope index frontmatter related-scopes must not reference the scope itself: ${toDisplayPath(scopeIndexPath)}`);
481
+ errors.push(`Scope index frontmatter related-scopes must not reference the scope itself: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
453
482
  continue;
454
483
  }
455
484
  if (followsEntries.includes(entry)) {
456
- errors.push(`Scope index frontmatter related-scopes must not repeat scope "${entry}" already declared in follows: ${toDisplayPath(scopeIndexPath)}`);
485
+ errors.push(`Scope index frontmatter related-scopes must not repeat scope "${entry}" already declared in follows: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
457
486
  continue;
458
487
  }
459
488
  if (!existsFile(path.join(xdrsRoot, entry, 'index.md'))) {
460
- errors.push(`Scope index frontmatter related-scopes references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)}`);
489
+ errors.push(`Scope index frontmatter related-scopes references scope "${entry}" which does not exist in the workspace: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
461
490
  }
462
491
  }
463
492
  }
@@ -465,7 +494,7 @@ function lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot,
465
494
  }
466
495
  }
467
496
 
468
- function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, ignoreExternal, externalScopes, knownScopeTypes = new Map()) {
497
+ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, ignoreExternal, externalScopes, knownScopeTypes = new Map(), readOnlyScopes = new Set()) {
469
498
  const scopePath = path.join(xdrsRoot, scopeName);
470
499
 
471
500
  if (ignoreExternal && externalScopes.has(scopeName)) {
@@ -473,7 +502,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, igno
473
502
  }
474
503
 
475
504
  if (!isValidScopeName(scopeName)) {
476
- errors.push(`Invalid scope name: ${toDisplayPath(scopePath)}`);
505
+ errors.push(`Invalid scope name: ${toDisplayPath(scopePath)} [_core-adr-policy-001]`);
477
506
  }
478
507
 
479
508
  const typeIndexesInScope = [];
@@ -482,7 +511,7 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, igno
482
511
  const entryPath = path.join(scopePath, entry.name);
483
512
  if (entry.isDirectory()) {
484
513
  if (!TYPE_NAMES.has(entry.name)) {
485
- errors.push(`Unexpected directory under scope ${scopeName}: ${toDisplayPath(entryPath)}`);
514
+ errors.push(`Unexpected directory under scope ${scopeName}: ${toDisplayPath(entryPath)} [_core-adr-policy-001]`);
486
515
  continue;
487
516
  }
488
517
  lintTypeDirectory(xdrsRoot, scopeName, entry.name, errors, actualTypeIndexes, externalScopes);
@@ -497,15 +526,15 @@ function lintScopeDirectory(xdrsRoot, scopeName, errors, actualTypeIndexes, igno
497
526
  continue;
498
527
  }
499
528
 
500
- errors.push(`Unexpected file under scope ${scopeName}: ${toDisplayPath(entryPath)}`);
529
+ errors.push(`Unexpected file under scope ${scopeName}: ${toDisplayPath(entryPath)} [_core-adr-policy-001]`);
501
530
  }
502
531
 
503
532
  const scopeIndexPath = path.join(scopePath, 'index.md');
504
533
  if (!existsFile(scopeIndexPath)) {
505
- errors.push(`Missing required scope index: ${toDisplayPath(scopeIndexPath)}`);
534
+ errors.push(`Missing required scope index: ${toDisplayPath(scopeIndexPath)} [_core-adr-policy-001]`);
506
535
  } else {
507
536
  lintScopeIndex(scopeIndexPath, xdrsRoot, scopeName, typeIndexesInScope, errors, externalScopes);
508
- lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes);
537
+ lintScopeIndexFrontmatter(scopeIndexPath, scopeName, errors, xdrsRoot, knownScopeTypes, readOnlyScopes);
509
538
  }
510
539
  lintScopeLocalStandards(xdrsRoot, scopeName, errors);
511
540
  }
@@ -533,7 +562,7 @@ function lintScopeLocalStandards(xdrsRoot, scopeName, errors) {
533
562
  }
534
563
  if (foundPolicies.length > 1) {
535
564
  const paths = foundPolicies.map((p) => toDisplayPath(p)).join(', ');
536
- errors.push(`Scope "${scopeName}" has more than one scope-local standards policy (name ending with "${corePolicySuffix}"): ${paths}`);
565
+ errors.push(`Scope "${scopeName}" has more than one scope-local standards policy (name ending with "${corePolicySuffix}"): ${paths} [_core-adr-policy-010.10-local-placement]`);
537
566
  }
538
567
  }
539
568
 
@@ -544,7 +573,7 @@ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeInde
544
573
  const artifacts = [];
545
574
 
546
575
  if (!existsFile(indexPath)) {
547
- errors.push(`Missing canonical index: ${toDisplayPath(indexPath)}`);
576
+ errors.push(`Missing canonical index: ${toDisplayPath(indexPath)} [_core-adr-policy-001]`);
548
577
  } else {
549
578
  actualTypeIndexes.push(indexPath);
550
579
  }
@@ -554,13 +583,13 @@ function lintTypeDirectory(xdrsRoot, scopeName, typeName, errors, actualTypeInde
554
583
  const entryPath = path.join(typePath, entry.name);
555
584
  if (entry.isFile()) {
556
585
  if (entry.name !== 'index.md') {
557
- errors.push(`Unexpected file under ${scopeName}/${typeName}: ${toDisplayPath(entryPath)}`);
586
+ errors.push(`Unexpected file under ${scopeName}/${typeName}: ${toDisplayPath(entryPath)} [_core-adr-policy-001]`);
558
587
  }
559
588
  continue;
560
589
  }
561
590
 
562
591
  if (!ALLOWED_SUBJECTS[typeName].has(entry.name)) {
563
- errors.push(`Invalid subject folder for ${typeName}: ${toDisplayPath(entryPath)}`);
592
+ errors.push(`Invalid subject folder for ${typeName}: ${toDisplayPath(entryPath)} [_core-adr-policy-001]`);
564
593
  continue;
565
594
  }
566
595
 
@@ -601,12 +630,12 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNu
601
630
  continue;
602
631
  }
603
632
 
604
- errors.push(`Unexpected directory under ${scopeName}/${typeName}/${subjectName}: ${toDisplayPath(entryPath)}`);
633
+ errors.push(`Unexpected directory under ${scopeName}/${typeName}/${subjectName}: ${toDisplayPath(entryPath)} [_core-adr-policy-001]`);
605
634
  continue;
606
635
  }
607
636
 
608
637
  if (!NUMBERED_FILE_RE.test(entry.name)) {
609
- errors.push(`Invalid Policy file name: ${toDisplayPath(entryPath)}`);
638
+ errors.push(`Invalid Policy file name: ${toDisplayPath(entryPath)} [_core-adr-policy-001]`);
610
639
  continue;
611
640
  }
612
641
 
@@ -631,25 +660,26 @@ function lintXdrsElementFile(xdrsRoot, scopeName, typeName, filePath, xdrsNumber
631
660
  const number = match[1];
632
661
  const previous = xdrsNumbers.get(number);
633
662
  if (previous) {
634
- errors.push(`Duplicate Policy number ${number} in ${scopeName}/${typeName}: ${toDisplayPath(previous)} and ${toDisplayPath(filePath)}`);
663
+ errors.push(`Duplicate Policy number ${number} in ${scopeName}/${typeName}: ${toDisplayPath(previous)} and ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
635
664
  } else {
636
665
  xdrsNumbers.set(number, filePath);
637
666
  }
638
667
 
639
668
  if (baseName !== baseName.toLowerCase()) {
640
- errors.push(`Policy file name must be lowercase: ${toDisplayPath(filePath)}`);
669
+ errors.push(`Policy file name must be lowercase: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
641
670
  }
642
671
 
643
672
  const content = fs.readFileSync(filePath, 'utf8');
644
673
  const expectedHeader = `# ${scopeName}-${TYPE_TO_ID[typeName]}-${number}:`;
645
674
  const firstLine = firstNonEmptyLine(stripFrontmatter(content));
646
675
  if (!firstLine.startsWith(expectedHeader)) {
647
- errors.push(`Policy title must start with "${expectedHeader}": ${toDisplayPath(filePath)}`);
676
+ errors.push(`Policy title must start with "${expectedHeader}": ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
648
677
  }
649
678
 
650
679
  const expectedName = extractExpectedXdrsNameFromHeading(firstLine)
651
680
  || `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${match[2]}`;
652
681
  lintXdrsElementFrontmatter(content, expectedName, filePath, errors);
682
+ lintStructuredRuleBlocks(content, filePath, errors);
653
683
  lintRequiredSections(content, filePath, XDRS_REQUIRED_SECTIONS, 'Policy', errors);
654
684
  lintNoEmojis(content, filePath, 'Policy', errors);
655
685
  lintWordCount(content, filePath, 'Policy', POLICY_MAX_WORDS, errors);
@@ -684,42 +714,45 @@ function slugifyTitle(title) {
684
714
  function lintXdrsElementFrontmatter(content, expectedName, filePath, errors) {
685
715
  const fm = extractFrontmatter(content);
686
716
  if (!fm.present) {
687
- errors.push(`Policy must start with a YAML frontmatter block: ${toDisplayPath(filePath)}`);
717
+ errors.push(`Policy must start with a YAML frontmatter block: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
688
718
  return;
689
719
  }
690
720
  if (!fm.name) {
691
- errors.push(`Policy frontmatter must include a non-empty name field: ${toDisplayPath(filePath)}`);
721
+ errors.push(`Policy frontmatter must include a non-empty name field: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
692
722
  } else {
693
723
  if (fm.name !== expectedName) {
694
- errors.push(`Policy frontmatter name must be "${expectedName}": ${toDisplayPath(filePath)}`);
724
+ errors.push(`Policy frontmatter name must be "${expectedName}": ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
725
+ }
726
+ if (fm.name.endsWith('-')) {
727
+ errors.push(`Policy frontmatter name must not end with a hyphen: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
695
728
  }
696
729
  if (fm.name.length > 64) {
697
- errors.push(`Policy frontmatter name must be 64 characters or fewer: ${toDisplayPath(filePath)}`);
730
+ errors.push(`Policy frontmatter name must be 64 characters or fewer: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
698
731
  }
699
732
  }
700
733
  if (!fm.description) {
701
- errors.push(`Policy frontmatter must include a non-empty description field: ${toDisplayPath(filePath)}`);
734
+ errors.push(`Policy frontmatter must include a non-empty description field: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
702
735
  } else if (fm.descriptionText && fm.descriptionText.length > 1024) {
703
- errors.push(`Policy frontmatter description must be 1024 characters or fewer: ${toDisplayPath(filePath)}`);
736
+ errors.push(`Policy frontmatter description must be 1024 characters or fewer: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
704
737
  }
705
738
  if (!fm.validFrom) {
706
- errors.push(`Policy frontmatter must include a valid-from field: ${toDisplayPath(filePath)}`);
739
+ errors.push(`Policy frontmatter must include a valid-from field: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
707
740
  } else if (!isIsoDate(fm.validFrom)) {
708
- errors.push(`Policy frontmatter valid-from must be a valid ISO date YYYY-MM-DD: ${toDisplayPath(filePath)}`);
741
+ errors.push(`Policy frontmatter valid-from must be a valid ISO date YYYY-MM-DD: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
709
742
  }
710
743
  if (!fm.appliedTo) {
711
- errors.push(`Policy frontmatter must include an apply-to field: ${toDisplayPath(filePath)}`);
744
+ errors.push(`Policy frontmatter must include an apply-to field: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
712
745
  } else {
713
746
  const words = countWords(fm.appliedTo);
714
747
  if (words === 0) {
715
- errors.push(`Policy frontmatter apply-to must not be empty: ${toDisplayPath(filePath)}`);
748
+ errors.push(`Policy frontmatter apply-to must not be empty: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
716
749
  } else if (words >= 40) {
717
- errors.push(`Policy frontmatter apply-to must be under 40 words: ${toDisplayPath(filePath)}`);
750
+ errors.push(`Policy frontmatter apply-to must be under 40 words: ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
718
751
  }
719
752
  }
720
753
  for (const key of fm.topLevelKeys) {
721
754
  if (!POLICY_ALLOWED_FRONTMATTER_KEYS.has(key)) {
722
- errors.push(`Policy frontmatter has unknown field "${key}": ${toDisplayPath(filePath)}`);
755
+ errors.push(`Policy frontmatter has unknown field "${key}": ${toDisplayPath(filePath)} [_core-adr-policy-002]`);
723
756
  }
724
757
  }
725
758
  }
@@ -732,26 +765,26 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
732
765
  for (const entry of entries) {
733
766
  const entryPath = path.join(skillsPath, entry.name);
734
767
  if (!entry.isDirectory()) {
735
- errors.push(`Unexpected file in skills directory: ${toDisplayPath(entryPath)}`);
768
+ errors.push(`Unexpected file in skills directory: ${toDisplayPath(entryPath)} [_core-adr-policy-003]`);
736
769
  continue;
737
770
  }
738
771
 
739
772
  const match = entry.name.match(NUMBERED_DIR_RE);
740
773
  if (!match) {
741
- errors.push(`Invalid skill package name: ${toDisplayPath(entryPath)}`);
774
+ errors.push(`Invalid skill package name: ${toDisplayPath(entryPath)} [_core-adr-policy-003]`);
742
775
  continue;
743
776
  }
744
777
 
745
778
  const number = match[1];
746
779
  const previous = skillNumbers.get(number);
747
780
  if (previous) {
748
- errors.push(`Duplicate skill number ${number} in ${scopeName}/${typeName}/${subjectName}/skills: ${toDisplayPath(previous)} and ${toDisplayPath(entryPath)}`);
781
+ errors.push(`Duplicate skill number ${number} in ${scopeName}/${typeName}/${subjectName}/skills: ${toDisplayPath(previous)} and ${toDisplayPath(entryPath)} [_core-adr-policy-003]`);
749
782
  } else {
750
783
  skillNumbers.set(number, entryPath);
751
784
  }
752
785
 
753
786
  if (entry.name !== entry.name.toLowerCase()) {
754
- errors.push(`Skill package name must be lowercase: ${toDisplayPath(entryPath)}`);
787
+ errors.push(`Skill package name must be lowercase: ${toDisplayPath(entryPath)} [_core-adr-policy-003]`);
755
788
  }
756
789
 
757
790
  const skillFilePath = path.join(entryPath, 'SKILL.md');
@@ -768,11 +801,11 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
768
801
  continue;
769
802
  }
770
803
 
771
- errors.push(`Unexpected directory in skill package: ${toDisplayPath(packageEntryPath)}`);
804
+ errors.push(`Unexpected directory in skill package: ${toDisplayPath(packageEntryPath)} [_core-adr-policy-003]`);
772
805
  }
773
806
 
774
807
  if (!existsFile(skillFilePath)) {
775
- errors.push(`Missing SKILL.md in skill package: ${toDisplayPath(entryPath)}`);
808
+ errors.push(`Missing SKILL.md in skill package: ${toDisplayPath(entryPath)} [_core-adr-policy-003]`);
776
809
  continue;
777
810
  }
778
811
 
@@ -782,27 +815,27 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
782
815
  lintRequiredSections(skillContent, skillFilePath, SKILL_REQUIRED_SECTIONS, 'Skill', errors);
783
816
  const skillFm = extractFrontmatter(skillContent);
784
817
  if (!skillFm.present) {
785
- errors.push(`SKILL.md must start with a YAML frontmatter block: ${toDisplayPath(skillFilePath)}`);
818
+ errors.push(`SKILL.md must start with a YAML frontmatter block: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
786
819
  } else {
787
820
  if (!skillFm.name) {
788
- errors.push(`SKILL.md frontmatter must include a non-empty name field: ${toDisplayPath(skillFilePath)}`);
821
+ errors.push(`SKILL.md frontmatter must include a non-empty name field: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
789
822
  } else {
790
823
  const expectedSkillName = `${number}-${match[2]}`;
791
824
  if (skillFm.name !== expectedSkillName) {
792
- errors.push(`Skill frontmatter name must be "${expectedSkillName}": ${toDisplayPath(skillFilePath)}`);
825
+ errors.push(`Skill frontmatter name must be "${expectedSkillName}": ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
793
826
  }
794
827
  if (skillFm.name.length > 64) {
795
- errors.push(`SKILL.md frontmatter name must be 64 characters or fewer: ${toDisplayPath(skillFilePath)}`);
828
+ errors.push(`SKILL.md frontmatter name must be 64 characters or fewer: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
796
829
  }
797
830
  }
798
831
  if (!skillFm.description) {
799
- errors.push(`SKILL.md frontmatter must include a non-empty description field: ${toDisplayPath(skillFilePath)}`);
832
+ errors.push(`SKILL.md frontmatter must include a non-empty description field: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
800
833
  } else if (skillFm.descriptionText && skillFm.descriptionText.length > 1024) {
801
- errors.push(`SKILL.md frontmatter description must be 1024 characters or fewer: ${toDisplayPath(skillFilePath)}`);
834
+ errors.push(`SKILL.md frontmatter description must be 1024 characters or fewer: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
802
835
  }
803
836
  for (const key of skillFm.topLevelKeys) {
804
837
  if (!SKILL_ALLOWED_FRONTMATTER_KEYS.has(key)) {
805
- errors.push(`SKILL.md frontmatter has unknown field "${key}": ${toDisplayPath(skillFilePath)}`);
838
+ errors.push(`SKILL.md frontmatter has unknown field "${key}": ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
806
839
  }
807
840
  }
808
841
  }
@@ -828,13 +861,13 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
828
861
  }
829
862
 
830
863
  if (!entry.isFile()) {
831
- errors.push(`Unexpected directory in articles folder: ${toDisplayPath(entryPath)}`);
864
+ errors.push(`Unexpected directory in articles folder: ${toDisplayPath(entryPath)} [_core-adr-policy-004]`);
832
865
  continue;
833
866
  }
834
867
 
835
868
  const match = entry.name.match(NUMBERED_FILE_RE);
836
869
  if (!match) {
837
- errors.push(`Invalid article file name: ${toDisplayPath(entryPath)}`);
870
+ errors.push(`Invalid article file name: ${toDisplayPath(entryPath)} [_core-adr-policy-004]`);
838
871
  continue;
839
872
  }
840
873
 
@@ -843,13 +876,13 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
843
876
  const number = match[1];
844
877
  const previous = articleNumbers.get(number);
845
878
  if (previous) {
846
- errors.push(`Duplicate article number ${number} in ${scopeName}/${typeName}/${subjectName}/articles: ${toDisplayPath(previous)} and ${toDisplayPath(entryPath)}`);
879
+ errors.push(`Duplicate article number ${number} in ${scopeName}/${typeName}/${subjectName}/articles: ${toDisplayPath(previous)} and ${toDisplayPath(entryPath)} [_core-adr-policy-004]`);
847
880
  } else {
848
881
  articleNumbers.set(number, entryPath);
849
882
  }
850
883
 
851
884
  if (entry.name !== entry.name.toLowerCase()) {
852
- errors.push(`Article file name must be lowercase: ${toDisplayPath(entryPath)}`);
885
+ errors.push(`Article file name must be lowercase: ${toDisplayPath(entryPath)} [_core-adr-policy-004]`);
853
886
  }
854
887
 
855
888
  const content = fs.readFileSync(entryPath, 'utf8');
@@ -857,7 +890,7 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
857
890
  const expectedHeader = `# ${scopeName}-${typeId}-article-${number}:`;
858
891
  const firstLine = firstNonEmptyLine(content);
859
892
  if (!firstLine.startsWith(expectedHeader)) {
860
- errors.push(`Article title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
893
+ errors.push(`Article title must start with "${expectedHeader}": ${toDisplayPath(entryPath)} [_core-adr-policy-004]`);
861
894
  }
862
895
 
863
896
  lintRequiredSections(content, entryPath, ARTICLE_REQUIRED_SECTIONS, 'Article', errors);
@@ -883,13 +916,13 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
883
916
  }
884
917
 
885
918
  if (!entry.isFile()) {
886
- errors.push(`Unexpected directory in researches folder: ${toDisplayPath(entryPath)}`);
919
+ errors.push(`Unexpected directory in researches folder: ${toDisplayPath(entryPath)} [_core-adr-policy-006]`);
887
920
  continue;
888
921
  }
889
922
 
890
923
  const match = entry.name.match(NUMBERED_FILE_RE);
891
924
  if (!match) {
892
- errors.push(`Invalid research file name: ${toDisplayPath(entryPath)}`);
925
+ errors.push(`Invalid research file name: ${toDisplayPath(entryPath)} [_core-adr-policy-006]`);
893
926
  continue;
894
927
  }
895
928
 
@@ -898,13 +931,13 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
898
931
  const number = match[1];
899
932
  const previous = researchNumbers.get(number);
900
933
  if (previous) {
901
- errors.push(`Duplicate research number ${number} in ${scopeName}/${typeName}/${subjectName}/researches: ${toDisplayPath(previous)} and ${toDisplayPath(entryPath)}`);
934
+ errors.push(`Duplicate research number ${number} in ${scopeName}/${typeName}/${subjectName}/researches: ${toDisplayPath(previous)} and ${toDisplayPath(entryPath)} [_core-adr-policy-006]`);
902
935
  } else {
903
936
  researchNumbers.set(number, entryPath);
904
937
  }
905
938
 
906
939
  if (entry.name !== entry.name.toLowerCase()) {
907
- errors.push(`Research file name must be lowercase: ${toDisplayPath(entryPath)}`);
940
+ errors.push(`Research file name must be lowercase: ${toDisplayPath(entryPath)} [_core-adr-policy-006]`);
908
941
  }
909
942
 
910
943
  const content = fs.readFileSync(entryPath, 'utf8');
@@ -912,11 +945,12 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
912
945
  const expectedHeader = `# ${scopeName}-${typeId}-research-${number}:`;
913
946
  const firstLine = firstNonEmptyLine(content);
914
947
  if (!firstLine.startsWith(expectedHeader)) {
915
- errors.push(`Research title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
948
+ errors.push(`Research title must start with "${expectedHeader}": ${toDisplayPath(entryPath)} [_core-adr-policy-006]`);
916
949
  }
917
950
 
918
951
  lintRequiredSections(content, entryPath, RESEARCH_REQUIRED_SECTIONS, 'Research', errors);
919
952
  lintResearchIntroductionQuestion(content, entryPath, errors);
953
+ lintResearchSectionWordLimits(content, entryPath, errors);
920
954
  lintNoEmojis(content, entryPath, 'Research', errors);
921
955
  lintWordCount(content, entryPath, 'Research', RESEARCH_MAX_WORDS, errors);
922
956
  lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors, externalScopes);
@@ -939,13 +973,13 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
939
973
  }
940
974
 
941
975
  if (!entry.isFile()) {
942
- errors.push(`Unexpected directory in plans folder: ${toDisplayPath(entryPath)}`);
976
+ errors.push(`Unexpected directory in plans folder: ${toDisplayPath(entryPath)} [_core-adr-policy-007]`);
943
977
  continue;
944
978
  }
945
979
 
946
980
  const match = entry.name.match(NUMBERED_FILE_RE);
947
981
  if (!match) {
948
- errors.push(`Invalid plan file name: ${toDisplayPath(entryPath)}`);
982
+ errors.push(`Invalid plan file name: ${toDisplayPath(entryPath)} [_core-adr-policy-007]`);
949
983
  continue;
950
984
  }
951
985
 
@@ -954,13 +988,13 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
954
988
  const number = match[1];
955
989
  const previous = planNumbers.get(number);
956
990
  if (previous) {
957
- errors.push(`Duplicate plan number ${number} in ${scopeName}/${typeName}/${subjectName}/plans: ${toDisplayPath(previous)} and ${toDisplayPath(entryPath)}`);
991
+ errors.push(`Duplicate plan number ${number} in ${scopeName}/${typeName}/${subjectName}/plans: ${toDisplayPath(previous)} and ${toDisplayPath(entryPath)} [_core-adr-policy-007]`);
958
992
  } else {
959
993
  planNumbers.set(number, entryPath);
960
994
  }
961
995
 
962
996
  if (entry.name !== entry.name.toLowerCase()) {
963
- errors.push(`Plan file name must be lowercase: ${toDisplayPath(entryPath)}`);
997
+ errors.push(`Plan file name must be lowercase: ${toDisplayPath(entryPath)} [_core-adr-policy-007]`);
964
998
  }
965
999
 
966
1000
  const content = fs.readFileSync(entryPath, 'utf8');
@@ -968,7 +1002,7 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
968
1002
  const expectedHeader = `# ${scopeName}-${typeId}-plan-${number}:`;
969
1003
  const firstLine = firstNonEmptyLine(content);
970
1004
  if (!firstLine.startsWith(expectedHeader)) {
971
- errors.push(`Plan title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
1005
+ errors.push(`Plan title must start with "${expectedHeader}": ${toDisplayPath(entryPath)} [_core-adr-policy-007]`);
972
1006
  }
973
1007
 
974
1008
  lintRequiredSections(content, entryPath, PLAN_REQUIRED_SECTIONS, 'Plan', errors);
@@ -988,7 +1022,7 @@ function lintNoEmojis(content, filePath, docType, errors) {
988
1022
  for (let i = 0; i < lines.length; i++) {
989
1023
  if (ignoredLines[i]) continue;
990
1024
  if (EMOJI_RE.test(lines[i])) {
991
- errors.push(`${docType} must not contain emojis: ${toDisplayPath(filePath)}:${i + 1}`);
1025
+ errors.push(`${docType} must not contain emojis: ${toDisplayPath(filePath)}:${i + 1} ${DOC_TYPE_POLICY_REF[docType] || ''}`);
992
1026
  }
993
1027
  }
994
1028
  }
@@ -997,7 +1031,7 @@ function lintWordCount(content, filePath, docType, maxWords, errors) {
997
1031
  const body = stripFrontmatter(content);
998
1032
  const wordCount = countWords(body);
999
1033
  if (wordCount > maxWords) {
1000
- errors.push(`${docType} exceeds maximum word count of ${maxWords} (${wordCount} words): ${toDisplayPath(filePath)}`);
1034
+ errors.push(`${docType} exceeds maximum word count of ${maxWords} (${wordCount} words): ${toDisplayPath(filePath)} ${DOC_TYPE_POLICY_REF[docType] || ''}`);
1001
1035
  }
1002
1036
  }
1003
1037
 
@@ -1024,7 +1058,7 @@ function lintResearchIntroductionQuestion(content, filePath, errors) {
1024
1058
  }
1025
1059
 
1026
1060
  if (!hasQuestion) {
1027
- errors.push(`Research ## Introduction must contain a "Question:" line: ${toDisplayPath(filePath)}`);
1061
+ errors.push(`Research ## Introduction must contain a "Question:" line: ${toDisplayPath(filePath)} [_core-adr-policy-006]`);
1028
1062
  }
1029
1063
  }
1030
1064
 
@@ -1039,7 +1073,7 @@ function lintRequiredSections(content, filePath, requiredSections, docType, erro
1039
1073
  const ignoredLines = findIgnoredMarkdownLines(lines);
1040
1074
  for (const section of requiredSections) {
1041
1075
  if (findHeadingLine(lines, ignoredLines, section) === -1) {
1042
- errors.push(`${docType} is missing required section "${section}": ${toDisplayPath(filePath)}`);
1076
+ errors.push(`${docType} is missing required section "${section}": ${toDisplayPath(filePath)} ${DOC_TYPE_POLICY_REF[docType] || ''}`);
1043
1077
  }
1044
1078
  }
1045
1079
  }
@@ -1050,17 +1084,17 @@ function lintPlanExpectedEndDate(content, filePath, errors) {
1050
1084
  const expectedEndDateLines = findFieldLines(lines, ignoredLines, 'Expected end date:');
1051
1085
 
1052
1086
  if (expectedEndDateLines.length === 0) {
1053
- errors.push(`Plan must include an Expected end date: field: ${toDisplayPath(filePath)}`);
1087
+ errors.push(`Plan must include an Expected end date: field: ${toDisplayPath(filePath)} [_core-adr-policy-007]`);
1054
1088
  return;
1055
1089
  }
1056
1090
 
1057
1091
  if (expectedEndDateLines.length > 1) {
1058
- errors.push(`Plan must not repeat Expected end date: ${toDisplayPath(filePath)}`);
1092
+ errors.push(`Plan must not repeat Expected end date: ${toDisplayPath(filePath)} [_core-adr-policy-007]`);
1059
1093
  }
1060
1094
 
1061
1095
  const value = lines[expectedEndDateLines[0]].slice('Expected end date:'.length).trim().replace(/\.$/, '');
1062
1096
  if (!isIsoDate(value)) {
1063
- errors.push(`Plan Expected end date: must be a valid ISO date in YYYY-MM-DD format: ${toDisplayPath(filePath)}`);
1097
+ errors.push(`Plan Expected end date: must be a valid ISO date in YYYY-MM-DD format: ${toDisplayPath(filePath)} [_core-adr-policy-007]`);
1064
1098
  }
1065
1099
  }
1066
1100
 
@@ -1075,12 +1109,12 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors, externalScopes =
1075
1109
  for (const linkPath of localLinks) {
1076
1110
  if (!fs.existsSync(linkPath)) {
1077
1111
  if (isExternalScopeLink(linkPath, xdrsRoot, externalScopes)) continue;
1078
- errors.push(`Broken link in canonical index ${toDisplayPath(indexPath)}: ${displayPath(indexPath, linkPath)}`);
1112
+ errors.push(`Broken link in canonical index ${toDisplayPath(indexPath)}: ${displayPath(indexPath, linkPath)} [_core-adr-policy-001]`);
1079
1113
  continue;
1080
1114
  }
1081
1115
 
1082
1116
  if (scopeName !== '_local' && (isPathInside(localScopePath, linkPath) || normalizePath(linkPath) === localScopePath)) {
1083
- errors.push(`Non-_local document must not link into _local scope: ${displayPath(indexPath, linkPath)}`);
1117
+ errors.push(`Non-_local document must not link into _local scope: ${displayPath(indexPath, linkPath)} [_core-adr-policy-001]`);
1084
1118
  }
1085
1119
 
1086
1120
  linkedSet.add(normalizePath(linkPath));
@@ -1088,7 +1122,7 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors, externalScopes =
1088
1122
 
1089
1123
  for (const artifactPath of artifacts) {
1090
1124
  if (!linkedSet.has(normalizePath(artifactPath))) {
1091
- errors.push(`Canonical index ${toDisplayPath(indexPath)} is missing an entry for ${toDisplayPath(artifactPath)}`);
1125
+ errors.push(`Canonical index ${toDisplayPath(indexPath)} is missing an entry for ${toDisplayPath(artifactPath)} [_core-adr-policy-001]`);
1092
1126
  }
1093
1127
  }
1094
1128
  }
@@ -1120,7 +1154,7 @@ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
1120
1154
 
1121
1155
  for (const assetPath of assetTree.files) {
1122
1156
  if (!referencedAssets.has(assetPath)) {
1123
- errors.push(`Orphan asset file not referenced by any document: ${toDisplayPath(assetPath)}`);
1157
+ errors.push(`Orphan asset file not referenced by any document: ${toDisplayPath(assetPath)} [_core-adr-policy-001]`);
1124
1158
  }
1125
1159
  }
1126
1160
 
@@ -1137,23 +1171,23 @@ function lintSlideFile(filePath, documentPaths, xdrsRoot, errors) {
1137
1171
  const fileName = path.basename(filePath);
1138
1172
 
1139
1173
  if (fileName !== fileName.toLowerCase()) {
1140
- errors.push(`Slide file name must be lowercase: ${toDisplayPath(filePath)}`);
1174
+ errors.push(`Slide file name must be lowercase: ${toDisplayPath(filePath)} [_core-adr-policy-009]`);
1141
1175
  }
1142
1176
 
1143
1177
  if (fileName.length > SLIDE_MAX_NAME_LENGTH) {
1144
- errors.push(`Slide file name must be ${SLIDE_MAX_NAME_LENGTH} characters or fewer: ${toDisplayPath(filePath)}`);
1178
+ errors.push(`Slide file name must be ${SLIDE_MAX_NAME_LENGTH} characters or fewer: ${toDisplayPath(filePath)} [_core-adr-policy-009]`);
1145
1179
  }
1146
1180
 
1147
1181
  const content = fs.readFileSync(filePath, 'utf8');
1148
1182
 
1149
1183
  const fm = extractFrontmatter(content);
1150
1184
  if (!fm.present) {
1151
- errors.push(`Slide file must start with a YAML frontmatter block containing marp: true: ${toDisplayPath(filePath)}`);
1185
+ errors.push(`Slide file must start with a YAML frontmatter block containing marp: true: ${toDisplayPath(filePath)} [_core-adr-policy-009]`);
1152
1186
  } else {
1153
1187
  const frontmatterBody = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)[1];
1154
1188
  const firstKey = frontmatterBody.trimStart().split(/\r?\n/)[0];
1155
1189
  if (!/^marp:\s*true$/.test(firstKey)) {
1156
- errors.push(`Slide frontmatter must include marp: true as the first key: ${toDisplayPath(filePath)}`);
1190
+ errors.push(`Slide frontmatter must include marp: true as the first key: ${toDisplayPath(filePath)} [_core-adr-policy-009]`);
1157
1191
  }
1158
1192
  }
1159
1193
 
@@ -1167,7 +1201,18 @@ function lintSlideFile(filePath, documentPaths, xdrsRoot, errors) {
1167
1201
 
1168
1202
  const linksToParent = documentPaths.some((docPath) => slideLinkSet.has(normalizePath(docPath)));
1169
1203
  if (!linksToParent) {
1170
- errors.push(`Slide file must contain a link back to its parent document: ${toDisplayPath(filePath)}`);
1204
+ errors.push(`Slide file must contain a link back to its parent document: ${toDisplayPath(filePath)} [_core-adr-policy-009]`);
1205
+ }
1206
+
1207
+ // Check that at least one parent document links to this slide file
1208
+ const parentLinksToSlide = documentPaths.some((docPath) => {
1209
+ if (!existsFile(docPath)) return false;
1210
+ const docContent = fs.readFileSync(docPath, 'utf8');
1211
+ const docLinks = parseLocalLinks(docContent, path.dirname(docPath), repoRoot);
1212
+ return docLinks.some((l) => normalizePath(l) === normalizePath(filePath));
1213
+ });
1214
+ if (!parentLinksToSlide) {
1215
+ errors.push(`Parent document must contain a link to its slide file: ${toDisplayPath(filePath)} [_core-adr-policy-009]`);
1171
1216
  }
1172
1217
  }
1173
1218
 
@@ -1212,25 +1257,25 @@ function lintDocumentLinks(documentPath, xdrsRoot, scopeName, errors, externalSc
1212
1257
  const isResourceLink = shouldValidateResourceLink(link.rawTarget);
1213
1258
 
1214
1259
  if (!isResourceLink && link.isAbsolutePath) {
1215
- errors.push(`Absolute path links are not allowed; use relative paths in ${toDisplayPath(documentPath)}:${index + 1}: ${link.rawTarget}`);
1260
+ errors.push(`Absolute path links are not allowed; use relative paths in ${toDisplayPath(documentPath)}:${index + 1}: ${link.rawTarget} [_core-adr-policy-001]`);
1216
1261
  }
1217
1262
 
1218
1263
  if (!fs.existsSync(link.resolvedPath)) {
1219
1264
  if (isExternalScopeLink(link.resolvedPath, xdrsRoot, externalScopes)) continue;
1220
1265
  if (isResourceLink) {
1221
- errors.push(`Broken asset link in ${toDisplayPath(documentPath)}: ${link.rawTarget}`);
1266
+ errors.push(`Broken asset link in ${toDisplayPath(documentPath)}: ${link.rawTarget} [_core-adr-policy-001]`);
1222
1267
  } else {
1223
- errors.push(`Broken local link in ${toDisplayPath(documentPath)}:${index + 1}: ${link.rawTarget}`);
1268
+ errors.push(`Broken local link in ${toDisplayPath(documentPath)}:${index + 1}: ${link.rawTarget} [_core-adr-policy-001]`);
1224
1269
  }
1225
1270
  continue;
1226
1271
  }
1227
1272
 
1228
1273
  if (scopeName !== '_local' && (isPathInside(localScopePath, link.resolvedPath) || normalizePath(link.resolvedPath) === localScopePath)) {
1229
- errors.push(`Non-_local document must not link into _local scope in ${toDisplayPath(documentPath)}:${index + 1}: ${link.rawTarget}`);
1274
+ errors.push(`Non-_local document must not link into _local scope in ${toDisplayPath(documentPath)}:${index + 1}: ${link.rawTarget} [_core-adr-policy-001]`);
1230
1275
  }
1231
1276
 
1232
1277
  if (isResourceLink && !isPathInside(resourceDir, link.resolvedPath)) {
1233
- errors.push(`Asset links in ${toDisplayPath(documentPath)} must point to ${toDisplayPath(resourceDir)}: ${link.rawTarget}`);
1278
+ errors.push(`Asset links in ${toDisplayPath(documentPath)} must point to ${toDisplayPath(resourceDir)}: ${link.rawTarget} [_core-adr-policy-001]`);
1234
1279
  }
1235
1280
  }
1236
1281
  }
@@ -1553,6 +1598,98 @@ function isPathInside(parentPath, childPath) {
1553
1598
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
1554
1599
  }
1555
1600
 
1601
+ function lintStructuredRuleBlocks(content, filePath, errors) {
1602
+ const body = stripFrontmatter(content);
1603
+ const lines = body.split(/\r?\n/);
1604
+ const ignoredLines = findIgnoredMarkdownLines(lines);
1605
+
1606
+ const ruleNumbers = new Map(); // number -> line index
1607
+ const ruleEntries = [];
1608
+
1609
+ for (let i = 0; i < lines.length; i++) {
1610
+ if (ignoredLines[i]) continue;
1611
+ const m = lines[i].match(/^#### (\d+)-(.*)$/);
1612
+ if (!m) continue;
1613
+ const number = m[1];
1614
+ const title = m[2].trim();
1615
+ ruleEntries.push({ lineIndex: i, number, title });
1616
+ }
1617
+
1618
+ for (let r = 0; r < ruleEntries.length; r++) {
1619
+ const { lineIndex, number, title } = ruleEntries[r];
1620
+
1621
+ if (!/^\d{2}$/.test(number)) {
1622
+ errors.push(`Policy structured rule number must be exactly two digits ("${number}" in "${lines[lineIndex].trim()}"): ${toDisplayPath(filePath)} [_core-adr-policy-008.02-rule-numbering-must-be-stable]`);
1623
+ }
1624
+
1625
+ if (title.length === 0 || !/^[a-z][a-z0-9-]*$/.test(title)) {
1626
+ errors.push(`Policy structured rule title must be in kebab-case ("${title}"): ${toDisplayPath(filePath)} [_core-adr-policy-008.01-always-use-numbered-rules-for-strong-or-referenceable-policies]`);
1627
+ }
1628
+
1629
+ const wordCount = title.split('-').filter(Boolean).length;
1630
+ if (wordCount > 12) {
1631
+ errors.push(`Policy structured rule title must be 12 words or fewer ("${title}"): ${toDisplayPath(filePath)} [_core-adr-policy-008.01-always-use-numbered-rules-for-strong-or-referenceable-policies]`);
1632
+ }
1633
+
1634
+ if (ruleNumbers.has(number)) {
1635
+ errors.push(`Duplicate structured rule number "${number}" in Policy: ${toDisplayPath(filePath)} [_core-adr-policy-008.02-rule-numbering-must-be-stable]`);
1636
+ } else {
1637
+ ruleNumbers.set(number, lineIndex);
1638
+ }
1639
+
1640
+ const bodyStart = lineIndex + 1;
1641
+ let bodyEnd = lines.length;
1642
+ for (let i = bodyStart; i < lines.length; i++) {
1643
+ if (!ignoredLines[i] && /^#{1,4}/.test(lines[i])) {
1644
+ bodyEnd = i;
1645
+ break;
1646
+ }
1647
+ }
1648
+ const ruleBodyText = lines.slice(bodyStart, bodyEnd).join('\n');
1649
+
1650
+ if (!NORMATIVE_KEYWORDS_RE.test(ruleBodyText)) {
1651
+ errors.push(`Policy structured rule body must contain normative language (MUST/SHOULD/MAY) in "${number}-${title}": ${toDisplayPath(filePath)} [_core-adr-policy-008.03-rule-body-must-use-normative-language]`);
1652
+ }
1653
+ }
1654
+ }
1655
+
1656
+ function lintResearchSectionWordLimits(content, filePath, errors) {
1657
+ const lines = content.split(/\r?\n/);
1658
+ const ignoredLines = findIgnoredMarkdownLines(lines);
1659
+
1660
+ const sectionStarts = {};
1661
+ for (let i = 0; i < lines.length; i++) {
1662
+ if (ignoredLines[i]) continue;
1663
+ const trimmed = lines[i].trim();
1664
+ for (const sectionName of Object.keys(RESEARCH_SECTION_LIMITS)) {
1665
+ if (trimmed === sectionName) {
1666
+ sectionStarts[sectionName] = i;
1667
+ }
1668
+ }
1669
+ }
1670
+
1671
+ for (const sectionName of Object.keys(RESEARCH_SECTION_LIMITS)) {
1672
+ const startLine = sectionStarts[sectionName];
1673
+ if (startLine === undefined) continue;
1674
+
1675
+ let endLine = lines.length;
1676
+ for (let i = startLine + 1; i < lines.length; i++) {
1677
+ if (!ignoredLines[i] && /^## /.test(lines[i].trim())) {
1678
+ endLine = i;
1679
+ break;
1680
+ }
1681
+ }
1682
+
1683
+ const sectionText = lines.slice(startLine + 1, endLine).join(' ');
1684
+ const wordCount = countWords(sectionText);
1685
+ const limit = RESEARCH_SECTION_LIMITS[sectionName];
1686
+
1687
+ if (wordCount > limit) {
1688
+ errors.push(`Research ${sectionName} section exceeds ${limit} words (${wordCount} words): ${toDisplayPath(filePath)} [_core-adr-policy-006]`);
1689
+ }
1690
+ }
1691
+ }
1692
+
1556
1693
  module.exports = {
1557
1694
  runLintCli,
1558
1695
  lintWorkspace