xdrs-core 0.22.0 → 0.23.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.
@@ -32,7 +32,7 @@ Articles are Markdown documents placed inside a subject folder alongside decisio
32
32
  - Sub-directories inside this `.assets/` folder are allowed only when it already has more than 10 files. Otherwise, keep files flat.
33
33
  - Always use lowercase file names.
34
34
  - Never use emojis in article content.
35
- - Articles should be kept under 5000 words. Move detailed content to referenced XDRs or Skills.
35
+ - Articles should be kept under 5000 words. Move or point to detailed contents referenced in XDRs decisions, researches, plans or skills.
36
36
 
37
37
  **Folder layout**
38
38
 
package/lib/lint.js CHANGED
@@ -28,6 +28,11 @@ const SKILL_PACKAGE_OPTIONAL_DIRS = new Set(['scripts', 'references', RESOURCE_D
28
28
  const XDR_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'apply-to', 'valid-from', 'license', 'metadata']);
29
29
  const SKILL_ALLOWED_FRONTMATTER_KEYS = new Set(['name', 'description', 'license', 'metadata', 'compatibility', 'allowed-tools']);
30
30
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
31
+ const EMOJI_RE = /\p{Extended_Pictographic}/u;
32
+ const XDR_MAX_WORDS = 2600;
33
+ const ARTICLE_MAX_WORDS = 5000;
34
+ const RESEARCH_MAX_WORDS = 5000;
35
+ const SKILL_MAX_WORDS = 6500;
31
36
 
32
37
  function runLintCli(args) {
33
38
  if (args.includes('--help') || args.includes('-h')) {
@@ -331,6 +336,8 @@ function lintXdrFile(xdrsRoot, scopeName, typeName, filePath, xdrNumbers, errors
331
336
  || `${scopeName}-${TYPE_TO_ID[typeName]}-${number}-${match[2]}`;
332
337
  lintXdrFrontmatter(content, expectedName, filePath, errors);
333
338
  lintRequiredSections(content, filePath, XDR_REQUIRED_SECTIONS, 'XDR', errors);
339
+ lintNoEmojis(content, filePath, 'XDR', errors);
340
+ lintWordCount(content, filePath, 'XDR', XDR_MAX_WORDS, errors);
334
341
  lintDocumentLinks(filePath, xdrsRoot, scopeName, errors, externalScopes);
335
342
  }
336
343
 
@@ -367,11 +374,18 @@ function lintXdrFrontmatter(content, expectedName, filePath, errors) {
367
374
  }
368
375
  if (!fm.name) {
369
376
  errors.push(`XDR frontmatter must include a non-empty name field: ${toDisplayPath(filePath)}`);
370
- } else if (fm.name !== expectedName) {
371
- errors.push(`XDR frontmatter name must be "${expectedName}": ${toDisplayPath(filePath)}`);
377
+ } else {
378
+ if (fm.name !== expectedName) {
379
+ errors.push(`XDR frontmatter name must be "${expectedName}": ${toDisplayPath(filePath)}`);
380
+ }
381
+ if (fm.name.length > 64) {
382
+ errors.push(`XDR frontmatter name must be 64 characters or fewer: ${toDisplayPath(filePath)}`);
383
+ }
372
384
  }
373
385
  if (!fm.description) {
374
386
  errors.push(`XDR frontmatter must include a non-empty description field: ${toDisplayPath(filePath)}`);
387
+ } else if (fm.descriptionText && fm.descriptionText.length > 1024) {
388
+ errors.push(`XDR frontmatter description must be 1024 characters or fewer: ${toDisplayPath(filePath)}`);
375
389
  }
376
390
  if (fm.validFrom !== undefined) {
377
391
  if (!isIsoDate(fm.validFrom)) {
@@ -455,11 +469,18 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
455
469
  } else {
456
470
  if (!skillFm.name) {
457
471
  errors.push(`SKILL.md frontmatter must include a non-empty name field: ${toDisplayPath(skillFilePath)}`);
458
- } else if (skillFm.name !== entry.name) {
459
- errors.push(`Skill frontmatter name must match package directory "${entry.name}": ${toDisplayPath(skillFilePath)}`);
472
+ } else {
473
+ if (skillFm.name !== entry.name) {
474
+ errors.push(`Skill frontmatter name must match package directory "${entry.name}": ${toDisplayPath(skillFilePath)}`);
475
+ }
476
+ if (skillFm.name.length > 64) {
477
+ errors.push(`SKILL.md frontmatter name must be 64 characters or fewer: ${toDisplayPath(skillFilePath)}`);
478
+ }
460
479
  }
461
480
  if (!skillFm.description) {
462
481
  errors.push(`SKILL.md frontmatter must include a non-empty description field: ${toDisplayPath(skillFilePath)}`);
482
+ } else if (skillFm.descriptionText && skillFm.descriptionText.length > 1024) {
483
+ errors.push(`SKILL.md frontmatter description must be 1024 characters or fewer: ${toDisplayPath(skillFilePath)}`);
463
484
  }
464
485
  for (const key of skillFm.topLevelKeys) {
465
486
  if (!SKILL_ALLOWED_FRONTMATTER_KEYS.has(key)) {
@@ -468,6 +489,8 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
468
489
  }
469
490
  }
470
491
 
492
+ lintNoEmojis(skillContent, skillFilePath, 'Skill', errors);
493
+ lintWordCount(skillContent, skillFilePath, 'Skill', SKILL_MAX_WORDS, errors);
471
494
  lintDocumentLinks(skillFilePath, xdrsRoot, scopeName, errors, externalScopes);
472
495
  lintOrphanAssets(path.join(entryPath, RESOURCE_DIR_NAME), [skillFilePath], xdrsRoot, errors);
473
496
  }
@@ -519,6 +542,8 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
519
542
  }
520
543
 
521
544
  lintRequiredSections(content, entryPath, ARTICLE_REQUIRED_SECTIONS, 'Article', errors);
545
+ lintNoEmojis(content, entryPath, 'Article', errors);
546
+ lintWordCount(content, entryPath, 'Article', ARTICLE_MAX_WORDS, errors);
522
547
  lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors, externalScopes);
523
548
  }
