xdrs-core 0.49.1 → 0.51.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
@@ -25,10 +25,12 @@ const SKILL_DIR_RE = /^[a-z0-9-]+$/;
25
25
  const REQUIRED_ROOT_INDEX_TEXT = 'XDRS scopes listed last override the ones listed first';
26
26
  const SUBJECT_ARTIFACT_DIRS = new Set(['skills', 'articles', 'researches', 'initiatives']);
27
27
  const RESOURCE_DIR_NAME = '.assets';
28
- const SKILL_PACKAGE_OPTIONAL_DIRS = new Set(['scripts', 'references', RESOURCE_DIR_NAME]);
28
+ const SOURCE_SNAPSHOT_DIR_NAME = 'sources';
29
+ const SOURCE_TRACKING_FILE_NAME = 'source.md';
30
+ const SKILL_PACKAGE_OPTIONAL_DIRS = new Set(['scripts', 'references', RESOURCE_DIR_NAME, 'dist']);
29
31
 
30
32
  const POLICY_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'freeze-reference']);
31
- const SKILL_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'license', 'metadata', 'compatibility', 'allowed-tools']);
33
+ const SKILL_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'license', 'metadata']);
32
34
  const SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS = new Set(['scope-type', 'name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'follows', 'extends']);
33
35
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
34
36
  const SLIDE_FILE_RE = /^.+-slides(?:-[a-z0-9-]+)?\.md$/;
@@ -37,7 +39,14 @@ const EMOJI_RE = /\p{Extended_Pictographic}/u;
37
39
  const POLICY_MAX_WORDS = 2600;
38
40
  const ARTICLE_MAX_WORDS = 8000;
39
41
  const RESEARCH_MAX_WORDS = 5000;
40
- const SKILL_MAX_WORDS = 6500;
42
+ const SKILL_MAX_WORDS = 7000;
43
+ const REFERENCE_MAX_WORDS = 4000;
44
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
45
+ const SKILL_OPTIONAL_RUNTIME_SECTION = '### Runtime Requirements';
46
+ const SKILL_OPTIONAL_USER_INTERACTION_SECTION = '### User Interaction';
47
+ const SKILL_ANTI_PATTERNS_MIN_ENTRIES = 3;
48
+ const SKILL_BUNDLING_MAKEFILE_TARGETS = ['build', 'test', 'clean'];
49
+ const SCRIPT_HEADER_RE = /^(#!|#|\/\/|\/\*|<!--|--|;)/;
41
50
 
42
51
  // Subject number block ranges per _core-adr-policy-017
43
52
  const SUBJECT_NUMBER_RANGES = {
@@ -73,6 +82,7 @@ const DOC_TYPE_POLICY_REF = {
73
82
  Research: '[_core-adr-policy-006]',
74
83
  Initiative: '[_core-adr-policy-007]',
75
84
  Slide: '[_core-adr-policy-009]',
85
+ Reference: '[_core-adr-policy-003]',
76
86
  };
77
87
 
78
88
  function runLintCli(args) {
@@ -762,19 +772,58 @@ function lintLocalMetaPolicies(xdrsRoot, scopeName, errors, isCompiledScope = fa
762
772
  errors.push(`Local meta-policy companion "${path.basename(companion.filePath)}" requires a primary "NNN-core.md" in the same type folder "${typeName}": ${toDisplayPath(companion.filePath)} [_core-adr-policy-010.10b-local-primary-required]`);
763
773
  }
764
774
  }
765
- // For compiled scopes: primary meta-policy must include ## Sources and ## Selectors
775
+ // For compiled scopes: primary meta-policy must include ## Sources, ## Selectors, and ## Sync Settings
766
776
  if (isCompiledScope && primaryInFolder) {
767
777
  let metaContent;
768
778
  try { metaContent = fs.readFileSync(primaryInFolder.filePath, 'utf8'); } catch { /* will be caught elsewhere */ }
769
779
  if (metaContent !== undefined) {
770
780
  const metaLines = metaContent.split(/\r?\n/);
771
781
  const metaIgnored = findIgnoredMarkdownLines(metaLines);
772
- if (findHeadingLine(metaLines, metaIgnored, '## Sources') === -1) {
782
+ const metaHeadingLines = findHeadingLines(metaLines, metaIgnored);
783
+
784
+ const sourcesHeadingIdx = findHeadingLine(metaLines, metaIgnored, '## Sources');
785
+ if (sourcesHeadingIdx === -1) {
773
786
  errors.push(`Compiled scope meta-policy must include a "## Sources" section: ${toDisplayPath(primaryInFolder.filePath)} [_core-adr-policy-019.07-compilation-meta-policy]`);
787
+ } else {
788
+ const [sourcesStart, sourcesEnd] = sectionBodyRange(metaLines, metaHeadingLines, sourcesHeadingIdx);
789
+ for (let i = sourcesStart; i < sourcesEnd; i += 1) {
790
+ if (metaIgnored[i]) continue;
791
+ const trimmed = metaLines[i].trim();
792
+ if (!trimmed.startsWith('-')) continue;
793
+ if (!/^-\s+\[(web|git|local)\]\s+[a-z0-9-]+:\s+.+$/.test(trimmed)) {
794
+ 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]`);
795
+ }
796
+ }
774
797
  }
798
+
775
799
  if (findHeadingLine(metaLines, metaIgnored, '## Selectors') === -1) {
776
800
  errors.push(`Compiled scope meta-policy must include a "## Selectors" section: ${toDisplayPath(primaryInFolder.filePath)} [_core-adr-policy-019.07-compilation-meta-policy]`);
777
801
  }
802
+
803
+ const syncHeadingIdx = findHeadingLine(metaLines, metaIgnored, '## Sync Settings');
804
+ if (syncHeadingIdx === -1) {
805
+ errors.push(`Compiled scope meta-policy must include a "## Sync Settings" section: ${toDisplayPath(primaryInFolder.filePath)} [_core-adr-policy-019.07-compilation-meta-policy]`);
806
+ } else {
807
+ const [syncStart, syncEnd] = sectionBodyRange(metaLines, metaHeadingLines, syncHeadingIdx);
808
+ let hasValidResyncPeriod = false;
809
+ for (let i = syncStart; i < syncEnd; i += 1) {
810
+ if (metaIgnored[i]) continue;
811
+ const trimmed = metaLines[i].trim();
812
+ if (!trimmed.startsWith('-')) continue;
813
+ const periodMatch = trimmed.match(/^-\s+Source re-sync period days:\s*(\d+)\s*$/);
814
+ if (periodMatch) {
815
+ hasValidResyncPeriod = Number(periodMatch[1]) > 0;
816
+ continue;
817
+ }
818
+ const storageMatch = trimmed.match(/^-\s+Source storage:\s*(.+)$/);
819
+ if (storageMatch && storageMatch[1].trim() !== 'temporary') {
820
+ 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]`);
821
+ }
822
+ }
823
+ if (!hasValidResyncPeriod) {
824
+ 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]`);
825
+ }
826
+ }
778
827
  }
779
828
  }
780
829
  // Check qualifier uniqueness across all type folders
@@ -845,7 +894,7 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNu
845
894
  continue;
846
895
  }
847
896
  if (entry.name === 'skills') {
848
- artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, externalScopes));
897
+ artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, entryPath, errors, externalScopes));
849
898
  continue;
850
899
  }
851
900
  if (entry.name === 'articles') {
@@ -876,7 +925,7 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNu
876
925
 
877
926
  const subjectAssetsDir = path.join(subjectPath, RESOURCE_DIR_NAME);
878
927
  const xdrsDocsInSubject = artifacts.filter((p) => path.dirname(p) === subjectPath);
879
- lintOrphanAssets(subjectAssetsDir, xdrsDocsInSubject, xdrsRoot, errors);
928
+ lintOrphanAssets(subjectAssetsDir, xdrsDocsInSubject, xdrsRoot, errors, isCompiledScope);
880
929
 
881
930
  return artifacts;
882
931
  }
@@ -1010,9 +1059,9 @@ function lintXdrsElementFrontmatter(content, expectedName, filePath, errors, war
1010
1059
  }
1011
1060
  }
1012
1061
 
1013
- function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors, externalScopes = new Set()) {
1062
+ function lintSkillsDirectory(xdrsRoot, scopeName, skillsPath, errors, externalScopes = new Set()) {
1014
1063
  const artifacts = [];
1015
- const entries = safeReadDir(skillsPath, errors, `read skills directory ${scopeName}/${typeName}/${subjectName}/skills`);
1064
+ const entries = safeReadDir(skillsPath, errors, `read skills directory ${toDisplayPath(skillsPath)}`);
1016
1065
 
1017
1066
  for (const entry of entries) {
1018
1067
  const entryPath = path.join(skillsPath, entry.name);
@@ -1041,10 +1090,30 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
1041
1090
  for (const packageEntry of packageEntries) {
1042
1091
  const packageEntryPath = path.join(entryPath, packageEntry.name);
1043
1092
 
1093
+ if (packageEntry.isFile() && packageEntry.name === 'Makefile') {
1094
+ lintSkillBundlingMakefile(packageEntryPath, entryPath, xdrsRoot, errors);
1095
+ }
1096
+
1044
1097
  if (!packageEntry.isDirectory()) {
1045
1098
  continue;
1046
1099
  }
1047
1100
 
1101
+ if (packageEntry.name === 'references') {
1102
+ const referenceEntries = safeReadDir(packageEntryPath, errors, `read references directory ${toDisplayPath(packageEntryPath)}`);
1103
+ for (const referenceEntry of referenceEntries) {
1104
+ if (referenceEntry.isDirectory() || !referenceEntry.name.endsWith('.md')) {
1105
+ continue;
1106
+ }
1107
+ const referencePath = path.join(packageEntryPath, referenceEntry.name);
1108
+ const referenceContent = fs.readFileSync(referencePath, 'utf8');
1109
+ lintWordCount(referenceContent, referencePath, 'Reference', REFERENCE_MAX_WORDS, errors);
1110
+ }
1111
+ }
1112
+
1113
+ if (packageEntry.name === 'scripts') {
1114
+ lintSkillScriptHeaders(packageEntryPath, errors);
1115
+ }
1116
+
1048
1117
  if (SKILL_PACKAGE_OPTIONAL_DIRS.has(packageEntry.name)) {
1049
1118
  continue;
1050
1119
  }
@@ -1061,6 +1130,11 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
1061
1130
 
1062
1131
  const skillContent = fs.readFileSync(skillFilePath, 'utf8');
1063
1132
  lintRequiredSections(skillContent, skillFilePath, SKILL_REQUIRED_SECTIONS, 'Skill', errors);
1133
+ lintSkillAntiPatternsCount(skillContent, skillFilePath, errors);
1134
+ lintSkillNestedSubsections(skillContent, skillFilePath, errors);
1135
+ lintSkillItemListWordCounts(skillContent, skillFilePath, errors);
1136
+ lintSkillTopLevelSectionOrder(skillContent, skillFilePath, errors);
1137
+ lintSkillOverviewSubsections(skillContent, skillFilePath, errors);
1064
1138
  const skillFm = extractFrontmatter(skillContent);
1065
1139
  if (!skillFm.present) {
1066
1140
  errors.push(`SKILL.md must start with a YAML frontmatter block: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
@@ -1085,6 +1159,7 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
1085
1159
  errors.push(`SKILL.md frontmatter has unknown field "${key}": ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1086
1160
  }
1087
1161
  }
1162
+ lintSkillMetadata(skillFm.metadata, skillFilePath, errors);
1088
1163
  }
1089
1164
 
1090
1165
  lintNoEmojis(skillContent, skillFilePath, 'Skill', errors);
@@ -1096,6 +1171,381 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
1096
1171
  return artifacts;
1097
1172
  }
1098
1173
 
1174
+ /**
1175
+ * Validates the required `metadata` block of a SKILL.md frontmatter per _core-adr-policy-003:
1176
+ * version, author, and updated are all required fields.
1177
+ */
1178
+ function lintSkillMetadata(metadata, skillFilePath, errors) {
1179
+ if (!metadata || !metadata.present) {
1180
+ errors.push(`SKILL.md frontmatter must include a metadata block: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1181
+ return;
1182
+ }
1183
+
1184
+ const fields = metadata.fields;
1185
+
1186
+ if (!fields.version) {
1187
+ errors.push(`SKILL.md frontmatter metadata must include a non-empty version field: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1188
+ } else if (!SEMVER_RE.test(fields.version)) {
1189
+ errors.push(`SKILL.md frontmatter metadata.version must use full semantic versioning (MAJOR.MINOR.PATCH): ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1190
+ }
1191
+
1192
+ if (!fields.author) {
1193
+ errors.push(`SKILL.md frontmatter metadata must include a non-empty author field: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1194
+ }
1195
+
1196
+ if (!fields.updated) {
1197
+ errors.push(`SKILL.md frontmatter metadata must include a non-empty updated field: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1198
+ } else if (!isIsoDate(fields.updated)) {
1199
+ errors.push(`SKILL.md frontmatter metadata.updated must be a valid ISO date YYYY-MM-DD: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1200
+ }
1201
+ }
1202
+
1203
+ /**
1204
+ * Returns the [start, end) body line range for a top-level "##" section given its heading
1205
+ * line index and the file's full list of "##" heading line indices.
1206
+ */
1207
+ function sectionBodyRange(lines, headingLines, headingIndex) {
1208
+ const nextHeadingIndex = headingLines.find((index) => index > headingIndex);
1209
+ return [headingIndex + 1, nextHeadingIndex === undefined ? lines.length : nextHeadingIndex];
1210
+ }
1211
+
1212
+ /**
1213
+ * Finds the first line within [start, end) whose trimmed text exactly matches headingText.
1214
+ */
1215
+ function findHeadingLineInRange(lines, ignoredLines, headingText, start, end) {
1216
+ for (let index = start; index < end; index += 1) {
1217
+ if (ignoredLines[index]) continue;
1218
+ if (lines[index].trim() === headingText) {
1219
+ return index;
1220
+ }
1221
+ }
1222
+ return -1;
1223
+ }
1224
+
1225
+ /**
1226
+ * Finds all "###"-prefixed heading line indices within [start, end).
1227
+ */
1228
+ function findSubHeadingLines(lines, ignoredLines, start, end) {
1229
+ const result = [];
1230
+ for (let index = start; index < end; index += 1) {
1231
+ if (ignoredLines[index]) continue;
1232
+ if (/^###\s+/.test(lines[index].trim())) {
1233
+ result.push(index);
1234
+ }
1235
+ }
1236
+ return result;
1237
+ }
1238
+
1239
+ /**
1240
+ * Finds all "####"-prefixed heading line indices within [start, end).
1241
+ */
1242
+ function findSubSubHeadingLines(lines, ignoredLines, start, end) {
1243
+ const result = [];
1244
+ for (let index = start; index < end; index += 1) {
1245
+ if (ignoredLines[index]) continue;
1246
+ if (/^####\s+/.test(lines[index].trim())) {
1247
+ result.push(index);
1248
+ }
1249
+ }
1250
+ return result;
1251
+ }
1252
+
1253
+ /**
1254
+ * Validates that "### Inputs" contains "#### Required" then "#### Optional", and that
1255
+ * "### Outputs" contains "#### Contents" then "#### Changes", per _core-adr-policy-003. Both
1256
+ * parents are nested inside "## Overview". Skips silently when "## Overview" or a parent
1257
+ * subsection itself is missing (already reported elsewhere).
1258
+ */
1259
+ function lintSkillNestedSubsections(content, filePath, errors) {
1260
+ const lines = content.split(/\r?\n/);
1261
+ const ignoredLines = findIgnoredMarkdownLines(lines);
1262
+ const headingLines = findHeadingLines(lines, ignoredLines);
1263
+
1264
+ const overviewIndex = findHeadingLine(lines, ignoredLines, '## Overview');
1265
+ if (overviewIndex === -1) return;
1266
+ const [overviewStart, overviewEnd] = sectionBodyRange(lines, headingLines, overviewIndex);
1267
+ const subHeadingLines = findSubHeadingLines(lines, ignoredLines, overviewStart, overviewEnd);
1268
+
1269
+ function checkPair(parentHeadingText, firstSub, secondSub) {
1270
+ const parentIndex = findHeadingLineInRange(lines, ignoredLines, parentHeadingText, overviewStart, overviewEnd);
1271
+ if (parentIndex === -1) return;
1272
+
1273
+ const nextParent = subHeadingLines.find((index) => index > parentIndex);
1274
+ const parentEnd = nextParent === undefined ? overviewEnd : nextParent;
1275
+ const firstIndex = findHeadingLineInRange(lines, ignoredLines, firstSub, parentIndex + 1, parentEnd);
1276
+ const secondIndex = findHeadingLineInRange(lines, ignoredLines, secondSub, parentIndex + 1, parentEnd);
1277
+
1278
+ if (firstIndex === -1) {
1279
+ errors.push(`Skill "${parentHeadingText}" section is missing "${firstSub}": ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1280
+ }
1281
+ if (secondIndex === -1) {
1282
+ errors.push(`Skill "${parentHeadingText}" section is missing "${secondSub}": ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1283
+ }
1284
+ if (firstIndex !== -1 && secondIndex !== -1 && secondIndex < firstIndex) {
1285
+ errors.push(`Skill "${parentHeadingText}" subsections must appear in order (${firstSub}, ${secondSub}): ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1286
+ }
1287
+ }
1288
+
1289
+ checkPair('### Inputs', '#### Required', '#### Optional');
1290
+ checkPair('### Outputs', '#### Contents', '#### Changes');
1291
+ }
1292
+
1293
+ /**
1294
+ * Validates a bullet-list section's item shape: either a single "None" bullet, or one-or-more
1295
+ * bullets each under 10 words, per _core-adr-policy-003. No-op when the range has no bullets
1296
+ * at all (an empty required section is reported by other checks).
1297
+ */
1298
+ function lintBulletWordCountSection(lines, ignoredLines, start, end, label, filePath, errors) {
1299
+ const bullets = [];
1300
+ for (let i = start; i < end; i += 1) {
1301
+ if (ignoredLines[i]) continue;
1302
+ const trimmed = lines[i].trim();
1303
+ if (!trimmed) continue;
1304
+ const bulletMatch = trimmed.match(/^-\s+(.*)$/);
1305
+ if (bulletMatch) {
1306
+ bullets.push(bulletMatch[1].trim());
1307
+ }
1308
+ }
1309
+
1310
+ if (bullets.length === 0) {
1311
+ return;
1312
+ }
1313
+
1314
+ if (bullets.length === 1 && bullets[0].toLowerCase() === 'none') {
1315
+ return;
1316
+ }
1317
+
1318
+ for (const bullet of bullets) {
1319
+ const wordCount = countWords(bullet);
1320
+ if (wordCount >= 10) {
1321
+ errors.push(`${label} bullet must be under 10 words or a single "None" (found ${wordCount}): ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1322
+ }
1323
+ }
1324
+ }
1325
+
1326
+ /**
1327
+ * Validates the item word-count rule (single "None" bullet, or one-or-more sub-10-word
1328
+ * bullets) across "### Inputs" -> "#### Required"/"#### Optional", "### Outputs" ->
1329
+ * "#### Contents"/"#### Changes", "### Halt Conditions", and "### User Interaction" (only
1330
+ * when present, since it is optional). All 4 subsections live inside "## Overview", per
1331
+ * _core-adr-policy-003.
1332
+ */
1333
+ function lintSkillItemListWordCounts(content, filePath, errors) {
1334
+ const lines = content.split(/\r?\n/);
1335
+ const ignoredLines = findIgnoredMarkdownLines(lines);
1336
+ const headingLines = findHeadingLines(lines, ignoredLines);
1337
+
1338
+ const overviewIndex = findHeadingLine(lines, ignoredLines, '## Overview');
1339
+ if (overviewIndex === -1) return;
1340
+ const [overviewStart, overviewEnd] = sectionBodyRange(lines, headingLines, overviewIndex);
1341
+ const subHeadingLines = findSubHeadingLines(lines, ignoredLines, overviewStart, overviewEnd);
1342
+
1343
+ function subsectionRange(headingText) {
1344
+ const index = findHeadingLineInRange(lines, ignoredLines, headingText, overviewStart, overviewEnd);
1345
+ if (index === -1) return null;
1346
+ const next = subHeadingLines.find((i) => i > index);
1347
+ return [index + 1, next === undefined ? overviewEnd : next];
1348
+ }
1349
+
1350
+ function subSubsectionRange(parentHeadingText, subHeadingText) {
1351
+ const parentRange = subsectionRange(parentHeadingText);
1352
+ if (!parentRange) return null;
1353
+ const [parentStart, parentEnd] = parentRange;
1354
+ const subIndex = findHeadingLineInRange(lines, ignoredLines, subHeadingText, parentStart, parentEnd);
1355
+ if (subIndex === -1) return null;
1356
+ const subSubHeadingLines = findSubSubHeadingLines(lines, ignoredLines, parentStart, parentEnd);
1357
+ const nextSub = subSubHeadingLines.find((index) => index > subIndex);
1358
+ return [subIndex + 1, nextSub === undefined ? parentEnd : nextSub];
1359
+ }
1360
+
1361
+ const subsectionTargets = [
1362
+ { range: subSubsectionRange('### Inputs', '#### Required'), label: 'Skill "### Inputs" -> "#### Required"' },
1363
+ { range: subSubsectionRange('### Inputs', '#### Optional'), label: 'Skill "### Inputs" -> "#### Optional"' },
1364
+ { range: subSubsectionRange('### Outputs', '#### Contents'), label: 'Skill "### Outputs" -> "#### Contents"' },
1365
+ { range: subSubsectionRange('### Outputs', '#### Changes'), label: 'Skill "### Outputs" -> "#### Changes"' },
1366
+ ];
1367
+
1368
+ for (const target of subsectionTargets) {
1369
+ if (!target.range) continue; // missing heading already reported elsewhere
1370
+ lintBulletWordCountSection(lines, ignoredLines, target.range[0], target.range[1], target.label, filePath, errors);
1371
+ }
1372
+
1373
+ const haltRange = subsectionRange('### Halt Conditions');
1374
+ if (haltRange) {
1375
+ lintBulletWordCountSection(lines, ignoredLines, haltRange[0], haltRange[1], 'Skill "### Halt Conditions"', filePath, errors);
1376
+ }
1377
+
1378
+ const interactionRange = subsectionRange(SKILL_OPTIONAL_USER_INTERACTION_SECTION);
1379
+ if (interactionRange) {
1380
+ lintBulletWordCountSection(lines, ignoredLines, interactionRange[0], interactionRange[1], 'Skill "### User Interaction"', filePath, errors);
1381
+ }
1382
+ }
1383
+
1384
+ /**
1385
+ * Validates that all recognized top-level "##" sections present in the file appear in the
1386
+ * template order defined by _core-adr-policy-003. Missing sections are not an error here
1387
+ * (see lintRequiredSections and lintSkillOverviewSubsections for presence checks).
1388
+ */
1389
+ function lintSkillTopLevelSectionOrder(content, filePath, errors) {
1390
+ const lines = content.split(/\r?\n/);
1391
+ const ignoredLines = findIgnoredMarkdownLines(lines);
1392
+ const headingLines = findHeadingLines(lines, ignoredLines);
1393
+
1394
+ const presentKnownHeadings = headingLines
1395
+ .map((index) => lines[index].trim())
1396
+ .filter((headingText) => SKILL_TEMPLATE_SECTION_ORDER.includes(headingText));
1397
+
1398
+ for (let i = 1; i < presentKnownHeadings.length; i += 1) {
1399
+ const prevOrder = SKILL_TEMPLATE_SECTION_ORDER.indexOf(presentKnownHeadings[i - 1]);
1400
+ const currOrder = SKILL_TEMPLATE_SECTION_ORDER.indexOf(presentKnownHeadings[i]);
1401
+ if (currOrder < prevOrder) {
1402
+ errors.push(`Skill sections must appear in template order (${SKILL_TEMPLATE_SECTION_ORDER.join(', ')}): ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1403
+ break;
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ /**
1409
+ * Validates the "## Overview" section's nested subsections per _core-adr-policy-003:
1410
+ * "### Inputs", "### Outputs", and "### Halt Conditions" are required; "### User
1411
+ * Interaction" and "### Runtime Requirements" are optional but, when present, must be
1412
+ * non-empty. All recognized subsections found must appear in template order. No-op when
1413
+ * "## Overview" itself is missing (already reported by lintRequiredSections).
1414
+ */
1415
+ function lintSkillOverviewSubsections(content, filePath, errors) {
1416
+ const lines = content.split(/\r?\n/);
1417
+ const ignoredLines = findIgnoredMarkdownLines(lines);
1418
+ const headingLines = findHeadingLines(lines, ignoredLines);
1419
+
1420
+ const overviewIndex = findHeadingLine(lines, ignoredLines, '## Overview');
1421
+ if (overviewIndex === -1) return;
1422
+
1423
+ const [overviewStart, overviewEnd] = sectionBodyRange(lines, headingLines, overviewIndex);
1424
+ const subHeadingLines = findSubHeadingLines(lines, ignoredLines, overviewStart, overviewEnd);
1425
+ const presentSubsections = subHeadingLines
1426
+ .map((index) => lines[index].trim())
1427
+ .filter((headingText) => SKILL_OVERVIEW_SUBSECTION_ORDER.includes(headingText));
1428
+
1429
+ for (const required of SKILL_OVERVIEW_REQUIRED_SUBSECTIONS) {
1430
+ if (!presentSubsections.includes(required)) {
1431
+ errors.push(`Skill "## Overview" is missing required subsection "${required}": ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1432
+ }
1433
+ }
1434
+
1435
+ for (const section of [SKILL_OPTIONAL_USER_INTERACTION_SECTION, SKILL_OPTIONAL_RUNTIME_SECTION]) {
1436
+ const headingIndex = findHeadingLineInRange(lines, ignoredLines, section, overviewStart, overviewEnd);
1437
+ if (headingIndex === -1) continue;
1438
+
1439
+ const nextSub = subHeadingLines.find((index) => index > headingIndex);
1440
+ const bodyEnd = nextSub === undefined ? overviewEnd : nextSub;
1441
+ const hasContent = lines
1442
+ .slice(headingIndex + 1, bodyEnd)
1443
+ .some((line, offset) => !ignoredLines[headingIndex + 1 + offset] && line.trim().length > 0);
1444
+ if (!hasContent) {
1445
+ errors.push(`Skill section "${section}" must not be empty: ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1446
+ }
1447
+ }
1448
+
1449
+ for (let i = 1; i < presentSubsections.length; i += 1) {
1450
+ const prevOrder = SKILL_OVERVIEW_SUBSECTION_ORDER.indexOf(presentSubsections[i - 1]);
1451
+ const currOrder = SKILL_OVERVIEW_SUBSECTION_ORDER.indexOf(presentSubsections[i]);
1452
+ if (currOrder < prevOrder) {
1453
+ errors.push(`Skill "## Overview" subsections must appear in order (${SKILL_OVERVIEW_SUBSECTION_ORDER.join(', ')}): ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1454
+ break;
1455
+ }
1456
+ }
1457
+ }
1458
+
1459
+ /**
1460
+ * Validates the SKILL.md "## Anti-Patterns" section has at least the minimum number of
1461
+ * "- **Mistake:**" entries required by _core-adr-policy-003.
1462
+ */
1463
+ function lintSkillAntiPatternsCount(content, filePath, errors) {
1464
+ const lines = content.split(/\r?\n/);
1465
+ const ignoredLines = findIgnoredMarkdownLines(lines);
1466
+ const headingIndex = findHeadingLine(lines, ignoredLines, '## Anti-Patterns');
1467
+ if (headingIndex === -1) {
1468
+ return; // Missing section is already reported by lintRequiredSections.
1469
+ }
1470
+
1471
+ const headingLines = findHeadingLines(lines, ignoredLines);
1472
+ const nextHeadingIndex = headingLines.find((index) => index > headingIndex);
1473
+ const sectionEnd = nextHeadingIndex === undefined ? lines.length : nextHeadingIndex;
1474
+
1475
+ let mistakeCount = 0;
1476
+ for (let i = headingIndex + 1; i < sectionEnd; i += 1) {
1477
+ if (!ignoredLines[i] && /^-\s+\*\*Mistake:\*\*/.test(lines[i].trim())) {
1478
+ mistakeCount += 1;
1479
+ }
1480
+ }
1481
+
1482
+ if (mistakeCount < SKILL_ANTI_PATTERNS_MIN_ENTRIES) {
1483
+ errors.push(`Skill "## Anti-Patterns" section must include at least ${SKILL_ANTI_PATTERNS_MIN_ENTRIES} entries (found ${mistakeCount}): ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1484
+ }
1485
+ }
1486
+
1487
+ /**
1488
+ * Validates that each file inside a skill's scripts/ directory declares its runtime or
1489
+ * interpreter at the top (shebang or header comment) per _core-adr-policy-003.
1490
+ */
1491
+ function lintSkillScriptHeaders(scriptsPath, errors) {
1492
+ const entries = safeReadDir(scriptsPath, errors, `read scripts directory ${toDisplayPath(scriptsPath)}`);
1493
+ for (const entry of entries) {
1494
+ if (!entry.isFile()) {
1495
+ continue;
1496
+ }
1497
+ const scriptPath = path.join(scriptsPath, entry.name);
1498
+ const content = fs.readFileSync(scriptPath, 'utf8');
1499
+ if (!SCRIPT_HEADER_RE.test(firstNonEmptyLine(content))) {
1500
+ errors.push(`Script must declare its runtime/interpreter at the top (shebang or header comment): ${toDisplayPath(scriptPath)} [_core-adr-policy-003]`);
1501
+ }
1502
+ }
1503
+ }
1504
+
1505
+ /**
1506
+ * Validates an optional skill bundling Makefile per _core-adr-policy-021: it must expose
1507
+ * build/test/clean targets, and its dist/ output must be covered by a .gitignore.
1508
+ */
1509
+ function lintSkillBundlingMakefile(makefilePath, skillPackagePath, xdrsRoot, errors) {
1510
+ const content = fs.readFileSync(makefilePath, 'utf8');
1511
+ for (const target of SKILL_BUNDLING_MAKEFILE_TARGETS) {
1512
+ const targetRe = new RegExp(`^${target}\\s*:`, 'm');
1513
+ if (!targetRe.test(content)) {
1514
+ errors.push(`Skill bundling Makefile is missing required "${target}" target: ${toDisplayPath(makefilePath)} [_core-adr-policy-021.09-standard-targets]`);
1515
+ }
1516
+ }
1517
+
1518
+ const buildTargetMatch = content.match(/^build\s*:(.*)$/m);
1519
+ if (buildTargetMatch && !buildTargetMatch[1].trim().split(/\s+/).includes('clean')) {
1520
+ errors.push(`Skill bundling Makefile's "build" target must depend on "clean" (e.g. "build: clean"): ${toDisplayPath(makefilePath)} [_core-adr-policy-021.11-build-depends-on-clean]`);
1521
+ }
1522
+
1523
+ if (!isDistGitignoredForSkill(skillPackagePath, xdrsRoot)) {
1524
+ errors.push(`Skill bundling Makefile's "dist" output is not covered by any .gitignore (cover it in the repository root .gitignore or in a local .gitignore inside the skill package): ${toDisplayPath(makefilePath)} [_core-adr-policy-021.02-dist-output-location]`);
1525
+ }
1526
+ }
1527
+
1528
+ function gitignoreCoversDistAnyDepth(content) {
1529
+ return content.split(/\r?\n/).some((line) => {
1530
+ const trimmed = line.trim().replace(/\/$/, '');
1531
+ return trimmed === 'dist' || trimmed === '**/dist';
1532
+ });
1533
+ }
1534
+
1535
+ function isDistGitignoredForSkill(skillPackagePath, xdrsRoot) {
1536
+ const localGitignorePath = path.join(skillPackagePath, '.gitignore');
1537
+ if (existsFile(localGitignorePath) && gitignoreCoversDistAnyDepth(fs.readFileSync(localGitignorePath, 'utf8'))) {
1538
+ return true;
1539
+ }
1540
+
1541
+ const rootGitignorePath = path.join(path.dirname(xdrsRoot), '.gitignore');
1542
+ if (existsFile(rootGitignorePath) && gitignoreCoversDistAnyDepth(fs.readFileSync(rootGitignorePath, 'utf8'))) {
1543
+ return true;
1544
+ }
1545
+
1546
+ return false;
1547
+ }
1548
+
1099
1549
  function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, articlesPath, errors, externalScopes = new Set()) {
1100
1550
  const artifacts = [];
1101
1551
  const articleNumbers = new Map();
@@ -1347,7 +1797,23 @@ const XDRS_REQUIRED_SECTIONS = ['## Context and Problem Statement', '## Decision
1347
1797
  const ARTICLE_REQUIRED_SECTIONS = ['## Overview', '## Content', '## References'];
1348
1798
  const RESEARCH_REQUIRED_SECTIONS = ['## Abstract', '## Introduction', '## Methods', '## Results', '## Discussion', '## Conclusion', '## References'];
1349
1799
  const INITIATIVE_REQUIRED_SECTIONS = ['## Executive Summary', '## Context and Problem Statement', '## Proposed Solution'];
1350
- const SKILL_REQUIRED_SECTIONS = ['## Overview', '## Instructions'];
1800
+ const SKILL_REQUIRED_SECTIONS = ['## Overview', '## Instructions', '## Anti-Patterns'];
1801
+ const SKILL_TEMPLATE_SECTION_ORDER = [
1802
+ '## Overview',
1803
+ '## Instructions',
1804
+ '## Examples',
1805
+ '## Edge Cases',
1806
+ '## Anti-Patterns',
1807
+ '## References',
1808
+ ];
1809
+ const SKILL_OVERVIEW_REQUIRED_SUBSECTIONS = ['### Inputs', '### Outputs', '### Halt Conditions'];
1810
+ const SKILL_OVERVIEW_SUBSECTION_ORDER = [
1811
+ '### Inputs',
1812
+ '### Outputs',
1813
+ '### Halt Conditions',
1814
+ '### User Interaction',
1815
+ '### Runtime Requirements',
1816
+ ];
1351
1817
 
1352
1818
  function lintRequiredSections(content, filePath, requiredSections, docType, errors) {
1353
1819
  const lines = content.split(/\r?\n/);
@@ -1408,11 +1874,16 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors, externalScopes =
1408
1874
  }
1409
1875
  }
1410
1876
 
1411
- function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
1877
+ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors, isCompiledScope = false) {
1412
1878
  if (!existsDirectory(assetsDir)) {
1413
1879
  return;
1414
1880
  }
1415
1881
 
1882
+ const sourcesDir = path.join(assetsDir, SOURCE_SNAPSHOT_DIR_NAME);
1883
+ if (isCompiledScope) {
1884
+ lintSourceSnapshotTracking(sourcesDir, errors);
1885
+ }
1886
+
1416
1887
  const assetTree = collectAssetTree(assetsDir, errors);
1417
1888
  if (assetTree.files.length === 0 && assetTree.directories.length === 0) {
1418
1889
  return;
@@ -1434,6 +1905,9 @@ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
1434
1905
  }
1435
1906
 
1436
1907
  for (const assetPath of assetTree.files) {
1908
+ if (isCompiledScope && isPathInside(sourcesDir, assetPath)) {
1909
+ continue;
1910
+ }
1437
1911
  if (!referencedAssets.has(assetPath)) {
1438
1912
  errors.push(`Orphan asset file not referenced by any document: ${toDisplayPath(assetPath)} [_core-adr-policy-001]`);
1439
1913
  }
@@ -1441,6 +1915,9 @@ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
1441
1915
 
1442
1916
  // Lint slide/presentation files inside .assets
1443
1917
  for (const assetPath of assetTree.files) {
1918
+ if (isCompiledScope && isPathInside(sourcesDir, assetPath)) {
1919
+ continue;
1920
+ }
1444
1921
  const fileName = path.basename(assetPath);
1445
1922
  if (SLIDE_FILE_RE.test(fileName)) {
1446
1923
  lintSlideFile(assetPath, documentPaths, xdrsRoot, errors);
@@ -1448,6 +1925,47 @@ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
1448
1925
  }
1449
1926
  }
1450
1927
 
1928
+ function lintSourceSnapshotTracking(sourcesDir, errors) {
1929
+ if (!existsDirectory(sourcesDir)) {
1930
+ return;
1931
+ }
1932
+
1933
+ const entries = safeReadDir(sourcesDir, errors, `read source snapshot directory ${toDisplayPath(sourcesDir)}`);
1934
+ for (const entry of entries) {
1935
+ if (!entry.isDirectory()) {
1936
+ continue;
1937
+ }
1938
+ const trackingFilePath = path.join(sourcesDir, entry.name, SOURCE_TRACKING_FILE_NAME);
1939
+ if (!existsFile(trackingFilePath)) {
1940
+ 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]`);
1941
+ continue;
1942
+ }
1943
+ validateSourceTrackingFile(trackingFilePath, errors);
1944
+ }
1945
+ }
1946
+
1947
+ function validateSourceTrackingFile(filePath, errors) {
1948
+ const content = fs.readFileSync(filePath, 'utf8');
1949
+ const lines = content.split(/\r?\n/);
1950
+
1951
+ if (firstNonEmptyLine(content) !== '# Source') {
1952
+ errors.push(`Source tracking file must start with a "# Source" heading: ${toDisplayPath(filePath)} [_core-adr-policy-019.12-source-storage-tracking]`);
1953
+ }
1954
+
1955
+ const timestampRe = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
1956
+ for (const field of ['last-fetch-timestamp', 'last-compilation-timestamp']) {
1957
+ const fieldLine = lines.find((line) => line.trim().startsWith(`${field}:`));
1958
+ if (!fieldLine) {
1959
+ errors.push(`Source tracking file must include a "${field}:" line: ${toDisplayPath(filePath)} [_core-adr-policy-019.12-source-storage-tracking]`);
1960
+ continue;
1961
+ }
1962
+ const value = fieldLine.trim().slice(`${field}:`.length).trim();
1963
+ if (!timestampRe.test(value)) {
1964
+ errors.push(`Source tracking file "${field}" must match "YYYY-MM-DD HH:MM:SS": ${toDisplayPath(filePath)} [_core-adr-policy-019.12-source-storage-tracking]`);
1965
+ }
1966
+ }
1967
+ }
1968
+
1451
1969
  function lintSlideFile(filePath, documentPaths, xdrsRoot, errors) {
1452
1970
  const fileName = path.basename(filePath);
1453
1971
 
@@ -1558,6 +2076,10 @@ function lintDocumentLinks(documentPath, xdrsRoot, scopeName, errors, externalSc
1558
2076
  if (isResourceLink && !isPathInside(resourceDir, link.resolvedPath)) {
1559
2077
  errors.push(`Asset links in ${toDisplayPath(documentPath)} must point to ${toDisplayPath(resourceDir)}: ${link.rawTarget} [_core-adr-policy-001]`);
1560
2078
  }
2079
+
2080
+ if (isSourceSnapshotPath(link.resolvedPath)) {
2081
+ 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]`);
2082
+ }
1561
2083
  }
1562
2084
  }
1563
2085
  }
@@ -1644,6 +2166,11 @@ function shouldValidateResourceLink(rawTarget) {
1644
2166
  || IMAGE_EXTENSIONS.has(extension);
1645
2167
  }
1646
2168
 
2169
+ function isSourceSnapshotPath(resolvedPath) {
2170
+ const normalized = normalizePath(resolvedPath).split(path.sep).join('/');
2171
+ return normalized.includes(`/${RESOURCE_DIR_NAME}/${SOURCE_SNAPSHOT_DIR_NAME}/`);
2172
+ }
2173
+
1647
2174
  function extractDescriptionText(block) {
1648
2175
  const lines = block.split(/\r?\n/);
1649
2176
  for (let i = 0; i < lines.length; i++) {
@@ -1670,7 +2197,7 @@ function extractDescriptionText(block) {
1670
2197
  function extractFrontmatter(content) {
1671
2198
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
1672
2199
  if (!match) {
1673
- return { present: false, name: null, description: false, descriptionText: null, validFrom: undefined, appliedTo: undefined, topLevelKeys: [] };
2200
+ return { present: false, name: null, description: false, descriptionText: null, validFrom: undefined, appliedTo: undefined, topLevelKeys: [], metadata: { present: false, fields: {} } };
1674
2201
  }
1675
2202
  const block = match[1];
1676
2203
  const nameMatch = block.match(/^name:\s*(.+)$/m);
@@ -1683,6 +2210,7 @@ function extractFrontmatter(content) {
1683
2210
  }
1684
2211
  const freezeReferenceMatch = block.match(/^freeze-reference:\s*(.+)$/m);
1685
2212
  const descriptionText = extractDescriptionText(block);
2213
+ const metadata = extractMetadataBlock(block);
1686
2214
  return {
1687
2215
  present: true,
1688
2216
  name: nameMatch ? nameMatch[1].trim() || null : null,
@@ -1691,10 +2219,69 @@ function extractFrontmatter(content) {
1691
2219
  validFrom: validFromMatch ? validFromMatch[1].trim() : undefined,
1692
2220
  appliedTo: appliedToMatch ? appliedToMatch[1].trim() : undefined,
1693
2221
  freezeReference: freezeReferenceMatch ? freezeReferenceMatch[1].trim() === 'true' : false,
2222
+ metadata,
1694
2223
  topLevelKeys,
1695
2224
  };
1696
2225
  }
1697
2226
 
2227
+ /**
2228
+ * Parses the nested `metadata:` block within a frontmatter YAML block, if present.
2229
+ * Supports scalar values, quoted strings, inline arrays (`tags: [a, b]`), and
2230
+ * block-style lists (`tags:` followed by ` - a` / ` - b` lines).
2231
+ */
2232
+ function extractMetadataBlock(block) {
2233
+ const lines = block.split('\n');
2234
+ const metadataIndex = lines.findIndex((line) => /^metadata:\s*$/.test(line));
2235
+ if (metadataIndex === -1) {
2236
+ return { present: false, fields: {} };
2237
+ }
2238
+
2239
+ const fields = {};
2240
+ let i = metadataIndex + 1;
2241
+ while (i < lines.length) {
2242
+ const line = lines[i];
2243
+ if (line.trim() === '') { i++; continue; }
2244
+ if (!/^\s+\S/.test(line)) break; // back to a top-level (unindented) key: stop
2245
+
2246
+ const kvMatch = line.match(/^\s+([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/);
2247
+ if (!kvMatch) { i++; continue; }
2248
+
2249
+ const key = kvMatch[1];
2250
+ let rawValue = kvMatch[2].trim();
2251
+
2252
+ if (rawValue === '') {
2253
+ // Possible block-style list on the following, more-indented lines.
2254
+ const listValues = [];
2255
+ let j = i + 1;
2256
+ while (j < lines.length) {
2257
+ const listMatch = lines[j].match(/^\s+-\s*(.+)$/);
2258
+ if (!listMatch) break;
2259
+ listValues.push(listMatch[1].trim().replace(/^["']|["']$/g, ''));
2260
+ j++;
2261
+ }
2262
+ if (listValues.length > 0) {
2263
+ fields[key] = listValues;
2264
+ i = j;
2265
+ continue;
2266
+ }
2267
+ fields[key] = '';
2268
+ i++;
2269
+ continue;
2270
+ }
2271
+
2272
+ rawValue = rawValue.replace(/^["']|["']$/g, '');
2273
+ if (/^\[.*\]$/.test(rawValue)) {
2274
+ const inner = rawValue.slice(1, -1).trim();
2275
+ fields[key] = inner === '' ? [] : inner.split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
2276
+ } else {
2277
+ fields[key] = rawValue;
2278
+ }
2279
+ i++;
2280
+ }
2281
+
2282
+ return { present: true, fields };
2283
+ }
2284
+
1698
2285
  function stripFrontmatter(content) {
1699
2286
  const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
1700
2287
  return match ? content.slice(match[0].length) : content;