xdrs-core 0.49.0 → 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
@@ -19,15 +19,16 @@ const ALLOWED_SUBJECTS = {
19
19
  const TYPE_NAMES = new Set(Object.keys(TYPE_TO_ID));
20
20
  const RESERVED_SCOPES = new Set(['_core', '_local']);
21
21
  const NUMBERED_FILE_RE = /^(\d{3,})-([a-z0-9-]+)\.md$/;
22
- // Skill package folders are not numbered (see _core-adr-policy-003): plain lowercase kebab-case.
22
+ // Skill package folders are not auto-numbered (see _core-adr-policy-003): plain lowercase
23
+ // kebab-case; digits are allowed anywhere in the name, including as a prefix.
23
24
  const SKILL_DIR_RE = /^[a-z0-9-]+$/;
24
25
  const REQUIRED_ROOT_INDEX_TEXT = 'XDRS scopes listed last override the ones listed first';
25
26
  const SUBJECT_ARTIFACT_DIRS = new Set(['skills', 'articles', 'researches', 'initiatives']);
26
27
  const RESOURCE_DIR_NAME = '.assets';
27
- 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']);
28
29
 
29
30
  const POLICY_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'freeze-reference']);
30
- 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']);
31
32
  const SCOPE_INDEX_ALLOWED_FRONTMATTER_KEYS = new Set(['scope-type', 'name', 'description', 'apply-to', 'valid-from', 'license', 'metadata', 'follows', 'extends']);
32
33
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
33
34
  const SLIDE_FILE_RE = /^.+-slides(?:-[a-z0-9-]+)?\.md$/;
@@ -36,7 +37,14 @@ const EMOJI_RE = /\p{Extended_Pictographic}/u;
36
37
  const POLICY_MAX_WORDS = 2600;
37
38
  const ARTICLE_MAX_WORDS = 8000;
38
39
  const RESEARCH_MAX_WORDS = 5000;
39
- 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 = /^(#!|#|\/\/|\/\*|<!--|--|;)/;
40
48
 
41
49
  // Subject number block ranges per _core-adr-policy-017
42
50
  const SUBJECT_NUMBER_RANGES = {
@@ -72,6 +80,7 @@ const DOC_TYPE_POLICY_REF = {
72
80
  Research: '[_core-adr-policy-006]',
73
81
  Initiative: '[_core-adr-policy-007]',
74
82
  Slide: '[_core-adr-policy-009]',
83
+ Reference: '[_core-adr-policy-003]',
75
84
  };
76
85
 
77
86
  function runLintCli(args) {
@@ -844,7 +853,7 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrsNu
844
853
  continue;
845
854
  }
846
855
  if (entry.name === 'skills') {
847
- artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors, externalScopes));
856
+ artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, entryPath, errors, externalScopes));
848
857
  continue;
849
858
  }
850
859
  if (entry.name === 'articles') {
@@ -1009,9 +1018,9 @@ function lintXdrsElementFrontmatter(content, expectedName, filePath, errors, war
1009
1018
  }
1010
1019
  }
1011
1020
 