524
549
 
@@ -571,6 +596,9 @@ function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, resea
571
596
  }
572
597
 
573
598
  lintRequiredSections(content, entryPath, RESEARCH_REQUIRED_SECTIONS, 'Research', errors);
599
+ lintResearchIntroductionQuestion(content, entryPath, errors);
600
+ lintNoEmojis(content, entryPath, 'Research', errors);
601
+ lintWordCount(content, entryPath, 'Research', RESEARCH_MAX_WORDS, errors);
574
602
  lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors, externalScopes);
575
603
  }
576
604
 
@@ -624,6 +652,7 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
624
652
 
625
653
  lintRequiredSections(content, entryPath, PLAN_REQUIRED_SECTIONS, 'Plan', errors);
626
654
  lintPlanExpectedEndDate(content, entryPath, errors);
655
+ lintNoEmojis(content, entryPath, 'Plan', errors);
627
656
  lintDocumentLinks(entryPath, xdrsRoot, scopeName, errors, externalScopes);
628
657
  }
629
658
 
@@ -632,6 +661,52 @@ function lintPlansDirectory(xdrsRoot, scopeName, typeName, subjectName, plansPat
632
661
  return artifacts;
633
662
  }
634
663
 
664
+ function lintNoEmojis(content, filePath, docType, errors) {
665
+ const lines = content.split(/\r?\n/);
666
+ const ignoredLines = findIgnoredMarkdownLines(lines);
667
+ for (let i = 0; i < lines.length; i++) {
668
+ if (ignoredLines[i]) continue;
669
+ if (EMOJI_RE.test(lines[i])) {
670
+ errors.push(`${docType} must not contain emojis: ${toDisplayPath(filePath)}:${i + 1}`);
671
+ }
672
+ }
673
+ }
674
+
675
+ function lintWordCount(content, filePath, docType, maxWords, errors) {
676
+ const body = stripFrontmatter(content);
677
+ const wordCount = countWords(body);
678
+ if (wordCount > maxWords) {
679
+ errors.push(`${docType} exceeds maximum word count of ${maxWords} (${wordCount} words): ${toDisplayPath(filePath)}`);
680
+ }
681
+ }
682
+
683
+ function lintResearchIntroductionQuestion(content, filePath, errors) {
684
+ const lines = content.split(/\r?\n/);
685
+ const ignoredLines = findIgnoredMarkdownLines(lines);
686
+ const introStart = findHeadingLine(lines, ignoredLines, '## Introduction');
687
+ if (introStart === -1) return; // Already caught by lintRequiredSections
688
+
689
+ let introEnd = lines.length;
690
+ for (let i = introStart + 1; i < lines.length; i++) {
691
+ if (!ignoredLines[i] && /^## /.test(lines[i].trim())) {
692
+ introEnd = i;
693
+ break;
694
+ }
695
+ }
696
+
697
+ let hasQuestion = false;
698
+ for (let i = introStart + 1; i < introEnd; i++) {
699
+ if (!ignoredLines[i] && lines[i].trim().startsWith('Question:')) {
700
+ hasQuestion = true;
701
+ break;
702
+ }
703
+ }
704
+
705
+ if (!hasQuestion) {
706
+ errors.push(`Research ## Introduction must contain a "Question:" line: ${toDisplayPath(filePath)}`);
707
+ }
708
+ }
709
+
635
710
  const XDR_REQUIRED_SECTIONS = ['## Context and Problem Statement', '## Decision Outcome'];
