xdrs-core 0.24.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/lint.js CHANGED
@@ -28,6 +28,8 @@ 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 SLIDE_FILE_RE = /^.+-slides(?:-[a-z0-9-]+)?\.md$/;
32
+ const SLIDE_MAX_NAME_LENGTH = 64;
31
33
  const EMOJI_RE = /\p{Extended_Pictographic}/u;
32
34
  const XDR_MAX_WORDS = 2600;
33
35
  const ARTICLE_MAX_WORDS = 5000;
@@ -808,6 +810,51 @@ function lintOrphanAssets(assetsDir, documentPaths, xdrsRoot, errors) {
808
810
  errors.push(`Orphan asset file not referenced by any document: ${toDisplayPath(assetPath)}`);
809
811
  }
810
812
  }
813
+
814
+ // Lint slide/presentation files inside .assets
815
+ for (const assetPath of assetTree.files) {
816
+ const fileName = path.basename(assetPath);
817
+ if (SLIDE_FILE_RE.test(fileName)) {
818
+ lintSlideFile(assetPath, documentPaths, xdrsRoot, errors);
819
+ }
820
+ }
821
+ }
822
+
823
+ function lintSlideFile(filePath, documentPaths, xdrsRoot, errors) {
824
+ const fileName = path.basename(filePath);
825
+
826
+ if (fileName !== fileName.toLowerCase()) {
827
+ errors.push(`Slide file name must be lowercase: ${toDisplayPath(filePath)}`);
828
+ }
829
+
830
+ if (fileName.length > SLIDE_MAX_NAME_LENGTH) {
831
+ errors.push(`Slide file name must be ${SLIDE_MAX_NAME_LENGTH} characters or fewer: ${toDisplayPath(filePath)}`);
832
+ }
833
+
834
+ const content = fs.readFileSync(filePath, 'utf8');
835
+
836
+ const fm = extractFrontmatter(content);
837
+ if (!fm.present) {
838
+ errors.push(`Slide file must start with a YAML frontmatter block containing marp: true: ${toDisplayPath(filePath)}`);
839
+ } else {
840
+ const hasMarp = /^marp:\s*true$/m.test(content.match(/^---\r?\n([\s\S]*?)\r?\n---/)[1]);
841
+ if (!hasMarp) {
842
+ errors.push(`Slide frontmatter must include marp: true: ${toDisplayPath(filePath)}`);
843
+ }
844
+ }
845
+
846
+ lintNoEmojis(content, filePath, 'Slide', errors);
847
+
848
+ // Check that the slide links back to at least one parent document
849
+ const repoRoot = path.dirname(xdrsRoot);
850
+ const slideDir = path.dirname(filePath);
851
+ const slideLinks = parseLocalLinks(content, slideDir, repoRoot);
852
+ const slideLinkSet = new Set(slideLinks.map(normalizePath));
853
+
854
+ const linksToParent = documentPaths.some((docPath) => slideLinkSet.has(normalizePath(docPath)));
855
+ if (!linksToParent) {
856
+ errors.push(`Slide file must contain a link back to its parent document: ${toDisplayPath(filePath)}`);
857
+ }
811
858
  }
812
859
 
