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