636
711
  const ARTICLE_REQUIRED_SECTIONS = ['## Overview', '## Content', '## References'];
637
712
  const RESEARCH_REQUIRED_SECTIONS = ['## Abstract', '## Introduction', '## Methods', '## Results', '## Discussion', '## Conclusion', '## References'];
@@ -882,10 +957,33 @@ function shouldValidateResourceLink(rawTarget) {
882
957
  || IMAGE_EXTENSIONS.has(extension);
883
958
  }
884
959
 
960
+ function extractDescriptionText(block) {
961
+ const lines = block.split(/\r?\n/);
962
+ for (let i = 0; i < lines.length; i++) {
963
+ const m = lines[i].match(/^description:\s*(.*)$/);
964
+ if (!m) continue;
965
+ const inlineValue = m[1].trim();
966
+ if (inlineValue && inlineValue !== '>' && inlineValue !== '|') {
967
+ return inlineValue;
968
+ }
969
+ // Block scalar: collect following indented lines
970
+ const bodyLines = [];
971
+ for (let j = i + 1; j < lines.length; j++) {
972
+ if (lines[j] === '' || /^\s+/.test(lines[j])) {
973
+ bodyLines.push(lines[j].trim());
974
+ } else {
975
+ break;
976
+ }
977
+ }
978
+ return bodyLines.join(' ').trim() || null;
979
+ }
980
+ return null;
981
+ }
982
+
885
983
  function extractFrontmatter(content) {
886
984
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
887
985
  if (!match) {
888
- return { present: false, name: null, description: false, validFrom: undefined, appliedTo: undefined, topLevelKeys: [] };
986
+ return { present: false, name: null, description: false, descriptionText: null, validFrom: undefined, appliedTo: undefined, topLevelKeys: [] };
889
987
  }
890
988
  const block = match[1];
891
989
  const nameMatch = block.match(/^name:\s*(.+)$/m);
@@ -896,10 +994,12 @@ function extractFrontmatter(content) {
896
994
  const keyMatch = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*/);
897
995
  if (keyMatch) topLevelKeys.push(keyMatch[1]);
898
996
  }
