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/.xdrs/_core/adrs/index.md +1 -0
- package/.xdrs/_core/adrs/principles/003-skill-standards.md +98 -12
- package/.xdrs/_core/adrs/principles/021-skill-bundling.md +85 -0
- package/.xdrs/_core/adrs/principles/skills/compile-scope/SKILL.md +52 -1
- package/.xdrs/_core/adrs/principles/skills/review/SKILL.md +48 -2
- package/.xdrs/_core/adrs/principles/skills/write-article/SKILL.md +46 -1
- package/.xdrs/_core/adrs/principles/skills/write-initiative/SKILL.md +44 -2
- package/.xdrs/_core/adrs/principles/skills/write-policy/SKILL.md +44 -1
- package/.xdrs/_core/adrs/principles/skills/write-presentation/SKILL.md +48 -1
- package/.xdrs/_core/adrs/principles/skills/write-research/SKILL.md +54 -9
- package/.xdrs/_core/adrs/principles/skills/write-skill/SKILL.md +99 -6
- package/.xdrs/_core/adrs/principles/skills/write-xdrs-doc/SKILL.md +48 -1
- package/lib/lint.js +495 -9
- package/lib/lint.test.js +669 -31
- package/package.json +1 -1
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
|
|
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'
|
|
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 =
|
|
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,
|
|
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,
|
|
1021
|
+
function lintSkillsDirectory(xdrsRoot, scopeName, skillsPath, errors, externalScopes = new Set()) {
|
|
1013
1022
|
const artifacts = [];
|
|
1014
|
-
const entries = safeReadDir(skillsPath, errors, `read skills directory ${
|
|
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;
|