xdrs-core 0.49.1 → 0.50.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,10 @@ 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 SKILL_PACKAGE_OPTIONAL_DIRS = new Set(['scripts', 'references', RESOURCE_DIR_NAME, 'dist']);
29
29
 
30
30
  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']);
31
+ const SKILL_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'license', 'metadata']);
32
32
  const SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS = new Set(['scope-type', 'name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'follows', 'extends']);
33
33
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
34
34
  const SLIDE_FILE_RE = /^.+-slides(?:-[a-z0-9-]+)?\.md$/;
@@ -37,7 +37,14 @@ const EMOJI_RE = /\p{Extended_Pictographic}/u;
37
37
  const POLICY_MAX_WORDS = 2600;
38
38
  const ARTICLE_MAX_WORDS = 8000;
39
39
  const RESEARCH_MAX_WORDS = 5000;
40
- const SKILL_MAX_WORDS = 6500;
40
+ const SKILL_MAX_WORDS = 7000;
41
+ const REFERENCE_MAX_WORDS = 4000;
42
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
43
+ const SKILL_OPTIONAL_RUNTIME_SECTION = '### Runtime Requirements';
44
+ const SKILL_OPTIONAL_USER_INTERACTION_SECTION = '### User Interaction';
45
+ const SKILL_ANTI_PATTERNS_MIN_ENTRIES = 3;
46
+ const SKILL_BUNDLING_MAKEFILE_TARGETS = ['build', 'test', 'clean'];
47
+ const SCRIPT_HEADER_RE = /^(#!|#|\/\/|\/\*|<!--|--|;)/;
41
48
 
42
49
  // Subject number block ranges per _core-adr-policy-017
43
50
  const SUBJECT_NUMBER_RANGES = {
@@ -73,6 +80,7 @@ const DOC_TYPE_POLICY_REF = {
73
80
  Research: '[_core-adr-policy-006]',
74
81
  Initiative: '[_core-adr-policy-007]',
75
82
  Slide: '[_core-adr-policy-009]',
83
+ Reference: '[_core-adr-policy-003]',
76
84
  };
77
85
 
78
86
  function runLintCli(args) {
@@ -845,7 +853,7 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNu
845
853
  continue;
846
854
  }
847
855
  if (entry.name === 'skills') {
848
- artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, externalScopes));
856
+ artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, entryPath, errors, externalScopes));
849
857
  continue;
850
858
  }
851
859
  if (entry.name === 'articles') {
@@ -1010,9 +1018,9 @@ function lintXdrsElementFrontmatter(content, expectedName, filePath, errors, war
1010
1018
  }
1011
1019
  }
1012
1020
 