1012
- function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors, externalScopes = new Set()) {
1021
+ function lintSkillsDirectory(xdrsRoot, scopeName, skillsPath, errors, externalScopes = new Set()) {
1013
1022
  const artifacts = [];
1014
- const entries = safeReadDir(skillsPath, errors, `read skills directory ${scopeName}/${typeName}/${subjectName}/skills`);
1023
+ const entries = safeReadDir(skillsPath, errors, `read skills directory ${toDisplayPath(skillsPath)}`);
1015
1024
 
1016
1025
  for (const entry of entries) {
1017
1026
  const entryPath = path.join(skillsPath, entry.name);
@@ -1040,10 +1049,30 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
1040
1049
  for (const packageEntry of packageEntries) {
1041
1050
  const packageEntryPath = path.join(entryPath, packageEntry.name);
1042
1051
 
1052
+ if (packageEntry.isFile() && packageEntry.name === 'Makefile') {
1053
+ lintSkillBundlingMakefile(packageEntryPath, entryPath, xdrsRoot, errors);
1054
+ }
1055
+
1043
1056
  if (!packageEntry.isDirectory()) {
1044
1057
  continue;
1045
1058
  }
1046
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
+
1047
1076
  if (SKILL_PACKAGE_OPTIONAL_DIRS.has(packageEntry.name)) {
1048
1077
  continue;
1049
1078
  }
@@ -1060,6 +1089,11 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
1060
1089
 
1061
1090
  const skillContent = fs.readFileSync(skillFilePath, 'utf8');
1062
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);
1063
1097
  const skillFm = extractFrontmatter(skillContent);
1064
1098
  if (!skillFm.present) {
1065
1099
  errors.push(`SKILL.md must start with a YAML frontmatter block: ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
@@ -1084,6 +1118,7 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
1084
1118
  errors.push(`SKILL.md frontmatter has unknown field "${key}": ${toDisplayPath(skillFilePath)} [_core-adr-policy-003]`);
1085
1119
  }
1086
1120
  }
1121
+ lintSkillMetadata(skillFm.metadata, skillFilePath, errors);
1087
1122
  }
1088
1123
 
1089
1124
  lintNoEmojis(skillContent, skillFilePath, 'Skill', errors);
@@ -1095,6 +1130,381 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
1095
1130
  return artifacts;
1096
1131
  }
1097
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
+
1098
1508
  function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, articlesPath, errors, externalScopes = new Set()) {
1099
1509
  const artifacts = [];
1100
1510
  const articleNumbers = new Map();
@@ -1346,7 +1756,23 @@ const XDRS_REQUIRED_SECTIONS = ['## Context and Problem Statement', '## Decision
1346
1756
  const ARTICLE_REQUIRED_SECTIONS = ['## Overview', '## Content', '## References'];
1347
1757
  const RESEARCH_REQUIRED_SECTIONS = ['## Abstract', '## Introduction', '## Methods', '## Results', '## Discussion', '## Conclusion', '## References'];
1348
1758
  const INITIATIVE_REQUIRED_SECTIONS = ['## Executive Summary', '## Context and Problem Statement', '## Proposed Solution'];
1349
- 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
+ ];
1350
1776
 
1351
1777
  function lintRequiredSections(content, filePath, requiredSections, docType, errors) {
1352
1778
  const lines = content.split(/\r?\n/);
@@ -1669,7 +2095,7 @@ function extractDescriptionText(block) {
1669
2095
  function extractFrontmatter(content) {
1670
2096
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
1671
2097
  if (!match) {
1672
- 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: {} } };
1673
2099
  }
1674
2100
  const block = match[1];
1675
2101
  const nameMatch = block.match(/^name:\s*(.+)$/m);
@@ -1682,6 +2108,7 @@ function extractFrontmatter(content) {
1682
2108
  }
1683
2109
  const freezeReferenceMatch = block.match(/^freeze-reference:\s*(.+)$/m);
1684
2110
  const descriptionText = extractDescriptionText(block);
2111
+ const metadata = extractMetadataBlock(block);
1685
2112
  return {
1686
2113
  present: true,
1687
2114
  name: nameMatch ? nameMatch[1].trim() || null : null,
@@ -1690,10 +2117,69 @@ function extractFrontmatter(content) {
1690
2117
  validFrom: validFromMatch ? validFromMatch[1].trim() : undefined,
1691
2118
  appliedTo: appliedToMatch ? appliedToMatch[1].trim() : undefined,
1692
2119
  freezeReference: freezeReferenceMatch ? freezeReferenceMatch[1].trim() === 'true' : false,
2120
+ metadata,
1693
2121
  topLevelKeys,
1694
2122
  };
1695
2123
  }
1696
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
+
1697
2183
  function stripFrontmatter(content) {
1698
2184
  const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
1699
2185
  return match ? content.slice(match[0].length) : content;