@rohal12/twee-ts 1.5.0 → 1.7.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/dist/index.d.cts CHANGED
@@ -261,6 +261,45 @@ interface BrokenLink {
261
261
  */
262
262
  declare function storyInspect(story: Story): StoryMap;
263
263
 
264
+ /**
265
+ * Lint mode: compile + inspect story structure without producing output.
266
+ * Surfaces broken links, dead ends, orphans, and compilation diagnostics.
267
+ */
268
+
269
+ interface LintResult {
270
+ /** Compilation diagnostics (errors and warnings). */
271
+ diagnostics: Diagnostic[];
272
+ /** Compilation statistics. */
273
+ stats: CompileStats;
274
+ /** Story format name from StoryData (e.g. "SugarCube"). */
275
+ formatName: string;
276
+ /** Story format version from StoryData (e.g. "2.37.3"). */
277
+ formatVersion: string;
278
+ /** The configured start passage name. */
279
+ start: string;
280
+ /** Total passage count. */
281
+ passages: number;
282
+ /** Story passage count (excludes StoryData, StoryTitle, scripts, etc.). */
283
+ storyPassages: number;
284
+ /** Info/special passage count. */
285
+ infoPassages: number;
286
+ /** Broken links: link targets that don't exist as passages. */
287
+ brokenLinks: BrokenLink[];
288
+ /** Story passages with no outgoing links. */
289
+ deadEnds: string[];
290
+ /** Story passages that no other passage links to. */
291
+ orphans: string[];
292
+ }
293
+ /**
294
+ * Lint a story: compile without output rendering, then inspect structure.
295
+ * Uses JSON output mode internally to avoid format resolution.
296
+ */
297
+ declare function lint(options: Omit<CompileOptions, 'outputMode'>): Promise<LintResult>;
298
+ /**
299
+ * Format a lint result as a human-readable report string.
300
+ */
301
+ declare function formatLintReport(result: LintResult): string;
302
+
264
303
  /**
265
304
  * Passage helpers and output methods.
266
305
  * Ported from passage.go / passagedata.go.
@@ -373,4 +412,4 @@ declare function resolveRemoteFormat(name: string, version: string, indices?: st
373
412
  /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
374
413
  declare function discoverCachedFormats(): Map<string, StoryFormatInfo>;
375
414
 
376
- export { type BrokenLink, CONFIG_FILENAME, type CompileOptions, type CompileResult, type CompileStats, type CompileToFileOptions, type Diagnostic, type InlineSource, ItemType, type LexerItem, type OutputMode, type Passage, type PassageMetadata, type SFAIndex, type SFAIndexEntry, type SourceInput, type SourceLocation, type Story, type StoryFormatInfo, type StoryMap, TweeLexer, type TweeTsConfig, TweeTsError, type WatchOptions, applyTagAliases, compile, compileToFile, discoverCachedFormats, discoverFormats, fetchAndCacheFormat, fetchDirectFormat, generateIFID, getCacheDir, getFormatSearchDirs, loadConfig, loadConfigFile, parseFormatJSON, parseSemver, parseTwee, resolveRemoteFormat, scaffoldConfig, semverCompare, storyInspect, tweeLexer, validateConfig, validateIFID, watch };
415
+ export { type BrokenLink, CONFIG_FILENAME, type CompileOptions, type CompileResult, type CompileStats, type CompileToFileOptions, type Diagnostic, type InlineSource, ItemType, type LexerItem, type LintResult, type OutputMode, type Passage, type PassageMetadata, type SFAIndex, type SFAIndexEntry, type SourceInput, type SourceLocation, type Story, type StoryFormatInfo, type StoryMap, TweeLexer, type TweeTsConfig, TweeTsError, type WatchOptions, applyTagAliases, compile, compileToFile, discoverCachedFormats, discoverFormats, fetchAndCacheFormat, fetchDirectFormat, formatLintReport, generateIFID, getCacheDir, getFormatSearchDirs, lint, loadConfig, loadConfigFile, parseFormatJSON, parseSemver, parseTwee, resolveRemoteFormat, scaffoldConfig, semverCompare, storyInspect, tweeLexer, validateConfig, validateIFID, watch };
package/dist/index.d.ts CHANGED
@@ -261,6 +261,45 @@ interface BrokenLink {
261
261
  */
262
262
  declare function storyInspect(story: Story): StoryMap;
263
263
 
264
+ /**
265
+ * Lint mode: compile + inspect story structure without producing output.
266
+ * Surfaces broken links, dead ends, orphans, and compilation diagnostics.
267
+ */
268
+
269
+ interface LintResult {
270
+ /** Compilation diagnostics (errors and warnings). */
271
+ diagnostics: Diagnostic[];
272
+ /** Compilation statistics. */
273
+ stats: CompileStats;
274
+ /** Story format name from StoryData (e.g. "SugarCube"). */
275
+ formatName: string;
276
+ /** Story format version from StoryData (e.g. "2.37.3"). */
277
+ formatVersion: string;
278
+ /** The configured start passage name. */
279
+ start: string;
280
+ /** Total passage count. */
281
+ passages: number;
282
+ /** Story passage count (excludes StoryData, StoryTitle, scripts, etc.). */
283
+ storyPassages: number;
284
+ /** Info/special passage count. */
285
+ infoPassages: number;
286
+ /** Broken links: link targets that don't exist as passages. */
287
+ brokenLinks: BrokenLink[];
288
+ /** Story passages with no outgoing links. */
289
+ deadEnds: string[];
290
+ /** Story passages that no other passage links to. */
291
+ orphans: string[];
292
+ }
293
+ /**
294
+ * Lint a story: compile without output rendering, then inspect structure.
295
+ * Uses JSON output mode internally to avoid format resolution.
296
+ */
297
+ declare function lint(options: Omit<CompileOptions, 'outputMode'>): Promise<LintResult>;
298
+ /**
299
+ * Format a lint result as a human-readable report string.
300
+ */
301
+ declare function formatLintReport(result: LintResult): string;
302
+
264
303
  /**
265
304
  * Passage helpers and output methods.
266
305
  * Ported from passage.go / passagedata.go.
@@ -373,4 +412,4 @@ declare function resolveRemoteFormat(name: string, version: string, indices?: st
373
412
  /** Discover all cached remote formats. Returns a Map like discoverFormats(). */
374
413
  declare function discoverCachedFormats(): Map<string, StoryFormatInfo>;
375
414
 
376
- export { type BrokenLink, CONFIG_FILENAME, type CompileOptions, type CompileResult, type CompileStats, type CompileToFileOptions, type Diagnostic, type InlineSource, ItemType, type LexerItem, type OutputMode, type Passage, type PassageMetadata, type SFAIndex, type SFAIndexEntry, type SourceInput, type SourceLocation, type Story, type StoryFormatInfo, type StoryMap, TweeLexer, type TweeTsConfig, TweeTsError, type WatchOptions, applyTagAliases, compile, compileToFile, discoverCachedFormats, discoverFormats, fetchAndCacheFormat, fetchDirectFormat, generateIFID, getCacheDir, getFormatSearchDirs, loadConfig, loadConfigFile, parseFormatJSON, parseSemver, parseTwee, resolveRemoteFormat, scaffoldConfig, semverCompare, storyInspect, tweeLexer, validateConfig, validateIFID, watch };
415
+ export { type BrokenLink, CONFIG_FILENAME, type CompileOptions, type CompileResult, type CompileStats, type CompileToFileOptions, type Diagnostic, type InlineSource, ItemType, type LexerItem, type LintResult, type OutputMode, type Passage, type PassageMetadata, type SFAIndex, type SFAIndexEntry, type SourceInput, type SourceLocation, type Story, type StoryFormatInfo, type StoryMap, TweeLexer, type TweeTsConfig, TweeTsError, type WatchOptions, applyTagAliases, compile, compileToFile, discoverCachedFormats, discoverFormats, fetchAndCacheFormat, fetchDirectFormat, formatLintReport, generateIFID, getCacheDir, getFormatSearchDirs, lint, loadConfig, loadConfigFile, parseFormatJSON, parseSemver, parseTwee, resolveRemoteFormat, scaffoldConfig, semverCompare, storyInspect, tweeLexer, validateConfig, validateIFID, watch };
package/dist/index.js CHANGED
@@ -258,8 +258,21 @@ function quote(s) {
258
258
  }
259
259
 
260
260
  // src/story.ts
261
+ var passageIndex = /* @__PURE__ */ new WeakMap();
262
+ function getIndex(story) {
263
+ let index = passageIndex.get(story);
264
+ if (!index) {
265
+ index = /* @__PURE__ */ new Map();
266
+ for (let i = 0; i < story.passages.length; i++) {
267
+ const passage = story.passages[i];
268
+ if (passage) index.set(passage.name, i);
269
+ }
270
+ passageIndex.set(story, index);
271
+ }
272
+ return index;
273
+ }
261
274
  function createStory() {
262
- return {
275
+ const story = {
263
276
  name: "",
264
277
  ifid: "",
265
278
  passages: [],
@@ -275,16 +288,17 @@ function createStory() {
275
288
  zoom: 1
276
289
  }
277
290
  };
291
+ passageIndex.set(story, /* @__PURE__ */ new Map());
292
+ return story;
278
293
  }
279
294
  function storyHas(story, name) {
280
- return story.passages.some((p) => p.name === name);
281
- }
282
- function storyIndex(story, name) {
283
- return story.passages.findIndex((p) => p.name === name);
295
+ return getIndex(story).has(name);
284
296
  }
285
297
  function storyAppend(story, p, diagnostics) {
286
- const i = storyIndex(story, p.name);
298
+ const index = getIndex(story);
299
+ const i = index.get(p.name) ?? -1;
287
300
  if (i === -1) {
301
+ index.set(p.name, story.passages.length);
288
302
  story.passages.push(p);
289
303
  } else {
290
304
  diagnostics.push({
@@ -295,9 +309,15 @@ function storyAppend(story, p, diagnostics) {
295
309
  }
296
310
  }
297
311
  function storyPrepend(story, p, diagnostics) {
298
- const i = storyIndex(story, p.name);
312
+ const index = getIndex(story);
313
+ const i = index.get(p.name) ?? -1;
299
314
  if (i === -1) {
300
315
  story.passages.unshift(p);
316
+ index.clear();
317
+ for (let j = 0; j < story.passages.length; j++) {
318
+ const passage = story.passages[j];
319
+ if (passage) index.set(passage.name, j);
320
+ }
301
321
  } else {
302
322
  diagnostics.push({
303
323
  level: "warning",
@@ -587,11 +607,15 @@ function watchFilesystem(pathnames, outFilename, callback) {
587
607
  const watchers = [];
588
608
  let buildTimer = null;
589
609
  const BUILD_DEBOUNCE = 500;
590
- function scheduleBuild() {
610
+ const pendingFiles = /* @__PURE__ */ new Set();
611
+ function scheduleBuild(changedFile) {
612
+ if (changedFile) pendingFiles.add(changedFile);
591
613
  if (buildTimer) clearTimeout(buildTimer);
592
614
  buildTimer = setTimeout(() => {
593
615
  buildTimer = null;
594
- callback();
616
+ const files = pendingFiles.size > 0 ? new Set(pendingFiles) : void 0;
617
+ pendingFiles.clear();
618
+ callback(files);
595
619
  }, BUILD_DEBOUNCE);
596
620
  }
597
621
  for (const pathname of pathnames) {
@@ -601,7 +625,8 @@ function watchFilesystem(pathnames, outFilename, callback) {
601
625
  const abs = resolve(pathname, filename);
602
626
  if (abs === absOutFile) return;
603
627
  if (isKnownFileType(filename)) {
604
- scheduleBuild();
628
+ const rel = relative(process.cwd(), abs);
629
+ scheduleBuild(rel || abs);
605
630
  }
606
631
  });
607
632
  watchers.push(watcher);
@@ -860,6 +885,7 @@ function readFormatSource(format) {
860
885
 
861
886
  // src/loader.ts
862
887
  import { basename } from "path";
888
+ import { statSync as statSync3 } from "fs";
863
889
 
864
890
  // src/lexer.ts
865
891
  var EOF = -1;
@@ -1383,54 +1409,182 @@ function loadInlineSources(story, sources, opts, diagnostics) {
1383
1409
  }
1384
1410
  }
1385
1411
  }
1386
- function loadTwee(story, filename, opts, diagnostics) {
1412
+ function parseTweeFile(filename, opts) {
1387
1413
  const source = readUTF8(filename);
1388
1414
  const result = parseTwee(source, {
1389
1415
  filename,
1390
1416
  trim: opts.trim ?? true,
1391
1417
  twee2Compat: opts.twee2Compat ?? false
1392
1418
  });
1393
- diagnostics.push(...result.diagnostics);
1394
- for (const p of result.passages) {
1395
- storyAdd(story, p, diagnostics);
1396
- }
1419
+ return { passages: result.passages, diagnostics: [...result.diagnostics] };
1397
1420
  }
1398
- function loadTagged(story, tag, filename, diagnostics) {
1421
+ function parseTaggedFile(tag, filename) {
1399
1422
  const source = readUTF8(filename);
1400
- storyAdd(story, { name: basename(filename), tags: [tag], text: source }, diagnostics);
1423
+ return { passages: [{ name: basename(filename), tags: [tag], text: source }], diagnostics: [] };
1401
1424
  }
1402
- function loadMedia(story, tag, filename, diagnostics) {
1425
+ function parseMediaFile(tag, filename) {
1403
1426
  const source = readBase64(filename);
1404
1427
  const name = baseNameWithoutExt(filename);
1405
- storyAdd(
1406
- story,
1407
- {
1408
- name,
1409
- tags: [tag],
1410
- text: `data:${mediaTypeFromFilename(filename)};base64,${source}`
1411
- },
1412
- diagnostics
1413
- );
1428
+ return {
1429
+ passages: [{ name, tags: [tag], text: `data:${mediaTypeFromFilename(filename)};base64,${source}` }],
1430
+ diagnostics: []
1431
+ };
1414
1432
  }
1415
- function loadFont(story, filename, diagnostics) {
1433
+ function parseFontFile(filename) {
1416
1434
  const source = readBase64(filename);
1417
1435
  const name = basename(filename);
1418
1436
  const family = baseNameWithoutExt(filename);
1419
1437
  const ext = normalizedFileExt(filename);
1420
1438
  const mediaType = mediaTypeFromExt(ext);
1421
1439
  const hint = fontFormatHint(ext);
1422
- storyAdd(
1423
- story,
1424
- {
1425
- name,
1426
- tags: ["stylesheet"],
1427
- text: `@font-face {
1440
+ return {
1441
+ passages: [
1442
+ {
1443
+ name,
1444
+ tags: ["stylesheet"],
1445
+ text: `@font-face {
1428
1446
  font-family: "${family}";
1429
1447
  src: url("data:${mediaType};base64,${source}") format("${hint}");
1430
1448
  }`
1431
- },
1432
- diagnostics
1433
- );
1449
+ }
1450
+ ],
1451
+ diagnostics: []
1452
+ };
1453
+ }
1454
+ function parseFile(filename, opts) {
1455
+ const ext = normalizedFileExt(filename);
1456
+ switch (ext) {
1457
+ case "tw":
1458
+ case "twee":
1459
+ return parseTweeFile(filename, opts);
1460
+ case "tw2":
1461
+ case "twee2":
1462
+ return parseTweeFile(filename, { ...opts, twee2Compat: true });
1463
+ case "css":
1464
+ return parseTaggedFile("stylesheet", filename);
1465
+ case "js":
1466
+ return parseTaggedFile("script", filename);
1467
+ case "otf":
1468
+ case "ttf":
1469
+ case "woff":
1470
+ case "woff2":
1471
+ return parseFontFile(filename);
1472
+ case "gif":
1473
+ case "jpeg":
1474
+ case "jpg":
1475
+ case "png":
1476
+ case "svg":
1477
+ case "tif":
1478
+ case "tiff":
1479
+ case "webp":
1480
+ return parseMediaFile("Twine.image", filename);
1481
+ case "aac":
1482
+ case "flac":
1483
+ case "m4a":
1484
+ case "mp3":
1485
+ case "oga":
1486
+ case "ogg":
1487
+ case "opus":
1488
+ case "wav":
1489
+ case "wave":
1490
+ case "weba":
1491
+ return parseMediaFile("Twine.audio", filename);
1492
+ case "mp4":
1493
+ case "ogv":
1494
+ case "webm":
1495
+ return parseMediaFile("Twine.video", filename);
1496
+ case "vtt":
1497
+ return parseMediaFile("Twine.vtt", filename);
1498
+ default:
1499
+ return void 0;
1500
+ }
1501
+ }
1502
+ function loadTwee(story, filename, opts, diagnostics) {
1503
+ const result = parseTweeFile(filename, opts);
1504
+ diagnostics.push(...result.diagnostics);
1505
+ for (const p of result.passages) {
1506
+ storyAdd(story, p, diagnostics);
1507
+ }
1508
+ }
1509
+ function loadTagged(story, tag, filename, diagnostics) {
1510
+ const result = parseTaggedFile(tag, filename);
1511
+ for (const p of result.passages) {
1512
+ storyAdd(story, p, diagnostics);
1513
+ }
1514
+ }
1515
+ function loadMedia(story, tag, filename, diagnostics) {
1516
+ const result = parseMediaFile(tag, filename);
1517
+ for (const p of result.passages) {
1518
+ storyAdd(story, p, diagnostics);
1519
+ }
1520
+ }
1521
+ function loadFont(story, filename, diagnostics) {
1522
+ const result = parseFontFile(filename);
1523
+ for (const p of result.passages) {
1524
+ storyAdd(story, p, diagnostics);
1525
+ }
1526
+ }
1527
+ function loadSourcesCached(story, filenames, opts, diagnostics, processedFiles, cache, changedFiles) {
1528
+ const currentFiles = /* @__PURE__ */ new Set();
1529
+ for (const filename of filenames) {
1530
+ if (processedFiles.has(filename)) {
1531
+ diagnostics.push({ level: "warning", message: `load ${filename}: Skipping duplicate.` });
1532
+ continue;
1533
+ }
1534
+ currentFiles.add(filename);
1535
+ const cached = cache.get(filename);
1536
+ if (changedFiles && cached && !changedFiles.has(filename)) {
1537
+ diagnostics.push(...cached.diagnostics);
1538
+ for (const p of cached.passages) {
1539
+ storyAdd(story, p, diagnostics);
1540
+ }
1541
+ processedFiles.add(filename);
1542
+ continue;
1543
+ }
1544
+ let mtimeMs;
1545
+ try {
1546
+ mtimeMs = statSync3(filename).mtimeMs;
1547
+ } catch {
1548
+ continue;
1549
+ }
1550
+ if (cached && cached.mtimeMs === mtimeMs) {
1551
+ diagnostics.push(...cached.diagnostics);
1552
+ for (const p of cached.passages) {
1553
+ storyAdd(story, p, diagnostics);
1554
+ }
1555
+ processedFiles.add(filename);
1556
+ continue;
1557
+ }
1558
+ try {
1559
+ const result = parseFile(filename, opts);
1560
+ if (!result) continue;
1561
+ cache.set(filename, {
1562
+ mtimeMs,
1563
+ passages: result.passages,
1564
+ diagnostics: result.diagnostics
1565
+ });
1566
+ diagnostics.push(...result.diagnostics);
1567
+ for (const p of result.passages) {
1568
+ storyAdd(story, p, diagnostics);
1569
+ }
1570
+ } catch (e) {
1571
+ diagnostics.push({
1572
+ level: "error",
1573
+ message: `load ${filename}: ${e instanceof Error ? e.message : String(e)}`,
1574
+ file: filename
1575
+ });
1576
+ continue;
1577
+ }
1578
+ processedFiles.add(filename);
1579
+ }
1580
+ for (const key of cache.keys()) {
1581
+ if (!currentFiles.has(key)) {
1582
+ cache.delete(key);
1583
+ }
1584
+ }
1585
+ if (story.name !== "" && !story.passages.some((p) => p.name === "StoryTitle")) {
1586
+ storyPrepend(story, { name: "StoryTitle", tags: [], text: story.name }, diagnostics);
1587
+ }
1434
1588
  }
1435
1589
 
1436
1590
  // src/version.ts
@@ -1699,7 +1853,7 @@ function modifyHead(html, modulePaths, headFile, diagnostics) {
1699
1853
  }
1700
1854
 
1701
1855
  // src/remote-formats.ts
1702
- import { mkdirSync, readFileSync as readFileSync3, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
1856
+ import { mkdirSync, readFileSync as readFileSync3, writeFileSync, existsSync, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
1703
1857
  import { join as join4 } from "path";
1704
1858
  import { homedir as homedir2 } from "os";
1705
1859
  var DEFAULT_SFA_INDICES = [
@@ -1911,7 +2065,7 @@ function discoverCachedFormats() {
1911
2065
  for (const name of names) {
1912
2066
  const nameDir = join4(cacheDir, name);
1913
2067
  try {
1914
- if (!statSync3(nameDir).isDirectory()) continue;
2068
+ if (!statSync4(nameDir).isDirectory()) continue;
1915
2069
  } catch {
1916
2070
  continue;
1917
2071
  }
@@ -1952,11 +2106,12 @@ async function compileToFile(options) {
1952
2106
  }
1953
2107
  async function watch(options) {
1954
2108
  const controller = new AbortController();
2109
+ const cache = /* @__PURE__ */ new Map();
1955
2110
  const filePaths = options.sources.filter((s) => typeof s === "string");
1956
2111
  const modulePaths = options.modules ?? [];
1957
2112
  const allPaths = [...filePaths, ...modulePaths];
1958
- const handle = watchFilesystem(allPaths, options.outFile, () => {
1959
- buildOutput(options).then((result) => {
2113
+ const handle = watchFilesystem(allPaths, options.outFile, (changedFiles) => {
2114
+ buildOutput(options, cache, changedFiles).then((result) => {
1960
2115
  writeFileSync2(options.outFile, result.output, "utf-8");
1961
2116
  options.onBuild?.(result);
1962
2117
  }).catch((e) => {
@@ -1966,7 +2121,7 @@ async function watch(options) {
1966
2121
  controller.signal.addEventListener("abort", () => handle.close());
1967
2122
  return controller;
1968
2123
  }
1969
- async function buildOutput(options) {
2124
+ async function buildOutput(options, cache, changedFiles) {
1970
2125
  const diagnostics = [];
1971
2126
  const outputMode = options.outputMode ?? "html";
1972
2127
  const trim = options.trim ?? true;
@@ -1987,7 +2142,11 @@ async function buildOutput(options) {
1987
2142
  const sourceFilenames = getFilenames(filePaths);
1988
2143
  const story = createStory();
1989
2144
  const processedFiles = /* @__PURE__ */ new Set();
1990
- loadSources(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles);
2145
+ if (cache) {
2146
+ loadSourcesCached(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles, cache, changedFiles);
2147
+ } else {
2148
+ loadSources(story, sourceFilenames, { trim, twee2Compat }, diagnostics, processedFiles);
2149
+ }
1991
2150
  loadInlineSources(story, inlineSources, { trim, twee2Compat }, diagnostics);
1992
2151
  if (options.tagAliases) {
1993
2152
  story.passages = applyTagAliases(story.passages, options.tagAliases);
@@ -2227,6 +2386,69 @@ function storyInspect(story) {
2227
2386
  };
2228
2387
  }
2229
2388
 
2389
+ // src/lint.ts
2390
+ async function lint(options) {
2391
+ const result = await compile({ ...options, outputMode: "json" });
2392
+ const map = storyInspect(result.story);
2393
+ return {
2394
+ diagnostics: result.diagnostics,
2395
+ stats: result.stats,
2396
+ formatName: result.story.twine2.format,
2397
+ formatVersion: result.story.twine2.formatVersion,
2398
+ start: map.start,
2399
+ passages: map.passages.length,
2400
+ storyPassages: map.storyPassages.length,
2401
+ infoPassages: map.infoPassages.length,
2402
+ brokenLinks: map.brokenLinks,
2403
+ deadEnds: map.deadEnds,
2404
+ orphans: map.orphans
2405
+ };
2406
+ }
2407
+ function formatLintReport(result) {
2408
+ const lines = [];
2409
+ const formatStr = result.formatName ? `${result.formatName}${result.formatVersion ? " " + result.formatVersion : ""}` : "unknown";
2410
+ lines.push(`Format: ${formatStr}`);
2411
+ lines.push(
2412
+ `Passages: ${result.passages} total (${result.storyPassages} story, ${result.infoPassages} info), ${result.stats.words.toLocaleString()} words, ${result.stats.files.length} files`
2413
+ );
2414
+ lines.push(`Start: ${result.start}`);
2415
+ if (result.brokenLinks.length > 0) {
2416
+ lines.push("");
2417
+ lines.push(`Broken links (${result.brokenLinks.length}):`);
2418
+ for (const link of result.brokenLinks) {
2419
+ lines.push(` ${link.from} -> ${link.to} (passage "${link.to}" does not exist)`);
2420
+ }
2421
+ }
2422
+ if (result.deadEnds.length > 0) {
2423
+ lines.push("");
2424
+ lines.push(`Dead ends (${result.deadEnds.length}): ${result.deadEnds.join(", ")}`);
2425
+ }
2426
+ if (result.orphans.length > 0) {
2427
+ lines.push("");
2428
+ lines.push(`Orphans (${result.orphans.length}): ${result.orphans.join(", ")}`);
2429
+ }
2430
+ const errors = result.diagnostics.filter((d) => d.level === "error");
2431
+ const warnings = result.diagnostics.filter((d) => d.level === "warning");
2432
+ if (errors.length > 0 || warnings.length > 0) {
2433
+ lines.push("");
2434
+ lines.push(`Diagnostics: ${errors.length} error(s), ${warnings.length} warning(s)`);
2435
+ for (const d of errors) {
2436
+ lines.push(` error: ${d.message}`);
2437
+ }
2438
+ for (const d of warnings) {
2439
+ lines.push(` warning: ${d.message}`);
2440
+ }
2441
+ }
2442
+ lines.push("");
2443
+ const hasErrors = errors.length > 0 || result.brokenLinks.length > 0;
2444
+ if (hasErrors) {
2445
+ lines.push("Lint failed.");
2446
+ } else {
2447
+ lines.push("Lint passed.");
2448
+ }
2449
+ return lines.join("\n");
2450
+ }
2451
+
2230
2452
  // src/config.ts
2231
2453
  import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
2232
2454
  import { join as join5 } from "path";
@@ -2338,9 +2560,11 @@ export {
2338
2560
  discoverFormats,
2339
2561
  fetchAndCacheFormat,
2340
2562
  fetchDirectFormat,
2563
+ formatLintReport,
2341
2564
  generateIFID,
2342
2565
  getCacheDir,
2343
2566
  getFormatSearchDirs,
2567
+ lint,
2344
2568
  loadConfig,
2345
2569
  loadConfigFile,
2346
2570
  parseFormatJSON,