codebase-snapshot 0.9.12 → 0.9.14

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/bin.js CHANGED
@@ -1254,9 +1254,18 @@ var DEFAULT_SECRET_PATTERNS = [
1254
1254
  // The unquoted twin for YAML, docker-compose and `.env`: those formats never
1255
1255
  // quote their values, so the rules above cannot see them. `valueGroup` keeps
1256
1256
  // `POSTGRES_PASSWORD:` in the output.
1257
+ //
1258
+ // This is the one unquoted rule without `NOT_AFTER_QUOTE`, and the lookbehind is a
1259
+ // string-literal guard rather than a precision rule: a credential assignment *inside* a
1260
+ // literal — `const dsn = "password=hunter2;host=db"`, `curl -d "password=hunter2"` — was
1261
+ // rejected before it could be considered, and a DSN is a credential in a string. What made the
1262
+ // lookbehind redundant is the shape of `UNQUOTED_LITERAL`: a value has to start with a word
1263
+ // character, so `["awsSecret:", "apiKey:"]` — the list of key names the lookbehind was added
1264
+ // for — cannot match here anyway. `redact.ts` still refuses a match whose separator is inside
1265
+ // the literal, which is the case that list actually is.
1257
1266
  rule: "credential-assignment-unquoted",
1258
1267
  pattern: new RegExp(
1259
- `${NOT_AFTER_QUOTE}${UNQUOTED_STRONG_NAME}\\s{0,8}${ASSIGN}\\s{0,8}${NOT_A_LITERAL_VALUE}${UNQUOTED_LITERAL}`,
1268
+ `${UNQUOTED_STRONG_NAME}\\s{0,8}${ASSIGN}\\s{0,8}${NOT_A_LITERAL_VALUE}${UNQUOTED_LITERAL}`,
1260
1269
  "i"
1261
1270
  ),
1262
1271
  valueGroup: 1
@@ -1291,6 +1300,27 @@ var DEFAULT_SECRET_PATTERNS = [
1291
1300
  pattern: /(?<!["'])(?:api[_-]?key|secret[_-]?key|auth[_-]?token|client[_-]?secret|access[_-]?token|refresh[_-]?token|private[_-]?key|password|passwd|pwd)["']?\s*(?<![=!<>])[:=](?!=)\s*["']([^"'\s]{8,256})["']/i,
1292
1301
  valueGroup: 1
1293
1302
  },
1303
+ {
1304
+ // `${VAR:-fallback}` — a default value in a shell script, a `.env`, docker-compose or a
1305
+ // GitHub Actions workflow.
1306
+ //
1307
+ // This shape defeated every rule above it, and each refusal was individually reasonable:
1308
+ // the quoted rules need a quote after the separator, and the unquoted ones need the value to
1309
+ // begin with a word character — a fallback begins with `$`. So
1310
+ // `POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-MyProdPassword123!}` was emitted whole, `.env` files
1311
+ // and compose files included, which is where that idiom is the norm.
1312
+ //
1313
+ // The **fallback** is what gets masked, not the whole expansion: the variable name is the
1314
+ // readable context, exactly as `POSTGRES_PASSWORD:` is kept by the rule above. A single
1315
+ // interpolation with no operator (`${DB_PASSWORD}`) still does not match — there is nothing
1316
+ // between the braces to mask, and `isTemplateInterpolation` keeps such a value readable.
1317
+ rule: "interpolation-fallback-secret",
1318
+ pattern: new RegExp(
1319
+ `(?:["']${STRONG_CREDENTIAL_NAME}["']|${NOT_AFTER_QUOTE}${STRONG_CREDENTIAL_NAME})\\s{0,8}${ASSIGN}\\s{0,8}["'\`]?\\$\\{[A-Za-z_][A-Za-z0-9_]*:?[-=?+]([^}"'\`\\r\\n]{2,512})\\}["'\`]?`,
1320
+ "i"
1321
+ ),
1322
+ valueGroup: 1
1323
+ },
1294
1324
  {
1295
1325
  // Covers `.env` / shell assignment syntax, which never quotes its values and
1296
1326
  // was therefore invisible to the rule above. Anchored to the line start and
@@ -1318,8 +1348,8 @@ var DEFAULT_SECRET_PATTERNS = [
1318
1348
  valueGroup: 1
1319
1349
  }
1320
1350
  ];
1321
- var PEM_BLOCK_PATTERN = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/g;
1322
- var PEM_UNTERMINATED_PATTERN = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----(?:\r?\n[A-Za-z0-9+/=]{4,}[ A-Za-z0-9+/=]*)+/g;
1351
+ var PEM_BLOCK_PATTERN = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----(?:(?!-----BEGIN)[\s\S])*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/g;
1352
+ var PEM_UNTERMINATED_PATTERN = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----(?:\r\n|\n|\r)(?:(?:[ \t]*(?:Proc-Type|DEK-Info|Comment|Version):[^\r\n]*|[ \t]*)(?:\r\n|\n|\r))*(?:[ \t]*[A-Za-z0-9+/=]{8,}[ \t]*(?:\r\n|\n|\r|$))+/g;
1323
1353
 
1324
1354
  // src/security/redact.ts
1325
1355
  var REDACTION_MARKER = "[REDACTED: potential secret-like value omitted from snapshot]";
@@ -1413,6 +1443,20 @@ function toGlobal(rule) {
1413
1443
  return new RegExp(rule.pattern.source, [...flags].join(""));
1414
1444
  }
1415
1445
  var LINE_BREAK = /(\r\n|\n|\r)/;
1446
+ var LINE_TERMINATOR = /\r\n|\n|\r/g;
1447
+ var COMPILED_RULES = /* @__PURE__ */ new WeakMap();
1448
+ function compiledRuleFor(rule) {
1449
+ let compiled = COMPILED_RULES.get(rule);
1450
+ if (compiled === void 0) {
1451
+ compiled = {
1452
+ rule: rule.rule,
1453
+ pattern: toGlobal(rule),
1454
+ ...rule.valueGroup === void 0 ? {} : { valueGroup: rule.valueGroup }
1455
+ };
1456
+ COMPILED_RULES.set(rule, compiled);
1457
+ }
1458
+ return compiled;
1459
+ }
1416
1460
  function scanAndRedactSecrets(content, rules, filePath) {
1417
1461
  const findings = [];
1418
1462
  let redacted = content;
@@ -1424,18 +1468,15 @@ function scanAndRedactSecrets(content, rules, filePath) {
1424
1468
  blockPattern.lastIndex += 1;
1425
1469
  continue;
1426
1470
  }
1427
- const line = redacted.slice(0, match.index).split("\n").length;
1428
- const newlines = (match[0].match(/\n/g) ?? []).length;
1471
+ const before = redacted.slice(0, match.index);
1472
+ const line = (before.match(LINE_TERMINATOR) ?? []).length + 1;
1473
+ const newlines = (match[0].match(LINE_TERMINATOR) ?? []).length;
1429
1474
  findings.push({ rules: ["private-key-block"], line, path: filePath });
1430
- redacted = redacted.slice(0, match.index) + PEM_REDACTION_MARKER + "\n".repeat(newlines) + redacted.slice(match.index + match[0].length);
1475
+ redacted = before + PEM_REDACTION_MARKER + "\n".repeat(newlines) + redacted.slice(match.index + match[0].length);
1431
1476
  blockPattern.lastIndex = match.index + PEM_REDACTION_MARKER.length + newlines;
1432
1477
  }
1433
1478
  }
1434
- const compiled = rules.map((rule) => ({
1435
- rule: rule.rule,
1436
- pattern: toGlobal(rule),
1437
- ...rule.valueGroup === void 0 ? {} : { valueGroup: rule.valueGroup }
1438
- }));
1479
+ const compiled = rules.map(compiledRuleFor);
1439
1480
  const segments = redacted.split(LINE_BREAK);
1440
1481
  let lineNumber = 1;
1441
1482
  for (let index = 0; index < segments.length; index += 2) {
@@ -1453,8 +1494,10 @@ function scanAndRedactSecrets(content, rules, filePath) {
1453
1494
  }
1454
1495
  const group = valueGroup === void 0 ? void 0 : match.indices?.[valueGroup];
1455
1496
  if (group && isSelfNamingConstant(line, group[0], group[1])) continue;
1456
- if (valueGroup !== void 0 && startsInsideStringLiteral(line, match.index)) continue;
1457
- if (group && isUnquotedValue(line, group[0]) && isDeclaredBinding(line, match.index)) {
1497
+ if (valueGroup !== void 0 && startsInsideStringLiteral(line, match.index, group?.[0])) {
1498
+ continue;
1499
+ }
1500
+ if (group && isUnquotedValue(line, group[0]) && isDeclaredBinding(line, match.index, filePath)) {
1458
1501
  continue;
1459
1502
  }
1460
1503
  if (group && isTemplateInterpolation(line, group[0], group[1])) continue;
@@ -1487,14 +1530,20 @@ function expandOverToken(line, start, end) {
1487
1530
  while (right < line.length && TOKEN_CHARS_RIGHT.test(line[right])) right += 1;
1488
1531
  return { start: left, end: right };
1489
1532
  }
1490
- function startsInsideStringLiteral(line, matchIndex) {
1533
+ function startsInsideStringLiteral(line, matchIndex, valueStart) {
1491
1534
  let runStart = matchIndex;
1492
1535
  while (runStart > 0 && /[A-Za-z0-9_-]/.test(line[runStart - 1])) runStart -= 1;
1493
1536
  const before = runStart > 0 ? line[runStart - 1] : "";
1494
- return before === '"' || before === "'" || before === "`";
1537
+ if (before !== '"' && before !== "'" && before !== "`") return false;
1538
+ if (valueStart === void 0) return true;
1539
+ return /["'`]/.test(line.slice(matchIndex, valueStart));
1495
1540
  }
1496
1541
  function isTemplateInterpolation(line, start, end) {
1497
- return line[start] === "$" && line[start + 1] === "{" && line[end - 1] === "}";
1542
+ if (line[start] !== "$" || line[start + 1] !== "{" || line[end - 1] !== "}") {
1543
+ return false;
1544
+ }
1545
+ const inner = line.slice(start + 2, end - 1).trim();
1546
+ return /^[A-Za-z_$][A-Za-z0-9_$.()[\]]*$/.test(inner);
1498
1547
  }
1499
1548
  function isSelfNamingConstant(line, start, end) {
1500
1549
  const value = line.slice(start, end);
@@ -1519,9 +1568,19 @@ function isUnquotedValue(line, start) {
1519
1568
  const before = start > 0 ? line[start - 1] : "";
1520
1569
  return before !== '"' && before !== "'" && before !== "`";
1521
1570
  }
1522
- var DECLARATION_KEYWORDS = /\b(?:const|let|var|readonly|public|private|protected|static|declare|export|using)\s+$/;
1571
+ var DECLARATION_KEYWORDS = /\b(?:const|let|var|readonly|public|private|protected|static|using)\s+$/;
1572
+ var ASSIGNMENT_FILE = /\.(?:sh|bash|zsh|ksh|fish|env|ya?ml|toml|ini|conf|cfg|properties|tf|tfvars|service)$/i;
1573
+ var ASSIGNMENT_BASENAME = /^(?:\.env(?:\.[^/]*)?|\.envrc|\.profile|\.bashrc|\.zshrc|\.bash_profile)$/i;
1574
+ function isAssignmentFile(filePath) {
1575
+ if (filePath === void 0) return false;
1576
+ const normalised = filePath.replace(/\\/g, "/");
1577
+ if (ASSIGNMENT_FILE.test(normalised)) return true;
1578
+ const base = normalised.slice(normalised.lastIndexOf("/") + 1);
1579
+ return ASSIGNMENT_BASENAME.test(base);
1580
+ }
1523
1581
  var DECLARATION_WINDOW = 40;
1524
- function isDeclaredBinding(line, matchIndex) {
1582
+ function isDeclaredBinding(line, matchIndex, filePath) {
1583
+ if (isAssignmentFile(filePath)) return false;
1525
1584
  let runStart = matchIndex;
1526
1585
  while (runStart > 0 && /[A-Za-z0-9_$]/.test(line[runStart - 1])) runStart -= 1;
1527
1586
  const from = Math.max(0, runStart - DECLARATION_WINDOW);
@@ -1563,6 +1622,15 @@ function readLinkTarget(linkPath, fallback) {
1563
1622
  return fallback;
1564
1623
  }
1565
1624
  }
1625
+ function brokenLinkRecord(linkPath, relativePath, rootReal) {
1626
+ const rawTarget = readLinkTarget(linkPath, linkPath);
1627
+ const resolvedFallback = path2.resolve(path2.dirname(linkPath), rawTarget);
1628
+ return isInside(rootReal, resolvedFallback) ? { path: relativePath, reason: "read-error", detail: "broken symlink" } : {
1629
+ path: relativePath,
1630
+ reason: "outside-root",
1631
+ detail: `link target resolves outside the scanned root (target: ${rawTarget}); not followed`
1632
+ };
1633
+ }
1566
1634
  function isInside(root, candidate) {
1567
1635
  const relative12 = path2.relative(root, candidate);
1568
1636
  return relative12 === "" || !relative12.startsWith(`..${path2.sep}`) && relative12 !== ".." && !path2.isAbsolute(relative12);
@@ -1652,6 +1720,13 @@ function isSensitivePath(relativePath) {
1652
1720
  return matchesSensitiveList(posixPath, base);
1653
1721
  }
1654
1722
  var GLOBAL_PATTERNS = /* @__PURE__ */ new WeakMap();
1723
+ var GLOB_REGEX_CACHE = /* @__PURE__ */ new Map();
1724
+ var GITIGNORE_REGEX_CACHE = /* @__PURE__ */ new Map();
1725
+ var REGEX_CACHE_LIMIT = 4096;
1726
+ function rememberCompiled(cache, key, value) {
1727
+ if (cache.size >= REGEX_CACHE_LIMIT) cache.clear();
1728
+ cache.set(key, value);
1729
+ }
1655
1730
  function globalPattern(pattern) {
1656
1731
  let compiled = GLOBAL_PATTERNS.get(pattern);
1657
1732
  if (compiled === void 0) {
@@ -1662,8 +1737,8 @@ function globalPattern(pattern) {
1662
1737
  }
1663
1738
  var DEFAULT_PATH_RULES = [...DEFAULT_SECRET_PATTERNS];
1664
1739
  function pathScanRules(extraRules) {
1665
- if (extraRules === void 0 || extraRules.length === 0) return DEFAULT_PATH_RULES;
1666
- return [...DEFAULT_SECRET_PATTERNS, ...extraRules];
1740
+ if (extraRules === void 0) return DEFAULT_PATH_RULES;
1741
+ return extraRules;
1667
1742
  }
1668
1743
  function findSecretInPath(relativePath, extraRules) {
1669
1744
  const posixPath = relativePath.replace(/\\/g, "/");
@@ -1712,11 +1787,22 @@ function maskSkipPaths(records, extraRules) {
1712
1787
  });
1713
1788
  }
1714
1789
  function globToRegExp(glob) {
1790
+ const cached = GLOB_REGEX_CACHE.get(glob);
1791
+ if (cached !== void 0) return cached;
1715
1792
  const normalized = glob.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
1716
1793
  const anchored = normalized.startsWith("/");
1717
1794
  const body = anchored ? normalized.slice(1) : normalized;
1718
1795
  const prefix = anchored || body.includes("/") ? "^" : "(?:^|.*/)";
1719
- return new RegExp(`${prefix}${globBodyToRegExp(body, true)}(?:/.*)?$`);
1796
+ let compiled;
1797
+ try {
1798
+ compiled = new RegExp(`${prefix}${globBodyToRegExp(body, true)}(?:/.*)?$`);
1799
+ } catch (error) {
1800
+ throw new Error(
1801
+ `"${glob}" is not a valid glob: ${error.message}. A character class must be ascending, as in "[a-z]".`
1802
+ );
1803
+ }
1804
+ rememberCompiled(GLOB_REGEX_CACHE, glob, compiled);
1805
+ return compiled;
1720
1806
  }
1721
1807
  function globBodyToRegExp(body, allowBraces) {
1722
1808
  let out = "";
@@ -1828,6 +1914,14 @@ function escapeRegExpChar(character) {
1828
1914
  return /[\\^$.*+?()[\]{}|/]/.test(character) ? `\\${character}` : character;
1829
1915
  }
1830
1916
  function matchGitignorePattern(target, pattern) {
1917
+ let compiled = GITIGNORE_REGEX_CACHE.get(pattern);
1918
+ if (compiled === void 0) {
1919
+ compiled = compileGitignorePattern(pattern);
1920
+ rememberCompiled(GITIGNORE_REGEX_CACHE, pattern, compiled);
1921
+ }
1922
+ return compiled.test(target);
1923
+ }
1924
+ function compileGitignorePattern(pattern) {
1831
1925
  const anchored = pattern.startsWith("/");
1832
1926
  const body = anchored ? pattern.slice(1) : pattern;
1833
1927
  let source = "";
@@ -1876,7 +1970,11 @@ function matchGitignorePattern(target, pattern) {
1876
1970
  source += escapeRegExpChar(character);
1877
1971
  }
1878
1972
  const prefix = anchored || body.includes("/") ? "^" : "(?:^|.*/)";
1879
- return new RegExp(`${prefix}${source}(?:/.*)?$`).test(target);
1973
+ try {
1974
+ return new RegExp(`${prefix}${source}(?:/.*)?$`);
1975
+ } catch {
1976
+ return /$^/;
1977
+ }
1880
1978
  }
1881
1979
  function matchesGitignoreRule(rule, scoped, isDirectory) {
1882
1980
  if (!rule.dirOnly) return matchGitignorePattern(scoped, rule.pattern);
@@ -1906,14 +2004,31 @@ function gitignoreVerdict(layers, relativePath, isDirectory) {
1906
2004
  }
1907
2005
  return verdict;
1908
2006
  }
1909
- function defaultIgnoreReason(relativePath) {
2007
+ var DEFAULT_TOOL_DIR_NAMES = ["snapshot"];
2008
+ function toolDirNamesFor(toolOwnedPaths) {
2009
+ const names = new Set(DEFAULT_TOOL_DIR_NAMES);
2010
+ for (const entry of toolOwnedPaths) {
2011
+ const base = entry.replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop();
2012
+ if (base !== void 0 && base.length > 0) names.add(base);
2013
+ }
2014
+ return [...names];
2015
+ }
2016
+ function isToolLeftoverName(name, dirNames) {
2017
+ for (const dirName of dirNames) {
2018
+ if (name.startsWith(`${dirName}.backup-`) || name.startsWith(`${dirName}.partial-`)) {
2019
+ return true;
2020
+ }
2021
+ }
2022
+ return false;
2023
+ }
2024
+ function defaultIgnoreReason(relativePath, toolDirNames = DEFAULT_TOOL_DIR_NAMES) {
1910
2025
  const posixPath = relativePath.replace(/\\/g, "/");
1911
2026
  const parts = posixPath.split("/");
1912
2027
  for (let i = 0; i < parts.length - 1; i++) {
1913
2028
  if (DEFAULT_IGNORED_DIRS.includes(parts[i])) {
1914
2029
  return `built-in ignore: directory "${parts[i]}"`;
1915
2030
  }
1916
- if (parts[i].startsWith("snapshot.backup-")) {
2031
+ if (isToolLeftoverName(parts[i], toolDirNames)) {
1917
2032
  return `built-in ignore: leftover snapshot backup "${parts[i]}"`;
1918
2033
  }
1919
2034
  }
@@ -1921,6 +2036,9 @@ function defaultIgnoreReason(relativePath) {
1921
2036
  if (DEFAULT_IGNORED_FILES.includes(base)) {
1922
2037
  return `built-in ignore: filename "${base}"`;
1923
2038
  }
2039
+ if (isToolLeftoverName(base, toolDirNames)) {
2040
+ return `built-in ignore: leftover snapshot backup "${base}"`;
2041
+ }
1924
2042
  return null;
1925
2043
  }
1926
2044
  function listIgnoredPaths(cwd) {
@@ -1989,41 +2107,72 @@ function dropLinkedDirectories(cwd, candidates, skipped) {
1989
2107
  }
1990
2108
  const reported = /* @__PURE__ */ new Set();
1991
2109
  const kept = [];
2110
+ const report = (linkedPath, record) => {
2111
+ if (reported.has(linkedPath)) return;
2112
+ reported.add(linkedPath);
2113
+ skipped.push(record);
2114
+ };
1992
2115
  for (const candidate of candidates) {
1993
2116
  const parts = candidate.split("/");
1994
- let linkedDir = null;
2117
+ let linkedPath = null;
1995
2118
  for (let depth = 1; depth <= parts.length; depth += 1) {
1996
2119
  const prefix = parts.slice(0, depth).join("/");
1997
2120
  if (isLink(path2.join(cwd, prefix))) {
1998
- linkedDir = prefix;
2121
+ linkedPath = prefix;
1999
2122
  break;
2000
2123
  }
2001
2124
  }
2002
- if (linkedDir === null) {
2125
+ if (linkedPath === null) {
2003
2126
  kept.push(candidate);
2004
2127
  continue;
2005
2128
  }
2006
- if (reported.has(linkedDir)) continue;
2007
- reported.add(linkedDir);
2008
- const absolute = path2.join(cwd, linkedDir);
2009
- let targetReal = absolute;
2129
+ const absolute = path2.join(cwd, linkedPath);
2130
+ const rawTarget = readLinkTarget(absolute, absolute);
2131
+ let targetReal = null;
2010
2132
  try {
2011
2133
  targetReal = fs2.realpathSync(absolute);
2012
2134
  } catch {
2135
+ targetReal = null;
2013
2136
  }
2014
- const target = readLinkTarget(absolute, targetReal);
2015
- const what = linkedDir === candidate ? "symbolic link" : "directory link";
2016
- skipped.push(
2017
- isInside(rootReal, targetReal) ? {
2018
- path: linkedDir,
2019
- reason: "symlink",
2020
- detail: `${what} not followed (target: ${target})`
2021
- } : {
2022
- path: linkedDir,
2137
+ if (targetReal === null) {
2138
+ report(linkedPath, brokenLinkRecord(absolute, linkedPath, rootReal));
2139
+ continue;
2140
+ }
2141
+ if (!isInside(rootReal, targetReal)) {
2142
+ report(linkedPath, {
2143
+ path: linkedPath,
2023
2144
  reason: "outside-root",
2024
- detail: `link target resolves outside the scanned root (target: ${target}); not followed`
2025
- }
2026
- );
2145
+ detail: `link target resolves outside the scanned root (target: ${rawTarget}); not followed`
2146
+ });
2147
+ continue;
2148
+ }
2149
+ let stats = null;
2150
+ try {
2151
+ stats = fs2.statSync(absolute);
2152
+ } catch {
2153
+ stats = null;
2154
+ }
2155
+ if (stats === null) {
2156
+ report(linkedPath, { path: linkedPath, reason: "read-error", detail: "unreadable symlink" });
2157
+ continue;
2158
+ }
2159
+ if (linkedPath !== candidate || stats.isDirectory()) {
2160
+ report(linkedPath, {
2161
+ path: linkedPath,
2162
+ reason: "symlink",
2163
+ detail: `directory link not followed (target: ${rawTarget})`
2164
+ });
2165
+ continue;
2166
+ }
2167
+ if (!stats.isFile()) {
2168
+ report(linkedPath, {
2169
+ path: linkedPath,
2170
+ reason: "symlink",
2171
+ detail: `link to neither a file nor a directory (target: ${rawTarget})`
2172
+ });
2173
+ continue;
2174
+ }
2175
+ kept.push(candidate);
2027
2176
  }
2028
2177
  return kept;
2029
2178
  }
@@ -2070,6 +2219,7 @@ function collectFiles(cwd, options = {}) {
2070
2219
  (options.toolOwnedPaths ?? []).map((entry) => entry.replace(/\\/g, "/"))
2071
2220
  );
2072
2221
  const isToolOwned = makeToolOwnedDecider(cwd, [...toolOwnedPaths]);
2222
+ const toolDirNames = toolDirNamesFor(toolOwnedPaths);
2073
2223
  let candidates;
2074
2224
  const skipped = [];
2075
2225
  const discovery = options.discovery ?? "auto";
@@ -2098,7 +2248,7 @@ function collectFiles(cwd, options = {}) {
2098
2248
  }
2099
2249
  const files = candidates.filter((file) => {
2100
2250
  if (isToolOwned(file)) return false;
2101
- const ignored = defaultIgnoreReason(file);
2251
+ const ignored = defaultIgnoreReason(file, toolDirNames);
2102
2252
  if (ignored) {
2103
2253
  skipped.push({ path: file, reason: "default-ignore", detail: ignored });
2104
2254
  return false;
@@ -2153,6 +2303,7 @@ function rootRealFor(root) {
2153
2303
  function walkDirectory(dir, baseDir, rootReal, visited, reservedPaths = /* @__PURE__ */ new Set(), parentGitignoreLayers = []) {
2154
2304
  const results = { files: [], skipped: [] };
2155
2305
  const stack = [];
2306
+ const toolDirNames = toolDirNamesFor(reservedPaths);
2156
2307
  const relativeToBase = (target) => path2.relative(baseDir, target).replace(/\\/g, "/") || ".";
2157
2308
  const enter = (current, layers) => {
2158
2309
  if (visited.has(current)) return false;
@@ -2197,11 +2348,11 @@ function walkDirectory(dir, baseDir, rootReal, visited, reservedPaths = /* @__PU
2197
2348
  });
2198
2349
  continue;
2199
2350
  }
2200
- if (DEFAULT_IGNORED_FILES.includes(entry.name) || entry.name.startsWith("snapshot.backup-")) {
2351
+ if (DEFAULT_IGNORED_FILES.includes(entry.name) || isToolLeftoverName(entry.name, toolDirNames)) {
2201
2352
  results.skipped.push({
2202
2353
  path: relPath,
2203
2354
  reason: "default-ignore",
2204
- detail: "built-in ignore: filename"
2355
+ detail: isToolLeftoverName(entry.name, toolDirNames) ? `built-in ignore: leftover snapshot backup "${entry.name}" (not descended)` : "built-in ignore: filename"
2205
2356
  });
2206
2357
  continue;
2207
2358
  }
@@ -2222,7 +2373,7 @@ function walkDirectory(dir, baseDir, rootReal, visited, reservedPaths = /* @__PU
2222
2373
  try {
2223
2374
  targetReal = fs2.realpathSync(fullPath);
2224
2375
  } catch {
2225
- results.skipped.push({ path: relPath, reason: "read-error", detail: "broken symlink" });
2376
+ results.skipped.push(brokenLinkRecord(fullPath, relPath, rootReal));
2226
2377
  continue;
2227
2378
  }
2228
2379
  let stats;
@@ -2322,8 +2473,9 @@ function findConfigFile(cwd) {
2322
2473
  const pkgJsonPath = path3.join(cwd, "package.json");
2323
2474
  if (!fs3.existsSync(pkgJsonPath)) return null;
2324
2475
  try {
2325
- const parsed = JSON.parse(fs3.readFileSync(pkgJsonPath, "utf8"));
2326
- return parsed.snapshot === void 0 ? null : pkgJsonPath;
2476
+ const parsed = JSON.parse(stripJsonComments(fs3.readFileSync(pkgJsonPath, "utf8")));
2477
+ const snapshot = parsed?.snapshot;
2478
+ return typeof snapshot === "object" && snapshot !== null && !Array.isArray(snapshot) ? pkgJsonPath : null;
2327
2479
  } catch {
2328
2480
  return null;
2329
2481
  }
@@ -2373,15 +2525,19 @@ function normalizeBudgets(budgets, maxPartBytes) {
2373
2525
  }
2374
2526
  function normalizeResolvedConfig(config) {
2375
2527
  const discovery = isDiscoveryMode(config.discovery) ? config.discovery : "auto";
2528
+ const monolithWanted = config.includeMonolith === true || config.artifacts?.monolith === true;
2529
+ const artifacts = resolveArtifacts(
2530
+ config.artifacts,
2531
+ void 0,
2532
+ monolithWanted,
2533
+ monolithWanted
2534
+ );
2376
2535
  return markResolvedConfig({
2377
2536
  ...config,
2378
2537
  discovery,
2538
+ includeMonolith: artifacts.monolith,
2379
2539
  secretRules: compileSecretRules(config.secretRules),
2380
- artifacts: resolveArtifacts(
2381
- config.artifacts,
2382
- void 0,
2383
- config.includeMonolith === true
2384
- ),
2540
+ artifacts,
2385
2541
  include: resolveInclude(config.include),
2386
2542
  preset: typeof config.preset === "string" ? config.preset : "generic",
2387
2543
  presetExclude: [
@@ -2590,6 +2746,16 @@ async function importConfigModule(fullPath) {
2590
2746
  url.searchParams.set("codebaseSnapshotLoad", String(moduleConfigLoadToken));
2591
2747
  return await import(url.href);
2592
2748
  }
2749
+ function attachConfigDir(config, filePath) {
2750
+ if (typeof config === "object" && config !== null) {
2751
+ Object.defineProperty(config, CONFIG_DIR_SYMBOL, {
2752
+ value: path3.dirname(filePath),
2753
+ enumerable: false,
2754
+ configurable: true
2755
+ });
2756
+ }
2757
+ return config;
2758
+ }
2593
2759
  async function loadConfigFileDetailed(cwd) {
2594
2760
  const warnings = [];
2595
2761
  const errors = [];
@@ -2605,7 +2771,7 @@ async function loadConfigFileDetailed(cwd) {
2605
2771
  );
2606
2772
  }
2607
2773
  assertKnownConfigKeys(config, fullPath);
2608
- return { config, file: fullPath, warnings, errors };
2774
+ return { config: attachConfigDir(config, fullPath), file: fullPath, warnings, errors };
2609
2775
  } catch (err) {
2610
2776
  const raw = err.message;
2611
2777
  const message = raw.includes(fullPath) ? raw : `${fullPath}: ${raw}`;
@@ -2624,7 +2790,7 @@ async function loadConfigFileDetailed(cwd) {
2624
2790
  const imported = await importConfigModule(fullPath);
2625
2791
  const hasDefault = Object.prototype.hasOwnProperty.call(imported, "default");
2626
2792
  const config = readConfigExport(hasDefault ? imported.default : imported, fullPath);
2627
- return { config, file: fullPath, warnings, errors };
2793
+ return { config: attachConfigDir(config, fullPath), file: fullPath, warnings, errors };
2628
2794
  } catch (err) {
2629
2795
  const message = `Could not load ${fullPath}: ${err.message}`;
2630
2796
  warnings.push(message);
@@ -2642,7 +2808,12 @@ async function loadConfigFileDetailed(cwd) {
2642
2808
  snapshotKey,
2643
2809
  `${pkgJsonPath} (the "snapshot" key)`
2644
2810
  );
2645
- return { config: snapshotKey, file: pkgJsonPath, warnings, errors };
2811
+ return {
2812
+ config: attachConfigDir(snapshotKey, pkgJsonPath),
2813
+ file: pkgJsonPath,
2814
+ warnings,
2815
+ errors
2816
+ };
2646
2817
  }
2647
2818
  if (snapshotKey !== void 0) {
2648
2819
  const kind = Array.isArray(snapshotKey) ? "array" : typeof snapshotKey;
@@ -2681,7 +2852,7 @@ var DEFAULT_ARTIFACTS = {
2681
2852
  function isArtifactName(value) {
2682
2853
  return ARTIFACT_NAMES.includes(value);
2683
2854
  }
2684
- function resolveArtifacts(requested, skipped, includeMonolith, monolithOverride) {
2855
+ function resolveArtifacts(requested, skipped, includeMonolith, monolithOverride, only) {
2685
2856
  const artifacts = {
2686
2857
  ...DEFAULT_ARTIFACTS,
2687
2858
  // `includeMonolith` predates this selection and stays the switch for it.
@@ -2706,6 +2877,21 @@ function resolveArtifacts(requested, skipped, includeMonolith, monolithOverride)
2706
2877
  if (monolithOverride !== void 0) {
2707
2878
  artifacts.monolith = monolithOverride;
2708
2879
  }
2880
+ if (only !== void 0 && only.length > 0) {
2881
+ const keep = /* @__PURE__ */ new Set();
2882
+ for (const name of only) {
2883
+ if (!isArtifactName(name)) {
2884
+ throw new Error(
2885
+ `Unknown artifact "${name}". Expected one of: ${ARTIFACT_NAMES.join(", ")}.`
2886
+ );
2887
+ }
2888
+ keep.add(name);
2889
+ }
2890
+ for (const name of ARTIFACT_NAMES) {
2891
+ artifacts[name] = keep.has(name);
2892
+ }
2893
+ return artifacts;
2894
+ }
2709
2895
  for (const name of skipped ?? []) {
2710
2896
  if (!isArtifactName(name)) {
2711
2897
  throw new Error(
@@ -3006,7 +3192,8 @@ function resolveConfig(userConfig, cliOptions, baseDir) {
3006
3192
  userConfig?.artifacts,
3007
3193
  cliOptions?.skipArtifacts,
3008
3194
  includeMonolith,
3009
- cliMonolith
3195
+ cliMonolith,
3196
+ cliOptions?.onlyArtifacts
3010
3197
  );
3011
3198
  const explicitMaxPartBytes = cliOptions?.maxPartBytes ?? userConfig?.maxPartBytes;
3012
3199
  const maxPartBytes = boundedBytes(
@@ -3180,7 +3367,18 @@ function configDigest(config) {
3180
3367
  excludePatterns: config.excludePatterns,
3181
3368
  preset: config.preset,
3182
3369
  layersMode: config.layersMode,
3183
- budgets: config.budgets,
3370
+ // `budgets.byLayer` is a plain object, and `JSON.stringify` preserves insertion order — so
3371
+ // `{ docs, api }` and `{ api, docs }` hashed differently despite being the same settings, and
3372
+ // `validate` reported configuration drift for a snapshot nobody had changed. The keys are
3373
+ // canonicalised here; nothing else about the object is order-dependent.
3374
+ budgets: {
3375
+ default: config.budgets.default,
3376
+ byLayer: Object.fromEntries(
3377
+ Object.entries(config.budgets.byLayer).sort(
3378
+ ([left], [right]) => left < right ? -1 : left > right ? 1 : 0
3379
+ )
3380
+ )
3381
+ },
3184
3382
  // The matcher is what makes a layer a layer. Hashing only the names meant that
3185
3383
  // moving `{ name: "api", match: "src/api/**" }` to `"src/v2/**"` produced the same
3186
3384
  // digest, so `validate` reported "valid and up to date" for a snapshot whose files
@@ -3288,6 +3486,10 @@ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
3288
3486
  ".mjs",
3289
3487
  ".cjs"
3290
3488
  ]);
3489
+ var PYTHON_EXTENSIONS = /* @__PURE__ */ new Set([".py", ".pyi"]);
3490
+ var RUBY_EXTENSIONS = /* @__PURE__ */ new Set([".rb", ".rake", ".gemspec"]);
3491
+ var C_EXTENSIONS = /* @__PURE__ */ new Set([".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hxx", ".m", ".mm"]);
3492
+ var PYTHON_SUFFIXES = [".py", ".pyi", "/__init__.py", "/__init__.pyi"];
3291
3493
  function buildImportResolver(files) {
3292
3494
  const byPath = /* @__PURE__ */ new Map();
3293
3495
  const byPathFolded = /* @__PURE__ */ new Map();
@@ -3314,11 +3516,45 @@ function buildImportResolver(files) {
3314
3516
  return { byPath, byPathFolded, byStem, byStemFolded };
3315
3517
  }
3316
3518
  function resolveSpecifier(fromFile, specifier, resolver) {
3519
+ const extension = path4.posix.extname(fromFile).toLowerCase();
3520
+ const exact = (key) => resolver.byPath.get(key) ?? resolver.byPathFolded.get(key.toLowerCase()) ?? null;
3521
+ const firstOf = (candidates) => {
3522
+ for (const candidate of candidates) {
3523
+ const hit = exact(candidate);
3524
+ if (hit) return hit;
3525
+ }
3526
+ return null;
3527
+ };
3528
+ if (PYTHON_EXTENSIONS.has(extension) && specifier.startsWith(".")) {
3529
+ const dots = /^\.+/.exec(specifier)?.[0].length ?? 0;
3530
+ const rest = specifier.slice(dots).replace(/\./g, "/");
3531
+ let base = path4.posix.dirname(fromFile);
3532
+ for (let up = 1; up < dots; up += 1) base = path4.posix.dirname(base);
3533
+ const joined2 = rest ? path4.posix.normalize(path4.posix.join(base, rest)) : base;
3534
+ return firstOf([joined2, ...PYTHON_SUFFIXES.map((suffix) => `${joined2}${suffix}`)]);
3535
+ }
3536
+ if (RUBY_EXTENSIONS.has(extension) && specifier.startsWith("./")) {
3537
+ const joined2 = path4.posix.normalize(
3538
+ path4.posix.join(path4.posix.dirname(fromFile), specifier)
3539
+ );
3540
+ return firstOf([`${joined2}.rb`, joined2]);
3541
+ }
3542
+ if (C_EXTENSIONS.has(extension) && specifier.startsWith("./")) {
3543
+ const joined2 = path4.posix.normalize(
3544
+ path4.posix.join(path4.posix.dirname(fromFile), specifier)
3545
+ );
3546
+ return exact(joined2);
3547
+ }
3548
+ if (extension === ".dart" && !specifier.includes(":")) {
3549
+ const joined2 = path4.posix.normalize(
3550
+ path4.posix.join(path4.posix.dirname(fromFile), specifier)
3551
+ );
3552
+ return firstOf([joined2, `${joined2}.dart`]);
3553
+ }
3317
3554
  if (!RELATIVE_SPECIFIER.test(specifier)) return null;
3318
3555
  const joined = path4.posix.normalize(
3319
3556
  path4.posix.join(path4.posix.dirname(fromFile), specifier)
3320
3557
  );
3321
- const exact = (key) => resolver.byPath.get(key) ?? resolver.byPathFolded.get(key.toLowerCase()) ?? null;
3322
3558
  const direct = exact(joined);
3323
3559
  if (direct) return direct;
3324
3560
  for (const suffix of IMPORT_CANDIDATE_SUFFIXES) {
@@ -3450,13 +3686,23 @@ function summarizeTypeScriptSource(relativePath, content) {
3450
3686
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
3451
3687
  summary.imports.push(node.moduleSpecifier.text);
3452
3688
  }
3689
+ if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && node.moduleReference.expression !== void 0 && ts.isStringLiteral(node.moduleReference.expression)) {
3690
+ summary.imports.push(node.moduleReference.expression.text);
3691
+ }
3692
+ if (ts.isCallExpression(node) && node.arguments.length >= 1) {
3693
+ const firstArgument = node.arguments[0];
3694
+ if (firstArgument !== void 0 && ts.isStringLiteral(firstArgument)) {
3695
+ const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
3696
+ const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require";
3697
+ if (isDynamicImport || isRequire) summary.imports.push(firstArgument.text);
3698
+ }
3699
+ }
3453
3700
  if (ts.isExportDeclaration(node)) {
3454
3701
  if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
3455
3702
  summary.imports.push(node.moduleSpecifier.text);
3703
+ summary.reExports.push(node.moduleSpecifier.text);
3456
3704
  if (node.exportClause && ts.isNamespaceExport(node.exportClause)) {
3457
3705
  summary.exports.push(node.exportClause.name.text);
3458
- } else if (!node.exportClause) {
3459
- summary.reExports.push(node.moduleSpecifier.text);
3460
3706
  }
3461
3707
  }
3462
3708
  if (node.exportClause && ts.isNamedExports(node.exportClause)) {
@@ -3601,7 +3847,7 @@ function isCapitalizedName(name) {
3601
3847
  }
3602
3848
 
3603
3849
  // src/ast/profiles/cfamily.ts
3604
- var INCLUDE = /^#\s*include\s*[<"]([^>"]+)[>"]/;
3850
+ var INCLUDE = /^#\s*include\s*([<"])([^>"]+)[>"]/;
3605
3851
  var DEFINE = /^#\s*define\s+([A-Za-z_]\w*)/;
3606
3852
  var USING = /^using\s+(?:namespace\s+)?([\w:]+)\s*;/;
3607
3853
  var TYPE_DECL = /^(?:typedef\s+)?(?:struct|union|class|enum)\s+([A-Za-z_]\w*)/;
@@ -3620,7 +3866,7 @@ var cfamilyProfile = {
3620
3866
  }
3621
3867
  const included = INCLUDE.exec(trimmed);
3622
3868
  if (included) {
3623
- summary.imports.push(included[1]);
3869
+ summary.imports.push(included[1] === '"' ? `./${included[2]}` : included[2]);
3624
3870
  return;
3625
3871
  }
3626
3872
  const used = USING.exec(trimmed);
@@ -4024,7 +4270,15 @@ var pythonProfile = {
4024
4270
  }
4025
4271
  const from = FROM_IMPORT.exec(trimmed);
4026
4272
  if (from) {
4027
- summary.imports.push(from[1]);
4273
+ const module = from[1];
4274
+ if (/^\.+$/.test(module)) {
4275
+ for (const name of from[2].split(",")) {
4276
+ const imported2 = name.replace(/[()]/g, "").trim().split(/\s+as\s+/)[0].trim();
4277
+ if (/^[A-Za-z_]\w*$/.test(imported2)) summary.imports.push(`${module}${imported2}`);
4278
+ }
4279
+ } else {
4280
+ summary.imports.push(module);
4281
+ }
4028
4282
  routePending = false;
4029
4283
  return;
4030
4284
  }
@@ -4073,7 +4327,8 @@ var pythonProfile = {
4073
4327
  };
4074
4328
 
4075
4329
  // src/ast/profiles/ruby.ts
4076
- var REQUIRE = /^require(?:_relative)?\s+["']([^"']+)["']/;
4330
+ var REQUIRE_RELATIVE = /^require_relative\s+["']([^"']+)["']/;
4331
+ var REQUIRE = /^require\s+["']([^"']+)["']/;
4077
4332
  var CLASS_OR_MODULE = /^(?:class|module)\s+([A-Z][\w:]*)/;
4078
4333
  var METHOD2 = /^def\s+(?:self\.)?([A-Za-z_]\w*[?!=]?)/;
4079
4334
  var rubyProfile = {
@@ -4084,6 +4339,11 @@ var rubyProfile = {
4084
4339
  eachLine(content, (line) => {
4085
4340
  const trimmed = line.trim();
4086
4341
  if (!trimmed || trimmed.startsWith("#")) return;
4342
+ const relative12 = REQUIRE_RELATIVE.exec(trimmed);
4343
+ if (relative12) {
4344
+ summary.imports.push(`./${relative12[1]}`);
4345
+ return;
4346
+ }
4087
4347
  const required = REQUIRE.exec(trimmed);
4088
4348
  if (required) {
4089
4349
  summary.imports.push(required[1]);
@@ -4174,7 +4434,8 @@ var rustProfile = {
4174
4434
  };
4175
4435
 
4176
4436
  // src/ast/profiles/sfc.ts
4177
- var SCRIPT_BLOCK = /<script[^>]*>([\s\S]*?)<\/script>/gi;
4437
+ var SCRIPT_BLOCK = /<script([^>]*)>([\s\S]*?)<\/script>/gi;
4438
+ var JSX_LANG = /\blang\s*=\s*["']?[tj]sx\b/i;
4178
4439
  var ASTRO_FRONTMATTER = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?=\r?\n|$)/;
4179
4440
  function componentName(path22) {
4180
4441
  const base = path22.split("/").pop() ?? path22;
@@ -4185,13 +4446,15 @@ var sfcProfile = {
4185
4446
  extensions: [".vue", ".svelte", ".astro"],
4186
4447
  summarize(path22, content) {
4187
4448
  let script = "";
4449
+ let usesJsx = path22.toLowerCase().endsWith(".astro");
4188
4450
  if (path22.toLowerCase().endsWith(".astro")) {
4189
4451
  const frontmatter = ASTRO_FRONTMATTER.exec(content);
4190
4452
  if (frontmatter) script += `${frontmatter[1]}
4191
4453
  `;
4192
4454
  }
4193
4455
  for (const match of content.matchAll(SCRIPT_BLOCK)) {
4194
- script += `${match[1]}
4456
+ if (JSX_LANG.test(match[1] ?? "")) usesJsx = true;
4457
+ script += `${match[2]}
4195
4458
  `;
4196
4459
  }
4197
4460
  const name = componentName(path22);
@@ -4203,7 +4466,7 @@ var sfcProfile = {
4203
4466
  }
4204
4467
  return { summary: finalizeSummary(summary2), quality: "heuristic" };
4205
4468
  }
4206
- const summary = summarizeTypeScriptSource(`${name}.ts`, script);
4469
+ const summary = summarizeTypeScriptSource(`${name}.${usesJsx ? "tsx" : "ts"}`, script);
4207
4470
  summary.reactComponents.push(name);
4208
4471
  summary.exports.push(name);
4209
4472
  return { summary: finalizeSummary(summary), quality: "ast" };
@@ -4641,18 +4904,22 @@ function isBinaryFile(absolutePath) {
4641
4904
  }
4642
4905
 
4643
4906
  // src/ast/imports.ts
4907
+ var SUMMARY_MAX_SOURCE_BYTES = 2 * 1024 * 1024;
4644
4908
  var TS_SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
4645
4909
  var isTsSource = (relativePath) => {
4646
4910
  const lower = relativePath.toLowerCase();
4647
4911
  return TS_SOURCE_EXTENSIONS.some((extension) => lower.endsWith(extension));
4648
4912
  };
4649
4913
  function importsOfText(relativePath, text2) {
4914
+ if (Buffer.byteLength(text2, "utf8") > SUMMARY_MAX_SOURCE_BYTES) return [];
4650
4915
  if (isTsSource(relativePath)) return summarizeTypeScriptSource(relativePath, text2).imports;
4651
4916
  return summarizeWithProfile(relativePath, text2)?.summary.imports ?? [];
4652
4917
  }
4653
4918
  function importsOfFile(relativePath, absolutePath) {
4654
4919
  let buffer;
4655
4920
  try {
4921
+ const stats = fs5.statSync(absolutePath);
4922
+ if (!stats.isFile() || stats.size > SUMMARY_MAX_SOURCE_BYTES) return [];
4656
4923
  buffer = fs5.readFileSync(absolutePath);
4657
4924
  } catch {
4658
4925
  return [];
@@ -4783,9 +5050,13 @@ function describeManifestShapeProblem(value) {
4783
5050
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
4784
5051
  return 'a "parts" entry is not an object';
4785
5052
  }
4786
- if (typeof entry.filename !== "string") {
5053
+ const filename = entry.filename;
5054
+ if (typeof filename !== "string" || filename.trim() === "") {
4787
5055
  return 'a "parts" entry has no "filename"';
4788
5056
  }
5057
+ if (filename.includes("/") || filename.includes("\\") || filename.includes("\0") || filename === "." || filename === ".." || path6.isAbsolute(filename)) {
5058
+ return 'a "parts" entry "filename" must be a bare filename inside parts/';
5059
+ }
4789
5060
  }
4790
5061
  if (!candidate.layers || typeof candidate.layers !== "object" || Array.isArray(candidate.layers)) {
4791
5062
  return 'missing "layers" object';
@@ -4896,7 +5167,7 @@ function validateSnapshot(configInput) {
4896
5167
  });
4897
5168
  for (const diskFile of collection.files) {
4898
5169
  const snapshotHash = manifestFileMap.get(diskFile);
4899
- if (!snapshotHash) {
5170
+ if (snapshotHash === void 0) {
4900
5171
  const skipReason = skippedReasonOnDisk(diskFile);
4901
5172
  if (skipReason === "unchanged") {
4902
5173
  if (unchangedScope !== null) {
@@ -4910,6 +5181,11 @@ function validateSnapshot(configInput) {
4910
5181
  drift.missingInSnapshot.push(diskFile);
4911
5182
  continue;
4912
5183
  }
5184
+ if (unchangedScope !== null && !unchangedScope.has(diskFile) && !becameDependent(diskFile)) {
5185
+ drift.configMismatches.push(
5186
+ `scope: ${maskedPath(diskFile, config.secretRules)} is in the snapshot but is no longer in the changed-files scope`
5187
+ );
5188
+ }
4913
5189
  let diskHash;
4914
5190
  try {
4915
5191
  diskHash = sha256Hex(fs6.readFileSync(path6.join(config.cwd, diskFile)));
@@ -5050,6 +5326,41 @@ function validateSnapshot(configInput) {
5050
5326
  `includeSensitiveFiles: manifest ${recorded.includeSensitiveFiles} vs current ${config.includeSensitiveFiles}`
5051
5327
  );
5052
5328
  }
5329
+ if ((recorded.maxPartTokens ?? void 0) !== (config.maxPartTokens ?? void 0)) {
5330
+ drift.configMismatches.push(
5331
+ `maxPartTokens: manifest ${String(recorded.maxPartTokens ?? "unset")} vs current ${String(config.maxPartTokens ?? "unset")}`
5332
+ );
5333
+ }
5334
+ if ((recorded.maxMonolithBytes ?? null) !== (config.maxMonolithBytes ?? null)) {
5335
+ drift.configMismatches.push(
5336
+ `maxMonolithBytes: manifest ${String(recorded.maxMonolithBytes ?? null)} vs current ${String(config.maxMonolithBytes ?? null)}`
5337
+ );
5338
+ }
5339
+ if (Boolean(recorded.skeleton) !== Boolean(config.skeleton)) {
5340
+ drift.configMismatches.push(
5341
+ `skeleton: manifest ${Boolean(recorded.skeleton)} vs current ${Boolean(config.skeleton)}`
5342
+ );
5343
+ }
5344
+ if (Boolean(recorded.sarif) !== Boolean(config.sarif)) {
5345
+ drift.configMismatches.push(
5346
+ `sarif: manifest ${Boolean(recorded.sarif)} vs current ${Boolean(config.sarif)}`
5347
+ );
5348
+ }
5349
+ if ((recorded.changedSince ?? void 0) !== (config.changedSince ?? void 0)) {
5350
+ drift.configMismatches.push(
5351
+ `changedSince: manifest ${String(recorded.changedSince ?? "unset")} vs current ${String(config.changedSince ?? "unset")}`
5352
+ );
5353
+ }
5354
+ if (Boolean(recorded.staged) !== Boolean(config.staged)) {
5355
+ drift.configMismatches.push(
5356
+ `staged: manifest ${Boolean(recorded.staged)} vs current ${Boolean(config.staged)}`
5357
+ );
5358
+ }
5359
+ if (Boolean(recorded.withDependents) !== Boolean(config.withDependents)) {
5360
+ drift.configMismatches.push(
5361
+ `withDependents: manifest ${Boolean(recorded.withDependents)} vs current ${Boolean(config.withDependents)}`
5362
+ );
5363
+ }
5053
5364
  const effectiveDiscovery = config.discovery ?? "auto";
5054
5365
  const recordedDiscovery = recorded.discovery;
5055
5366
  if (recordedDiscovery !== void 0 && recordedDiscovery !== effectiveDiscovery) {
@@ -5528,9 +5839,21 @@ import * as fs8 from "fs";
5528
5839
  import * as path9 from "path";
5529
5840
 
5530
5841
  // src/partitioner/preview.ts
5842
+ var LINE_ENDINGS = /\r\n|\r/g;
5843
+ var normaliseLineEndings = (content) => content.replace(LINE_ENDINGS, "\n");
5531
5844
  function countLines(content) {
5532
5845
  if (content.length === 0) return 0;
5533
- return content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
5846
+ let breaks = 0;
5847
+ for (let index = 0; index < content.length; index += 1) {
5848
+ const code = content.charCodeAt(index);
5849
+ if (code === 10) {
5850
+ breaks += 1;
5851
+ continue;
5852
+ }
5853
+ if (code === 13 && content.charCodeAt(index + 1) !== 10) breaks += 1;
5854
+ }
5855
+ const last = content.charCodeAt(content.length - 1);
5856
+ return last === 10 || last === 13 ? breaks : breaks + 1;
5534
5857
  }
5535
5858
  function truncateToBytes(content, maxBytes) {
5536
5859
  if (maxBytes <= 0) return { content: "", isTruncated: content.length > 0 };
@@ -5541,17 +5864,18 @@ function truncateToBytes(content, maxBytes) {
5541
5864
  return { content: slice, isTruncated: true };
5542
5865
  }
5543
5866
  function truncatePreview(content, headLines, tailLines) {
5544
- const lines = content.split("\n");
5545
- const total = countLines(content);
5867
+ const normalised = normaliseLineEndings(content);
5868
+ const lines = normalised.split("\n");
5869
+ const total = countLines(normalised);
5546
5870
  if (total <= headLines + tailLines) {
5547
5871
  return {
5548
- content,
5872
+ content: normalised,
5549
5873
  isTruncated: false,
5550
5874
  originalLines: total,
5551
5875
  includedLines: total
5552
5876
  };
5553
5877
  }
5554
- const window = content.endsWith("\n") ? lines.slice(0, -1) : lines;
5878
+ const window = normalised.endsWith("\n") ? lines.slice(0, -1) : lines;
5555
5879
  const head = window.slice(0, headLines);
5556
5880
  const tail = tailLines > 0 ? window.slice(window.length - tailLines) : [];
5557
5881
  const omitted = total - head.length - tail.length;
@@ -5674,10 +5998,13 @@ function describeMatcher(matcher) {
5674
5998
  return String(matcher);
5675
5999
  }
5676
6000
  function normalizeTarget(cwd, target) {
5677
- const absolute = path9.resolve(cwd, target);
5678
- const relative12 = path9.relative(cwd, absolute);
5679
- if (relative12.startsWith("..") || path9.isAbsolute(relative12)) return target;
5680
- return relative12.split(path9.sep).join("/");
6001
+ const resolvedCwd = path9.resolve(cwd);
6002
+ const absolute = path9.resolve(resolvedCwd, target);
6003
+ const relative12 = path9.relative(resolvedCwd, absolute);
6004
+ if (relative12 === "" || relative12 === ".." || relative12.startsWith(`..${path9.sep}`) || path9.isAbsolute(relative12)) {
6005
+ return { relative: target.replace(/\\/g, "/"), outsideRoot: true };
6006
+ }
6007
+ return { relative: relative12.split(path9.sep).join("/"), outsideRoot: false };
5681
6008
  }
5682
6009
  function findRuleMatch(rules, relativePath) {
5683
6010
  return rules.find(
@@ -5704,9 +6031,19 @@ function readManifest2(config) {
5704
6031
  }
5705
6032
  }
5706
6033
  function explainPath(config, target) {
5707
- const relative12 = normalizeTarget(config.cwd, target);
5708
- const absolute = path9.join(config.cwd, relative12);
6034
+ const normalized = normalizeTarget(config.cwd, target);
6035
+ const relative12 = normalized.relative;
5709
6036
  const maskedPath2 = findSecretInPath(relative12, config.secretRules)?.masked ?? relative12;
6037
+ if (normalized.outsideRoot) {
6038
+ return {
6039
+ path: maskedPath2,
6040
+ exists: false,
6041
+ included: false,
6042
+ reason: "outside-root",
6043
+ detail: "path resolves outside the configured repository root and was not inspected"
6044
+ };
6045
+ }
6046
+ const absolute = path9.join(config.cwd, relative12);
5710
6047
  const result = {
5711
6048
  path: maskedPath2,
5712
6049
  exists: fs8.existsSync(absolute),
@@ -5861,6 +6198,178 @@ function renderExplainText(result) {
5861
6198
  import * as fs10 from "fs";
5862
6199
  import * as path11 from "path";
5863
6200
 
6201
+ // src/ast/structured.ts
6202
+ function stripJsonComments2(content) {
6203
+ const text2 = content.replace(/^\uFEFF/, "");
6204
+ const withoutComments = stripComments(text2);
6205
+ return stripTrailingCommas2(withoutComments);
6206
+ }
6207
+ function stripComments(text2) {
6208
+ let out = "";
6209
+ let inString = false;
6210
+ let escaped = false;
6211
+ for (let index = 0; index < text2.length; index += 1) {
6212
+ const char = text2[index];
6213
+ if (inString) {
6214
+ out += char;
6215
+ if (escaped) escaped = false;
6216
+ else if (char === "\\") escaped = true;
6217
+ else if (char === '"') inString = false;
6218
+ continue;
6219
+ }
6220
+ if (char === '"') {
6221
+ inString = true;
6222
+ out += char;
6223
+ continue;
6224
+ }
6225
+ if (char === "/" && text2[index + 1] === "/") {
6226
+ while (index < text2.length && text2[index] !== "\n" && text2[index] !== "\r") index += 1;
6227
+ index -= 1;
6228
+ continue;
6229
+ }
6230
+ if (char === "/" && text2[index + 1] === "*") {
6231
+ index += 2;
6232
+ while (index < text2.length && !(text2[index] === "*" && text2[index + 1] === "/")) {
6233
+ if (text2[index] === "\n" || text2[index] === "\r") out += text2[index];
6234
+ index += 1;
6235
+ }
6236
+ index += 1;
6237
+ continue;
6238
+ }
6239
+ out += char;
6240
+ }
6241
+ return out;
6242
+ }
6243
+ function stripTrailingCommas2(text2) {
6244
+ let out = "";
6245
+ let inString = false;
6246
+ let escaped = false;
6247
+ for (let index = 0; index < text2.length; index += 1) {
6248
+ const char = text2[index];
6249
+ if (inString) {
6250
+ out += char;
6251
+ if (escaped) escaped = false;
6252
+ else if (char === "\\") escaped = true;
6253
+ else if (char === '"') inString = false;
6254
+ continue;
6255
+ }
6256
+ if (char === '"') {
6257
+ inString = true;
6258
+ out += char;
6259
+ continue;
6260
+ }
6261
+ if (char === ",") {
6262
+ let next = index + 1;
6263
+ while (next < text2.length && /\s/.test(text2[next])) next += 1;
6264
+ if (text2[next] === "}" || text2[next] === "]") continue;
6265
+ }
6266
+ out += char;
6267
+ }
6268
+ return out;
6269
+ }
6270
+ function summarizeJson(content) {
6271
+ try {
6272
+ const parsed = JSON.parse(stripJsonComments2(content));
6273
+ if (Array.isArray(parsed)) {
6274
+ const sample = parsed[0];
6275
+ const sampleKeys = sample && typeof sample === "object" && !Array.isArray(sample) ? Object.keys(sample).slice(0, 10).sort() : [];
6276
+ return {
6277
+ shape: "array",
6278
+ itemCount: parsed.length,
6279
+ keys: [],
6280
+ sampleKeys
6281
+ };
6282
+ }
6283
+ if (parsed && typeof parsed === "object") {
6284
+ const keys = Object.keys(parsed).sort();
6285
+ return {
6286
+ shape: "object",
6287
+ itemCount: keys.length,
6288
+ keys: keys.slice(0, 30),
6289
+ sampleKeys: []
6290
+ };
6291
+ }
6292
+ return {
6293
+ shape: typeof parsed,
6294
+ itemCount: null,
6295
+ keys: [],
6296
+ sampleKeys: []
6297
+ };
6298
+ } catch {
6299
+ return {
6300
+ shape: "invalid-json",
6301
+ itemCount: null,
6302
+ keys: [],
6303
+ sampleKeys: []
6304
+ };
6305
+ }
6306
+ }
6307
+ function frontMatterEnd(lines) {
6308
+ if (lines.length < 3 || lines[0].trim() !== "---") return 0;
6309
+ for (let index = 1; index < lines.length; index += 1) {
6310
+ const line = lines[index];
6311
+ const candidate = line.trim();
6312
+ if (candidate === "---" || candidate === "...") {
6313
+ return index > 1 ? index : 0;
6314
+ }
6315
+ if (candidate === "") continue;
6316
+ if (/^#(\s|$)/.test(candidate)) continue;
6317
+ if (/^(`{3,}|~{3,})/.test(candidate)) return 0;
6318
+ if (/^#{1,6}\s+\S/.test(candidate)) return 0;
6319
+ const isYamlMapping = /^[A-Za-z0-9_.-]+\s*:(\s|$)/.test(candidate);
6320
+ const isListItem = /^-(?:(\s|$))/.test(candidate);
6321
+ const isIndented = /^[ \t]/.test(line);
6322
+ if (!isYamlMapping && !isListItem && !isIndented) return 0;
6323
+ }
6324
+ return 0;
6325
+ }
6326
+ function summarizeMarkdown(content) {
6327
+ const headings = [];
6328
+ const frontmatterKeys = [];
6329
+ const lines = content.replace(/^\uFEFF/, "").split(/\r\n|\n|\r/);
6330
+ let inFrontmatter = false;
6331
+ let fence = null;
6332
+ const frontMatterEndLine = frontMatterEnd(lines);
6333
+ for (let index = 0; index < lines.length; index++) {
6334
+ const line = lines[index];
6335
+ if (index === 0 && frontMatterEndLine > 0) {
6336
+ inFrontmatter = true;
6337
+ continue;
6338
+ }
6339
+ if (inFrontmatter) {
6340
+ const trimmedFrontmatter = line.trim();
6341
+ if (trimmedFrontmatter === "---" || trimmedFrontmatter === "...") {
6342
+ inFrontmatter = false;
6343
+ continue;
6344
+ }
6345
+ const match = /^([A-Za-z0-9_.-]+)\s*:/.exec(line);
6346
+ if (match) frontmatterKeys.push(match[1]);
6347
+ continue;
6348
+ }
6349
+ const trimmed = line.trim();
6350
+ const fenceMatch = /^(`{3,}|~{3,})(.*)$/.exec(trimmed);
6351
+ if (fenceMatch) {
6352
+ const run = fenceMatch[1];
6353
+ const rest = fenceMatch[2].trim();
6354
+ if (fence === null) {
6355
+ fence = { marker: run[0], length: run.length };
6356
+ } else if (run[0] === fence.marker && run.length >= fence.length && rest === "") {
6357
+ fence = null;
6358
+ }
6359
+ continue;
6360
+ }
6361
+ if (fence !== null) continue;
6362
+ const headingMatch = /^(#{1,6})\s+(.+)$/.exec(trimmed);
6363
+ if (headingMatch) {
6364
+ headings.push(`${headingMatch[1]} ${headingMatch[2].trim()}`);
6365
+ }
6366
+ }
6367
+ return {
6368
+ headings: headings.slice(0, 40),
6369
+ frontmatterKeys: Array.from(new Set(frontmatterKeys)).sort()
6370
+ };
6371
+ }
6372
+
5864
6373
  // src/gitignore.ts
5865
6374
  import * as fs9 from "fs";
5866
6375
  import * as path10 from "path";
@@ -5901,7 +6410,7 @@ function gitignorePath(cwd, filePath) {
5901
6410
 
5902
6411
  // src/version.ts
5903
6412
  var GENERATOR_NAME = "codebase-snapshot";
5904
- var GENERATOR_VERSION = "0.9.12";
6413
+ var GENERATOR_VERSION = "0.9.14";
5905
6414
  var LEGACY_GENERATOR_NAMES = ["@ankurhalder/codebase-snapshot"];
5906
6415
  function isOurGeneratorName(name) {
5907
6416
  if (typeof name !== "string") return false;
@@ -6111,6 +6620,24 @@ function displayWidth(line) {
6111
6620
  return width;
6112
6621
  }
6113
6622
  var GUTTER_PATTERN = /^[ \t]*(?:[❯◆◻◼▏✖][ \t]+)*/;
6623
+ function sliceByDisplayWidth(text2, maxCells) {
6624
+ let cells = 0;
6625
+ let lastSpace = -1;
6626
+ let index = 0;
6627
+ for (const character of text2) {
6628
+ const width = isWideCodePoint(character.codePointAt(0) ?? 0) ? 2 : 1;
6629
+ if (cells + width > maxCells) break;
6630
+ if (character === " ") lastSpace = index;
6631
+ cells += width;
6632
+ index += character.length;
6633
+ }
6634
+ if (index >= text2.length) return { head: text2, tail: "" };
6635
+ if (lastSpace > 0) {
6636
+ return { head: text2.slice(0, lastSpace), tail: text2.slice(lastSpace + 1).trimStart() };
6637
+ }
6638
+ const safeIndex = index > 0 ? index : Array.from(text2)[0]?.length ?? 1;
6639
+ return { head: text2.slice(0, safeIndex), tail: text2.slice(safeIndex).trimStart() };
6640
+ }
6114
6641
  function wrapOneLine(line, limit) {
6115
6642
  if (displayWidth(line) <= limit) return [line];
6116
6643
  const gutter = GUTTER_PATTERN.exec(line)?.[0] ?? "";
@@ -6125,12 +6652,11 @@ function wrapOneLine(line, limit) {
6125
6652
  rows.push(prefix + rest);
6126
6653
  break;
6127
6654
  }
6128
- let cut = room;
6129
- const space = rest.slice(0, room).lastIndexOf(" ");
6130
- if (space > 0) cut = space;
6131
- const taken = rest.slice(0, cut).trimEnd();
6132
- rows.push(`${prefix}${taken.length > 0 ? taken : rest.slice(0, cut)}`);
6133
- rest = rest.slice(cut).trimStart();
6655
+ const { head, tail } = sliceByDisplayWidth(rest, room);
6656
+ rows.push(
6657
+ `${prefix}${head.length > 0 ? head : rest.slice(0, 1)}`
6658
+ );
6659
+ rest = tail;
6134
6660
  prefix = continuation;
6135
6661
  }
6136
6662
  return rows;
@@ -6201,7 +6727,7 @@ function keyReader(input) {
6201
6727
  next() {
6202
6728
  const queued = queue.shift();
6203
6729
  if (queued) return Promise.resolve(queued);
6204
- return new Promise((resolve9) => waiters.push(resolve9));
6730
+ return new Promise((resolve10) => waiters.push(resolve10));
6205
6731
  },
6206
6732
  close() {
6207
6733
  input.off("keypress", onKey);
@@ -6418,17 +6944,25 @@ var CONFIG_CANDIDATES = [
6418
6944
  ".snapshotrc.json",
6419
6945
  ".snapshotrc"
6420
6946
  ];
6421
- function chooseConfigFileName(cwd) {
6422
- if (!supportsTypeScriptImports()) return "snapshot.config.mjs";
6423
- const pkgPath = path11.join(cwd, "package.json");
6424
- if (!fs10.existsSync(pkgPath)) return "snapshot.config.mjs";
6947
+ function parsePackageJson(raw) {
6948
+ const parsed = JSON.parse(stripJsonComments2(raw));
6949
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
6950
+ throw new Error("package.json is not a JSON object");
6951
+ }
6952
+ return parsed;
6953
+ }
6954
+ function readPackageJson(cwd) {
6425
6955
  try {
6426
- const parsed = JSON.parse(fs10.readFileSync(pkgPath, "utf8"));
6427
- return parsed.type === "module" ? "snapshot.config.ts" : "snapshot.config.mjs";
6956
+ return parsePackageJson(fs10.readFileSync(path11.join(cwd, "package.json"), "utf8"));
6428
6957
  } catch {
6429
- return "snapshot.config.mjs";
6958
+ return null;
6430
6959
  }
6431
6960
  }
6961
+ function chooseConfigFileName(cwd) {
6962
+ if (!supportsTypeScriptImports()) return "snapshot.config.mjs";
6963
+ const parsed = readPackageJson(cwd);
6964
+ return parsed?.type === "module" ? "snapshot.config.ts" : "snapshot.config.mjs";
6965
+ }
6432
6966
  var SIZE_CHOICES = [
6433
6967
  { value: "60k", label: "Small", hint: "60 KB \xB7 ~15k tokens \xB7 tight context windows" },
6434
6968
  { value: "120k", label: "Medium", hint: "120 KB \xB7 ~30k tokens \xB7 recommended" },
@@ -6436,12 +6970,9 @@ var SIZE_CHOICES = [
6436
6970
  { value: "custom", label: "Custom\u2026", hint: "type a size like 150k or 1.5mb" }
6437
6971
  ];
6438
6972
  function packageJsonHasConfig(cwd) {
6439
- try {
6440
- const parsed = JSON.parse(fs10.readFileSync(path11.join(cwd, "package.json"), "utf8"));
6441
- return typeof parsed.snapshot === "object" && parsed.snapshot !== null && !Array.isArray(parsed.snapshot);
6442
- } catch {
6443
- return false;
6444
- }
6973
+ const parsed = readPackageJson(cwd);
6974
+ if (parsed === null) return false;
6975
+ return typeof parsed.snapshot === "object" && parsed.snapshot !== null && !Array.isArray(parsed.snapshot);
6445
6976
  }
6446
6977
  function artifactAnswers(options) {
6447
6978
  const artifacts = { ...DEFAULT_ANSWERS.artifacts };
@@ -6482,12 +7013,7 @@ function writeFileOrExplain(target, content) {
6482
7013
  function canWriteModuleConfig(name, cwd) {
6483
7014
  if (name.endsWith(".mjs") || name.endsWith(".ts")) return true;
6484
7015
  if (!name.endsWith(".js")) return false;
6485
- try {
6486
- const parsed = JSON.parse(fs10.readFileSync(path11.join(cwd, "package.json"), "utf8"));
6487
- return parsed.type === "module";
6488
- } catch {
6489
- return false;
6490
- }
7016
+ return readPackageJson(cwd)?.type === "module";
6491
7017
  }
6492
7018
  function configFormatLabel(name) {
6493
7019
  if (name.endsWith(".js")) return 'CommonJS `.js` (package.json has no "type": "module")';
@@ -6515,7 +7041,8 @@ function serializePackageJson(raw, value) {
6515
7041
  const crlf = raw.includes("\r\n");
6516
7042
  const body = JSON.stringify(value, null, spacing);
6517
7043
  const text2 = crlf ? body.replace(/\n/g, "\r\n") : body;
6518
- return `${text2}${raw.endsWith("\n") ? crlf ? "\r\n" : "\n" : ""}`;
7044
+ const bom = raw.startsWith("\uFEFF") ? "\uFEFF" : "";
7045
+ return `${bom}${text2}${raw.endsWith("\n") ? crlf ? "\r\n" : "\n" : ""}`;
6519
7046
  }
6520
7047
  function updatePackageJson(cwd, notes) {
6521
7048
  const packagePath = path11.join(cwd, "package.json");
@@ -6531,7 +7058,7 @@ function updatePackageJson(cwd, notes) {
6531
7058
  let parsed;
6532
7059
  try {
6533
7060
  raw = fs10.readFileSync(packagePath, "utf8");
6534
- parsed = JSON.parse(raw);
7061
+ parsed = parsePackageJson(raw);
6535
7062
  } catch {
6536
7063
  notes.push("Could not parse package.json; npm scripts and the devDependency were not added.");
6537
7064
  return unchanged;
@@ -6949,158 +7476,76 @@ function hasPriorSnapshot(cwd, outDir) {
6949
7476
  }
6950
7477
  if (parsed === null || typeof parsed !== "object") return false;
6951
7478
  return isOurGeneratorName(parsed.generator);
6952
- }
6953
-
6954
- // src/index.ts
6955
- import * as crypto4 from "crypto";
6956
- import { AsyncLocalStorage } from "async_hooks";
6957
- import * as fs14 from "fs";
6958
- import * as os2 from "os";
6959
- import * as path16 from "path";
6960
-
6961
- // src/ast/structured.ts
6962
- function stripJsonComments2(content) {
6963
- const text2 = content.replace(/^\uFEFF/, "");
6964
- let out = "";
6965
- let inString = false;
6966
- let inLineComment = false;
6967
- let inBlockComment = false;
6968
- for (let i = 0; i < text2.length; i++) {
6969
- const char = text2[i];
6970
- const next = text2[i + 1];
6971
- if (inLineComment) {
6972
- if (char === "\n") {
6973
- inLineComment = false;
6974
- out += char;
6975
- }
6976
- continue;
6977
- }
6978
- if (inBlockComment) {
6979
- if (char === "*" && next === "/") {
6980
- inBlockComment = false;
6981
- i += 1;
6982
- }
6983
- continue;
6984
- }
6985
- if (inString) {
6986
- out += char;
6987
- if (char === "\\") {
6988
- if (next !== void 0) {
6989
- out += next;
6990
- i += 1;
6991
- }
6992
- } else if (char === '"') {
6993
- inString = false;
6994
- }
6995
- continue;
6996
- }
6997
- if (char === '"') {
6998
- inString = true;
6999
- out += char;
7000
- continue;
7001
- }
7002
- if (char === "/" && next === "/") {
7003
- inLineComment = true;
7004
- i += 1;
7005
- continue;
7006
- }
7007
- if (char === "/" && next === "*") {
7008
- inBlockComment = true;
7009
- i += 1;
7010
- continue;
7011
- }
7012
- out += char;
7013
- }
7014
- return out;
7015
- }
7016
- function summarizeJson(content) {
7017
- try {
7018
- const parsed = JSON.parse(stripJsonComments2(content));
7019
- if (Array.isArray(parsed)) {
7020
- const sample = parsed[0];
7021
- const sampleKeys = sample && typeof sample === "object" && !Array.isArray(sample) ? Object.keys(sample).slice(0, 10).sort() : [];
7022
- return {
7023
- shape: "array",
7024
- itemCount: parsed.length,
7025
- keys: [],
7026
- sampleKeys
7027
- };
7028
- }
7029
- if (parsed && typeof parsed === "object") {
7030
- const keys = Object.keys(parsed).sort();
7031
- return {
7032
- shape: "object",
7033
- itemCount: keys.length,
7034
- keys: keys.slice(0, 30),
7035
- sampleKeys: []
7036
- };
7037
- }
7038
- return {
7039
- shape: typeof parsed,
7040
- itemCount: null,
7041
- keys: [],
7042
- sampleKeys: []
7043
- };
7044
- } catch {
7045
- return {
7046
- shape: "invalid-json",
7047
- itemCount: null,
7048
- keys: [],
7049
- sampleKeys: []
7050
- };
7051
- }
7052
- }
7053
- function summarizeMarkdown(content) {
7054
- const headings = [];
7055
- const frontmatterKeys = [];
7056
- const lines = content.replace(/^\uFEFF/, "").split("\n");
7057
- let inFrontmatter = false;
7058
- let fence = null;
7059
- let closesFrontmatter = false;
7060
- for (let index = 1; index < lines.length; index++) {
7061
- if (lines[index].trim() === "---") {
7062
- closesFrontmatter = true;
7063
- break;
7064
- }
7065
- }
7066
- for (let index = 0; index < lines.length; index++) {
7067
- const line = lines[index];
7068
- if (index === 0 && line.trim() === "---" && closesFrontmatter) {
7069
- inFrontmatter = true;
7070
- continue;
7071
- }
7072
- if (inFrontmatter) {
7073
- if (line.trim() === "---") {
7074
- inFrontmatter = false;
7075
- continue;
7479
+ }
7480
+
7481
+ // src/index.ts
7482
+ import * as crypto4 from "crypto";
7483
+
7484
+ // src/stdout.ts
7485
+ import { AsyncLocalStorage } from "async_hooks";
7486
+ var hookStdoutScope = new AsyncLocalStorage();
7487
+ var captureScope = new AsyncLocalStorage();
7488
+ var installs = 0;
7489
+ var underlying = null;
7490
+ var router = null;
7491
+ var asText = (chunk) => typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
7492
+ var callbackIn = (rest) => {
7493
+ for (const argument of rest) {
7494
+ if (typeof argument === "function") return argument;
7495
+ }
7496
+ return void 0;
7497
+ };
7498
+ function installStdoutRouter() {
7499
+ if (installs === 0) {
7500
+ underlying = process.stdout.write;
7501
+ const realStdout = underlying.bind(process.stdout);
7502
+ const realStderr = process.stderr.write.bind(process.stderr);
7503
+ router = ((...args) => {
7504
+ if (hookStdoutScope.getStore() === true) return realStderr(...args);
7505
+ const capture = captureScope.getStore();
7506
+ if (capture !== void 0) {
7507
+ capture.write(asText(args[0]));
7508
+ const callback = callbackIn(args.slice(1));
7509
+ if (callback) callback(null);
7510
+ return true;
7076
7511
  }
7077
- const match = /^([a-zA-Z0-9_-]+):/.exec(line);
7078
- if (match) frontmatterKeys.push(match[1]);
7079
- continue;
7080
- }
7081
- const trimmed = line.trim();
7082
- const fenceMatch = /^(`{3,}|~{3,})(.*)$/.exec(trimmed);
7083
- if (fenceMatch) {
7084
- const run = fenceMatch[1];
7085
- const rest = fenceMatch[2].trim();
7086
- if (fence === null) {
7087
- fence = { marker: run[0], length: run.length };
7088
- } else if (run[0] === fence.marker && run.length >= fence.length && rest === "") {
7089
- fence = null;
7512
+ return realStdout(...args);
7513
+ });
7514
+ process.stdout.write = router;
7515
+ }
7516
+ installs += 1;
7517
+ let released = false;
7518
+ return () => {
7519
+ if (released) return;
7520
+ released = true;
7521
+ installs -= 1;
7522
+ if (installs === 0) {
7523
+ if (process.stdout.write === router && underlying !== null) {
7524
+ process.stdout.write = underlying;
7090
7525
  }
7091
- continue;
7526
+ underlying = null;
7527
+ router = null;
7092
7528
  }
7093
- if (fence !== null) continue;
7094
- const headingMatch = /^(#{1,6})\s+(.+)$/.exec(trimmed);
7095
- if (headingMatch) {
7096
- headings.push(`${headingMatch[1]} ${headingMatch[2].trim()}`);
7097
- }
7098
- }
7099
- return {
7100
- headings: headings.slice(0, 40),
7101
- frontmatterKeys: Array.from(new Set(frontmatterKeys)).sort()
7102
7529
  };
7103
7530
  }
7531
+ async function runWithStdoutCapture(fn) {
7532
+ let text2 = "";
7533
+ const release = installStdoutRouter();
7534
+ try {
7535
+ const value = await captureScope.run(
7536
+ { write: (chunk) => void (text2 += chunk) },
7537
+ fn
7538
+ );
7539
+ return { value, text: text2 };
7540
+ } finally {
7541
+ release();
7542
+ }
7543
+ }
7544
+
7545
+ // src/index.ts
7546
+ import * as fs14 from "fs";
7547
+ import * as os2 from "os";
7548
+ import * as path16 from "path";
7104
7549
 
7105
7550
  // src/ast/skeleton.ts
7106
7551
  import ts2 from "typescript";
@@ -7109,25 +7554,36 @@ function hasCollapsibleBody(node) {
7109
7554
  const body = node.body;
7110
7555
  return body !== void 0 && ts2.isBlock(body);
7111
7556
  }
7112
- function isTsxFileName(fileName) {
7113
- return fileName.toLowerCase().endsWith(".tsx");
7557
+ function scriptKindFor(fileName) {
7558
+ const lower = fileName.toLowerCase();
7559
+ if (lower.endsWith(".tsx")) return ts2.ScriptKind.TSX;
7560
+ if (lower.endsWith(".jsx")) return ts2.ScriptKind.JSX;
7561
+ if (lower.endsWith(".js") || lower.endsWith(".mjs") || lower.endsWith(".cjs")) {
7562
+ return ts2.ScriptKind.JS;
7563
+ }
7564
+ return ts2.ScriptKind.TS;
7114
7565
  }
7115
7566
  function skeletonizeTypeScript(source, fileName = "file.ts") {
7116
- const scriptKind = isTsxFileName(fileName) ? ts2.ScriptKind.TSX : ts2.ScriptKind.TS;
7117
7567
  const sourceFile = ts2.createSourceFile(
7118
7568
  fileName,
7119
7569
  source,
7120
7570
  ts2.ScriptTarget.Latest,
7121
- true,
7122
- scriptKind
7571
+ // `setParentNodes: false`. Nothing here reads `.parent` — the one `getStart` call is given the
7572
+ // source file explicitly, which is the reason that argument exists. Asking for parent pointers
7573
+ // makes the parser run a second full pass over every node in the file to set them, on top of
7574
+ // the summary parse this same file already pays for.
7575
+ false,
7576
+ scriptKindFor(fileName)
7123
7577
  );
7124
7578
  const spans = [];
7125
7579
  const visit = (node) => {
7126
7580
  if ((ts2.isFunctionDeclaration(node) || ts2.isMethodDeclaration(node) || ts2.isConstructorDeclaration(node) || ts2.isGetAccessorDeclaration(node) || ts2.isSetAccessorDeclaration(node) || ts2.isFunctionExpression(node) || ts2.isArrowFunction(node)) && hasCollapsibleBody(node)) {
7127
7581
  const body = node.body;
7128
- const start = body.getStart(sourceFile);
7129
- const end = body.getEnd();
7130
- if (end > start) spans.push({ start, end });
7582
+ if (body.statements.length > 0) {
7583
+ const start = body.getStart(sourceFile);
7584
+ const end = body.getEnd();
7585
+ if (end > start) spans.push({ start, end });
7586
+ }
7131
7587
  }
7132
7588
  ts2.forEachChild(node, visit);
7133
7589
  };
@@ -7139,16 +7595,21 @@ function skeletonizeTypeScript(source, fileName = "file.ts") {
7139
7595
  if (last && span.start < last.end) continue;
7140
7596
  outermost.push(span);
7141
7597
  }
7142
- let text2 = source;
7143
- for (let index = outermost.length - 1; index >= 0; index -= 1) {
7144
- const span = outermost[index];
7145
- text2 = `${text2.slice(0, span.start)}{ /* ... */ }${text2.slice(span.end)}`;
7598
+ const chunks = [];
7599
+ let cursor = 0;
7600
+ for (const span of outermost) {
7601
+ chunks.push(source.slice(cursor, span.start), "{ /* ... */ }");
7602
+ cursor = span.end;
7146
7603
  }
7147
- return { text: text2, collapsed: outermost.length };
7604
+ chunks.push(source.slice(cursor));
7605
+ return { text: chunks.join(""), collapsed: outermost.length };
7148
7606
  }
7149
7607
  function isSkeletonizable(filePath) {
7150
7608
  const lower = filePath.toLowerCase();
7151
- return lower.endsWith(".ts") || lower.endsWith(".tsx") || lower.endsWith(".mts") || lower.endsWith(".cts");
7609
+ if (lower.endsWith(".d.ts") || lower.endsWith(".d.mts") || lower.endsWith(".d.cts")) {
7610
+ return false;
7611
+ }
7612
+ return lower.endsWith(".ts") || lower.endsWith(".tsx") || lower.endsWith(".mts") || lower.endsWith(".cts") || lower.endsWith(".js") || lower.endsWith(".jsx") || lower.endsWith(".mjs") || lower.endsWith(".cjs");
7152
7613
  }
7153
7614
 
7154
7615
  // src/generator/audit.ts
@@ -7422,23 +7883,55 @@ function truncateWithBudgetMarker(content, maxBytes, budget, originalLines) {
7422
7883
  };
7423
7884
  }
7424
7885
  function partitionFiles(files, maxPartBytes, contentFingerprint) {
7425
- if (files.length === 0) return [];
7426
- const budget = Math.max(MIN_SECTION_BUDGET_BYTES + HEADER_RESERVE_BYTES, maxPartBytes);
7427
- const sectionBudget = budget - HEADER_RESERVE_BYTES;
7428
- const prepared = files.map((file) => prepareSection(file, sectionBudget));
7429
- let groups = greedyGroups(prepared, budget);
7886
+ return partitionFilesForBuckets([{ files, budgetBytes: maxPartBytes }], contentFingerprint);
7887
+ }
7888
+ function partitionFilesForBuckets(buckets, contentFingerprint) {
7889
+ const groups = [];
7890
+ for (const bucket of buckets) {
7891
+ if (bucket.files.length === 0) continue;
7892
+ const budget = Math.max(
7893
+ MIN_SECTION_BUDGET_BYTES + HEADER_RESERVE_BYTES,
7894
+ bucket.budgetBytes
7895
+ );
7896
+ const sectionBudget = budget - HEADER_RESERVE_BYTES;
7897
+ const prepared = bucket.files.map((file) => prepareSection(file, sectionBudget));
7898
+ for (const sections of greedyGroups(prepared, budget)) groups.push({ sections, budget });
7899
+ }
7900
+ if (groups.length === 0) return [];
7901
+ const measurements = /* @__PURE__ */ new Map();
7902
+ const widthOf = (total) => Math.max(3, String(total).length);
7903
+ const measure = (group, index, total) => {
7904
+ const cached = measurements.get(group);
7905
+ const width = widthOf(total);
7906
+ if (cached !== void 0 && cached.index === index && cached.width === width) {
7907
+ return cached.size;
7908
+ }
7909
+ const { content } = assembleGroup(group, index, total, contentFingerprint);
7910
+ const size2 = Buffer.byteLength(content, "utf8");
7911
+ measurements.set(group, { index, width, size: size2 });
7912
+ return size2;
7913
+ };
7430
7914
  for (let pass = 0; pass < MAX_SPLIT_PASSES; pass++) {
7431
- const rendered = renderGroups(groups, budget, contentFingerprint);
7432
- const oversized = rendered.findIndex((part) => part.metadata.byteSize > budget);
7433
- if (oversized === -1) return rendered;
7915
+ const total = groups.length;
7916
+ let oversized = -1;
7917
+ for (let index = 0; index < total; index += 1) {
7918
+ const group2 = groups[index];
7919
+ if (measure(group2, index, total) > group2.budget) {
7920
+ oversized = index;
7921
+ break;
7922
+ }
7923
+ }
7924
+ if (oversized === -1) return renderGroups(groups, contentFingerprint);
7434
7925
  const group = groups[oversized];
7435
- if (group.length > 1) {
7436
- const moved = group.pop();
7437
- groups.splice(oversized + 1, 0, [moved]);
7926
+ if (group.sections.length > 1) {
7927
+ const moved = group.sections.pop();
7928
+ groups.splice(oversized + 1, 0, { sections: [moved], budget: group.budget });
7929
+ measurements.delete(group);
7438
7930
  continue;
7439
7931
  }
7440
- const [section] = group;
7441
- const overflow = rendered[oversized].metadata.byteSize - budget;
7932
+ const [section] = group.sections;
7933
+ if (section === void 0) return renderGroups(groups, contentFingerprint);
7934
+ const overflow = measure(group, oversized, total) - group.budget;
7442
7935
  const currentContent = section.file.content ?? "";
7443
7936
  const shrinkTo = Math.max(
7444
7937
  0,
@@ -7447,26 +7940,33 @@ function partitionFiles(files, maxPartBytes, contentFingerprint) {
7447
7940
  const shrunk = truncateWithBudgetMarker(
7448
7941
  currentContent,
7449
7942
  shrinkTo,
7450
- budget,
7943
+ group.budget,
7451
7944
  section.file.originalLines
7452
7945
  );
7453
7946
  if (shrunk.isTruncated) {
7454
7947
  applyTruncatedContent(section.file, shrunk.content);
7455
7948
  section.text = renderFileSection(section.file);
7456
7949
  section.byteSize = Buffer.byteLength(section.text, "utf8");
7950
+ measurements.delete(group);
7457
7951
  continue;
7458
7952
  }
7953
+ const rendered = renderGroups(groups, contentFingerprint);
7459
7954
  const marker2 = TRUNCATION_MARKER;
7460
- assertBudgetHoldsMarker(budget);
7955
+ assertBudgetHoldsMarker(group.budget);
7461
7956
  const target = Math.max(
7462
7957
  0,
7463
- budget - Buffer.byteLength(marker2, "utf8") - TOKEN_LINE_SLACK
7958
+ group.budget - Buffer.byteLength(marker2, "utf8") - TOKEN_LINE_SLACK
7464
7959
  );
7465
7960
  const clipped = truncateToBytes(rendered[oversized].markdown, target);
7466
- rendered[oversized] = rehashPart(rendered[oversized], clipped.content + marker2);
7961
+ rendered[oversized] = rehashPart(
7962
+ rendered[oversized],
7963
+ clipped.content + marker2
7964
+ );
7467
7965
  return rendered;
7468
7966
  }
7469
- return enforceBudget(renderGroups(groups, budget, contentFingerprint), budget);
7967
+ return renderGroups(groups, contentFingerprint).flatMap(
7968
+ (part) => enforceBudget([part], part.metadata.budgetBytes)
7969
+ );
7470
7970
  }
7471
7971
  var TRUNCATION_MARKER = "\n... [part truncated: cannot fit budget] ...\n";
7472
7972
  function assertBudgetHoldsMarker(budget) {
@@ -7489,69 +7989,7 @@ function enforceBudget(parts, budget) {
7489
7989
  return rehashPart(part, clipped.content + TRUNCATION_MARKER);
7490
7990
  });
7491
7991
  }
7492
- function renumberParts(parts, contentFingerprint) {
7493
- const total = parts.length;
7494
- if (total === 0) return [];
7495
- const zeroPad = (index) => String(index).padStart(Math.max(3, String(total).length), "0");
7496
- const nameFor = (position) => `part-${zeroPad(position)}.md`;
7497
- const marker2 = "## Contents\n";
7498
- const renumbered = parts.map((part, index) => {
7499
- const markerIndex = part.markdown.indexOf(marker2);
7500
- const body = markerIndex === -1 ? "" : part.markdown.slice(markerIndex + marker2.length);
7501
- const included = part.metadata.includedFiles;
7502
- if (markerIndex === -1) {
7503
- throw new Error(`${part.filename} has no "## Contents" section to re-number.`);
7504
- }
7505
- const layerCounts = {};
7506
- const kindCounts = {};
7507
- for (const file of included) {
7508
- layerCounts[file.layer] = (layerCounts[file.layer] || 0) + 1;
7509
- kindCounts[file.kind] = (kindCounts[file.kind] || 0) + 1;
7510
- }
7511
- const pathLines = included.map((file) => displayPath(file.relativePath));
7512
- const listFence = fenceForText(pathLines.join("\n"), 3);
7513
- const header = [
7514
- `# Codebase Snapshot Part ${zeroPad(index + 1)} of ${zeroPad(total)}`,
7515
- "",
7516
- `- Previous: ${index === 0 ? "none" : nameFor(index)}`,
7517
- `- Next: ${index === total - 1 ? "none" : nameFor(index + 2)}`,
7518
- `- Files: ${included.length}`,
7519
- `- Content Fingerprint: ${contentFingerprint || "none"}`,
7520
- `- Layers: ${topEntries(layerCounts, 6) || "none"}`,
7521
- `- Kinds: ${topEntries(kindCounts, 8) || "none"}`,
7522
- "",
7523
- UNTRUSTED_CONTENT_NOTICE,
7524
- "",
7525
- "## Files",
7526
- "",
7527
- `${listFence}text`,
7528
- ...pathLines,
7529
- listFence,
7530
- "",
7531
- "## Contents",
7532
- ""
7533
- ].join("\n");
7534
- const assembled = (header + body).replace(/\r\n/g, "\n");
7535
- const estimatedTokens = estimateTokens(assembled);
7536
- const markdown = insertTokenLine(assembled, estimatedTokens);
7537
- const metadata = {
7538
- ...part.metadata,
7539
- index: index + 1,
7540
- filename: nameFor(index + 1),
7541
- byteSize: Buffer.byteLength(markdown, "utf8"),
7542
- estimatedTokens,
7543
- sha256: crypto3.createHash("sha256").update(markdown).digest("hex"),
7544
- lines: countLines(markdown),
7545
- fileCount: included.length,
7546
- layersCovered: Object.keys(layerCounts).sort()
7547
- };
7548
- return { filename: metadata.filename, markdown, metadata };
7549
- });
7550
- return renumbered.map(
7551
- (part) => enforceBudget([part], part.metadata.budgetBytes)[0] ?? part
7552
- );
7553
- }
7554
- var SECTION_TRUNCATION_NOTICE = "\n... [section truncated: metadata alone exceeds the part budget] ...\n";
7992
+ var SECTION_TRUNCATION_NOTICE = "... [section truncated: the fixed metadata and a code fence did not fit the part budget] ...";
7555
7993
  var METADATA_VALUE_MAX = 200;
7556
7994
  var DISPLAY_PATH_MAX = 300;
7557
7995
  function capValue(value, max) {
@@ -7593,13 +8031,57 @@ function prepareSection(file, sectionBudget) {
7593
8031
  shrinkBody();
7594
8032
  }
7595
8033
  if (byteSize > sectionBudget) {
7596
- const target = sectionBudget - Buffer.byteLength(SECTION_TRUNCATION_NOTICE, "utf8");
7597
- const truncated = truncateToBytes(text2, Math.max(0, target));
7598
- text2 = truncated.content + SECTION_TRUNCATION_NOTICE;
8034
+ applyTruncatedContent(file, SECTION_TRUNCATION_NOTICE);
8035
+ file.summary = void 0;
8036
+ file.summaryOmitted = true;
8037
+ text2 = renderFileSection(file);
7599
8038
  byteSize = Buffer.byteLength(text2, "utf8");
8039
+ if (byteSize > sectionBudget) {
8040
+ text2 = truncateRenderedSection(text2, sectionBudget, fenceForContent(file.content ?? ""));
8041
+ byteSize = Buffer.byteLength(text2, "utf8");
8042
+ }
7600
8043
  }
7601
8044
  return { file, text: text2, byteSize };
7602
8045
  }
8046
+ function truncateRenderedSection(text2, sectionBudget, fence) {
8047
+ const closing = `
8048
+ ${SECTION_TRUNCATION_NOTICE}
8049
+ ${fence}
8050
+ `;
8051
+ const bodyRoom = sectionBudget - Buffer.byteLength(closing, "utf8");
8052
+ if (bodyRoom > 0) {
8053
+ const cut2 = truncateToBytes(text2, bodyRoom).content;
8054
+ const lastBreak2 = cut2.lastIndexOf("\n");
8055
+ const kept = lastBreak2 >= 0 ? cut2.slice(0, lastBreak2 + 1) : "";
8056
+ if (fenceIsOpen(kept, fence)) return `${kept}${closing}`;
8057
+ }
8058
+ const probe = text2.slice(0, 512);
8059
+ const emptyFence = fenceForText(probe.slice(0, Math.max(0, probe.lastIndexOf("\n") + 1)), 4);
8060
+ const block = `
8061
+ ${emptyFence}
8062
+ ${SECTION_TRUNCATION_NOTICE}
8063
+ ${emptyFence}
8064
+ `;
8065
+ const prefixRoom = sectionBudget - Buffer.byteLength(block, "utf8");
8066
+ const cut = prefixRoom > 0 ? truncateToBytes(text2, prefixRoom).content : "";
8067
+ const lastBreak = cut.lastIndexOf("\n");
8068
+ const prefix = lastBreak >= 0 ? cut.slice(0, lastBreak + 1) : "";
8069
+ return `${dropTrailingOpenFence(prefix, fence)}${block}`;
8070
+ }
8071
+ function dropTrailingOpenFence(text2, fence) {
8072
+ if (!fenceIsOpen(text2, fence)) return text2;
8073
+ const lines = text2.split("\n");
8074
+ const opening = lines.findIndex((line) => line.startsWith(fence));
8075
+ if (opening < 0) return text2;
8076
+ return lines.slice(0, opening).join("\n") + (opening > 0 ? "\n" : "");
8077
+ }
8078
+ function fenceIsOpen(text2, fence) {
8079
+ let open = false;
8080
+ for (const line of text2.split("\n")) {
8081
+ if (line.startsWith(fence)) open = !open;
8082
+ }
8083
+ return open;
8084
+ }
7603
8085
  function applyTruncatedContent(file, content) {
7604
8086
  file.content = content;
7605
8087
  file.isPreviewTruncated = true;
@@ -7647,52 +8129,64 @@ function greedyGroups(sections, budget) {
7647
8129
  if (current.length > 0) groups.push(current);
7648
8130
  return groups;
7649
8131
  }
7650
- function renderGroups(groups, budget, contentFingerprint) {
8132
+ function assembleGroup(group, index, total, contentFingerprint) {
8133
+ const width = Math.max(3, String(total).length);
8134
+ const zeroPad = (value) => String(value).padStart(width, "0");
8135
+ const filename = `part-${zeroPad(index + 1)}.md`;
8136
+ const prev = index === 0 ? "none" : `part-${zeroPad(index)}.md`;
8137
+ const next = index === total - 1 ? "none" : `part-${zeroPad(index + 2)}.md`;
8138
+ for (const section of group.sections) {
8139
+ section.file.part = filename;
8140
+ }
8141
+ const layerCounts = {};
8142
+ const kindCounts = {};
8143
+ for (const section of group.sections) {
8144
+ layerCounts[section.file.layer] = (layerCounts[section.file.layer] || 0) + 1;
8145
+ kindCounts[section.file.kind] = (kindCounts[section.file.kind] || 0) + 1;
8146
+ }
8147
+ const pathLines = group.sections.map((s) => displayPath(s.file.relativePath));
8148
+ const listFence = fenceForText(pathLines.join("\n"), 3);
8149
+ const header = [
8150
+ `# Codebase Snapshot Part ${zeroPad(index + 1)} of ${zeroPad(total)}`,
8151
+ "",
8152
+ `- Previous: ${prev}`,
8153
+ `- Next: ${next}`,
8154
+ `- Files: ${group.sections.length}`,
8155
+ `- Content Fingerprint: ${contentFingerprint || "none"}`,
8156
+ `- Layers: ${topEntries(layerCounts, 6) || "none"}`,
8157
+ `- Kinds: ${topEntries(kindCounts, 8) || "none"}`,
8158
+ "",
8159
+ UNTRUSTED_CONTENT_NOTICE,
8160
+ "",
8161
+ "## Files",
8162
+ "",
8163
+ `${listFence}text`,
8164
+ ...pathLines,
8165
+ listFence,
8166
+ "",
8167
+ "## Contents",
8168
+ ""
8169
+ ].join("\n");
8170
+ const assembled = (header + group.sections.map((s) => s.text).join("")).replace(
8171
+ /\r\n|\r/g,
8172
+ "\n"
8173
+ );
8174
+ const estimatedTokens = estimateTokens(assembled);
8175
+ return { filename, content: insertTokenLine(assembled, estimatedTokens), estimatedTokens };
8176
+ }
8177
+ function renderGroups(groups, contentFingerprint) {
7651
8178
  const total = groups.length;
7652
- const zeroPad = (index) => String(index).padStart(Math.max(3, String(total).length), "0");
7653
8179
  return groups.map((group, index) => {
7654
- const filename = `part-${zeroPad(index + 1)}.md`;
7655
- const prev = index === 0 ? "none" : `part-${zeroPad(index)}.md`;
7656
- const next = index === total - 1 ? "none" : `part-${zeroPad(index + 2)}.md`;
7657
- for (const section of group) {
7658
- section.file.part = filename;
7659
- }
7660
- const layersCovered = Array.from(new Set(group.map((s) => s.file.layer))).sort();
7661
- const layerCounts = {};
7662
- const kindCounts = {};
7663
- for (const section of group) {
7664
- layerCounts[section.file.layer] = (layerCounts[section.file.layer] || 0) + 1;
7665
- kindCounts[section.file.kind] = (kindCounts[section.file.kind] || 0) + 1;
7666
- }
7667
- const pathLines = group.map((s) => displayPath(s.file.relativePath));
7668
- const listFence = fenceForText(pathLines.join("\n"), 3);
7669
- const header = [
7670
- `# Codebase Snapshot Part ${zeroPad(index + 1)} of ${zeroPad(total)}`,
7671
- "",
7672
- `- Previous: ${prev}`,
7673
- `- Next: ${next}`,
7674
- `- Files: ${group.length}`,
7675
- `- Content Fingerprint: ${contentFingerprint || "none"}`,
7676
- `- Layers: ${topEntries(layerCounts, 6) || "none"}`,
7677
- `- Kinds: ${topEntries(kindCounts, 8) || "none"}`,
7678
- "",
7679
- UNTRUSTED_CONTENT_NOTICE,
7680
- "",
7681
- "## Files",
7682
- "",
7683
- `${listFence}text`,
7684
- ...pathLines,
7685
- listFence,
7686
- "",
7687
- "## Contents",
7688
- ""
7689
- ].join("\n");
7690
- const assembled = (header + group.map((s) => s.text).join("")).replace(/\r\n/g, "\n");
7691
- const estimatedTokens = estimateTokens(assembled);
7692
- const content = insertTokenLine(assembled, estimatedTokens);
8180
+ const { filename, content, estimatedTokens } = assembleGroup(
8181
+ group,
8182
+ index,
8183
+ total,
8184
+ contentFingerprint
8185
+ );
7693
8186
  const byteSize = Buffer.byteLength(content, "utf8");
7694
8187
  const sha256 = crypto3.createHash("sha256").update(content).digest("hex");
7695
- const includedFiles = group.map(({ file }) => ({
8188
+ const layersCovered = Array.from(new Set(group.sections.map((s) => s.file.layer))).sort();
8189
+ const includedFiles = group.sections.map(({ file }) => ({
7696
8190
  relativePath: file.relativePath,
7697
8191
  sourceBytes: file.sizeBytes,
7698
8192
  includedBytes: file.includedBytes,
@@ -7705,10 +8199,10 @@ function renderGroups(groups, budget, contentFingerprint) {
7705
8199
  filename,
7706
8200
  byteSize,
7707
8201
  estimatedTokens,
7708
- budgetBytes: budget,
8202
+ budgetBytes: group.budget,
7709
8203
  sha256,
7710
8204
  lines: countLines(content),
7711
- fileCount: group.length,
8205
+ fileCount: group.sections.length,
7712
8206
  layersCovered,
7713
8207
  includedFiles
7714
8208
  };
@@ -7716,7 +8210,7 @@ function renderGroups(groups, budget, contentFingerprint) {
7716
8210
  });
7717
8211
  }
7718
8212
  function emittedBody(content) {
7719
- const normalised = content.replace(/\r\n/g, "\n");
8213
+ const normalised = content.replace(/\r\n|\r/g, "\n");
7720
8214
  return normalised.endsWith("\n") ? normalised.slice(0, -1) : normalised;
7721
8215
  }
7722
8216
  function compactMetadata(file) {
@@ -7784,7 +8278,7 @@ function generateMonolithJson(manifest, filesWithContent) {
7784
8278
  }
7785
8279
  function generateMonolithMarkdown(manifest, filesWithContent) {
7786
8280
  const sections = filesWithContent.map((file) => {
7787
- const content = (file.content ?? "").replace(/\r\n/g, "\n");
8281
+ const content = (file.content ?? "").replace(/\r\n|\r/g, "\n");
7788
8282
  const fence = fenceForContent(content);
7789
8283
  return [
7790
8284
  `## File: ${escapeControls(file.relativePath)}`,
@@ -8026,6 +8520,7 @@ function generateProjectTree(filePaths) {
8026
8520
 
8027
8521
  // src/update-check.ts
8028
8522
  import * as fs12 from "fs";
8523
+ import * as http from "http";
8029
8524
  import * as https from "https";
8030
8525
  import * as os from "os";
8031
8526
  import * as path13 from "path";
@@ -8050,22 +8545,28 @@ function updateCommand(kind) {
8050
8545
  if (kind === "local") return "npm install codebase-snapshot@latest";
8051
8546
  return "npm install -g codebase-snapshot";
8052
8547
  }
8053
- function versionParts(value) {
8054
- const core = value.trim().replace(/^v/i, "").split("-")[0];
8055
- return core.split(".").map((part) => {
8548
+ function parseSemver(value) {
8549
+ const cleaned = value.trim().replace(/^v/i, "").split("+")[0] ?? "";
8550
+ const dash = cleaned.indexOf("-");
8551
+ const corePart = dash === -1 ? cleaned : cleaned.slice(0, dash);
8552
+ const prerelease = dash === -1 ? null : cleaned.slice(dash + 1);
8553
+ const core = corePart.split(".").map((part) => {
8056
8554
  const parsed = Number(part);
8057
8555
  return Number.isFinite(parsed) ? parsed : 0;
8058
8556
  });
8557
+ return { core, prerelease };
8059
8558
  }
8060
8559
  function isNewer(candidate, current) {
8061
- const left = versionParts(candidate);
8062
- const right = versionParts(current);
8063
- const length = Math.max(left.length, right.length);
8560
+ const left = parseSemver(candidate);
8561
+ const right = parseSemver(current);
8562
+ if (left.prerelease !== null && right.prerelease === null) return false;
8563
+ const length = Math.max(left.core.length, right.core.length);
8064
8564
  for (let index = 0; index < length; index += 1) {
8065
- const a = left[index] ?? 0;
8066
- const b = right[index] ?? 0;
8565
+ const a = left.core[index] ?? 0;
8566
+ const b = right.core[index] ?? 0;
8067
8567
  if (a !== b) return a > b;
8068
8568
  }
8569
+ if (left.prerelease === null && right.prerelease !== null) return true;
8069
8570
  return false;
8070
8571
  }
8071
8572
  function cacheDirectory(env = process.env, platform = process.platform) {
@@ -8133,14 +8634,22 @@ function writeCache(file, entry) {
8133
8634
  }
8134
8635
  }
8135
8636
  function fetchLatestVersion(url = PACKAGE_LATEST_URL, timeoutMs = REQUEST_TIMEOUT_MS) {
8136
- return new Promise((resolve9) => {
8637
+ return new Promise((resolve10) => {
8137
8638
  let settled = false;
8639
+ let request = null;
8138
8640
  const finish = (value) => {
8139
8641
  if (settled) return;
8140
8642
  settled = true;
8141
- resolve9(value);
8643
+ clearTimeout(deadline);
8644
+ resolve10(value);
8142
8645
  };
8143
- const request = https.get(
8646
+ const deadline = setTimeout(() => {
8647
+ request?.destroy();
8648
+ finish(null);
8649
+ }, timeoutMs);
8650
+ deadline.unref();
8651
+ const transport = url.startsWith("http:") ? http : https;
8652
+ request = transport.get(
8144
8653
  url,
8145
8654
  {
8146
8655
  headers: {
@@ -8159,7 +8668,7 @@ function fetchLatestVersion(url = PACKAGE_LATEST_URL, timeoutMs = REQUEST_TIMEOU
8159
8668
  response.on("data", (chunk) => {
8160
8669
  body += chunk;
8161
8670
  if (body.length > 64 * 1024) {
8162
- request.destroy();
8671
+ request?.destroy();
8163
8672
  finish(null);
8164
8673
  }
8165
8674
  });
@@ -8174,10 +8683,6 @@ function fetchLatestVersion(url = PACKAGE_LATEST_URL, timeoutMs = REQUEST_TIMEOU
8174
8683
  }
8175
8684
  );
8176
8685
  request.on("error", () => finish(null));
8177
- request.setTimeout(timeoutMs, () => {
8178
- request.destroy();
8179
- finish(null);
8180
- });
8181
8686
  });
8182
8687
  }
8183
8688
  async function checkForUpdate(options) {
@@ -8345,7 +8850,10 @@ const { parentPort, workerData } = require("node:worker_threads");
8345
8850
  parentPort.postMessage({ ok: false, message: loaded.errors.join("; ") });
8346
8851
  return;
8347
8852
  }
8348
- const config = mod.resolveConfig(loaded.config, workerData.cliOptions, workerData.cwd);
8853
+ // \`hooks: {}\` is a value, not an absence: the config is applied with the hook list emptied,
8854
+ // so the run is the documented MCP run rather than the CLI run with one setting removed.
8855
+ const base = loaded.config ? { ...loaded.config, hooks: {} } : null;
8856
+ const config = mod.resolveConfig(base, workerData.cliOptions, workerData.cwd);
8349
8857
  const manifest = mod.generateSnapshot(config);
8350
8858
  parentPort.postMessage({
8351
8859
  ok: true,
@@ -8368,14 +8876,15 @@ const { parentPort, workerData } = require("node:worker_threads");
8368
8876
  })();
8369
8877
  `;
8370
8878
  function generateInWorker(cwd, cliOptions) {
8371
- return new Promise((resolve9) => {
8879
+ return new Promise((resolve10) => {
8372
8880
  let settled = false;
8881
+ let worker = null;
8373
8882
  const finish = (result) => {
8374
8883
  if (settled) return;
8375
8884
  settled = true;
8376
- resolve9(result);
8885
+ if (worker !== null) void worker.terminate();
8886
+ resolve10(result);
8377
8887
  };
8378
- let worker;
8379
8888
  const isCjs = typeof __dirname !== "undefined";
8380
8889
  const directory = isCjs ? __dirname : path15.dirname(fileURLToPath(import.meta.url));
8381
8890
  try {
@@ -8408,7 +8917,100 @@ function generateInWorker(cwd, cliOptions) {
8408
8917
 
8409
8918
  // src/index.ts
8410
8919
  var SCHEMA_VERSION = "3.0.0";
8411
- var SUMMARY_MAX_SOURCE_BYTES = 2 * 1024 * 1024;
8920
+ var SUMMARY_MAX_SOURCE_BYTES2 = SUMMARY_MAX_SOURCE_BYTES;
8921
+ var FILE_DERIVATIONS = /* @__PURE__ */ new Map();
8922
+ var FILE_DERIVATION_LIMIT_BYTES = 64 * 1024 * 1024;
8923
+ var fileDerivationBytes = 0;
8924
+ function takeDerivation(path22, hash, settings) {
8925
+ const cached = FILE_DERIVATIONS.get(path22);
8926
+ if (cached === void 0 || cached.hash !== hash || cached.settings !== settings) return void 0;
8927
+ return cached;
8928
+ }
8929
+ function rememberDerivation(path22, hash, settings, derivation) {
8930
+ const bodyBytes = derivation.finalContent === derivation.redacted ? derivation.redacted.length : derivation.redacted.length + derivation.finalContent.length;
8931
+ const bytes = bodyBytes * 2 + 512;
8932
+ if (bytes > FILE_DERIVATION_LIMIT_BYTES / 4) return;
8933
+ while (fileDerivationBytes + bytes > FILE_DERIVATION_LIMIT_BYTES) {
8934
+ const oldest = FILE_DERIVATIONS.keys().next();
8935
+ if (oldest.done === true) break;
8936
+ const evicted = FILE_DERIVATIONS.get(oldest.value);
8937
+ FILE_DERIVATIONS.delete(oldest.value);
8938
+ if (evicted !== void 0) fileDerivationBytes -= evicted.bytes;
8939
+ }
8940
+ FILE_DERIVATIONS.set(path22, { ...derivation, hash, settings, bytes });
8941
+ fileDerivationBytes += bytes;
8942
+ }
8943
+ function derivationSettings(config) {
8944
+ const rules = config.secretRules.map(
8945
+ (rule) => `${rule.rule}\0${rule.pattern.source}\0${rule.pattern.flags}\0${rule.valueGroup ?? ""}`
8946
+ ).join("");
8947
+ return `${GENERATOR_VERSION}${configDigest(config)}${sha256Hex2(rules)}`;
8948
+ }
8949
+ function deriveFile(relativePath, rawContent, sizeBytes, config) {
8950
+ const lineCount = countLines(rawContent);
8951
+ const { redacted, findings } = scanAndRedactSecrets(rawContent, config.secretRules, relativePath);
8952
+ const isLarge = sizeBytes > config.largeFileBytes;
8953
+ let finalContent = redacted;
8954
+ let isPreviewTruncated = false;
8955
+ let includedLines = lineCount;
8956
+ let inclusionMode = "full";
8957
+ if (config.skeleton === true && isSkeletonizable(relativePath)) {
8958
+ const skeleton = skeletonizeTypeScript(redacted, relativePath);
8959
+ if (skeleton.collapsed > 0) {
8960
+ finalContent = skeleton.text;
8961
+ inclusionMode = "signatures";
8962
+ includedLines = countLines(skeleton.text);
8963
+ }
8964
+ }
8965
+ if (isLarge && inclusionMode !== "signatures") {
8966
+ const preview = truncatePreview(redacted, config.previewHeadLines, config.previewTailLines);
8967
+ finalContent = preview.content;
8968
+ isPreviewTruncated = preview.isTruncated;
8969
+ includedLines = preview.includedLines;
8970
+ inclusionMode = isPreviewTruncated ? "preview" : "full";
8971
+ }
8972
+ let summary;
8973
+ let summaryQuality;
8974
+ let summarySkip = null;
8975
+ let summaryWarning = null;
8976
+ let dependentImports = null;
8977
+ if (Buffer.byteLength(rawContent, "utf8") > SUMMARY_MAX_SOURCE_BYTES2) {
8978
+ summarySkip = "oversize";
8979
+ dependentImports = [];
8980
+ } else if (SKIP_PREVIEW_SUMMARIES && isPreviewTruncated && hasStructuralSummary(relativePath)) {
8981
+ summarySkip = "preview";
8982
+ dependentImports = importsOfText(relativePath, rawContent);
8983
+ } else {
8984
+ try {
8985
+ const outcome = summarizeFile(relativePath, rawContent);
8986
+ summary = outcome?.summary ? maskSummaryStrings(outcome.summary, config.secretRules) : void 0;
8987
+ summaryQuality = outcome?.quality;
8988
+ if (isSourceSummary(summary) && summary.parseErrors) {
8989
+ summaryWarning = `Parse errors in ${maskPathText(relativePath, config.secretRules)} (${summary.parseErrors}); its summary is incomplete.`;
8990
+ }
8991
+ } catch (err) {
8992
+ summaryWarning = `Summary skipped for ${maskPathText(relativePath, config.secretRules)}: ${err.message}`.replace(
8993
+ /\s+/g,
8994
+ " "
8995
+ );
8996
+ }
8997
+ }
8998
+ return {
8999
+ redacted,
9000
+ findings,
9001
+ lineCount,
9002
+ finalContent,
9003
+ inclusionMode,
9004
+ isPreviewTruncated,
9005
+ includedLines,
9006
+ isLarge,
9007
+ summary,
9008
+ summaryQuality,
9009
+ summarySkip,
9010
+ summaryWarning,
9011
+ dependentImports
9012
+ };
9013
+ }
8412
9014
  function sha256Hex2(input) {
8413
9015
  return crypto4.createHash("sha256").update(input).digest("hex");
8414
9016
  }
@@ -8953,10 +9555,7 @@ function acquireRunLock(stagingRoot) {
8953
9555
  };
8954
9556
  } catch (err) {
8955
9557
  if (err.code !== "EEXIST") throw err;
8956
- if (attempt === 0 && lockOwnerIsGone(lockPath)) {
8957
- fs14.rmSync(lockPath, { force: true });
8958
- continue;
8959
- }
9558
+ if (attempt === 0 && reclaimStaleLock(lockPath)) continue;
8960
9559
  throw new Error(
8961
9560
  `Another generate run is already using the staging directory ${stagingRoot}. Wait for it to finish, or delete ${lockPath} if no other run is active.`
8962
9561
  );
@@ -8965,14 +9564,25 @@ function acquireRunLock(stagingRoot) {
8965
9564
  throw new Error(`Could not acquire the generate lock for ${stagingRoot}.`);
8966
9565
  }
8967
9566
  function lockKeyFor(stagingRoot) {
8968
- let key = stagingRoot;
8969
- try {
8970
- key = fs14.realpathSync(stagingRoot);
8971
- } catch {
8972
- }
9567
+ let key = canonicalLockTarget(stagingRoot);
8973
9568
  if (process.platform === "win32" || process.platform === "darwin") key = key.toLowerCase();
8974
9569
  return sha256Hex2(key).slice(0, 16);
8975
9570
  }
9571
+ function canonicalLockTarget(targetPath2) {
9572
+ const resolved = path16.resolve(targetPath2);
9573
+ let existing = resolved;
9574
+ const tail = [];
9575
+ for (; ; ) {
9576
+ try {
9577
+ return path16.join(fs14.realpathSync(existing), ...tail);
9578
+ } catch {
9579
+ const parent = path16.dirname(existing);
9580
+ if (parent === existing) return resolved;
9581
+ tail.unshift(path16.basename(existing));
9582
+ existing = parent;
9583
+ }
9584
+ }
9585
+ }
8976
9586
  function lockPathFor(stagingRoot) {
8977
9587
  return path16.join(os2.tmpdir(), `codebase-snapshot-${lockKeyFor(stagingRoot)}.lock`);
8978
9588
  }
@@ -8997,6 +9607,24 @@ function lockOwnerIsGone(lockPath) {
8997
9607
  return err.code === "ESRCH";
8998
9608
  }
8999
9609
  }
9610
+ function reclaimStaleLock(lockPath) {
9611
+ let before;
9612
+ try {
9613
+ before = fs14.statSync(lockPath);
9614
+ } catch {
9615
+ return true;
9616
+ }
9617
+ if (!lockOwnerIsGone(lockPath)) return false;
9618
+ try {
9619
+ const current = fs14.statSync(lockPath);
9620
+ const sameFile = current.ino === before.ino && current.birthtimeMs === before.birthtimeMs && current.mtimeMs === before.mtimeMs;
9621
+ if (!sameFile) return false;
9622
+ fs14.rmSync(lockPath, { force: true });
9623
+ return true;
9624
+ } catch {
9625
+ return false;
9626
+ }
9627
+ }
9000
9628
  function lockFileIsAncient(lockPath) {
9001
9629
  try {
9002
9630
  return Date.now() - fs14.statSync(lockPath).mtimeMs > STALE_RUN_MS;
@@ -9054,7 +9682,6 @@ function swapOnce(stagingDir, outDir) {
9054
9682
  throw copyErr;
9055
9683
  }
9056
9684
  }
9057
- if (hadPrevious) fs14.rmSync(backupDir, { recursive: true, force: true });
9058
9685
  } catch (err) {
9059
9686
  if (hadPrevious && fs14.existsSync(backupDir)) {
9060
9687
  if (fs14.existsSync(outDir)) fs14.rmSync(outDir, { recursive: true, force: true });
@@ -9062,6 +9689,16 @@ function swapOnce(stagingDir, outDir) {
9062
9689
  }
9063
9690
  throw err;
9064
9691
  }
9692
+ if (hadPrevious) {
9693
+ try {
9694
+ fs14.rmSync(backupDir, { recursive: true, force: true });
9695
+ } catch (err) {
9696
+ process.stderr.write(
9697
+ `\u26A0 The previous snapshot could not be removed from ${backupDir} (${err.message}). The new snapshot is in place; delete that directory by hand when convenient.
9698
+ `
9699
+ );
9700
+ }
9701
+ }
9065
9702
  }
9066
9703
  function sleepSync(milliseconds) {
9067
9704
  const buffer = new Int32Array(new SharedArrayBuffer(4));
@@ -9084,22 +9721,18 @@ function artifactWarnings(artifacts, sarif) {
9084
9721
  }
9085
9722
  return messages;
9086
9723
  }
9724
+ function installHookStdoutRedirect() {
9725
+ return installStdoutRouter();
9726
+ }
9087
9727
  function runAfterGenerate(config, manifest) {
9088
9728
  const hook = config.hooks.afterGenerate;
9089
9729
  if (!hook) return null;
9090
- const stdoutWrite = process.stdout.write.bind(process.stdout);
9091
- const stderrWrite = process.stderr.write.bind(process.stderr);
9092
- const hookScope = new AsyncLocalStorage();
9093
- const patchedWrite = ((...args) => hookScope.getStore() === true ? stderrWrite(...args) : stdoutWrite(...args));
9094
- const restoreStdout = () => {
9095
- if (process.stdout.write === patchedWrite) process.stdout.write = stdoutWrite;
9096
- };
9730
+ const releaseRedirect = installHookStdoutRedirect();
9097
9731
  let returned;
9098
9732
  try {
9099
- process.stdout.write = patchedWrite;
9100
- returned = hookScope.run(true, () => hook(manifest));
9733
+ returned = hookStdoutScope.run(true, () => hook(manifest));
9101
9734
  } catch (err) {
9102
- restoreStdout();
9735
+ releaseRedirect();
9103
9736
  const message = maskPathText(err.message, config.secretRules);
9104
9737
  manifest.warnings.push(`hooks.afterGenerate failed: ${message}`);
9105
9738
  manifest.totals.warningCount = manifest.warnings.length;
@@ -9107,10 +9740,10 @@ function runAfterGenerate(config, manifest) {
9107
9740
  }
9108
9741
  const thenable = returned;
9109
9742
  if (!thenable || typeof thenable.then !== "function") {
9110
- restoreStdout();
9743
+ releaseRedirect();
9111
9744
  return null;
9112
9745
  }
9113
- return Promise.resolve(returned).finally(restoreStdout);
9746
+ return Promise.resolve(returned).finally(releaseRedirect);
9114
9747
  }
9115
9748
  function partitionByBudget(files, config, fingerprint) {
9116
9749
  const budgetFor = (file) => config.budgets.byLayer[file.layer] ?? config.budgets.default;
@@ -9121,23 +9754,14 @@ function partitionByBudget(files, config, fingerprint) {
9121
9754
  if (bucket) bucket.push(file);
9122
9755
  else buckets.set(budget, [file]);
9123
9756
  }
9124
- if (buckets.size <= 1) {
9125
- const only = buckets.size === 1 ? Array.from(buckets.keys())[0] : config.maxPartBytes;
9126
- return partitionFiles(files, only, fingerprint);
9757
+ if (buckets.size === 0) return [];
9758
+ if (buckets.size === 1) {
9759
+ return partitionFiles(files, Array.from(buckets.keys())[0], fingerprint);
9127
9760
  }
9128
- const parts = [];
9129
- for (const budget of Array.from(buckets.keys()).sort((a, b) => a - b)) {
9130
- parts.push(...partitionFiles(buckets.get(budget) ?? [], budget, fingerprint));
9131
- }
9132
- const merged = renumberParts(parts, fingerprint);
9133
- const byPath = new Map(files.map((file) => [file.relativePath, file]));
9134
- for (const part of merged) {
9135
- for (const included of part.metadata.includedFiles) {
9136
- const file = byPath.get(included.relativePath);
9137
- if (file) file.part = part.filename;
9138
- }
9139
- }
9140
- return merged;
9761
+ return partitionFilesForBuckets(
9762
+ Array.from(buckets.entries()).sort(([left], [right]) => left - right).map(([budgetBytes, bucketFiles]) => ({ files: bucketFiles, budgetBytes })),
9763
+ fingerprint
9764
+ );
9141
9765
  }
9142
9766
  function generateSnapshot(configInput, options) {
9143
9767
  assertUsableConfigInput(configInput);
@@ -9169,6 +9793,7 @@ function generateSnapshot(configInput, options) {
9169
9793
  return false;
9170
9794
  }) : collection.files;
9171
9795
  const files = [];
9796
+ const derivationKey = derivationSettings(config);
9172
9797
  const monolithBodies = /* @__PURE__ */ new Map();
9173
9798
  const skipped = maskSkipRecords([...collection.skipped], config.secretRules);
9174
9799
  const secretFindings = [];
@@ -9181,6 +9806,7 @@ function generateSnapshot(configInput, options) {
9181
9806
  const fileWarnings = [];
9182
9807
  const oversizeSummaryPaths = [];
9183
9808
  const previewSummaryPaths = [];
9809
+ const skippedSummaryImports = /* @__PURE__ */ new Map();
9184
9810
  const layerCounts = {};
9185
9811
  const kindCounts = {};
9186
9812
  let totalLines = 0;
@@ -9245,8 +9871,6 @@ function generateSnapshot(configInput, options) {
9245
9871
  }
9246
9872
  const hashSha256 = sha256Hex2(buffer);
9247
9873
  const rawContent = decoded.text;
9248
- const lineCount = countLines(rawContent);
9249
- totalLines += lineCount;
9250
9874
  totalSizeBytes += stats.size;
9251
9875
  if (decoded.encoding !== "utf-8") {
9252
9876
  encodingNotes.push({
@@ -9260,66 +9884,25 @@ function generateSnapshot(configInput, options) {
9260
9884
  const language = detectLanguage(relativePath);
9261
9885
  bump(layerCounts, layer2);
9262
9886
  bump(kindCounts, kind);
9263
- const { redacted, findings } = scanAndRedactSecrets(
9264
- rawContent,
9265
- config.secretRules,
9266
- relativePath
9267
- );
9268
- secretFindings.push(...findings);
9269
- const isLarge = stats.size > config.largeFileBytes;
9270
- if (isLarge) largeFileCount += 1;
9271
- let finalContent = redacted;
9272
- let isPreviewTruncated = false;
9273
- let includedLines = lineCount;
9274
- let inclusionMode = "full";
9275
- if (config.skeleton === true && isSkeletonizable(relativePath)) {
9276
- const skeleton = skeletonizeTypeScript(redacted, relativePath);
9277
- if (skeleton.collapsed > 0) {
9278
- finalContent = skeleton.text;
9279
- inclusionMode = "signatures";
9280
- includedLines = countLines(skeleton.text);
9281
- }
9282
- }
9283
- if (isLarge && inclusionMode !== "signatures") {
9284
- const preview = truncatePreview(
9285
- redacted,
9286
- config.previewHeadLines,
9287
- config.previewTailLines
9288
- );
9289
- finalContent = preview.content;
9290
- isPreviewTruncated = preview.isTruncated;
9291
- includedLines = preview.includedLines;
9292
- inclusionMode = isPreviewTruncated ? "preview" : "full";
9293
- }
9294
- if (config.includeMonolith && (isPreviewTruncated || inclusionMode === "signatures")) {
9295
- monolithBodies.set(relativePath, redacted);
9296
- }
9297
- let summary;
9298
- let summaryQuality;
9299
- if (Buffer.byteLength(rawContent, "utf8") > SUMMARY_MAX_SOURCE_BYTES) {
9300
- oversizeSummaryPaths.push(relativePath);
9301
- } else if (SKIP_PREVIEW_SUMMARIES && isPreviewTruncated && hasStructuralSummary(relativePath)) {
9302
- previewSummaryPaths.push(relativePath);
9303
- } else {
9304
- try {
9305
- const outcome = summarizeFile(relativePath, rawContent);
9306
- summary = outcome?.summary ? maskSummaryStrings(outcome.summary, config.secretRules) : void 0;
9307
- summaryQuality = outcome?.quality;
9308
- if (isSourceSummary(summary) && summary.parseErrors) {
9309
- fileWarnings.push({
9310
- path: relativePath,
9311
- message: `Parse errors in ${maskPathText(relativePath, config.secretRules)} (${summary.parseErrors}); its summary is incomplete.`
9312
- });
9313
- }
9314
- } catch (err) {
9315
- fileWarnings.push({
9316
- path: relativePath,
9317
- message: `Summary skipped for ${maskPathText(relativePath, config.secretRules)}: ${err.message}`.replace(
9318
- /\s+/g,
9319
- " "
9320
- )
9321
- });
9322
- }
9887
+ const derivation = takeDerivation(absolutePath, hashSha256, derivationKey) ?? (() => {
9888
+ const fresh = deriveFile(relativePath, rawContent, stats.size, config);
9889
+ rememberDerivation(absolutePath, hashSha256, derivationKey, fresh);
9890
+ return fresh;
9891
+ })();
9892
+ const { lineCount } = derivation;
9893
+ totalLines += lineCount;
9894
+ secretFindings.push(...derivation.findings);
9895
+ if (derivation.isLarge) largeFileCount += 1;
9896
+ if (derivation.summarySkip === "oversize") oversizeSummaryPaths.push(relativePath);
9897
+ else if (derivation.summarySkip === "preview") previewSummaryPaths.push(relativePath);
9898
+ if (derivation.dependentImports !== null) {
9899
+ skippedSummaryImports.set(relativePath, derivation.dependentImports);
9900
+ }
9901
+ if (derivation.summaryWarning !== null) {
9902
+ fileWarnings.push({ path: relativePath, message: derivation.summaryWarning });
9903
+ }
9904
+ if (config.includeMonolith && (derivation.isPreviewTruncated || derivation.inclusionMode === "signatures")) {
9905
+ monolithBodies.set(relativePath, derivation.redacted);
9323
9906
  }
9324
9907
  files.push({
9325
9908
  relativePath,
@@ -9330,24 +9913,25 @@ function generateSnapshot(configInput, options) {
9330
9913
  layer: layer2,
9331
9914
  kind,
9332
9915
  language,
9333
- inclusionMode,
9334
- isLarge,
9335
- isPreviewTruncated,
9916
+ inclusionMode: derivation.inclusionMode,
9917
+ isLarge: derivation.isLarge,
9918
+ isPreviewTruncated: derivation.isPreviewTruncated,
9336
9919
  originalLines: lineCount,
9337
- includedLines,
9338
- includedBytes: Buffer.byteLength(finalContent, "utf8"),
9339
- summary,
9340
- summaryQuality,
9920
+ includedLines: derivation.includedLines,
9921
+ includedBytes: Buffer.byteLength(derivation.finalContent, "utf8"),
9922
+ summary: derivation.summary,
9923
+ summaryQuality: derivation.summaryQuality,
9341
9924
  ...decoded.encoding === "utf-8" ? {} : { encoding: decoded.encoding },
9342
- content: finalContent
9925
+ content: derivation.finalContent
9343
9926
  });
9344
9927
  }
9345
9928
  if (changedScope !== null && changedPaths !== null && config.withDependents === true) {
9346
9929
  const keep = new Set(changedPaths);
9347
9930
  const resolver = buildImportResolver(files);
9348
9931
  for (const file of files) {
9349
- if (!isSourceSummary(file.summary)) continue;
9350
- if (importsChangedFile(file.relativePath, file.summary.imports, changedPaths, resolver)) {
9932
+ const fileImports = isSourceSummary(file.summary) ? file.summary.imports : skippedSummaryImports.get(file.relativePath) ?? [];
9933
+ if (fileImports.length === 0) continue;
9934
+ if (importsChangedFile(file.relativePath, fileImports, changedPaths, resolver)) {
9351
9935
  keep.add(file.relativePath);
9352
9936
  }
9353
9937
  }
@@ -9378,6 +9962,9 @@ function generateSnapshot(configInput, options) {
9378
9962
  for (const path22 of [...monolithBodies.keys()]) {
9379
9963
  if (!keep.has(path22)) monolithBodies.delete(path22);
9380
9964
  }
9965
+ for (const path22 of [...skippedSummaryImports.keys()]) {
9966
+ if (!keep.has(path22)) skippedSummaryImports.delete(path22);
9967
+ }
9381
9968
  }
9382
9969
  if (changedScope !== null) {
9383
9970
  for (const relativePath of unchangedPaths) {
@@ -9413,7 +10000,7 @@ function generateSnapshot(configInput, options) {
9413
10000
  const shown = oversizeSummaryPaths.slice(0, ENCODING_PATHS_SHOWN).map((entry) => maskPathText(entry, config.secretRules));
9414
10001
  const hidden = oversizeSummaryPaths.length - ENCODING_PATHS_SHOWN;
9415
10002
  warnings.push(
9416
- `${oversizeSummaryPaths.length} file(s) exceed ${Math.round(SUMMARY_MAX_SOURCE_BYTES / (1024 * 1024))} MB and were not parsed for a structural summary: ${shown.join(", ")}${hidden > 0 ? ` and ${hidden} more` : ""}. Their bodies are emitted as head/tail previews.`
10003
+ `${oversizeSummaryPaths.length} file(s) exceed ${Math.round(SUMMARY_MAX_SOURCE_BYTES2 / (1024 * 1024))} MB and were not parsed for a structural summary: ${shown.join(", ")}${hidden > 0 ? ` and ${hidden} more` : ""}. Their bodies are emitted as head/tail previews.`
9417
10004
  );
9418
10005
  }
9419
10006
  for (const [encoding, bucket] of encodingBuckets) {
@@ -9437,11 +10024,13 @@ function generateSnapshot(configInput, options) {
9437
10024
  ).join("\n")
9438
10025
  );
9439
10026
  const fullContents = /* @__PURE__ */ new Map();
9440
- for (const file of files) {
9441
- fullContents.set(
9442
- file.relativePath,
9443
- monolithBodies.get(file.relativePath) ?? file.content ?? ""
9444
- );
10027
+ if (config.includeMonolith) {
10028
+ for (const file of files) {
10029
+ fullContents.set(
10030
+ file.relativePath,
10031
+ monolithBodies.get(file.relativePath) ?? file.content ?? ""
10032
+ );
10033
+ }
9445
10034
  }
9446
10035
  const parts = partitionByBudget(files, config, fingerprint);
9447
10036
  const truncatedPreviewCount = files.filter((file) => file.isPreviewTruncated).length;
@@ -9702,7 +10291,7 @@ function generateSnapshot(configInput, options) {
9702
10291
  };
9703
10292
  shipHookState();
9704
10293
  if (pendingHook) {
9705
- pendingHook.then(
10294
+ const tracked = pendingHook.then(
9706
10295
  () => {
9707
10296
  try {
9708
10297
  shipHookState();
@@ -9721,8 +10310,10 @@ function generateSnapshot(configInput, options) {
9721
10310
  } catch {
9722
10311
  }
9723
10312
  }
9724
- );
9725
- pendingHooks.set(outDir, pendingHook);
10313
+ ).finally(() => {
10314
+ if (pendingHooks.get(outDir) === tracked) pendingHooks.delete(outDir);
10315
+ });
10316
+ pendingHooks.set(outDir, tracked);
9726
10317
  }
9727
10318
  return manifest;
9728
10319
  } catch (err) {
@@ -9904,12 +10495,35 @@ async function openWorkspace(root, overrides = {}) {
9904
10495
  }
9905
10496
  cliOptions.maxPartBytes = parsed;
9906
10497
  }
10498
+ if (overrides.maxPartTokens !== void 0) {
10499
+ const parsed = parseByteSize(overrides.maxPartTokens);
10500
+ if (parsed === void 0) {
10501
+ return {
10502
+ ok: false,
10503
+ message: '"maxPartTokens" must be a token count, for example 32000 or "32k".'
10504
+ };
10505
+ }
10506
+ cliOptions.maxPartTokens = parsed;
10507
+ }
9907
10508
  if (overrides.outDir !== void 0) {
9908
10509
  if (typeof overrides.outDir !== "string" || overrides.outDir.trim().length === 0) {
9909
10510
  return { ok: false, message: '"outDir" must be a non-empty string.' };
9910
10511
  }
9911
10512
  cliOptions.outDir = overrides.outDir.trim();
9912
10513
  }
10514
+ if (overrides.changedSince !== void 0) {
10515
+ if (typeof overrides.changedSince !== "string" || overrides.changedSince.trim().length === 0) {
10516
+ return {
10517
+ ok: false,
10518
+ message: '"changedSince" must be a git ref such as "main" or "HEAD~3".'
10519
+ };
10520
+ }
10521
+ cliOptions.changedSince = overrides.changedSince.trim();
10522
+ }
10523
+ if (overrides.skeleton === true) cliOptions.skeleton = true;
10524
+ if (overrides.sarif === true) cliOptions.sarif = true;
10525
+ if (overrides.staged === true) cliOptions.staged = true;
10526
+ if (overrides.withDependents === true) cliOptions.withDependents = true;
9913
10527
  try {
9914
10528
  const config = resolveConfig(loaded.config, cliOptions, root);
9915
10529
  return { ok: true, workspace: { root, config, configFile: loaded.file, cliOptions } };
@@ -9921,6 +10535,16 @@ var manifestCache = /* @__PURE__ */ new Map();
9921
10535
  function manifestPathFor(workspace) {
9922
10536
  return path17.resolve(workspace.root, workspace.config.outDir, "manifest.json");
9923
10537
  }
10538
+ function partPathFor(workspace, filename) {
10539
+ if (filename.length === 0 || filename.includes("\0")) return null;
10540
+ const partsDir = path17.resolve(workspace.root, workspace.config.outDir, "parts");
10541
+ const resolved = path17.resolve(partsDir, filename);
10542
+ const relative12 = path17.relative(partsDir, resolved);
10543
+ if (relative12.length === 0 || relative12.includes(path17.sep) || relative12.includes("/") || relative12.includes("\\") || path17.isAbsolute(relative12)) {
10544
+ return null;
10545
+ }
10546
+ return resolved;
10547
+ }
9924
10548
  function readManifest3(workspace) {
9925
10549
  const file = manifestPathFor(workspace);
9926
10550
  const shown = path17.relative(workspace.root, file).split(path17.sep).join("/") || file;
@@ -9991,6 +10615,44 @@ var OUT_DIR_PROPERTY = {
9991
10615
  description: "Snapshot directory, relative to the root. Defaults to the project configuration's outDir."
9992
10616
  };
9993
10617
  var outDirOverride = (args) => ({ outDir: args.outDir });
10618
+ var SCOPE_PROPERTIES = {
10619
+ maxPartTokens: {
10620
+ type: "string",
10621
+ description: 'Token ceiling per part, for example 32000 or "32k". Narrower than maxPartBytes; it converts at four bytes per token.'
10622
+ },
10623
+ skeleton: {
10624
+ type: "boolean",
10625
+ description: "Replace every function body with `{ /* ... */ }`, keeping signatures, imports and exports. Applies to TypeScript, JavaScript and JSX."
10626
+ },
10627
+ sarif: {
10628
+ type: "boolean",
10629
+ description: "Also write reports/audit.sarif beside reports/audit.md, for CI code scanning."
10630
+ },
10631
+ changedSince: {
10632
+ type: "string",
10633
+ description: 'Only files changed since this git ref, for example "main" or "HEAD~3".'
10634
+ },
10635
+ staged: {
10636
+ type: "boolean",
10637
+ description: "Only files staged in the git index."
10638
+ },
10639
+ withDependents: {
10640
+ type: "boolean",
10641
+ description: "With staged or changedSince, also include the files that import the changed ones (one hop)."
10642
+ }
10643
+ };
10644
+ function workspaceOverrides(args) {
10645
+ return {
10646
+ outDir: args.outDir,
10647
+ maxPartBytes: args.maxPartBytes,
10648
+ maxPartTokens: args.maxPartTokens,
10649
+ skeleton: args.skeleton,
10650
+ sarif: args.sarif,
10651
+ changedSince: args.changedSince,
10652
+ staged: args.staged,
10653
+ withDependents: args.withDependents
10654
+ };
10655
+ }
9994
10656
  function failure(message) {
9995
10657
  return { text: message, isError: true };
9996
10658
  }
@@ -10021,12 +10683,13 @@ function summarizeDrift(drift) {
10021
10683
  }
10022
10684
  function toRelativePath(root, value) {
10023
10685
  const cleaned = value.trim();
10024
- if (cleaned.length === 0) return null;
10025
- if (!path18.isAbsolute(cleaned)) {
10026
- return cleaned.replace(/\\/g, "/").replace(/^\.\//, "");
10686
+ if (cleaned.length === 0 || cleaned.includes("\0")) return null;
10687
+ const resolvedRoot = path18.resolve(root);
10688
+ const absolute = path18.resolve(resolvedRoot, cleaned);
10689
+ const relative12 = path18.relative(resolvedRoot, absolute);
10690
+ if (relative12 === "" || relative12 === ".." || relative12.startsWith(`..${path18.sep}`) || path18.isAbsolute(relative12)) {
10691
+ return null;
10027
10692
  }
10028
- const relative12 = path18.relative(root, cleaned);
10029
- if (relative12.startsWith("..") || path18.isAbsolute(relative12)) return null;
10030
10693
  return relative12.split(path18.sep).join("/");
10031
10694
  }
10032
10695
  function symbolsOf(entry) {
@@ -10162,7 +10825,8 @@ var planTool = {
10162
10825
  maxPartBytes: {
10163
10826
  type: "string",
10164
10827
  description: 'Byte budget per part, for example 307200 or "300k". Defaults to the project configuration.'
10165
- }
10828
+ },
10829
+ ...SCOPE_PROPERTIES
10166
10830
  },
10167
10831
  additionalProperties: false
10168
10832
  },
@@ -10179,7 +10843,7 @@ var planTool = {
10179
10843
  },
10180
10844
  annotations: READ_ONLY,
10181
10845
  async handler(args, context) {
10182
- const prepared = await prepare(args, context, { maxPartBytes: args.maxPartBytes, outDir: args.outDir });
10846
+ const prepared = await prepare(args, context, workspaceOverrides(args));
10183
10847
  if (!prepared.ok) return prepared.outcome;
10184
10848
  const workspace = prepared.workspace;
10185
10849
  let manifest;
@@ -10237,7 +10901,8 @@ var generateTool = {
10237
10901
  maxPartBytes: {
10238
10902
  type: "string",
10239
10903
  description: 'Byte budget per part, for example 307200 or "300k". Defaults to the project configuration.'
10240
- }
10904
+ },
10905
+ ...SCOPE_PROPERTIES
10241
10906
  },
10242
10907
  additionalProperties: false
10243
10908
  },
@@ -10253,10 +10918,7 @@ var generateTool = {
10253
10918
  },
10254
10919
  annotations: WRITES,
10255
10920
  async handler(args, context) {
10256
- const prepared = await prepare(args, context, {
10257
- maxPartBytes: args.maxPartBytes,
10258
- outDir: args.outDir
10259
- });
10921
+ const prepared = await prepare(args, context, workspaceOverrides(args));
10260
10922
  if (!prepared.ok) return prepared.outcome;
10261
10923
  const workspace = prepared.workspace;
10262
10924
  const result = await generateInWorker(workspace.root, workspace.cliOptions);
@@ -10295,7 +10957,7 @@ var validateTool = {
10295
10957
  description: "Re-read the repository and report whether the snapshot on disk still matches it. Use it before trusting context that came from an earlier session, or after changing the config.",
10296
10958
  inputSchema: {
10297
10959
  type: "object",
10298
- properties: { root: ROOT_PROPERTY, outDir: OUT_DIR_PROPERTY },
10960
+ properties: { root: ROOT_PROPERTY, outDir: OUT_DIR_PROPERTY, ...SCOPE_PROPERTIES },
10299
10961
  additionalProperties: false
10300
10962
  },
10301
10963
  outputSchema: {
@@ -10307,7 +10969,7 @@ var validateTool = {
10307
10969
  },
10308
10970
  annotations: READ_ONLY,
10309
10971
  async handler(args, context) {
10310
- const prepared = await prepare(args, context, outDirOverride(args));
10972
+ const prepared = await prepare(args, context, workspaceOverrides(args));
10311
10973
  if (!prepared.ok) return prepared.outcome;
10312
10974
  const workspace = prepared.workspace;
10313
10975
  let result;
@@ -10652,7 +11314,12 @@ var readTool = {
10652
11314
  `${relativePath} is not in the snapshot. Call snapshot_locate to find the path, or snapshot_explain to learn why it was excluded.`
10653
11315
  );
10654
11316
  }
10655
- const partPath = path18.resolve(workspace.root, workspace.config.outDir, "parts", entry.part);
11317
+ const partPath = partPathFor(workspace, entry.part);
11318
+ if (partPath === null) {
11319
+ return failure(
11320
+ `${entry.part} is not a part of this snapshot: its name leaves ${workspace.config.outDir}/parts. Run snapshot_generate to rebuild it.`
11321
+ );
11322
+ }
10656
11323
  let partText;
10657
11324
  try {
10658
11325
  partText = fs16.readFileSync(partPath, "utf8");
@@ -10732,7 +11399,8 @@ var explainTool = {
10732
11399
  description: "Path relative to the repository root. Directories are explained as a whole."
10733
11400
  },
10734
11401
  root: ROOT_PROPERTY,
10735
- outDir: OUT_DIR_PROPERTY
11402
+ outDir: OUT_DIR_PROPERTY,
11403
+ ...SCOPE_PROPERTIES
10736
11404
  },
10737
11405
  required: ["path"],
10738
11406
  additionalProperties: false
@@ -10747,7 +11415,7 @@ var explainTool = {
10747
11415
  },
10748
11416
  annotations: READ_ONLY,
10749
11417
  async handler(args, context) {
10750
- const prepared = await prepare(args, context, outDirOverride(args));
11418
+ const prepared = await prepare(args, context, workspaceOverrides(args));
10751
11419
  if (!prepared.ok) return prepared.outcome;
10752
11420
  const workspace = prepared.workspace;
10753
11421
  if (typeof args.path !== "string") return failure('"path" must be a string.');
@@ -11589,7 +12257,7 @@ function buildAgentContract() {
11589
12257
  { key: "preset", type: "string", default: "detected", summary: "Force a preset. Unknown names are an error." },
11590
12258
  { key: "maxPartBytes", type: "number | string", default: 307200, summary: "Per-part byte budget." },
11591
12259
  { key: "maxPartTokens", type: "number | string | null", default: null, summary: "Per-part token ceiling; narrows maxPartBytes. Minimum 1024." },
11592
- { key: "skeleton", type: "boolean", default: false, summary: "Collapse function bodies to `{ /* ... */ }` in TypeScript files." },
12260
+ { key: "skeleton", type: "boolean", default: false, summary: "Collapse function bodies to `{ /* ... */ }` in TS, JS, TSX and JSX files." },
11593
12261
  { key: "sarif", type: "boolean", default: false, summary: "Also write reports/audit.sarif." },
11594
12262
  { key: "changedSince", type: "string | null", default: null, summary: "Snapshot only files that differ from this git ref." },
11595
12263
  { key: "staged", type: "boolean", default: false, summary: "Snapshot only files staged in the index." },
@@ -12533,9 +13201,9 @@ function buildResourceProvider() {
12533
13201
  const index = Number(partMatch[1]);
12534
13202
  const part = read.manifest.parts.find((candidate) => candidate.index === index);
12535
13203
  if (!part) return null;
12536
- const text2 = readFileIfPresent(
12537
- path20.resolve(workspace.root, workspace.config.outDir, "parts", part.filename)
12538
- );
13204
+ const partPath = partPathFor(workspace, part.filename);
13205
+ if (partPath === null) return null;
13206
+ const text2 = readFileIfPresent(partPath);
12539
13207
  if (text2 === null) return null;
12540
13208
  return { uri, mimeType: "text/markdown", text: capUtf8(text2, MAX_RESOURCE_BYTES).text };
12541
13209
  }
@@ -12567,11 +13235,11 @@ var MAX_LINE_BYTES = 32 * 1024 * 1024;
12567
13235
  function createWriter(output) {
12568
13236
  return {
12569
13237
  write(value) {
12570
- return new Promise((resolve9, reject) => {
13238
+ return new Promise((resolve10, reject) => {
12571
13239
  output.write(`${JSON.stringify(value)}
12572
13240
  `, (error) => {
12573
13241
  if (error) reject(error);
12574
- else resolve9();
13242
+ else resolve10();
12575
13243
  });
12576
13244
  });
12577
13245
  }
@@ -12579,6 +13247,7 @@ function createWriter(output) {
12579
13247
  }
12580
13248
  function createLineReader(input, options) {
12581
13249
  let buffer = "";
13250
+ let bufferBytes = 0;
12582
13251
  let discarding = false;
12583
13252
  let discardedBytes = 0;
12584
13253
  const deliver = (line) => {
@@ -12591,6 +13260,13 @@ function createLineReader(input, options) {
12591
13260
  if (trimmed.trim().length === 0) return;
12592
13261
  options.onLine(trimmed);
12593
13262
  };
13263
+ const discardBuffered = () => {
13264
+ discarding = true;
13265
+ discardedBytes += bufferBytes;
13266
+ buffer = "";
13267
+ bufferBytes = 0;
13268
+ options.onOverflow?.(discardedBytes);
13269
+ };
12594
13270
  const drain = () => {
12595
13271
  let index = buffer.indexOf("\n");
12596
13272
  while (index >= 0) {
@@ -12598,42 +13274,57 @@ function createLineReader(input, options) {
12598
13274
  buffer = buffer.slice(index + 1);
12599
13275
  if (discarding) {
12600
13276
  deliver(line);
12601
- } else if (line.length > MAX_LINE_BYTES) {
12602
- options.onOverflow?.(line.length);
12603
13277
  } else {
12604
- deliver(line);
13278
+ const lineBytes = Buffer.byteLength(line, "utf8");
13279
+ if (lineBytes > MAX_LINE_BYTES) options.onOverflow?.(lineBytes);
13280
+ else deliver(line);
12605
13281
  }
12606
13282
  index = buffer.indexOf("\n");
12607
13283
  }
13284
+ bufferBytes = Buffer.byteLength(buffer, "utf8");
13285
+ if (!discarding && bufferBytes > MAX_LINE_BYTES) discardBuffered();
12608
13286
  };
12609
13287
  const onData = (chunk) => {
13288
+ const chunkBytes = Buffer.byteLength(chunk, "utf8");
12610
13289
  if (discarding) {
12611
13290
  const index = chunk.indexOf("\n");
12612
13291
  if (index < 0) {
12613
- discardedBytes += chunk.length;
13292
+ discardedBytes += chunkBytes;
12614
13293
  return;
12615
13294
  }
12616
13295
  buffer = chunk.slice(index + 1);
13296
+ bufferBytes = Buffer.byteLength(buffer, "utf8");
12617
13297
  deliver("");
12618
13298
  drain();
12619
13299
  return;
12620
13300
  }
12621
- buffer += chunk;
12622
- drain();
12623
- if (buffer.length > MAX_LINE_BYTES) {
13301
+ if (chunk.indexOf("\n") < 0 && bufferBytes + chunkBytes > MAX_LINE_BYTES) {
12624
13302
  discarding = true;
12625
- discardedBytes = buffer.length;
13303
+ discardedBytes = bufferBytes + chunkBytes;
12626
13304
  buffer = "";
13305
+ bufferBytes = 0;
12627
13306
  options.onOverflow?.(discardedBytes);
12628
13307
  return;
12629
13308
  }
13309
+ buffer += chunk;
13310
+ drain();
12630
13311
  };
12631
13312
  const onEnd = () => {
12632
- if (buffer.length > 0) {
12633
- const rest = buffer;
12634
- buffer = "";
12635
- deliver(rest);
13313
+ const rest = buffer;
13314
+ const restBytes = bufferBytes || Buffer.byteLength(rest, "utf8");
13315
+ buffer = "";
13316
+ bufferBytes = 0;
13317
+ if (rest.length === 0) return;
13318
+ if (discarding) {
13319
+ discarding = false;
13320
+ discardedBytes = 0;
13321
+ return;
12636
13322
  }
13323
+ if (restBytes > MAX_LINE_BYTES) {
13324
+ options.onOverflow?.(restBytes);
13325
+ return;
13326
+ }
13327
+ deliver(rest);
12637
13328
  };
12638
13329
  input.setEncoding("utf8");
12639
13330
  input.on("data", onData);
@@ -12684,8 +13375,8 @@ async function runStdioServer(options) {
12684
13375
  let inFlight = 0;
12685
13376
  let inputEnded = false;
12686
13377
  let resolveDone = () => void 0;
12687
- const finished = new Promise((resolve9) => {
12688
- resolveDone = resolve9;
13378
+ const finished = new Promise((resolve10) => {
13379
+ resolveDone = resolve10;
12689
13380
  });
12690
13381
  const finishIfIdle = () => {
12691
13382
  if (inputEnded && inFlight === 0) resolveDone(0);
@@ -13197,7 +13888,9 @@ function nextStepHint(cwd, scriptsAddedNow) {
13197
13888
  let hasScript = scriptsAddedNow;
13198
13889
  if (!hasScript) {
13199
13890
  try {
13200
- const parsed = JSON.parse(fs19.readFileSync(path21.join(cwd, "package.json"), "utf8"));
13891
+ const parsed = JSON.parse(
13892
+ stripJsonComments2(fs19.readFileSync(path21.join(cwd, "package.json"), "utf8"))
13893
+ );
13201
13894
  hasScript = typeof parsed.scripts?.snapshot === "string";
13202
13895
  } catch {
13203
13896
  hasScript = false;
@@ -13299,18 +13992,8 @@ function installDependencies(cwd) {
13299
13992
  return { manager: manager.name, command, ok: true, detail: seconds };
13300
13993
  }
13301
13994
  async function captureStdout(fn) {
13302
- const original = process.stdout.write.bind(process.stdout);
13303
- let text2 = "";
13304
- process.stdout.write = ((chunk) => {
13305
- text2 += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
13306
- return true;
13307
- });
13308
- try {
13309
- const code = await fn();
13310
- return { code, text: text2 };
13311
- } finally {
13312
- process.stdout.write = original;
13313
- }
13995
+ const { value, text: text2 } = await runWithStdoutCapture(fn);
13996
+ return { code: value, text: text2 };
13314
13997
  }
13315
13998
  async function runSetupCommand(invocation, options = {}) {
13316
13999
  try {
@@ -13425,31 +14108,33 @@ async function runSetupCommand(invocation, options = {}) {
13425
14108
  if (invocation.watch) generateArgs.push("--watch");
13426
14109
  if (invocation.format === "json") {
13427
14110
  const generated = await captureStdout(() => main(generateArgs));
13428
- let manifest = null;
14111
+ let parsed = null;
13429
14112
  try {
13430
- manifest = JSON.parse(generated.text);
14113
+ parsed = JSON.parse(generated.text);
13431
14114
  } catch {
13432
- manifest = null;
14115
+ parsed = null;
14116
+ }
14117
+ const setup = {
14118
+ configPath: init.configPath,
14119
+ configReused: init.reused,
14120
+ preset: init.preset,
14121
+ scripts: init.scripts,
14122
+ dependency: init.dependency,
14123
+ gitignoreEntries: init.gitignoreEntries,
14124
+ install,
14125
+ notes: init.notes,
14126
+ hook
14127
+ };
14128
+ if (generated.code !== 0) {
14129
+ const inner = parsed && typeof parsed.error === "object" && parsed.error !== null ? parsed.error : { message: "Snapshot generation failed during setup.", exitCode: generated.code };
14130
+ process.stdout.write(
14131
+ `${JSON.stringify({ error: inner, setup, manifest: null }, null, 2)}
14132
+ `
14133
+ );
14134
+ return generated.code;
13433
14135
  }
13434
14136
  process.stdout.write(
13435
- `${JSON.stringify(
13436
- {
13437
- setup: {
13438
- configPath: init.configPath,
13439
- configReused: init.reused,
13440
- preset: init.preset,
13441
- scripts: init.scripts,
13442
- dependency: init.dependency,
13443
- gitignoreEntries: init.gitignoreEntries,
13444
- install,
13445
- notes: init.notes,
13446
- hook
13447
- },
13448
- manifest
13449
- },
13450
- null,
13451
- 2
13452
- )}
14137
+ `${JSON.stringify({ setup, manifest: parsed }, null, 2)}
13453
14138
  `
13454
14139
  );
13455
14140
  return generated.code;
@@ -13642,6 +14327,10 @@ ${USAGE}
13642
14327
  largeFileBytes: invocation.largeFileBytes,
13643
14328
  maxMonolithBytes: invocation.maxMonolithBytes,
13644
14329
  skipArtifacts: invocation.skipArtifacts,
14330
+ // `--only-artifacts` is forwarded as itself, not only as the derived suppression list:
14331
+ // suppression cannot switch an artifact *on*, so a config that had turned one of the
14332
+ // requested artifacts off left the run with nothing to write.
14333
+ onlyArtifacts: invocation.onlyArtifacts,
13645
14334
  discovery: invocation.discovery,
13646
14335
  changedSince: invocation.changedSince,
13647
14336
  staged: invocation.staged,
@@ -13653,19 +14342,6 @@ ${USAGE}
13653
14342
  );
13654
14343
  if (invocation.command === "validate") {
13655
14344
  const result = validateSnapshot(resolved);
13656
- if (result.valid) {
13657
- const manifestPath = path21.resolve(resolved.cwd, resolved.outDir, "manifest.json");
13658
- let manifest;
13659
- try {
13660
- manifest = JSON.parse(fs19.readFileSync(manifestPath, "utf8"));
13661
- } catch {
13662
- manifest = void 0;
13663
- }
13664
- const shapeError = manifest === void 0 ? null : describeManifestShapeProblem(manifest);
13665
- if (shapeError) {
13666
- return fail(invocation.format, `${manifestPath} is not a snapshot manifest: ${shapeError}`);
13667
- }
13668
- }
13669
14345
  emit(invocation.format, result, () => renderValidateText(result));
13670
14346
  return result.valid ? 0 : 1;
13671
14347
  }
@@ -13860,10 +14536,13 @@ function isGeneratedWatchPath(candidate, paths) {
13860
14536
  const outDir = comparablePath(path21.resolve(paths.cwd, paths.outDir));
13861
14537
  const tmpDir = comparablePath(path21.resolve(paths.cwd, paths.tmpDir));
13862
14538
  if (isSameOrInside(outDir, absolute) || isSameOrInside(tmpDir, absolute)) return true;
13863
- const parent = path21.dirname(absolute);
13864
- if (parent === path21.dirname(outDir) && path21.basename(absolute).startsWith(`${path21.basename(outDir)}.backup-`)) {
13865
- return true;
13866
- }
14539
+ const offsets = [path21.basename(outDir), path21.basename(tmpDir)].filter((name) => name.length > 0);
14540
+ const leftover = absolute.split(/[\\/]+/).some(
14541
+ (segment) => offsets.some(
14542
+ (name) => segment.startsWith(`${name}.backup-`) || segment.startsWith(`${name}.partial-`)
14543
+ )
14544
+ );
14545
+ if (leftover) return true;
13867
14546
  return absolute.split(/[\\/]+/).some((segment) => VCS_SEGMENTS.has(segment));
13868
14547
  }
13869
14548
  function decideWatchEvent(state, event, paths) {
@@ -13938,6 +14617,7 @@ async function runWatchLoop(invocation, resolved, options) {
13938
14617
  largeFileBytes: invocation.largeFileBytes,
13939
14618
  maxMonolithBytes: invocation.maxMonolithBytes,
13940
14619
  skipArtifacts: invocation.skipArtifacts,
14620
+ onlyArtifacts: invocation.onlyArtifacts,
13941
14621
  discovery: invocation.discovery,
13942
14622
  changedSince: invocation.changedSince,
13943
14623
  staged: invocation.staged,
@@ -13995,12 +14675,12 @@ async function runWatchLoop(invocation, resolved, options) {
13995
14675
  info(`\u26A0 Watch error: ${err.message}`);
13996
14676
  info(" The watcher may have missed changes. Restart with Ctrl+C if they stop arriving.");
13997
14677
  });
13998
- await new Promise((resolve9) => {
14678
+ await new Promise((resolve10) => {
13999
14679
  const stop = () => {
14000
14680
  stopped = true;
14001
14681
  if (timer) clearTimeout(timer);
14002
14682
  watcher.close();
14003
- resolve9();
14683
+ resolve10();
14004
14684
  };
14005
14685
  process.once("SIGINT", stop);
14006
14686
  process.once("SIGTERM", stop);