813
860
  function collectAssetTree(dirPath, errors) {
package/lib/lint.test.js CHANGED
@@ -1238,8 +1238,158 @@ test('passes when research ## Introduction contains Question: line', () => {
1238
1238
  expect(result.errors.join('\n')).not.toContain('Research ## Introduction must contain');
1239
1239
  });
1240
1240
 
1241
+ // ─── Slide / Presentation checks ─────────────────────────────────────────────
1242
+
1243
+ test('passes for valid slide file in .assets with marp frontmatter and backlink', () => {
1244
+ const workspaceRoot = createWorkspace('marp-valid', {
1245
+ '.xdrs/index.md': rootIndex(),
1246
+ '.xdrs/_local/index.md': '# _local Scope Overview\n\n## Content\n\nLocal scope.\n\n- [ADRs](adrs/index.md)\n',
1247
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1248
+ '- [001-main](principles/001-main.md) - Main decision'
1249
+ ]),
1250
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Body.\n\n[Slides](.assets/001-main-slides.md)'),
1251
+ '.xdrs/_local/adrs/principles/.assets/001-main-slides.md': slideDocument('[Parent](../001-main.md)'),
1252
+ });
1253
+
1254
+ const result = lintWorkspace(workspaceRoot);
1255
+
1256
+ const slideErrors = result.errors.filter((e) => /\bSlide\b/.test(e) || /\bSlide\b/i.test(e) && e.includes('slides'));
1257
+ expect(slideErrors).toHaveLength(0);
1258
+ });
1259
+
1260
+ test('reports slide file missing marp: true in frontmatter', () => {
1261
+ const workspaceRoot = createWorkspace('slide-no-marp', {
1262
+ '.xdrs/index.md': rootIndex(),
1263
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1264
+ '- [001-main](principles/001-main.md) - Main decision'
1265
+ ]),
1266
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Body.\n\n[Slides](.assets/001-main-slides.md)'),
1267
+ '.xdrs/_local/adrs/principles/.assets/001-main-slides.md': [
1268
+ '---',
1269
+ 'theme: default',
1270
+ '---',
1271
+ '',
1272
+ '# Slides',
1273
+ '',
1274
+ '[Parent](../001-main.md)',
1275
+ ''
1276
+ ].join('\n'),
1277
+ });
1278
+
1279
+ const result = lintWorkspace(workspaceRoot);
1280
+
1281
+ expect(result.errors.join('\n')).toContain('Slide frontmatter must include marp: true');
1282
+ });
1283
+
1284
+ test('reports slide file without any frontmatter', () => {
1285
+ const workspaceRoot = createWorkspace('slide-no-frontmatter', {
1286
+ '.xdrs/index.md': rootIndex(),
1287
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1288
+ '- [001-main](principles/001-main.md) - Main decision'
1289
+ ]),
1290
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Body.\n\n[Slides](.assets/001-main-slides.md)'),
1291
+ '.xdrs/_local/adrs/principles/.assets/001-main-slides.md': '# Slides\n\n[Parent](../001-main.md)\n',
1292
+ });
1293
+
1294
+ const result = lintWorkspace(workspaceRoot);
1295
+
1296
+ expect(result.errors.join('\n')).toContain('Slide file must start with a YAML frontmatter block containing marp: true');
1297
+ });
1298
+
1299
+ test('reports slide file missing backlink to parent document', () => {
1300
+ const workspaceRoot = createWorkspace('slide-no-backlink', {
1301
+ '.xdrs/index.md': rootIndex(),
1302
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1303
+ '- [001-main](principles/001-main.md) - Main decision'
1304
+ ]),
1305
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Body.\n\n[Slides](.assets/001-main-slides.md)'),
1306
+ '.xdrs/_local/adrs/principles/.assets/001-main-slides.md': slideDocument('No link back here.'),
1307
+ });
1308
+
1309
+ const result = lintWorkspace(workspaceRoot);
1310
+
1311
+ expect(result.errors.join('\n')).toContain('Slide file must contain a link back to its parent document');
1312
+ });
1313
+
1314
+ test('reports slide file name exceeding 64 characters', () => {
1315
+ const longName = '001-main-slides-' + 'a'.repeat(46) + '.md'; // > 64 chars
1316
+ const workspaceRoot = createWorkspace('slide-name-too-long', {
1317
+ '.xdrs/index.md': rootIndex(),
1318
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1319
+ '- [001-main](principles/001-main.md) - Main decision'
1320
+ ]),
1321
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument(`Body.\n\n[Slides](.assets/${longName})`),
1322
+ [`.xdrs/_local/adrs/principles/.assets/${longName}`]: slideDocument('[Parent](../001-main.md)'),
1323
+ });
1324
+
1325
+ const result = lintWorkspace(workspaceRoot);
1326
+
1327
+ expect(result.errors.join('\n')).toContain('Slide file name must be 64 characters or fewer');
1328
+ });
1329
+
1330
+ test('reports emojis in slide files', () => {
1331
+ const workspaceRoot = createWorkspace('slide-emoji', {
1332
+ '.xdrs/index.md': rootIndex(),
1333
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1334
+ '- [001-main](principles/001-main.md) - Main decision'
1335
+ ]),
1336
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Body.\n\n[Slides](.assets/001-main-slides.md)'),
1337
+ '.xdrs/_local/adrs/principles/.assets/001-main-slides.md': slideDocument('Great stuff! \u{1F680}\n\n[Parent](../001-main.md)'),
1338
+ });
1339
+
1340
+ const result = lintWorkspace(workspaceRoot);
1341
+
1342
+ expect(result.errors.join('\n')).toContain('Slide must not contain emojis');
1343
+ });
1344
+
1345
+ test('does not lint non-slide files in .assets as slides', () => {
1346
+ const workspaceRoot = createWorkspace('non-slide-asset', {
1347
+ '.xdrs/index.md': rootIndex(),
1348
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1349
+ '- [001-main](principles/001-main.md) - Main decision'
1350
+ ]),
1351
+ '.xdrs/_local/adrs/principles/001-main.md': xdrDocument('Body.\n\n[Schema](.assets/schema.json)'),
1352
+ '.xdrs/_local/adrs/principles/.assets/schema.json': '{"type":"object"}',
1353
+ });
1354
+
1355
+ const result = lintWorkspace(workspaceRoot);
1356
+
1357
+ expect(result.errors.join('\n')).not.toContain('Slide');
1358
+ expect(result.errors.join('\n')).not.toContain('marp');
1359
+ });
1360
+
1361
+ test('passes for slide file in article .assets with backlink', () => {
1362
+ const workspaceRoot = createWorkspace('marp-article-valid', {
1363
+ '.xdrs/index.md': rootIndex(),
1364
+ '.xdrs/_local/index.md': '# _local Scope Overview\n\n## Content\n\nLocal scope.\n\n- [ADRs](adrs/index.md)\n',
1365
+ '.xdrs/_local/adrs/index.md': localAdrIndex([
1366
+ '- [001-guide](principles/articles/001-guide.md) - Guide'
1367
+ ]),
1368
+ '.xdrs/_local/adrs/principles/articles/001-guide.md': articleDocument('Body.\n\n[Slides](.assets/001-guide-slides.md)'),
1369
+ '.xdrs/_local/adrs/principles/articles/.assets/001-guide-slides.md': slideDocument('[Parent](../001-guide.md)'),
1370
+ });
1371
+
1372
+ const result = lintWorkspace(workspaceRoot);
1373
+
1374
+ expect(result.errors.join('\n')).not.toContain('Slide');
1375
+ expect(result.errors.join('\n')).not.toContain('marp');
1376
+ });
1377
+
1241
1378
  // ─── New helpers ──────────────────────────────────────────────────────────────
1242
1379
 
1380
+ function slideDocument(body) {
1381
+ return [
1382
+ '---',
1383
+ 'marp: true',
1384
+ '---',
1385
+ '',
1386
+ '# Presentation',
1387
+ '',
1388
+ body,
1389
+ ''
1390
+ ].join('\n');
1391
+ }
1392
+
1243
1393
  function articleDocument(body) {
1244
1394
  return [
1245
1395
  '# _local-article-001: Guide',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xdrs-core",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
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",