1013
- function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors, externalScopes = new Set()) {
1021
+ function lintSkillsDirectory(xdrsRoot, scopeName, skillsPath, errors, externalScopes = new Set()) {
1014
1022
  const artifacts = [];
1015
- const entries = safeReadDir(skillsPath, errors, `read skills directory ${scopeName}/${typeName}/${subjectName}/skills`);
1023
+ const entries = safeReadDir(skillsPath, errors, `read skills directory ${toDisplayPath(skillsPath)}`);
1016
1024
 
1017
1025
  for (const entry of entries) {
1018
1026
  const entryPath = path.join(skillsPath, entry.name);
@@ -1041,10 +1049,30 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
1041
1049
  for (const packageEntry of packageEntries) {
1042
1050
  const packageEntryPath = path.join(entryPath, packageEntry.name);
1043
1051
 
1052
+ if (packageEntry.isFile() && packageEntry.name === 'Makefile') {
1053
+ lintSkillBundlingMakefile(packageEntryPath, entryPath, xdrsRoot, errors);
1054
+ }
1055
+
1044
1056
  if (!packageEntry.isDirectory()) {
1045
1057
  continue;
1046
1058
  }
1047
1059
 
1060
+ if (packageEntry.name === 'references') {
1061
+ const referenceEntries = safeReadDir(packageEntryPath, errors, `read references directory ${toDisplayPath(packageEntryPath)}`);
1062
+ for (const referenceEntry of referenceEntries) {
1063
+ if (referenceEntry.isDirectory() || !referenceEntry.name.endsWith('.md')) {
1064
+ continue;
1065
+ }
1066
+ const referencePath = path.join(packageEntryPath, referenceEntry.name);
1067
+ const referenceContent = fs.readFileSync(referencePath, 'utf8');
1068
+ lintWordCount(referenceContent, referencePath, 'Reference', REFERENCE_MAX_WORDS, errors);
1069
+ }
1070
+ }
1071
+
1072
+ if (packageEntry.name === 'scripts') {
1073
+ lintSkillScriptHeaders(packageEntryPath, errors);
1074
+ }
1075
+
1048
1076
  if (SKILL_PACKAGE_OPTIONAL_DIRS.has(packageEntry.name)) {
1049
1077
  continue;
1050
1078
  }
@@ -1061,6 +1089,11 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
1061
1089
 
1062
1090
  const skillContent = fs.readFileSync(skillFilePath, 'utf8');
1063
1091
  lintRequiredSections(skillContent, skillFilePath, SKILL_REQUIRED_SECTIONS, 'Skill', errors);
1092
+ lintSkillAntiPatternsCount(skillContent, skillFilePath, errors);
1093
+ lintSkillNestedSubsections(skillContent, skillFilePath, errors);
1094
+ lintSkillItemListWordCounts(skillContent, skillFilePath, errors);
1095
+ lintSkillTopLevelSectionOrder(skillContent, skillFilePath, errors);
1096
+ lintSkillOverviewSubsections(skillContent, skillFilePath, errors);
1064
1097
  const skillFm = extractFrontmatter(skillContent);
1065
1098
  if (!skillFm.present) {
1066
1099
  errors.push(`SKILL.md must start with a YAML frontmatter block: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
@@ -1085,6 +1118,7 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
1085
1118
  errors.push(`SKILL.md frontmatter has unknown field "${key}": ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1086
1119
  }
1087
1120
  }
1121
+ lintSkillMetadata(skillFm.metadata, skillFilePath, errors);
1088
1122
  }
1089
1123
 
1090
1124
  lintNoEmojis(skillContent, skillFilePath, 'Skill', errors);
@@ -1096,6 +1130,381 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
1096
1130
  return artifacts;
1097
1131
  }
1098
1132
 
1133
+ /**
1134
+ * Validates the required `metadata` block of a SKILL.md frontmatter per _core-adr-policy-003:
1135
+ * version, author, and updated are all required fields.
1136
+ */
1137
+ function lintSkillMetadata(metadata, skillFilePath, errors) {
1138
+ if (!metadata || !metadata.present) {
1139
+ errors.push(`SKILL.md frontmatter must include a metadata block: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1140
+ return;
1141
+ }
1142
+
1143
+ const fields = metadata.fields;
1144
+
1145
+ if (!fields.version) {
1146
+ errors.push(`SKILL.md frontmatter metadata must include a non-empty version field: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1147
+ } else if (!SEMVER_RE.test(fields.version)) {
1148
+ errors.push(`SKILL.md frontmatter metadata.version must use full semantic versioning (MAJOR.MINOR.PATCH): ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1149
+ }
1150
+
1151
+ if (!fields.author) {
1152
+ errors.push(`SKILL.md frontmatter metadata must include a non-empty author field: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1153
+ }
1154
+
1155
+ if (!fields.updated) {
1156
+ errors.push(`SKILL.md frontmatter metadata must include a non-empty updated field: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1157
+ } else if (!isIsoDate(fields.updated)) {
1158
+ errors.push(`SKILL.md frontmatter metadata.updated must be a valid ISO date YYYY-MM-DD: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1159
+ }
1160
+ }
1161
+
1162
+ /**
1163
+ * Returns the [start, end) body line range for a top-level "##" section given its heading
1164
+ * line index and the file's full list of "##" heading line indices.
1165
+ */
1166
+ function sectionBodyRange(lines, headingLines, headingIndex) {
1167
+ const nextHeadingIndex = headingLines.find((index) => index > headingIndex);
1168
+ return [headingIndex + 1, nextHeadingIndex === undefined ? lines.length : nextHeadingIndex];
1169
+ }
1170
+
1171
+ /**
1172
+ * Finds the first line within [start, end) whose trimmed text exactly matches headingText.
1173
+ */
1174
+ function findHeadingLineInRange(lines, ignoredLines, headingText, start, end) {
1175
+ for (let index = start; index < end; index += 1) {
1176
+ if (ignoredLines[index]) continue;
1177
+ if (lines[index].trim() === headingText) {
1178
+ return index;
1179
+ }
1180
+ }
1181
+ return -1;
1182
+ }
1183
+
1184
+ /**
1185
+ * Finds all "###"-prefixed heading line indices within [start, end).
1186
+ */
1187
+ function findSubHeadingLines(lines, ignoredLines, start, end) {
1188
+ const result = [];
1189
+ for (let index = start; index < end; index += 1) {
1190
+ if (ignoredLines[index]) continue;
1191
+ if (/^###\s+/.test(lines[index].trim())) {
1192
+ result.push(index);
1193
+ }
1194
+ }
1195
+ return result;
1196
+ }
1197
+
1198
+ /**
1199
+ * Finds all "####"-prefixed heading line indices within [start, end).
1200
+ */
1201
+ function findSubSubHeadingLines(lines, ignoredLines, start, end) {
1202
+ const result = [];
1203
+ for (let index = start; index < end; index += 1) {
1204
+ if (ignoredLines[index]) continue;
1205
+ if (/^####\s+/.test(lines[index].trim())) {
1206
+ result.push(index);
1207
+ }
1208
+ }
1209
+ return result;
1210
+ }
1211
+
1212
+ /**
1213
+ * Validates that "### Inputs" contains "#### Required" then "#### Optional", and that
1214
+ * "### Outputs" contains "#### Contents" then "#### Changes", per _core-adr-policy-003. Both
1215
+ * parents are nested inside "## Overview". Skips silently when "## Overview" or a parent
1216
+ * subsection itself is missing (already reported elsewhere).
1217
+ */
1218
+ function lintSkillNestedSubsections(content, filePath, errors) {
1219
+ const lines = content.split(/\r?\n/);
1220
+ const ignoredLines = findIgnoredMarkdownLines(lines);
1221
+ const headingLines = findHeadingLines(lines, ignoredLines);
1222
+
1223
+ const overviewIndex = findHeadingLine(lines, ignoredLines, '## Overview');
1224
+ if (overviewIndex === -1) return;
1225
+ const [overviewStart, overviewEnd] = sectionBodyRange(lines, headingLines, overviewIndex);
1226
+ const subHeadingLines = findSubHeadingLines(lines, ignoredLines, overviewStart, overviewEnd);
1227
+
1228
+ function checkPair(parentHeadingText, firstSub, secondSub) {
1229
+ const parentIndex = findHeadingLineInRange(lines, ignoredLines, parentHeadingText, overviewStart, overviewEnd);
1230
+ if (parentIndex === -1) return;
1231
+
1232
+ const nextParent = subHeadingLines.find((index) => index > parentIndex);
1233
+ const parentEnd = nextParent === undefined ? overviewEnd : nextParent;
1234
+ const firstIndex = findHeadingLineInRange(lines, ignoredLines, firstSub, parentIndex + 1, parentEnd);
1235
+ const secondIndex = findHeadingLineInRange(lines, ignoredLines, secondSub, parentIndex + 1, parentEnd);
1236
+
1237
+ if (firstIndex === -1) {
1238
+ errors.push(`Skill "${parentHeadingText}" section is missing "${firstSub}": ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1239
+ }
1240
+ if (secondIndex === -1) {
1241
+ errors.push(`Skill "${parentHeadingText}" section is missing "${secondSub}": ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1242
+ }
1243
+ if (firstIndex !== -1 && secondIndex !== -1 && secondIndex < firstIndex) {
1244
+ errors.push(`Skill "${parentHeadingText}" subsections must appear in order (${firstSub}, ${secondSub}): ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1245
+ }
1246
+ }
1247
+
1248
+ checkPair('### Inputs', '#### Required', '#### Optional');
1249
+ checkPair('### Outputs', '#### Contents', '#### Changes');
1250
+ }
1251
+
1252
+ /**
1253
+ * Validates a bullet-list section's item shape: either a single "None" bullet, or one-or-more
1254
+ * bullets each under 10 words, per _core-adr-policy-003. No-op when the range has no bullets
1255
+ * at all (an empty required section is reported by other checks).
1256
+ */
1257
+ function lintBulletWordCountSection(lines, ignoredLines, start, end, label, filePath, errors) {
1258
+ const bullets = [];
1259
+ for (let i = start; i < end; i += 1) {
1260
+ if (ignoredLines[i]) continue;
1261
+ const trimmed = lines[i].trim();
1262
+ if (!trimmed) continue;
1263
+ const bulletMatch = trimmed.match(/^-\s+(.*)$/);
1264
+ if (bulletMatch) {
1265
+ bullets.push(bulletMatch[1].trim());
1266
+ }
1267
+ }
1268
+
1269
+ if (bullets.length === 0) {
1270
+ return;
1271
+ }
1272
+
1273
+ if (bullets.length === 1 && bullets[0].toLowerCase() === 'none') {
1274
+ return;
1275
+ }
1276
+
1277
+ for (const bullet of bullets) {
1278
+ const wordCount = countWords(bullet);
1279
+ if (wordCount >= 10) {
1280
+ errors.push(`${label} bullet must be under 10 words or a single "None" (found ${wordCount}): ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ /**
1286
+ * Validates the item word-count rule (single "None" bullet, or one-or-more sub-10-word
1287
+ * bullets) across "### Inputs" -> "#### Required"/"#### Optional", "### Outputs" ->
1288
+ * "#### Contents"/"#### Changes", "### Halt Conditions", and "### User Interaction" (only
1289
+ * when present, since it is optional). All 4 subsections live inside "## Overview", per
1290
+ * _core-adr-policy-003.
1291
+ */
1292
+ function lintSkillItemListWordCounts(content, filePath, errors) {
1293
+ const lines = content.split(/\r?\n/);
1294
+ const ignoredLines = findIgnoredMarkdownLines(lines);
1295
+ const headingLines = findHeadingLines(lines, ignoredLines);
1296
+
1297
+ const overviewIndex = findHeadingLine(lines, ignoredLines, '## Overview');
1298
+ if (overviewIndex === -1) return;
1299
+ const [overviewStart, overviewEnd] = sectionBodyRange(lines, headingLines, overviewIndex);
1300
+ const subHeadingLines = findSubHeadingLines(lines, ignoredLines, overviewStart, overviewEnd);
1301
+
1302
+ function subsectionRange(headingText) {
1303
+ const index = findHeadingLineInRange(lines, ignoredLines, headingText, overviewStart, overviewEnd);
1304
+ if (index === -1) return null;
1305
+ const next = subHeadingLines.find((i) => i > index);
1306
+ return [index + 1, next === undefined ? overviewEnd : next];
1307
+ }
1308
+
1309
+ function subSubsectionRange(parentHeadingText, subHeadingText) {
1310
+ const parentRange = subsectionRange(parentHeadingText);
1311
+ if (!parentRange) return null;
1312
+ const [parentStart, parentEnd] = parentRange;
1313
+ const subIndex = findHeadingLineInRange(lines, ignoredLines, subHeadingText, parentStart, parentEnd);
1314
+ if (subIndex === -1) return null;
1315
+ const subSubHeadingLines = findSubSubHeadingLines(lines, ignoredLines, parentStart, parentEnd);
1316
+ const nextSub = subSubHeadingLines.find((index) => index > subIndex);
1317
+ return [subIndex + 1, nextSub === undefined ? parentEnd : nextSub];
1318
+ }
1319
+
1320
+ const subsectionTargets = [
1321
+ { range: subSubsectionRange('### Inputs', '#### Required'), label: 'Skill "### Inputs" -> "#### Required"' },
1322
+ { range: subSubsectionRange('### Inputs', '#### Optional'), label: 'Skill "### Inputs" -> "#### Optional"' },
1323
+ { range: subSubsectionRange('### Outputs', '#### Contents'), label: 'Skill "### Outputs" -> "#### Contents"' },
1324
+ { range: subSubsectionRange('### Outputs', '#### Changes'), label: 'Skill "### Outputs" -> "#### Changes"' },
1325
+ ];
1326
+
1327
+ for (const target of subsectionTargets) {
1328
+ if (!target.range) continue; // missing heading already reported elsewhere
1329
+ lintBulletWordCountSection(lines, ignoredLines, target.range[0], target.range[1], target.label, filePath, errors);
1330
+ }
1331
+
1332
+ const haltRange = subsectionRange('### Halt Conditions');
1333
+ if (haltRange) {
1334
+ lintBulletWordCountSection(lines, ignoredLines, haltRange[0], haltRange[1], 'Skill "### Halt Conditions"', filePath, errors);
1335
+ }
1336
+
1337
+ const interactionRange = subsectionRange(SKILL_OPTIONAL_USER_INTERACTION_SECTION);
1338
+ if (interactionRange) {
1339
+ lintBulletWordCountSection(lines, ignoredLines, interactionRange[0], interactionRange[1], 'Skill "### User Interaction"', filePath, errors);
1340
+ }
1341
+ }
1342
+
1343
+ /**
1344
+ * Validates that all recognized top-level "##" sections present in the file appear in the
1345
+ * template order defined by _core-adr-policy-003. Missing sections are not an error here
1346
+ * (see lintRequiredSections and lintSkillOverviewSubsections for presence checks).
1347
+ */
1348
+ function lintSkillTopLevelSectionOrder(content, filePath, errors) {
1349
+ const lines = content.split(/\r?\n/);
1350
+ const ignoredLines = findIgnoredMarkdownLines(lines);
1351
+ const headingLines = findHeadingLines(lines, ignoredLines);
1352
+
1353
+ const presentKnownHeadings = headingLines
1354
+ .map((index) => lines[index].trim())
1355
+ .filter((headingText) => SKILL_TEMPLATE_SECTION_ORDER.includes(headingText));
1356
+
1357
+ for (let i = 1; i < presentKnownHeadings.length; i += 1) {
1358
+ const prevOrder = SKILL_TEMPLATE_SECTION_ORDER.indexOf(presentKnownHeadings[i - 1]);
1359
+ const currOrder = SKILL_TEMPLATE_SECTION_ORDER.indexOf(presentKnownHeadings[i]);
1360
+ if (currOrder < prevOrder) {
1361
+ errors.push(`Skill sections must appear in template order (${SKILL_TEMPLATE_SECTION_ORDER.join(', ')}): ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1362
+ break;
1363
+ }
1364
+ }
1365
+ }
1366
+
1367
+ /**
1368
+ * Validates the "## Overview" section's nested subsections per _core-adr-policy-003:
1369
+ * "### Inputs", "### Outputs", and "### Halt Conditions" are required; "### User
1370
+ * Interaction" and "### Runtime Requirements" are optional but, when present, must be
1371
+ * non-empty. All recognized subsections found must appear in template order. No-op when
1372
+ * "## Overview" itself is missing (already reported by lintRequiredSections).
1373
+ */
1374
+ function lintSkillOverviewSubsections(content, filePath, errors) {
1375
+ const lines = content.split(/\r?\n/);
1376
+ const ignoredLines = findIgnoredMarkdownLines(lines);
1377
+ const headingLines = findHeadingLines(lines, ignoredLines);
1378
+
1379
+ const overviewIndex = findHeadingLine(lines, ignoredLines, '## Overview');
1380
+ if (overviewIndex === -1) return;
1381
+
1382
+ const [overviewStart, overviewEnd] = sectionBodyRange(lines, headingLines, overviewIndex);
1383
+ const subHeadingLines = findSubHeadingLines(lines, ignoredLines, overviewStart, overviewEnd);
1384
+ const presentSubsections = subHeadingLines
1385
+ .map((index) => lines[index].trim())
1386
+ .filter((headingText) => SKILL_OVERVIEW_SUBSECTION_ORDER.includes(headingText));
1387
+
1388
+ for (const required of SKILL_OVERVIEW_REQUIRED_SUBSECTIONS) {
1389
+ if (!presentSubsections.includes(required)) {
1390
+ errors.push(`Skill "## Overview" is missing required subsection "${required}": ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1391
+ }
1392
+ }
1393
+
1394
+ for (const section of [SKILL_OPTIONAL_USER_INTERACTION_SECTION, SKILL_OPTIONAL_RUNTIME_SECTION]) {
1395
+ const headingIndex = findHeadingLineInRange(lines, ignoredLines, section, overviewStart, overviewEnd);
1396
+ if (headingIndex === -1) continue;
1397
+
1398
+ const nextSub = subHeadingLines.find((index) => index > headingIndex);
1399
+ const bodyEnd = nextSub === undefined ? overviewEnd : nextSub;
1400
+ const hasContent = lines
1401
+ .slice(headingIndex + 1, bodyEnd)
1402
+ .some((line, offset) => !ignoredLines[headingIndex + 1 + offset] && line.trim().length > 0);
1403
+ if (!hasContent) {
1404
+ errors.push(`Skill section "${section}" must not be empty: ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1405
+ }
1406
+ }
1407
+
1408
+ for (let i = 1; i < presentSubsections.length; i += 1) {
1409
+ const prevOrder = SKILL_OVERVIEW_SUBSECTION_ORDER.indexOf(presentSubsections[i - 1]);
1410
+ const currOrder = SKILL_OVERVIEW_SUBSECTION_ORDER.indexOf(presentSubsections[i]);
1411
+ if (currOrder < prevOrder) {
1412
+ errors.push(`Skill "## Overview" subsections must appear in order (${SKILL_OVERVIEW_SUBSECTION_ORDER.join(', ')}): ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1413
+ break;
1414
+ }
1415
+ }
1416
+ }
1417
+
1418
+ /**
1419
+ * Validates the SKILL.md "## Anti-Patterns" section has at least the minimum number of
1420
+ * "- **Mistake:**" entries required by _core-adr-policy-003.
1421
+ */
1422
+ function lintSkillAntiPatternsCount(content, filePath, errors) {
1423
+ const lines = content.split(/\r?\n/);
1424
+ const ignoredLines = findIgnoredMarkdownLines(lines);
1425
+ const headingIndex = findHeadingLine(lines, ignoredLines, '## Anti-Patterns');
1426
+ if (headingIndex === -1) {
1427
+ return; // Missing section is already reported by lintRequiredSections.
1428
+ }
1429
+
1430
+ const headingLines = findHeadingLines(lines, ignoredLines);
1431
+ const nextHeadingIndex = headingLines.find((index) => index > headingIndex);
1432
+ const sectionEnd = nextHeadingIndex === undefined ? lines.length : nextHeadingIndex;
1433
+
1434
+ let mistakeCount = 0;
1435
+ for (let i = headingIndex + 1; i < sectionEnd; i += 1) {
1436
+ if (!ignoredLines[i] && /^-\s+\*\*Mistake:\*\*/.test(lines[i].trim())) {
1437
+ mistakeCount += 1;
1438
+ }
1439
+ }
1440
+
1441
+ if (mistakeCount < SKILL_ANTI_PATTERNS_MIN_ENTRIES) {
1442
+ errors.push(`Skill "## Anti-Patterns" section must include at least ${SKILL_ANTI_PATTERNS_MIN_ENTRIES} entries (found ${mistakeCount}): ${toDisplayPath(filePath)} [_core-adr-policy-003]`);
1443
+ }
1444
+ }
1445
+
1446
+ /**
1447
+ * Validates that each file inside a skill's scripts/ directory declares its runtime or
1448
+ * interpreter at the top (shebang or header comment) per _core-adr-policy-003.
1449
+ */
1450
+ function lintSkillScriptHeaders(scriptsPath, errors) {
1451
+ const entries = safeReadDir(scriptsPath, errors, `read scripts directory ${toDisplayPath(scriptsPath)}`);
1452
+ for (const entry of entries) {
1453
+ if (!entry.isFile()) {
1454
+ continue;
1455
+ }
1456
+ const scriptPath = path.join(scriptsPath, entry.name);
1457
+ const content = fs.readFileSync(scriptPath, 'utf8');
1458
+ if (!SCRIPT_HEADER_RE.test(firstNonEmptyLine(content))) {
1459
+ errors.push(`Script must declare its runtime/interpreter at the top (shebang or header comment): ${toDisplayPath(scriptPath)} [_core-adr-policy-003]`);
1460
+ }
1461
+ }
1462
+ }
1463
+
1464
+ /**
1465
+ * Validates an optional skill bundling Makefile per _core-adr-policy-021: it must expose
1466
+ * build/test/clean targets, and its dist/ output must be covered by a .gitignore.
1467
+ */
1468
+ function lintSkillBundlingMakefile(makefilePath, skillPackagePath, xdrsRoot, errors) {
1469
+ const content = fs.readFileSync(makefilePath, 'utf8');
1470
+ for (const target of SKILL_BUNDLING_MAKEFILE_TARGETS) {
1471
+ const targetRe = new RegExp(`^${target}\\s*:`, 'm');
1472
+ if (!targetRe.test(content)) {
1473
+ errors.push(`Skill bundling Makefile is missing required "${target}" target: ${toDisplayPath(makefilePath)} [_core-adr-policy-021.09-standard-targets]`);
1474
+ }
1475
+ }
1476
+
1477
+ const buildTargetMatch = content.match(/^build\s*:(.*)$/m);
1478
+ if (buildTargetMatch && !buildTargetMatch[1].trim().split(/\s+/).includes('clean')) {
1479
+ 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]`);
1480
+ }
1481
+
1482
+ if (!isDistGitignoredForSkill(skillPackagePath, xdrsRoot)) {
1483
+ 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]`);
1484
+ }
1485
+ }
1486
+
1487
+ function gitignoreCoversDistAnyDepth(content) {
1488
+ return content.split(/\r?\n/).some((line) => {
1489
+ const trimmed = line.trim().replace(/\/$/, '');
1490
+ return trimmed === 'dist' || trimmed === '**/dist';
1491
+ });
1492
+ }
1493
+
1494
+ function isDistGitignoredForSkill(skillPackagePath, xdrsRoot) {
1495
+ const localGitignorePath = path.join(skillPackagePath, '.gitignore');
1496
+ if (existsFile(localGitignorePath) && gitignoreCoversDistAnyDepth(fs.readFileSync(localGitignorePath, 'utf8'))) {
1497
+ return true;
1498
+ }
1499
+
1500
+ const rootGitignorePath = path.join(path.dirname(xdrsRoot), '.gitignore');
1501
+ if (existsFile(rootGitignorePath) && gitignoreCoversDistAnyDepth(fs.readFileSync(rootGitignorePath, 'utf8'))) {
1502
+ return true;
1503
+ }
1504
+
1505
+ return false;
1506
+ }
1507
+
1099
1508
  function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, articlesPath, errors, externalScopes = new Set()) {
1100
1509
  const artifacts = [];
1101
1510
  const articleNumbers = new Map();
@@ -1347,7 +1756,23 @@ const XDRS_REQUIRED_SECTIONS = ['## Context and Problem Statement', '## Decision
1347
1756
  const ARTICLE_REQUIRED_SECTIONS = ['## Overview', '## Content', '## References'];
1348
1757
  const RESEARCH_REQUIRED_SECTIONS = ['## Abstract', '## Introduction', '## Methods', '## Results', '## Discussion', '## Conclusion', '## References'];
1349
1758
  const INITIATIVE_REQUIRED_SECTIONS = ['## Executive Summary', '## Context and Problem Statement', '## Proposed Solution'];
1350
- const SKILL_REQUIRED_SECTIONS = ['## Overview', '## Instructions'];
1759
+ const SKILL_REQUIRED_SECTIONS = ['## Overview', '## Instructions', '## Anti-Patterns'];
1760
+ const SKILL_TEMPLATE_SECTION_ORDER = [
1761
+ '## Overview',
1762
+ '## Instructions',
1763
+ '## Examples',
1764
+ '## Edge Cases',
1765
+ '## Anti-Patterns',
1766
+ '## References',
1767
+ ];
1768
+ const SKILL_OVERVIEW_REQUIRED_SUBSECTIONS = ['### Inputs', '### Outputs', '### Halt Conditions'];
1769
+ const SKILL_OVERVIEW_SUBSECTION_ORDER = [
1770
+ '### Inputs',
1771
+ '### Outputs',
1772
+ '### Halt Conditions',
1773
+ '### User Interaction',
1774
+ '### Runtime Requirements',
1775
+ ];
1351
1776
 
1352
1777
  function lintRequiredSections(content, filePath, requiredSections, docType, errors) {
1353
1778
  const lines = content.split(/\r?\n/);
@@ -1670,7 +2095,7 @@ function extractDescriptionText(block) {
1670
2095
  function extractFrontmatter(content) {
1671
2096
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
1672
2097
  if (!match) {
1673
- return { present: false, name: null, description: false, descriptionText: null, validFrom: undefined, appliedTo: undefined, topLevelKeys: [] };
2098
+ return { present: false, name: null, description: false, descriptionText: null, validFrom: undefined, appliedTo: undefined, topLevelKeys: [], metadata: { present: false, fields: {} } };
1674
2099
  }
1675
2100
  const block = match[1];
1676
2101
  const nameMatch = block.match(/^name:\s*(.+)$/m);
@@ -1683,6 +2108,7 @@ function extractFrontmatter(content) {
1683
2108
  }
1684
2109
  const freezeReferenceMatch = block.match(/^freeze-reference:\s*(.+)$/m);
1685
2110
  const descriptionText = extractDescriptionText(block);
2111
+ const metadata = extractMetadataBlock(block);
1686
2112
  return {
1687
2113
  present: true,
1688
2114
  name: nameMatch ? nameMatch[1].trim() || null : null,
@@ -1691,10 +2117,69 @@ function extractFrontmatter(content) {
1691
2117
  validFrom: validFromMatch ? validFromMatch[1].trim() : undefined,
1692
2118
  appliedTo: appliedToMatch ? appliedToMatch[1].trim() : undefined,
1693
2119
  freezeReference: freezeReferenceMatch ? freezeReferenceMatch[1].trim() === 'true' : false,
2120
+ metadata,
1694
2121
  topLevelKeys,
1695
2122
  };
1696
2123
  }
1697
2124
 
2125
+ /**
2126
+ * Parses the nested `metadata:` block within a frontmatter YAML block, if present.
2127
+ * Supports scalar values, quoted strings, inline arrays (`tags: [a, b]`), and
2128
+ * block-style lists (`tags:` followed by ` - a` / ` - b` lines).
2129
+ */
2130
+ function extractMetadataBlock(block) {
2131
+ const lines = block.split('\n');
2132
+ const metadataIndex = lines.findIndex((line) => /^metadata:\s*$/.test(line));
2133
+ if (metadataIndex === -1) {
2134
+ return { present: false, fields: {} };
2135
+ }
2136
+
2137
+ const fields = {};
2138
+ let i = metadataIndex + 1;
2139
+ while (i < lines.length) {
2140
+ const line = lines[i];
2141
+ if (line.trim() === '') { i++; continue; }
2142
+ if (!/^\s+\S/.test(line)) break; // back to a top-level (unindented) key: stop
2143
+
2144
+ const kvMatch = line.match(/^\s+([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/);
2145
+ if (!kvMatch) { i++; continue; }
2146
+
2147
+ const key = kvMatch[1];
2148
+ let rawValue = kvMatch[2].trim();
2149
+
2150
+ if (rawValue === '') {
2151
+ // Possible block-style list on the following, more-indented lines.
2152
+ const listValues = [];
2153
+ let j = i + 1;
2154
+ while (j < lines.length) {
2155
+ const listMatch = lines[j].match(/^\s+-\s*(.+)$/);
2156
+ if (!listMatch) break;
2157
+ listValues.push(listMatch[1].trim().replace(/^["']|["']$/g, ''));
2158
+ j++;
2159
+ }
2160
+ if (listValues.length > 0) {
2161
+ fields[key] = listValues;
2162
+ i = j;
2163
+ continue;
2164
+ }
2165
+ fields[key] = '';
2166
+ i++;
2167
+ continue;
2168
+ }
2169
+
2170
+ rawValue = rawValue.replace(/^["']|["']$/g, '');
2171
+ if (/^\[.*\]$/.test(rawValue)) {
2172
+ const inner = rawValue.slice(1, -1).trim();
2173
+ fields[key] = inner === '' ? [] : inner.split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
2174
+ } else {
2175
+ fields[key] = rawValue;
2176
+ }
2177
+ i++;
2178
+ }
2179
+
2180
+ return { present: true, fields };
2181
+ }
2182
+
1698
2183
  function stripFrontmatter(content) {
1699
2184
  const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
1700
2185
  return match ? content.slice(match[0].length) : content;