997
+ const descriptionText = extractDescriptionText(block);
899
998
  return {
900
999
  present: true,
901
1000
  name: nameMatch ? nameMatch[1].trim() || null : null,
902
1001
  description: /^description:\s*\S/m.test(block),
1002
+ descriptionText,
903
1003
  validFrom: validFromMatch ? validFromMatch[1].trim() : undefined,
904
1004
  appliedTo: appliedToMatch ? appliedToMatch[1].trim() : undefined,
905
1005
  topLevelKeys,
package/lib/lint.test.js CHANGED
@@ -919,4 +919,401 @@ test('falls back to .xdrs subdirectory when given path has no index.md', () => {
919
919
 
920
920
  expect(result.errors).toHaveLength(0);
921
921
  expect(result.xdrsRoot).toBe(path.join(workspaceRoot, '.xdrs'));
922
- });
922
+ });
923
+
924
+ // ─── Emoji checks ─────────────────────────────────────────────────────────────
925
+
926
+ test('reports emoji in XDR document body', () => {
927
+ const workspaceRoot = createWorkspace('xdr-emoji-body', {
928
+ '.xdrs/index.md': rootIndex(),
929
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
930
+ '- [001-main](principles/001-main.md) - Main decision'
931
+ ]),
932
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('This is great \uD83C\uDF89'),
933
+ });
934
+
935
+ const result = lintWorkspace(workspaceRoot);
936
+
937
+ expect(result.errors.join('\n')).toContain('XDR must not contain emojis');
938
+ });
939
+
940
+ test('does not report emoji inside a code block in XDR documents', () => {
941
+ const workspaceRoot = createWorkspace('xdr-emoji-in-code-block', {
942
+ '.xdrs/index.md': rootIndex(),
943
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
944
+ '- [001-main](principles/001-main.md) - Main decision'
945
+ ]),
946
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('```\necho "hello \uD83C\uDF89"\n```'),
947
+ });
948
+
949
+ const result = lintWorkspace(workspaceRoot);
950
+
951
+ expect(result.errors.join('\n')).not.toContain('must not contain emojis');
952
+ });
953
+
954
+ test('reports emoji in SKILL.md', () => {
955
+ const workspaceRoot = createWorkspace('skill-emoji', {
956
+ '.xdrs/index.md': rootIndex(),
957
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
958
+ '- [001-check-links](principles/skills/001-check-links/SKILL.md) - Check links'
959
+ ]),
960
+ '.xdrs/_local/adrs/principles/skills/001-check-links/SKILL.md': skillDocument('Step 1: do something \u2705'),
961
+ });
962
+
963
+ const result = lintWorkspace(workspaceRoot);
964
+
965
+ expect(result.errors.join('\n')).toContain('Skill must not contain emojis');
966
+ });
967
+
968
+ test('reports emoji in article document', () => {
969
+ const workspaceRoot = createWorkspace('article-emoji', {
970
+ '.xdrs/index.md': rootIndex(),
971
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
972
+ '- [001-guide](principles/articles/001-guide.md) - Guide'
973
+ ]),
974
+ '.xdrs/_local/adrs/principles/articles/001-guide.md': articleDocument('Nice overview \uD83D\uDE80'),
975
+ });
976
+
977
+ const result = lintWorkspace(workspaceRoot);
978
+
979
+ expect(result.errors.join('\n')).toContain('Article must not contain emojis');
980
+ });
981
+
982
+ test('reports emoji in research document', () => {
983
+ const workspaceRoot = createWorkspace('research-emoji', {
984
+ '.xdrs/index.md': rootIndex(),
985
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
986
+ '- [001-study](principles/researches/001-study.md) - Study'
987
+ ]),
988
+ '.xdrs/_local/adrs/principles/researches/001-study.md': researchDocument('Some finding \uD83D\uDD2C', 'Question: Is this right?'),
989
+ });
990
+
991
+ const result = lintWorkspace(workspaceRoot);
992
+
993
+ expect(result.errors.join('\n')).toContain('Research must not contain emojis');
994
+ });
995
+
996
+ test('reports emoji in plan document', () => {
997
+ const workspaceRoot = createWorkspace('plan-emoji', {
998
+ '.xdrs/index.md': rootIndex(),
999
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1000
+ '- [001-myplan](principles/plans/001-myplan.md) - My plan'
1001
+ ]),
1002
+ '.xdrs/_local/adrs/principles/plans/001-myplan.md': planDocument('Great plan \uD83C\uDFAF'),
1003
+ });
1004
+
1005
+ const result = lintWorkspace(workspaceRoot);
1006
+
1007
+ expect(result.errors.join('\n')).toContain('Plan must not contain emojis');
1008
+ });
1009
+
1010
+ // ─── Name / description length ────────────────────────────────────────────────
1011
+
1012
+ test('reports XDR frontmatter name exceeding 64 characters', () => {
1013
+ const longName = '_local-adr-001-' + 'a'.repeat(50);
1014
+ const workspaceRoot = createWorkspace('xdr-name-too-long', {
1015
+ '.xdrs/index.md': rootIndex(),
1016
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1017
+ '- [001-main](principles/001-main.md) - Main decision'
1018
+ ]),
1019
+ '.xdrs/_local/adrs/principles/001-main.md': [
1020
+ '---',
1021
+ `name: ${longName}`,
1022
+ 'description: Test XDR document',
1023
+ '---',
1024
+ '',
1025
+ '# _local-adr-001: Main decision',
1026
+ '',
1027
+ '## Context and Problem Statement',
1028
+ '',
1029
+ 'Test body.',
1030
+ '',
1031
+ '## Decision Outcome',
1032
+ '',
1033
+ 'Test decision outcome.',
1034
+ ''
1035
+ ].join('\n'),
1036
+ });
1037
+
1038
+ const result = lintWorkspace(workspaceRoot);
1039
+
1040
+ expect(result.errors.join('\n')).toContain('XDR frontmatter name must be 64 characters or fewer');
1041
+ });
1042
+
1043
+ test('reports XDR frontmatter description exceeding 1024 characters', () => {
1044
+ const workspaceRoot = createWorkspace('xdr-description-too-long', {
1045
+ '.xdrs/index.md': rootIndex(),
1046
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1047
+ '- [001-main](principles/001-main.md) - Main decision'
1048
+ ]),
1049
+ '.xdrs/_local/adrs/principles/001-main.md': [
1050
+ '---',
1051
+ 'name: _local-adr-001-main',
1052
+ `description: ${'x'.repeat(1025)}`,
1053
+ '---',
1054
+ '',
1055
+ '# _local-adr-001: Main decision',
1056
+ '',
1057
+ '## Context and Problem Statement',
1058
+ '',
1059
+ 'Test body.',
1060
+ '',
1061
+ '## Decision Outcome',
1062
+ '',
1063
+ 'Test decision outcome.',
1064
+ ''
1065
+ ].join('\n'),
1066
+ });
1067
+
1068
+ const result = lintWorkspace(workspaceRoot);
1069
+
1070
+ expect(result.errors.join('\n')).toContain('XDR frontmatter description must be 1024 characters or fewer');
1071
+ });
1072
+
1073
+ test('reports SKILL.md frontmatter name exceeding 64 characters', () => {
1074
+ const longName = '001-check-links-' + 'a'.repeat(50);
1075
+ const workspaceRoot = createWorkspace('skill-name-too-long', {
1076
+ '.xdrs/index.md': rootIndex(),
1077
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1078
+ '- [001-check-links](principles/skills/001-check-links/SKILL.md) - Check links'
1079
+ ]),
1080
+ '.xdrs/_local/adrs/principles/skills/001-check-links/SKILL.md': [
1081
+ '---',
1082
+ `name: ${longName}`,
1083
+ 'description: Test skill document',
1084
+ '---',
1085
+ '',
1086
+ '## Overview',
1087
+ '',
1088
+ 'Overview.',
1089
+ '',
1090
+ '## Instructions',
1091
+ '',
1092
+ 'Instructions.',
1093
+ ''
1094
+ ].join('\n'),
1095
+ });
1096
+
1097
+ const result = lintWorkspace(workspaceRoot);
1098
+
1099
+ expect(result.errors.join('\n')).toContain('SKILL.md frontmatter name must be 64 characters or fewer');
1100
+ });
1101
+
1102
+ test('reports SKILL.md frontmatter description exceeding 1024 characters', () => {
1103
+ const workspaceRoot = createWorkspace('skill-description-too-long', {
1104
+ '.xdrs/index.md': rootIndex(),
1105
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1106
+ '- [001-check-links](principles/skills/001-check-links/SKILL.md) - Check links'
1107
+ ]),
1108
+ '.xdrs/_local/adrs/principles/skills/001-check-links/SKILL.md': [
1109
+ '---',
1110
+ 'name: 001-check-links',
1111
+ `description: ${'x'.repeat(1025)}`,
1112
+ '---',
1113
+ '',
1114
+ '## Overview',
1115
+ '',
1116
+ 'Overview.',
1117
+ '',
1118
+ '## Instructions',
1119
+ '',
1120
+ 'Instructions.',
1121
+ ''
1122
+ ].join('\n'),
1123
+ });
1124
+
1125
+ const result = lintWorkspace(workspaceRoot);
1126
+
1127
+ expect(result.errors.join('\n')).toContain('SKILL.md frontmatter description must be 1024 characters or fewer');
1128
+ });
1129
+
1130
+ // ─── Word count ───────────────────────────────────────────────────────────────
1131
+
1132
+ test('reports XDR document exceeding 2600 word limit', () => {
1133
+ const longBody = ('word '.repeat(2601)).trimEnd();
1134
+ const workspaceRoot = createWorkspace('xdr-too-many-words', {
1135
+ '.xdrs/index.md': rootIndex(),
1136
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1137
+ '- [001-main](principles/001-main.md) - Main decision'
1138
+ ]),
1139
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument(longBody),
1140
+ });
1141
+
1142
+ const result = lintWorkspace(workspaceRoot);
1143
+
1144
+ expect(result.errors.join('\n')).toContain('XDR exceeds maximum word count of 2600');
1145
+ });
1146
+
1147
+ test('reports article exceeding 5000 word limit', () => {
1148
+ const longBody = ('word '.repeat(5001)).trimEnd();
1149
+ const workspaceRoot = createWorkspace('article-too-many-words', {
1150
+ '.xdrs/index.md': rootIndex(),
1151
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1152
+ '- [001-guide](principles/articles/001-guide.md) - Guide'
1153
+ ]),
1154
+ '.xdrs/_local/adrs/principles/articles/001-guide.md': articleDocument(longBody),
1155
+ });
1156
+
1157
+ const result = lintWorkspace(workspaceRoot);
1158
+
1159
+ expect(result.errors.join('\n')).toContain('Article exceeds maximum word count of 5000');
1160
+ });
1161
+
1162
+ test('reports research exceeding 5000 word limit', () => {
1163
+ const longBody = ('word '.repeat(5001)).trimEnd();
1164
+ const workspaceRoot = createWorkspace('research-too-many-words', {
1165
+ '.xdrs/index.md': rootIndex(),
1166
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1167
+ '- [001-study](principles/researches/001-study.md) - Study'
1168
+ ]),
1169
+ '.xdrs/_local/adrs/principles/researches/001-study.md': researchDocument(longBody, 'Question: What is the answer?'),
1170
+ });
1171
+
1172
+ const result = lintWorkspace(workspaceRoot);
1173
+
1174
+ expect(result.errors.join('\n')).toContain('Research exceeds maximum word count of 5000');
1175
+ });
1176
+
1177
+ test('reports SKILL.md exceeding 6500 word limit', () => {
1178
+ const longBody = ('word '.repeat(6501)).trimEnd();
1179
+ const workspaceRoot = createWorkspace('skill-too-many-words', {
1180
+ '.xdrs/index.md': rootIndex(),
1181
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1182
+ '- [001-check-links](principles/skills/001-check-links/SKILL.md) - Check links'
1183
+ ]),
1184
+ '.xdrs/_local/adrs/principles/skills/001-check-links/SKILL.md': skillDocument(longBody),
1185
+ });
1186
+
1187
+ const result = lintWorkspace(workspaceRoot);
1188
+
1189
+ expect(result.errors.join('\n')).toContain('Skill exceeds maximum word count of 6500');
1190
+ });
1191
+
1192
+ // ─── Files outside .assets in skill package root ────────────────────────────
1193
+
1194
+ test('passes when extra files exist at skill package root', () => {
1195
+ const workspaceRoot = createWorkspace('extra-skill-file', {
1196
+ '.xdrs/index.md': rootIndex(),
1197
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1198
+ '- [001-check-links](principles/skills/001-check-links/SKILL.md) - Check links'
1199
+ ]),
1200
+ '.xdrs/_local/adrs/principles/skills/001-check-links/SKILL.md': skillDocument('Body.'),
1201
+ '.xdrs/_local/adrs/principles/skills/001-check-links/extra.md': 'Allowed extra file',
1202
+ '.xdrs/_local/adrs/principles/skills/001-check-links/schema.json': '{"type":"object"}',
1203
+ });
1204
+
1205
+ const result = lintWorkspace(workspaceRoot);
1206
+
1207
+ expect(result.errors.join('\n')).not.toContain('extra.md');
1208
+ expect(result.errors.join('\n')).not.toContain('schema.json');
1209
+ });
1210
+
1211
+ // ─── Research Question: in Introduction ──────────────────────────────────────
1212
+
1213
+ test('reports research ## Introduction missing Question: line', () => {
1214
+ const workspaceRoot = createWorkspace('research-no-question', {
1215
+ '.xdrs/index.md': rootIndex(),
1216
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1217
+ '- [001-study](principles/researches/001-study.md) - Study'
1218
+ ]),
1219
+ '.xdrs/_local/adrs/principles/researches/001-study.md': researchDocument('No question here.', null),
1220
+ });
1221
+
1222
+ const result = lintWorkspace(workspaceRoot);
1223
+
1224
+ expect(result.errors.join('\n')).toContain('Research ## Introduction must contain a "Question:" line');
1225
+ });
1226
+
1227
+ test('passes when research ## Introduction contains Question: line', () => {
1228
+ const workspaceRoot = createWorkspace('research-with-question', {
1229
+ '.xdrs/index.md': rootIndex(),
1230
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1231
+ '- [001-study](principles/researches/001-study.md) - Study'
1232
+ ]),
1233
+ '.xdrs/_local/adrs/principles/researches/001-study.md': researchDocument('Some context.', 'Question: Is this the right approach?'),
1234
+ });
1235
+
1236
+ const result = lintWorkspace(workspaceRoot);
1237
+
1238
+ expect(result.errors.join('\n')).not.toContain('Research ## Introduction must contain');
1239
+ });
1240
+
1241
+ // ─── New helpers ──────────────────────────────────────────────────────────────
1242
+
1243
+ function articleDocument(body) {
1244
+ return [
1245
+ '# _local-article-001: Guide',
1246
+ '',
1247
+ '## Overview',
1248
+ '',
1249
+ 'Overview text.',
1250
+ '',
1251
+ '## Content',
1252
+ '',
1253
+ body,
1254
+ '',
1255
+ '## References',
1256
+ '',
1257
+ '- No references.',
1258
+ ''
1259
+ ].join('\n');
1260
+ }
1261
+
1262
+ function researchDocument(introBody, questionLine) {
1263
+ const introSection = questionLine
1264
+ ? `${introBody}\n\n${questionLine}`
1265
+ : introBody;
1266
+ return [
1267
+ '# _local-research-001: Study',
1268
+ '',
1269
+ '## Abstract',
1270
+ '',
1271
+ 'Single paragraph abstract.',
1272
+ '',
1273
+ '## Introduction',
1274
+ '',
1275
+ introSection,
1276
+ '',
1277
+ '## Methods',
1278
+ '',
1279
+ 'Study methods.',
1280
+ '',
1281
+ '## Results',
1282
+ '',
1283
+ 'Study results.',
1284
+ '',
1285
+ '## Discussion',
1286
+ '',
1287
+ 'Discussion.',
1288
+ '',
1289
+ '## Conclusion',
1290
+ '',
1291
+ 'Conclusion.',
1292
+ '',
1293
+ '## References',
1294
+ '',
1295
+ '- No references.',
1296
+ ''
1297
+ ].join('\n');
1298
+ }
1299
+
1300
+ function planDocument(body) {
1301
+ return [
1302
+ '# _local-plan-001: My plan',
1303
+ '',
1304
+ '## Executive Summary',
1305
+ '',
1306
+ 'Summary.',
1307
+ '',
1308
+ '## Context and Problem Statement',
1309
+ '',
1310
+ body,
1311
+ '',
1312
+ '## Proposed Solution',
1313
+ '',
1314
+ 'We will fix it.',
1315
+ '',
1316
+ 'Expected end date: 2026-12-31',
1317
+ ''
1318
+ ].join('\n');
1319
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xdrs-core",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "A standard way to organize Decision Records (XDRs) across scopes, subjects, and teams so that AI agents can reliably query and follow them.",
5
5
  "repository": {
6
6
  "type": "git",