canship 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // <define:__CANSHIP_BUILD_INFO__>
4
+ var define_CANSHIP_BUILD_INFO_default = { version: "0.8.0", channel: "release", revision: "0700a3d119573b1d13c5f5b60f4e93ab87244b35", dirty: false };
5
+
3
6
  // src/index.ts
4
7
  import { statSync as statSync3 } from "fs";
5
8
  import { resolve as resolve2 } from "path";
@@ -749,7 +752,7 @@ function listViaGit(root, gitExecutable) {
749
752
  return null;
750
753
  }
751
754
  }
752
- function walkTree(root, skipped, wantAll, maxEntries) {
755
+ function walkTree(root, skipped, wantAll, maxEntries, excludePath) {
753
756
  const all = [];
754
757
  const found = [];
755
758
  let visited = 0;
@@ -793,9 +796,10 @@ function walkTree(root, skipped, wantAll, maxEntries) {
793
796
  visited++;
794
797
  const full = join2(dir, entry.name);
795
798
  const rel = relative2(root, full).split(sep2).join("/");
796
- let metadata;
799
+ if (excludePath?.(rel)) continue;
800
+ let metadata2;
797
801
  try {
798
- metadata = lstatSync2(full);
802
+ metadata2 = lstatSync2(full);
799
803
  } catch (err) {
800
804
  if (!isMissing(err)) skipped.push({
801
805
  path: rel,
@@ -804,18 +808,18 @@ function walkTree(root, skipped, wantAll, maxEntries) {
804
808
  });
805
809
  continue;
806
810
  }
807
- if (metadata.isSymbolicLink()) {
811
+ if (metadata2.isSymbolicLink()) {
808
812
  if (!SKIP_DIRS.has(entry.name)) {
809
813
  skipped.push({ path: rel, reason: "symlink", detail: "symbolic links are not followed" });
810
814
  }
811
815
  continue;
812
816
  }
813
- if (metadata.isDirectory()) {
817
+ if (metadata2.isDirectory()) {
814
818
  if (SKIP_DIRS.has(entry.name)) continue;
815
819
  walk(full, depth + 1);
816
820
  continue;
817
821
  }
818
- if (!metadata.isFile()) continue;
822
+ if (!metadata2.isFile()) continue;
819
823
  if (wantAll) all.push(rel);
820
824
  if (isCredentialName(entry.name) || isWorthProbing(rel)) found.push(rel);
821
825
  }
@@ -902,16 +906,17 @@ function readablePath(root, path, skipped) {
902
906
  }
903
907
  return true;
904
908
  }
905
- function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root), limits = {}, honorIgnoreMarkers = true) {
909
+ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root), limits = {}, honorIgnoreMarkers = true, excludePath) {
906
910
  root = realpathSync2(root);
907
911
  const skipped = [];
908
912
  const ignored = [];
909
913
  const fromGit = isGitRepo ? listViaGit(root, gitExecutable) : null;
910
- const walked = walkTree(root, skipped, fromGit === null, limits.maxEntries ?? MAX_WALK_ENTRIES);
914
+ const walked = walkTree(root, skipped, fromGit === null, limits.maxEntries ?? MAX_WALK_ENTRIES, excludePath);
911
915
  const listed = fromGit?.files ?? walked.all;
912
916
  const candidates = /* @__PURE__ */ new Set();
913
917
  let vendored = 0;
914
918
  for (const path of fromGit?.nestedRepositories ?? []) {
919
+ if (excludePath?.(path)) continue;
915
920
  if (isVendored(path)) {
916
921
  vendored++;
917
922
  continue;
@@ -923,11 +928,13 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
923
928
  });
924
929
  }
925
930
  for (const path of listed) {
931
+ if (excludePath?.(path)) continue;
926
932
  if (isVendored(path)) vendored++;
927
933
  else candidates.add(path);
928
934
  }
929
935
  const forced = /* @__PURE__ */ new Set();
930
936
  for (const hidden of walked.forced) {
937
+ if (excludePath?.(hidden)) continue;
931
938
  candidates.add(hidden);
932
939
  forced.add(hidden);
933
940
  }
@@ -1870,7 +1877,7 @@ function gitOrThrow(root, gitExecutable, args) {
1870
1877
  return out;
1871
1878
  }
1872
1879
  function repoPrefix(root, gitExecutable) {
1873
- return (git(root, gitExecutable, ["rev-parse", "--show-prefix"]) ?? "").trim();
1880
+ return gitOrThrow(root, gitExecutable, ["rev-parse", "--show-prefix"]).trim();
1874
1881
  }
1875
1882
  function trackedEnvFiles(root, gitExecutable) {
1876
1883
  const out = gitOrThrow(root, gitExecutable, ["ls-files", "-z"]);
@@ -1979,6 +1986,7 @@ var gitleakRule = {
1979
1986
  const remoteNote = remote ? `This repository has a remote configured, so these commits have most likely been pushed. Bots scrape public commits within minutes \u2014 assume every key in this file is already in someone else's hands.` : `This repository has no remote yet, so the damage may still be contained. Fix it before you push.`;
1980
1987
  const reportedTracked = /* @__PURE__ */ new Set();
1981
1988
  for (const path of tracked) {
1989
+ if (ctx.excludePath?.(path)) continue;
1982
1990
  const scanned = ctx.files.find((f) => f.path === path);
1983
1991
  const evidence = scanned ? evidenceIn(scanned.lines) : "hint";
1984
1992
  if (evidence === "none") continue;
@@ -2009,6 +2017,7 @@ var gitleakRule = {
2009
2017
  for (const entry of historical) {
2010
2018
  if (reportedTracked.has(entry.localPath)) continue;
2011
2019
  const path = entry.localPath;
2020
+ if (ctx.excludePath?.(path)) continue;
2012
2021
  const stillTracked = tracked.has(entry.localPath);
2013
2022
  const history = historicalEvidence(ctx.root, ctx.gitExecutable, entry);
2014
2023
  if (history && history.unread > 0) {
@@ -3092,7 +3101,7 @@ var firebaseRulesRule = {
3092
3101
  };
3093
3102
 
3094
3103
  // src/rules/apiauth.ts
3095
- import { posix } from "path";
3104
+ import { posix as posix2 } from "path";
3096
3105
 
3097
3106
  // src/rules/bindings.ts
3098
3107
  var cache = /* @__PURE__ */ new WeakMap();
@@ -3464,12 +3473,364 @@ function identityRequirement(value, allowParameters) {
3464
3473
  return null;
3465
3474
  }
3466
3475
 
3476
+ // src/rules/routers.ts
3477
+ import { createHash as createHash2 } from "crypto";
3478
+
3479
+ // src/rules/factory-modules.ts
3480
+ import { posix } from "path";
3481
+ var unknown = (limited2 = false) => ({ handled: true, file: null, ...limited2 ? { limited: true } : {} });
3482
+ var object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
3483
+ var safePath = (path) => Boolean(path) && path.length <= 512 && !/[\\\u0000-\u0020:%?#]/.test(path) && !posix.isAbsolute(path);
3484
+ var contained = (path) => path !== ".." && !path.startsWith("../") && !posix.isAbsolute(path);
3485
+ function metadata(file) {
3486
+ if (file.content.length > 65536) return null;
3487
+ let source = commentsMaskedOf(file);
3488
+ const code = noiseMaskedOf(file);
3489
+ const commas = [...code.matchAll(/,\s*[}\]]/g)].map((match) => match.index);
3490
+ if (commas.length) {
3491
+ const characters = source.split("");
3492
+ for (const at of commas) characters[at] = " ";
3493
+ source = characters.join("");
3494
+ }
3495
+ try {
3496
+ const value = JSON.parse(source);
3497
+ return object(value) ? value : null;
3498
+ } catch {
3499
+ return null;
3500
+ }
3501
+ }
3502
+ function exportTarget(value, limit, depth = 0) {
3503
+ if (depth >= 8) {
3504
+ limit();
3505
+ return null;
3506
+ }
3507
+ if (typeof value === "string") return value.startsWith("./") && safePath(value) && !value.includes("*") && value.split("/").slice(1).every((part) => part !== "." && part !== ".." && part !== "node_modules") ? value : null;
3508
+ if (!object(value)) return null;
3509
+ if (Object.keys(value).length > 32) {
3510
+ limit();
3511
+ return null;
3512
+ }
3513
+ const entries = Object.entries(value).filter(([key]) => key !== "types" && !key.startsWith("types@"));
3514
+ if (!entries.length || entries.some(([key]) => key.startsWith("."))) return null;
3515
+ const targets = entries.map(([, item]) => exportTarget(item, limit, depth + 1));
3516
+ return targets.every((target) => target !== null && target === targets[0]) ? targets[0] : null;
3517
+ }
3518
+ function factoryModuleResolver(files) {
3519
+ const byPath = new Map(files.map((file) => [file.path, file]));
3520
+ const parsed = /* @__PURE__ */ new Map();
3521
+ let metadataLimited = false;
3522
+ const json = (file) => {
3523
+ if (file.content.length > 65536) {
3524
+ metadataLimited = true;
3525
+ return null;
3526
+ }
3527
+ if (!parsed.has(file)) parsed.set(file, metadata(file));
3528
+ return parsed.get(file);
3529
+ };
3530
+ const parents = (file) => {
3531
+ const found = [];
3532
+ for (let dir = posix.dirname(file.path); ; dir = posix.dirname(dir)) {
3533
+ found.push(dir);
3534
+ if (dir === "." || posix.dirname(dir) === dir) break;
3535
+ }
3536
+ return found;
3537
+ };
3538
+ const nearest = (file, names) => {
3539
+ for (const dir of parents(file)) for (const name of names) {
3540
+ const found = byPath.get(posix.join(dir, name));
3541
+ if (found) return found;
3542
+ }
3543
+ return void 0;
3544
+ };
3545
+ const sourceAt = (path, from) => {
3546
+ if (!contained(path)) return null;
3547
+ const relative3 = posix.relative(posix.dirname(from.path), path);
3548
+ return bindingModule(relative3.startsWith(".") ? relative3 : "./" + relative3, from, files, "");
3549
+ };
3550
+ const configPath = (spec, from) => {
3551
+ const config = nearest(from, ["tsconfig.json", "jsconfig.json"]);
3552
+ if (!config) return null;
3553
+ if (config.content.length > 65536) return unknown(true);
3554
+ const data = json(config);
3555
+ if (!data || "extends" in data || "references" in data) return unknown();
3556
+ const options = data.compilerOptions;
3557
+ if (options === void 0) return null;
3558
+ if (!object(options)) return unknown();
3559
+ if (options.paths === void 0) return null;
3560
+ if (!object(options.paths)) return unknown();
3561
+ if (Object.keys(options.paths).length > 128) return unknown(true);
3562
+ const matches = [];
3563
+ for (const [pattern, target2] of Object.entries(options.paths)) {
3564
+ if (pattern.length > 512) return unknown(true);
3565
+ if (pattern.split("*").length > 2) return unknown();
3566
+ const [prefix, suffix = ""] = pattern.split("*");
3567
+ const exact = !pattern.includes("*");
3568
+ if (exact ? pattern !== spec : !spec.startsWith(prefix) || !spec.endsWith(suffix) || spec.length < prefix.length + suffix.length) continue;
3569
+ matches.push({ target: target2, prefix, suffix, exact, middle: exact ? "" : spec.slice(prefix.length, spec.length - suffix.length) });
3570
+ }
3571
+ if (!matches.length) return null;
3572
+ matches.sort((a, b) => Number(b.exact) - Number(a.exact) || b.prefix.length - a.prefix.length);
3573
+ const selected = matches[0];
3574
+ if (matches[1] && matches[1].exact === selected.exact && matches[1].prefix.length === selected.prefix.length) return unknown();
3575
+ if (!Array.isArray(selected.target) || selected.target.length !== 1 || typeof selected.target[0] !== "string") return unknown();
3576
+ const target = selected.target[0];
3577
+ if (!safePath(target) || target.split("*").length > 2 || selected.exact && target.includes("*")) return unknown();
3578
+ const base = options.baseUrl ?? ".";
3579
+ if (typeof base !== "string" || !safePath(base) || base.includes("*")) return unknown();
3580
+ const path = posix.normalize(posix.join(posix.dirname(config.path), base, target.replace("*", selected.middle)));
3581
+ return { handled: true, file: sourceAt(path, from) };
3582
+ };
3583
+ const manifests = files.filter((file) => /(?:^|\/)package\.json$/.test(file.path));
3584
+ const workspace = (spec, from) => {
3585
+ const name = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
3586
+ const subpath = spec === name ? "." : "." + spec.slice(name.length);
3587
+ const owner = nearest(from, ["package.json"]);
3588
+ if (!owner) return null;
3589
+ if (owner.content.length > 65536) return unknown(true);
3590
+ const ownerData = json(owner);
3591
+ const deps = ownerData?.dependencies;
3592
+ const link = object(deps) ? deps[name] : void 0;
3593
+ if (typeof link !== "string" || !/^workspace:(?:\*|\^|~)$/.test(link)) return null;
3594
+ const roots = parents(owner).map((dir2) => byPath.get(posix.join(dir2, "package.json"))).filter((file) => Boolean(file));
3595
+ if (roots.some((file) => file.content.length > 65536)) return unknown(true);
3596
+ const root = roots.find((file) => json(file)?.workspaces !== void 0);
3597
+ if (!root) return unknown();
3598
+ const raw = json(root).workspaces;
3599
+ const patterns = Array.isArray(raw) ? raw : object(raw) ? raw.packages : null;
3600
+ if (!Array.isArray(patterns)) return unknown();
3601
+ if (patterns.length > 64) return unknown(true);
3602
+ if (patterns.some((item) => typeof item !== "string" || !safePath(item) || item.split("*").length > 2)) return unknown();
3603
+ const rootDir = posix.dirname(root.path);
3604
+ const matched = manifests.filter((file) => {
3605
+ const dir2 = posix.relative(rootDir, posix.dirname(file.path));
3606
+ return Boolean(dir2) && contained(dir2) && patterns.some((pattern) => {
3607
+ const [before, after = ""] = pattern.replace(/\/$/, "").split("*");
3608
+ return pattern.includes("*") ? dir2.length >= before.length + after.length && dir2.startsWith(before) && dir2.endsWith(after) && !dir2.slice(before.length, dir2.length - after.length).includes("/") : dir2 === before;
3609
+ }) && json(file)?.name === name;
3610
+ });
3611
+ if (matched.length !== 1) return unknown();
3612
+ const manifest = matched[0], data = json(manifest);
3613
+ let target;
3614
+ if ("exports" in data) {
3615
+ const value = data.exports;
3616
+ if (object(value) && Object.keys(value).some((key) => key.startsWith(".")) && Object.keys(value).some((key) => !key.startsWith("."))) return unknown();
3617
+ const entry = object(value) && Object.keys(value).some((key) => key.startsWith(".")) ? value[subpath] : subpath === "." ? value : null;
3618
+ let limited2 = false;
3619
+ target = exportTarget(entry, () => {
3620
+ limited2 = true;
3621
+ });
3622
+ if (limited2) return unknown(true);
3623
+ } else target = subpath === "." && typeof data.main === "string" && (!data.module || data.module === data.main) ? data.main : null;
3624
+ if (!target || !safePath(target)) return unknown();
3625
+ const dir = posix.dirname(manifest.path), path = posix.normalize(posix.join(dir, target));
3626
+ if (!contained(posix.relative(dir, path))) return unknown();
3627
+ return { handled: true, file: sourceAt(path, from) };
3628
+ };
3629
+ const resolve3 = (spec, from) => {
3630
+ if (!spec || spec.length > 512 || /[\\\u0000-\u0020:%?]/.test(spec) || posix.isAbsolute(spec)) return unknown();
3631
+ if (spec.startsWith(".")) return { handled: true, file: bindingModule(spec, from, files, "") };
3632
+ const configured = configPath(spec, from);
3633
+ if (configured) return configured;
3634
+ const local = workspace(spec, from);
3635
+ if (local) return local;
3636
+ const scope = /^(.*?\/)?(?:src|app|lib|server|routes|api|controllers|plugins)\//.exec(from.path)?.[1] ?? "";
3637
+ const file = bindingModule(spec, from, files, scope);
3638
+ return { handled: Boolean(file), file };
3639
+ };
3640
+ return (spec, from) => {
3641
+ metadataLimited = false;
3642
+ const result = resolve3(spec, from);
3643
+ if (result.file && (!/\.[mc]?[jt]sx?$/.test(result.file.path) || /\.d\.[cm]?ts$/.test(result.file.path))) return unknown();
3644
+ return metadataLimited ? unknown(true) : result;
3645
+ };
3646
+ }
3647
+
3648
+ // src/rules/registration-context.ts
3649
+ function registrationContext(code, pairs, bodies) {
3650
+ const regions = [];
3651
+ const openers = new Map([...pairs].map(([open, close]) => [close, open]));
3652
+ const controlBraces = /* @__PURE__ */ new Set();
3653
+ let limited2 = false;
3654
+ const space = (at) => {
3655
+ while (at < code.length && /\s/.test(code[at])) at++;
3656
+ return at;
3657
+ };
3658
+ const add = (start, end, dead = false, owner2 = false) => {
3659
+ if (regions.some((region) => region.start === start && region.end === end && region.dead === dead && region.owner === owner2)) return;
3660
+ if (regions.length >= 512) {
3661
+ limited2 = true;
3662
+ return;
3663
+ }
3664
+ regions.push({ start, end, dead, owner: owner2 });
3665
+ };
3666
+ const literal2 = (text) => {
3667
+ const match = /^\s*(!\s*)?(true|false)\s*$/.exec(text);
3668
+ return match ? match[2] === "true" !== Boolean(match[1]) : null;
3669
+ };
3670
+ const simpleEnd = (start) => {
3671
+ for (let at = start; at < code.length; at++) {
3672
+ const quote = code[at];
3673
+ if (quote === '"' || quote === "'" || quote === "`") {
3674
+ let end2 = at + 1;
3675
+ for (; end2 < code.length; end2++) {
3676
+ if (quote === "`" && code[end2] === "$" && code[end2 + 1] === "{") {
3677
+ const close = pairs.get(end2 + 1);
3678
+ if (close !== void 0) {
3679
+ end2 = close;
3680
+ continue;
3681
+ }
3682
+ }
3683
+ if (code[end2] === quote) break;
3684
+ }
3685
+ if (end2 === code.length) limited2 = true;
3686
+ at = end2;
3687
+ continue;
3688
+ }
3689
+ const end = pairs.get(at);
3690
+ if (end !== void 0) {
3691
+ at = end;
3692
+ continue;
3693
+ }
3694
+ if (/[;,}\n]/.test(code[at])) return at + (code[at] === ";" ? 1 : 0);
3695
+ }
3696
+ return code.length;
3697
+ };
3698
+ const statementEnd3 = (rawStart, depth = 0) => {
3699
+ const start = space(rawStart);
3700
+ if (depth >= 32) {
3701
+ limited2 = true;
3702
+ return code.length;
3703
+ }
3704
+ if (code[start] === "{") {
3705
+ const end = pairs.get(start);
3706
+ if (end === void 0) limited2 = true;
3707
+ return (end ?? code.length - 1) + 1;
3708
+ }
3709
+ const head = /^(if|for(?:\s+await)?|while|switch|with)\s*\(/.exec(code.slice(start, start + 100));
3710
+ if (head) {
3711
+ const close = pairs.get(start + head[0].length - 1);
3712
+ if (close === void 0) {
3713
+ limited2 = true;
3714
+ return code.length;
3715
+ }
3716
+ let end = statementEnd3(close + 1, depth + 1);
3717
+ const tail = space(end);
3718
+ if (head[1] === "if" && /^else\b/.test(code.slice(tail, tail + 8))) end = statementEnd3(tail + 4, depth + 1);
3719
+ return end;
3720
+ }
3721
+ return simpleEnd(start);
3722
+ };
3723
+ for (const body of bodies) add(body.start, body.end + 1, false, true);
3724
+ for (const match of code.matchAll(/=>\s*(?!\s*\{)/g)) {
3725
+ const start = space(match.index + 2);
3726
+ if (code[start] !== "{") add(start, simpleEnd(start), false, true);
3727
+ }
3728
+ for (const match of code.matchAll(/(?<![\w$.])(if|for(?:\s+await)?|while|switch|catch|with)\s*\(/g)) {
3729
+ const open = match.index + match[0].length - 1, close = pairs.get(open);
3730
+ if (close === void 0) {
3731
+ limited2 = true;
3732
+ continue;
3733
+ }
3734
+ const start = space(close + 1), end = statementEnd3(start);
3735
+ if (code[start] === "{") controlBraces.add(start);
3736
+ const condition = literal2(code.slice(open + 1, close));
3737
+ if (match[1] === "if") {
3738
+ if (condition !== true) add(start, end, condition === false);
3739
+ const next = space(end);
3740
+ if (/^else\b/.test(code.slice(next, next + 8))) {
3741
+ const alternate = space(next + 4), alternateEnd = statementEnd3(alternate);
3742
+ if (code[alternate] === "{") controlBraces.add(alternate);
3743
+ if (condition !== false) add(alternate, alternateEnd, condition === true);
3744
+ }
3745
+ } else {
3746
+ add(start, end, match[1] === "while" && condition === false);
3747
+ if (match[1] === "switch" && code[start] === "{") {
3748
+ const labels = [];
3749
+ for (let at = start + 1; at < end - 1; at++) {
3750
+ const nested = pairs.get(at);
3751
+ if (nested !== void 0) {
3752
+ at = nested;
3753
+ continue;
3754
+ }
3755
+ if ((at === 0 || !/[\w$.]/.test(code[at - 1])) && /^(?:case|default)\b/.test(code.slice(at, at + 10))) labels.push(at);
3756
+ }
3757
+ labels.forEach((at, index) => add(at, labels[index + 1] ?? end - 1));
3758
+ }
3759
+ }
3760
+ }
3761
+ for (const match of code.matchAll(/(?<![\w$.])(?:try|finally|do|catch)\s*\{/g)) {
3762
+ const start = match.index + match[0].length - 1;
3763
+ controlBraces.add(start);
3764
+ add(start, (pairs.get(start) ?? code.length - 1) + 1);
3765
+ }
3766
+ for (const [start, end] of pairs) {
3767
+ if (code[start] !== "{" || controlBraces.has(start) || bodies.some((body) => body.start === start)) continue;
3768
+ if (/\)\s*(?::[\w\s.<>,[\]|?]+)?$/.test(code.slice(Math.max(0, start - 200), start))) add(start, end + 1, false, true);
3769
+ }
3770
+ const contains = (region, at) => region.start <= at && at < region.end;
3771
+ const owner = (at) => regions.filter((region) => region.owner && contains(region, at)).sort((a, b) => a.end - a.start - (b.end - b.start))[0];
3772
+ const inline = (at) => {
3773
+ let start = at;
3774
+ for (let cursor = at - 1; cursor >= 0; cursor--) {
3775
+ if (at - cursor > 4e3) {
3776
+ limited2 = true;
3777
+ return "conditional";
3778
+ }
3779
+ const open = openers.get(cursor);
3780
+ if (open !== void 0) {
3781
+ cursor = open;
3782
+ start = open;
3783
+ continue;
3784
+ }
3785
+ if (code[cursor] === ";" || code[cursor] === "{") {
3786
+ start = cursor + 1;
3787
+ break;
3788
+ }
3789
+ if (code[cursor] === "\n" && !/[&|?:=,(.\\]$/.test(code.slice(Math.max(0, cursor - 100), cursor).trimEnd())) {
3790
+ start = cursor + 1;
3791
+ break;
3792
+ }
3793
+ start = cursor;
3794
+ }
3795
+ let prefix = "";
3796
+ for (let cursor = start; cursor < at; cursor++) {
3797
+ const close = pairs.get(cursor);
3798
+ if (close !== void 0 && close < at) {
3799
+ prefix += " ";
3800
+ cursor = close;
3801
+ continue;
3802
+ }
3803
+ prefix += code[cursor];
3804
+ }
3805
+ prefix = prefix.trim();
3806
+ const simple = /^(?:const\s+[\w$]+\s*=\s*)?(!?\s*(?:true|false))\s*(&&|\|\|)\s*$/.exec(prefix);
3807
+ if (simple) {
3808
+ const truth = literal2(simple[1]);
3809
+ return (simple[2] === "&&" ? truth : !truth) ? "always" : "dead";
3810
+ }
3811
+ return /&&|\|\||\?/.test(prefix) ? "conditional" : "always";
3812
+ };
3813
+ const reachable = (at) => limited2 || !regions.some((region) => region.dead && contains(region, at)) && inline(at) !== "dead";
3814
+ return {
3815
+ get limited() {
3816
+ return limited2;
3817
+ },
3818
+ reachable,
3819
+ covers(guard, operation) {
3820
+ if (limited2 || !reachable(guard) || inline(guard) !== "always") return false;
3821
+ const guardOwner = owner(guard);
3822
+ if (guardOwner && guardOwner !== owner(operation)) return false;
3823
+ return regions.filter((region) => !region.dead && contains(region, guard)).every((region) => contains(region, operation));
3824
+ }
3825
+ };
3826
+ }
3827
+
3467
3828
  // src/rules/routers.ts
3468
3829
  var NODE_FRAMEWORKS = /* @__PURE__ */ new Set(["express", "hono", "fastify"]);
3469
3830
  var METHODS = "get|post|put|patch|delete|all|options|head";
3470
3831
  var GENERICS = String.raw`(?:<[^()]{0,300}>\s*)?`;
3471
3832
  var FASTIFY_HOOKS = /^(?:onRequest|preParsing|preValidation|preHandler)$/;
3472
- var HONO_CALL = `${METHODS}|on|use|route`;
3833
+ var HONO_CALL = `${METHODS}|on|use|route|openapiRoutes|openapi`;
3473
3834
  var analysedCache = /* @__PURE__ */ new WeakMap();
3474
3835
  function analyse(file) {
3475
3836
  const cached = analysedCache.get(file);
@@ -3508,8 +3869,9 @@ function argsOf(a, open, close) {
3508
3869
  function flatten(a, args) {
3509
3870
  return args.flatMap((arg) => {
3510
3871
  if (!arg.text.startsWith("[")) return [arg];
3511
- const close = a.pairs.get(arg.at);
3512
- return close === void 0 ? [arg] : argsOf(a, arg.at, close);
3872
+ const owner = arg.file ? analyse(arg.file) : a;
3873
+ const close = owner.pairs.get(arg.at);
3874
+ return close === void 0 ? [arg] : argsOf(owner, arg.at, close).map((item) => ({ ...item, file: owner.file }));
3513
3875
  });
3514
3876
  }
3515
3877
  function scopeOf(path) {
@@ -3542,7 +3904,7 @@ function entriesOf(a, arg) {
3542
3904
  return entries;
3543
3905
  }
3544
3906
  function frameworkNamesOf(a) {
3545
- const names = { express: /* @__PURE__ */ new Set(), router: /* @__PURE__ */ new Set(), hono: /* @__PURE__ */ new Set(), fastify: /* @__PURE__ */ new Set(), types: /* @__PURE__ */ new Set() };
3907
+ const names = { express: /* @__PURE__ */ new Set(), router: /* @__PURE__ */ new Set(), hono: /* @__PURE__ */ new Set(), openapi: /* @__PURE__ */ new Set(), fastify: /* @__PURE__ */ new Set(), types: /* @__PURE__ */ new Set() };
3546
3908
  const named = (list, wanted, into, fallback) => {
3547
3909
  for (const part of list.split(",")) {
3548
3910
  const m = /^\s*(?:type\s+)?([A-Za-z_$][\w$]*)\s*(?:(?:as|:)\s*([A-Za-z_$][\w$]*))?\s*$/.exec(part);
@@ -3552,6 +3914,7 @@ function frameworkNamesOf(a) {
3552
3914
  }
3553
3915
  };
3554
3916
  for (const m of a.source.matchAll(/\bimport\s+(type\s+)?(?:([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([^{}]*)\}|\*\s+as\s+([A-Za-z_$][\w$]*))?\s*from\s*['"](express|hono(?:\/tiny|\/quick)?|@hono\/zod-openapi|fastify)['"]/g)) {
3917
+ if (a.code.slice(m.index, m.index + 6) !== "import") continue;
3555
3918
  const typeOnly = Boolean(m[1]);
3556
3919
  if (m[5] === "express") {
3557
3920
  if (!typeOnly && m[2]) names.express.add(m[2]);
@@ -3564,20 +3927,27 @@ function frameworkNamesOf(a) {
3564
3927
  names.types.add("fastify");
3565
3928
  } else {
3566
3929
  if (!typeOnly && m[3]) named(m[3], /^(?:Hono|OpenAPIHono)$/, names.hono);
3930
+ if (!typeOnly && m[3] && m[5] === "@hono/zod-openapi") named(m[3], /^OpenAPIHono$/, names.openapi);
3567
3931
  names.types.add("hono");
3568
3932
  }
3569
3933
  }
3570
3934
  for (const m of a.source.matchAll(/\b(?:const|let|var)\s+(?:([A-Za-z_$][\w$]*)|\{([^{}]*)\})\s*=\s*require\s*\(\s*['"](express|hono(?:\/tiny|\/quick)?|@hono\/zod-openapi|fastify)['"]\s*\)(?!\s*\()/g)) {
3935
+ if (!/^(?:const|let|var)\b/.test(a.code.slice(m.index))) continue;
3571
3936
  if (m[3] === "express") {
3572
3937
  if (m[1]) names.express.add(m[1]);
3573
3938
  if (m[2]) named(m[2], /^Router$/, names.router);
3574
3939
  } else if (m[3] === "fastify") {
3575
3940
  if (m[1]) names.fastify.add(m[1]);
3576
3941
  if (m[2]) named(m[2], /^fastify$/, names.fastify);
3577
- } else if (m[2]) named(m[2], /^(?:Hono|OpenAPIHono)$/, names.hono);
3942
+ } else if (m[2]) {
3943
+ named(m[2], /^(?:Hono|OpenAPIHono)$/, names.hono);
3944
+ if (m[3] === "@hono/zod-openapi") named(m[2], /^OpenAPIHono$/, names.openapi);
3945
+ }
3578
3946
  names.types.add(m[3] === "express" || m[3] === "fastify" ? m[3] : "hono");
3579
3947
  }
3580
- for (const m of a.source.matchAll(/\brequire\s*\(\s*['"](express|fastify)['"]\s*\)/g)) names.types.add(m[1]);
3948
+ for (const m of a.source.matchAll(/\brequire\s*\(\s*['"](express|fastify)['"]\s*\)/g)) {
3949
+ if (a.code.slice(m.index, m.index + 7) === "require") names.types.add(m[1]);
3950
+ }
3581
3951
  return names.types.size ? names : null;
3582
3952
  }
3583
3953
  function instancesOf(a, names) {
@@ -3586,6 +3956,7 @@ function instancesOf(a, names) {
3586
3956
  if (!found.some((other) => other.name === instance.name)) found.push(instance);
3587
3957
  };
3588
3958
  for (const m of a.source.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]{0,100})?=\s*(?:await\s+)?(new\s+)?([A-Za-z_$][\w$]*)\s*(?:\.\s*(Router)\s*)?(<[^()]{0,300}>\s*)?\(/g)) {
3959
+ if (!/^(?:const|let|var)\b/.test(a.code.slice(m.index))) continue;
3589
3960
  const [, name, isNew, callee, router] = m;
3590
3961
  if (!isNew && (names.express.has(callee) && (router === "Router" || router === void 0) || names.router.has(callee) && router === void 0)) {
3591
3962
  add({ name, framework: "express", prefix: "", weak: false });
@@ -3602,12 +3973,13 @@ function instancesOf(a, names) {
3602
3973
  prefix = (arg && pathOf(a, arg)) ?? "";
3603
3974
  if (baseClose !== void 0) close = baseClose;
3604
3975
  }
3605
- add({ name, framework: "hono", prefix, weak: false, chainFrom: close });
3976
+ add({ name, framework: "hono", prefix, weak: false, chainFrom: close, openapi: names.openapi.has(callee) });
3606
3977
  } else if (!isNew && names.fastify.has(callee) && router === void 0) {
3607
3978
  add({ name, framework: "fastify", prefix: "", weak: false });
3608
3979
  }
3609
3980
  }
3610
3981
  for (const m of a.source.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?require\s*\(\s*['"](express|fastify)['"]\s*\)\s*(?:\.\s*(Router)\s*)?\(/g)) {
3982
+ if (!/^(?:const|let|var)\b/.test(a.code.slice(m.index))) continue;
3611
3983
  if (m[2] === "express" || !m[3]) add({ name: m[1], framework: m[2], prefix: "", weak: false });
3612
3984
  }
3613
3985
  const typed = [
@@ -3618,11 +3990,180 @@ function instancesOf(a, names) {
3618
3990
  for (const [framework, type] of typed) {
3619
3991
  if (!names.types.has(framework)) continue;
3620
3992
  for (const m of a.code.matchAll(new RegExp(String.raw`([A-Za-z_$][\w$]*)\s*\??:\s*(?:[A-Za-z_$][\w$]*\s*\.\s*)?${type}`, "g"))) {
3621
- add({ name: m[1], framework, prefix: "", weak: true });
3993
+ add({ name: m[1], framework, prefix: "", weak: true, openapi: framework === "hono" && /\bOpenAPIHono\b/.test(m[0]) });
3622
3994
  }
3623
3995
  }
3624
3996
  return found;
3625
3997
  }
3998
+ function factoryBindingClean(a, name, initializer = -1) {
3999
+ const escaped = name.replace(/\$/g, "\\$");
4000
+ if ([...a.code.matchAll(new RegExp(`\\b(?:const|let|var|function|class)\\s+${escaped}(?![\\w$])`, "g"))].length > 1) return false;
4001
+ if (initializer < 0) {
4002
+ const declaration = new RegExp(`\\bconst\\s+(${escaped})\\s*(?::[^=;]{0,200})?=`).exec(a.code);
4003
+ if (declaration) initializer = declaration.index + /^const\s+/.exec(declaration[0])[0].length;
4004
+ }
4005
+ if (new RegExp(`[(,]\\s*${escaped}\\s*(?=[:,)=])|(?<![\\w$.])${escaped}\\s*=>`).test(a.code)) return false;
4006
+ for (const match of a.code.matchAll(/\b(?:const|let|var)\s*([\[{])/g)) {
4007
+ const open = match.index + match[0].length - 1, close = a.pairs.get(open);
4008
+ if (close !== void 0 && new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`).test(a.code.slice(open, close + 1))) return false;
4009
+ }
4010
+ const writes = new RegExp(`(?<![\\w$.])${escaped}\\s*(?:=(?![=>])|[+*/%&|^-]=|\\?\\?=|&&=|\\|\\|=|\\+\\+|--)`, "g");
4011
+ for (const match of a.code.matchAll(writes)) if (match.index !== initializer) return false;
4012
+ return !new RegExp(`(?<![\\w$.])${escaped}(?:\\s*\\.\\s*[\\w$]+|\\s*\\[[^\\]]{0,200}\\])+\\s*=(?!=)|\\bObject\\s*\\.\\s*(?:assign|defineProperty|defineProperties)\\s*\\(\\s*${escaped}(?![\\w$])`).test(a.code);
4013
+ }
4014
+ function projectFactoryResolver(files, gaps) {
4015
+ const cache3 = /* @__PURE__ */ new Map();
4016
+ const module = factoryModuleResolver(files);
4017
+ let limited2 = false;
4018
+ const topLevel = (a, at) => ![...a.pairs].some(([open, close]) => a.code[open] === "{" && open < at && at < close);
4019
+ const argAt = (a, at, end) => {
4020
+ const lead = a.code.slice(at, end).length - a.code.slice(at, end).trimStart().length;
4021
+ at += lead;
4022
+ const text = a.code.slice(at, end).trimEnd();
4023
+ return { at, text, source: a.source.slice(at, at + text.length) };
4024
+ };
4025
+ const basePath = (a, close, shape) => {
4026
+ for (let i = 0; i < 8; i++) {
4027
+ const next = /^\s*\.\s*basePath\s*\(/.exec(a.code.slice(close + 1, close + 200));
4028
+ if (!next) return { shape, close };
4029
+ if (shape.framework !== "hono") return null;
4030
+ const open = close + next[0].length, end = a.pairs.get(open);
4031
+ const args = end === void 0 ? [] : argsOf(a, open, end);
4032
+ const path = args.length === 1 ? literalOf(args[0]) : null;
4033
+ if (end === void 0 || path === null) return null;
4034
+ shape = { ...shape, prefix: joinPath(shape.prefix, path) };
4035
+ close = end;
4036
+ }
4037
+ if (/^\s*\.\s*basePath\s*\(/.test(a.code.slice(close + 1, close + 200))) {
4038
+ limited2 = true;
4039
+ return null;
4040
+ }
4041
+ return { shape, close };
4042
+ };
4043
+ const expression = (a, arg, depth) => {
4044
+ if (depth >= 8 || arg.text.length > 4e3) {
4045
+ limited2 = true;
4046
+ return null;
4047
+ }
4048
+ if (arg.text[0] === "(" && a.pairs.get(arg.at) === arg.at + arg.text.length - 1) return expression(a, argAt(a, arg.at + 1, arg.at + arg.text.length - 1), depth + 1);
4049
+ const call = /^(new\s+)?([A-Za-z_$][\w$]*)(?:\s*\.\s*(Router))?\s*(?:<[^()]{0,300}>\s*)?\(/.exec(arg.text);
4050
+ if (!call) return null;
4051
+ const [, isNew, name, member] = call;
4052
+ const open = arg.at + call[0].length - 1, close = a.pairs.get(open);
4053
+ if (close === void 0 || close >= arg.at + arg.text.length || !factoryBindingClean(a, name)) return null;
4054
+ const imports = bindingsOf(a.file).imports.filter((item) => item.local === name);
4055
+ const imported = imports.length === 1 ? imports[0] : void 0;
4056
+ let shape = null;
4057
+ const origin = imported?.spec ? module(imported.spec, a.file) : null;
4058
+ if (origin?.limited) limited2 = true;
4059
+ if (imported?.spec && !origin?.handled && !new RegExp(`\\b(?:const|let|var|function|class)\\s+${name.replace(/\$/g, "\\$")}(?![\\w$])`).test(a.code)) {
4060
+ if (isNew && !member && ["hono", "hono/tiny", "hono/quick"].includes(imported.spec ?? "") && imported.imported === "Hono") shape = { framework: "hono", prefix: "", openapi: false };
4061
+ if (isNew && !member && imported.spec === "@hono/zod-openapi" && imported.imported === "OpenAPIHono") shape = { framework: "hono", prefix: "", openapi: true };
4062
+ if (!isNew && imported.spec === "express" && (imported.imported === "default" && (!member || member === "Router") || imported.imported === "Router" && !member)) shape = { framework: "express", prefix: "", openapi: false };
4063
+ if (!isNew && !member && imported.spec === "fastify" && ["default", "fastify"].includes(imported.imported)) shape = { framework: "fastify", prefix: "", openapi: false };
4064
+ }
4065
+ if (!shape && !isNew && !member && !a.source.slice(open + 1, close).trim()) shape = resolve3(a, name, false, depth + 1);
4066
+ if (!shape) return null;
4067
+ const chained = basePath(a, close, shape);
4068
+ return chained && !arg.text.slice(chained.close - arg.at + 1).trim() ? chained.shape : null;
4069
+ };
4070
+ const functionValue = (a, value, depth) => {
4071
+ if (depth >= 8) {
4072
+ limited2 = true;
4073
+ return null;
4074
+ }
4075
+ if (/^[A-Za-z_$][\w$]*$/.test(value.text)) return resolve3(a, value.text, false, depth + 1);
4076
+ const signature = /^(?:function(?:\s+[A-Za-z_$][\w$]*)?\s*\(\s*\)\s*(?::[^{};=]{1,200})?\s*|\(\s*\)\s*(?::[^{};=]{1,200})?\s*=>\s*)/.exec(value.text);
4077
+ if (!signature) return null;
4078
+ let body = argAt(a, value.at + signature[0].length, value.at + value.text.length);
4079
+ if (body.text.startsWith("{")) {
4080
+ const end = a.pairs.get(body.at);
4081
+ if (end !== body.at + body.text.length - 1) return null;
4082
+ const returned = /^\s*return\b[ \t]*/.exec(a.code.slice(body.at + 1, end));
4083
+ if (!returned) return null;
4084
+ const from = body.at + 1 + returned[0].length;
4085
+ if (/[\r\n]/.test(a.code[from] ?? "")) return null;
4086
+ body = argAt(a, from, end);
4087
+ if (body.text.endsWith(";")) body = argAt(a, body.at, body.at + body.text.length - 1);
4088
+ }
4089
+ return expression(a, body, depth);
4090
+ };
4091
+ const resolve3 = (a, name, exported, depth) => {
4092
+ if (depth >= 8) {
4093
+ limited2 = true;
4094
+ return null;
4095
+ }
4096
+ const key = `${a.file.path}\0${name}\0${exported}\0${depth}`;
4097
+ if (cache3.has(key)) return cache3.get(key);
4098
+ const follow = (spec, symbol) => {
4099
+ const resolved = module(spec, a.file);
4100
+ if (resolved.limited) limited2 = true;
4101
+ const target = resolved.file;
4102
+ return target ? resolve3(analyse(target), symbol, true, depth + 1) : null;
4103
+ };
4104
+ const bindings = bindingsOf(a.file), escaped = name.replace(/\$/g, "\\$");
4105
+ let result = null;
4106
+ if (exported) {
4107
+ if (name === "default") {
4108
+ const match = /\bexport\s+default\s+/.exec(a.code);
4109
+ if (match && topLevel(a, match.index)) {
4110
+ const at = match.index + match[0].length;
4111
+ const named = /^function\s+([A-Za-z_$][\w$]*)/.exec(a.code.slice(at));
4112
+ if (!named || factoryBindingClean(a, named[1])) result = functionValue(a, argAt(a, at, expressionEnd(a, at)), depth);
4113
+ }
4114
+ } else if (new RegExp(`\\bexport\\s+(?:const|function)\\s+${escaped}(?![\\w$])`).test(a.code)) result = resolve3(a, name, false, depth);
4115
+ else {
4116
+ const matches = bindings.exports.filter((item) => item.local === name);
4117
+ if (matches.length === 1) {
4118
+ const entry = matches[0];
4119
+ result = entry.spec ? follow(entry.spec, entry.imported) : resolve3(a, entry.imported, false, depth + 1);
4120
+ } else if (!matches.length && name !== "default" && bindings.stars.length === 1) result = follow(bindings.stars[0], name);
4121
+ }
4122
+ } else {
4123
+ const declarations = [...a.code.matchAll(new RegExp(`\\b(const|function)\\s+${escaped}(?![\\w$])`, "g"))];
4124
+ const declaration = declarations.length === 1 ? declarations[0] : void 0;
4125
+ if (declaration && topLevel(a, declaration.index)) {
4126
+ const at = declaration.index;
4127
+ if (declaration[1] === "function" && factoryBindingClean(a, name)) result = functionValue(a, argAt(a, at, expressionEnd(a, at)), depth);
4128
+ if (declaration[1] === "const") {
4129
+ const prefix = new RegExp(`^const\\s+${escaped}\\s*(?::[^=;]{0,200})?=\\s*`).exec(a.code.slice(at));
4130
+ const nameAt = at + /^const\s+/.exec(declaration[0])[0].length;
4131
+ if (prefix && factoryBindingClean(a, name, nameAt)) {
4132
+ const valueAt = at + prefix[0].length;
4133
+ result = functionValue(a, argAt(a, valueAt, expressionEnd(a, valueAt)), depth);
4134
+ }
4135
+ }
4136
+ } else if (!declarations.length && factoryBindingClean(a, name)) {
4137
+ const imports = bindings.imports.filter((item) => item.local === name && item.spec);
4138
+ if (imports.length === 1) result = follow(imports[0].spec, imports[0].imported);
4139
+ }
4140
+ }
4141
+ if (result) cache3.set(key, result);
4142
+ return result;
4143
+ };
4144
+ return (a, known) => {
4145
+ const found = [];
4146
+ const receivers = new Set([...a.code.matchAll(new RegExp(`(?<![\\w$.])([A-Za-z_$][\\w$]*)\\s*\\.\\s*(?:${METHODS}|on|openapi|openapiRoutes|route|register)\\s*${GENERICS}\\(`, "g"))].map((match) => match[1]));
4147
+ let inspected = 0;
4148
+ for (const m of a.code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\s*\(\s*\)/g)) {
4149
+ const [, name, factory] = m;
4150
+ if (!receivers.has(name) || known.has(name)) continue;
4151
+ if (++inspected > 256) {
4152
+ gaps.set(a.file, m.index);
4153
+ break;
4154
+ }
4155
+ const open = a.code.indexOf("(", m.index), close = m.index + m[0].length - 1;
4156
+ if (!topLevel(a, m.index) || a.source.slice(open + 1, close).trim() || !factoryBindingClean(a, factory) || !factoryBindingClean(a, name)) continue;
4157
+ limited2 = false;
4158
+ const shape = resolve3(a, factory, false, 0);
4159
+ const chained = shape ? basePath(a, m.index + m[0].length - 1, shape) : null;
4160
+ if (limited2) gaps.set(a.file, m.index);
4161
+ const end = expressionEnd(a, open);
4162
+ if (chained && !a.code.slice(chained.close + 1, end).trim()) found.push({ name, ...chained.shape, weak: false, chainFrom: chained.close });
4163
+ }
4164
+ return found;
4165
+ };
4166
+ }
3626
4167
  function literalOf(arg) {
3627
4168
  const m = /^(['"`])([^'"`$]*)\1$/.exec(arg.source);
3628
4169
  return m ? m[2] : null;
@@ -3716,11 +4257,199 @@ function expressUses(a, name) {
3716
4257
  }
3717
4258
  return found.sort((x, y) => x.at - y.at);
3718
4259
  }
3719
- function honoOps(a, name, chainFrom) {
4260
+ function openapiMiddleware(a, arg, depth = 0) {
4261
+ if (depth >= 8) return [];
4262
+ if (arg.text.startsWith("[")) {
4263
+ const close = a.pairs.get(arg.at);
4264
+ if (close === void 0 || !/^\s*(?:as\s+const)?\s*$/.test(arg.text.slice(close - arg.at + 1))) return [];
4265
+ return argsOf(a, arg.at, close).flatMap((item) => openapiMiddleware(a, item, depth + 1));
4266
+ }
4267
+ if (/^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*$/.test(arg.text) || FUNCTION_EXPRESSION.test(arg.text)) return [arg];
4268
+ const call = /^[A-Za-z_$][\w$.]*\s*\(/.exec(arg.text);
4269
+ return call && a.pairs.get(arg.at + call[0].length - 1) === arg.at + arg.text.length - 1 ? [arg] : [];
4270
+ }
4271
+ function openapiEntries(a, arg, files, depth = 0) {
4272
+ const value = openapiValue(a, arg, files, depth);
4273
+ if (!value || !value.text.startsWith("{")) return /* @__PURE__ */ new Map();
4274
+ const owner = analyse(value.file);
4275
+ const close = owner.pairs.get(value.at);
4276
+ if (argsOf(owner, value.at, close).some((part) => /^(?:\.\.\.|\[|(?:get|set)\s+)/.test(part.text))) return /* @__PURE__ */ new Map();
4277
+ return new Map([...entriesOf(owner, value)].map(([key, entry]) => [key, {
4278
+ ...entry,
4279
+ value: entry.value ? { ...entry.value, file: owner.file } : null
4280
+ }]));
4281
+ }
4282
+ function openapiValue(a, arg, files, depth = 0) {
4283
+ if (depth >= 8) return null;
4284
+ const root = /^([A-Za-z_$][\w$]*)(?:\s*[.(]|$)/.exec(arg.text)?.[1];
4285
+ if (root) {
4286
+ const token = new RegExp(`(?<![\\w$])${root.replace(/\$/g, "\\$")}(?![\\w$])`);
4287
+ if (a.bodies.some((body) => body.start < arg.at && arg.at < body.end && token.test(a.code.slice(body.declaration, body.start)))) return null;
4288
+ for (const match of a.code.matchAll(/\b(?:const|let|var)\s*([\[{])/g)) {
4289
+ const open = match.index + match[0].length - 1;
4290
+ const close = a.pairs.get(open);
4291
+ if (close !== void 0 && token.test(a.code.slice(open, close + 1))) return null;
4292
+ }
4293
+ }
4294
+ if (/^[{[]/.test(arg.text)) {
4295
+ const close = a.pairs.get(arg.at);
4296
+ if (close === void 0 || !/^\s*(?:as\s+const|satisfies\s+[\w$.]+)?\s*$/.test(arg.text.slice(close - arg.at + 1))) return null;
4297
+ return { ...arg, file: a.file };
4298
+ }
4299
+ const call = /^([A-Za-z_$][\w$]*)\s*\(/.exec(arg.text);
4300
+ if (call) {
4301
+ const binding = bindingsOf(a.file).imports.find((item) => item.local === call[1]);
4302
+ if (!binding || !["createRoute", "defineOpenAPIRoute"].includes(binding.imported) || binding.spec !== "@hono/zod-openapi") return null;
4303
+ if (new RegExp(`\\b(?:const|let|var|function)\\s+${call[1].replace(/\$/g, "\\$")}(?![\\w$])`).test(a.code)) return null;
4304
+ const open = arg.at + call[0].length - 1;
4305
+ const close = a.pairs.get(open);
4306
+ if (close !== arg.at + arg.text.length - 1) return null;
4307
+ const args = argsOf(a, open, close);
4308
+ return args.length === 1 ? openapiValue(a, args[0], files, depth + 1) : null;
4309
+ }
4310
+ if (root && !unchangedOpenapiReference(a, root)) return null;
4311
+ const member = /^([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)$/.exec(arg.text);
4312
+ if (member) {
4313
+ const escaped = member[1].replace(/\$/g, "\\$");
4314
+ if (new RegExp(`\\b(?:const|let|var|function|class)\\s+${escaped}(?![\\w$])`).test(a.code)) return null;
4315
+ const namespace = new RegExp(`\\bimport\\s+\\*\\s+as\\s+${escaped}\\s+from\\s*['"]([^'"]+)['"]`).exec(a.source);
4316
+ const module = namespace && a.code.slice(namespace.index, namespace.index + 6) === "import" ? moduleFor(namespace[1], a.file, files) : null;
4317
+ return module ? openapiExportValue(analyse(module), member[2], files, depth + 1) : null;
4318
+ }
4319
+ if (!/^[A-Za-z_$][\w$]*$/.test(arg.text)) return null;
4320
+ const name = arg.text.replace(/\$/g, "\\$");
4321
+ const declarations = [...a.code.matchAll(new RegExp(`\\b(const|let|var)\\s+${name}\\s*(?::[^=;]{0,200})?=(?![=>])\\s*`, "g"))];
4322
+ if (declarations.length === 0) {
4323
+ const imports = bindingsOf(a.file).imports.filter((item) => item.local === arg.text && item.spec);
4324
+ const imported = imports.length === 1 ? imports[0] : void 0;
4325
+ const module = imported ? moduleFor(imported.spec, a.file, files) : null;
4326
+ return module ? openapiExportValue(analyse(module), imported.imported, files, depth + 1) : null;
4327
+ }
4328
+ if (declarations.length !== 1 || declarations[0][1] !== "const") return null;
4329
+ const declaration = declarations[0];
4330
+ if (declaration.index >= arg.at) return null;
4331
+ for (const [open, close] of a.pairs) {
4332
+ if (a.code[open] === "{" && open < declaration.index && declaration.index < close && !(open < arg.at && arg.at < close)) return null;
4333
+ }
4334
+ const from = declaration.index + declaration[0].length;
4335
+ const end = expressionEnd(a, from);
4336
+ const text = a.code.slice(from, end).trimEnd();
4337
+ return openapiValue(a, { at: from, text, source: a.source.slice(from, from + text.length) }, files, depth + 1);
4338
+ }
4339
+ function unchangedOpenapiReference(a, name) {
4340
+ const aliases = /* @__PURE__ */ new Set([name]);
4341
+ const assignedAliases = [...a.code.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\s*(?=[;,\n]|$)/g)];
4342
+ for (let pass = 0; pass < 8; pass++) {
4343
+ const size = aliases.size;
4344
+ for (const match of assignedAliases) if (aliases.has(match[2])) aliases.add(match[1]);
4345
+ if (aliases.size > 64) return false;
4346
+ if (aliases.size === size) break;
4347
+ if (pass === 7) return false;
4348
+ }
4349
+ for (const alias of aliases) {
4350
+ const escaped = alias.replace(/\$/g, "\\$");
4351
+ const mutable = new RegExp(`(?<![\\w$.])${escaped}\\s*(?:\\.\\s*[\\w$]+|\\[[^\\]]{0,200}\\])+(?:\\s*(?:=|\\?\\?=|\\|\\|=|&&=)|\\s*\\()|\\bdelete\\s+${escaped}(?![\\w$])|\\bObject\\s*\\.\\s*(?:assign|defineProperty|defineProperties)\\s*\\(\\s*${escaped}(?![\\w$])`);
4352
+ if (mutable.test(a.code)) return false;
4353
+ }
4354
+ return true;
4355
+ }
4356
+ function openapiExportValue(a, name, files, depth) {
4357
+ if (depth >= 8) return null;
4358
+ if (name === "default") {
4359
+ const declaration = /\bexport\s+default\s+/.exec(a.code);
4360
+ if (declaration) {
4361
+ const at = declaration.index + declaration[0].length;
4362
+ const text = a.code.slice(at, expressionEnd(a, at)).trimEnd();
4363
+ return openapiValue(a, { at, text, source: a.source.slice(at, at + text.length) }, files, depth + 1);
4364
+ }
4365
+ } else if (new RegExp(`\\bexport\\s+const\\s+${name.replace(/\$/g, "\\$")}\\s*(?=[:=])`).test(a.code)) {
4366
+ return openapiValue(a, { text: name, source: name, at: a.code.length }, files, depth + 1);
4367
+ }
4368
+ const bindings = bindingsOf(a.file);
4369
+ const matches = bindings.exports.filter((item) => item.local === name);
4370
+ if (matches.length > 1) return null;
4371
+ const binding = matches[0];
4372
+ if (binding?.spec) {
4373
+ const module = moduleFor(binding.spec, a.file, files);
4374
+ return module ? openapiExportValue(analyse(module), binding.imported, files, depth + 1) : null;
4375
+ }
4376
+ if (binding) return openapiValue(a, { text: binding.imported, source: binding.imported, at: a.code.length }, files, depth + 1);
4377
+ if (name !== "default" && bindings.stars.length === 1) {
4378
+ const module = moduleFor(bindings.stars[0], a.file, files);
4379
+ if (module) return openapiExportValue(analyse(module), name, files, depth + 1);
4380
+ }
4381
+ return null;
4382
+ }
4383
+ function honoOps(a, name, chainFrom, files, openapi = false) {
3720
4384
  const registrations = [];
3721
4385
  const uses = [];
4386
+ const batchGaps = /* @__PURE__ */ new Set();
4387
+ const addOpenapi = (config, handler, handlerRange, at, batch = false) => {
4388
+ const entries = openapiEntries(config.file ? analyse(config.file) : a, config, files);
4389
+ const path = entries.get("path")?.value;
4390
+ const resolved = path ? literalOf(path) : null;
4391
+ const routingPath = resolved?.replace(/\/\{(.+?)\}/g, "/:$1") ?? null;
4392
+ const middleware = entries.get("middleware")?.value;
4393
+ const owner = middleware?.file ? analyse(middleware.file) : a;
4394
+ registrations.push({
4395
+ path: routingPath,
4396
+ middleware: middleware ? openapiMiddleware(owner, middleware).map((arg) => ({ ...arg, file: owner.file })) : [],
4397
+ handler,
4398
+ handlerRange,
4399
+ at,
4400
+ batch
4401
+ });
4402
+ return [routingPath];
4403
+ };
3722
4404
  const visit = (method, open, close, at, previous) => {
3723
4405
  const args = argsOf(a, open, close);
4406
+ if (method === "openapiRoutes") {
4407
+ if (!openapi) return previous;
4408
+ let paths2 = previous;
4409
+ let inspected = 0;
4410
+ const gap = () => {
4411
+ batchGaps.add(at);
4412
+ paths2 = [null];
4413
+ };
4414
+ const walk = (origin, list, depth) => {
4415
+ if (!list || depth >= 8) {
4416
+ gap();
4417
+ return;
4418
+ }
4419
+ const value = openapiValue(origin, list, files);
4420
+ if (!value || !value.text.startsWith("[")) {
4421
+ gap();
4422
+ return;
4423
+ }
4424
+ const owner = analyse(value.file);
4425
+ for (const item of argsOf(owner, value.at, owner.pairs.get(value.at))) {
4426
+ if (++inspected > 256) {
4427
+ gap();
4428
+ return;
4429
+ }
4430
+ if (item.text.startsWith("...")) {
4431
+ const lead = 3 + item.text.slice(3).length - item.text.slice(3).trimStart().length;
4432
+ walk(owner, { at: item.at + lead, text: item.text.slice(lead), source: item.source.slice(lead) }, depth + 1);
4433
+ continue;
4434
+ }
4435
+ const entries = openapiEntries(owner, item, files);
4436
+ if (entries.get("addRoute")?.value?.text === "false") continue;
4437
+ const config = entries.get("route")?.value;
4438
+ const handler = entries.get("handler");
4439
+ if (!config || !handler || !handler.value && !handler.range) {
4440
+ gap();
4441
+ continue;
4442
+ }
4443
+ paths2 = addOpenapi(config, handler.value, handler.range, at, true);
4444
+ }
4445
+ };
4446
+ walk(a, args[0], 0);
4447
+ return paths2;
4448
+ }
4449
+ if (method === "openapi") {
4450
+ if (!openapi || !args[0] || !args[1]) return previous;
4451
+ return addOpenapi(args[0], args[1], null, at);
4452
+ }
3724
4453
  if (method === "use") {
3725
4454
  const prefixes = args[0] ? usePrefixes(a, args[0]) : null;
3726
4455
  const middleware = flatten(a, prefixes === null ? args : args.slice(1));
@@ -3762,7 +4491,7 @@ function honoOps(a, name, chainFrom) {
3762
4491
  }
3763
4492
  registrations.sort((x, y) => x.at - y.at);
3764
4493
  uses.sort((x, y) => x.at - y.at);
3765
- return { registrations, uses };
4494
+ return { registrations, uses, batchGaps: [...batchGaps] };
3766
4495
  }
3767
4496
  function fastifyOps(a, name) {
3768
4497
  const registrations = [];
@@ -4044,7 +4773,7 @@ function exportedPlugin(t, files, depth) {
4044
4773
  const text = t.code.slice(from, end).trimEnd();
4045
4774
  return pluginOf(t, { text, source: t.source.slice(from, from + text.length), at: from }, files, depth);
4046
4775
  }
4047
- var toRefs = (a, args) => args.map((arg) => ({ text: arg.source, at: arg.at, file: a.file }));
4776
+ var toRefs = (a, args) => args.map((arg) => ({ text: arg.source, at: arg.at, file: arg.file ?? a.file }));
4048
4777
  function mountedFile(a, arg, files) {
4049
4778
  const inline = /^require\s*\(\s*['"]([^'"]+)['"]\s*\)$/.exec(arg.source);
4050
4779
  if (inline) return moduleFor(inline[1], a.file, files);
@@ -4096,31 +4825,42 @@ function paramNamesOf(a, range) {
4096
4825
  }
4097
4826
  var within = (site, at) => site.range === null || site.range.start <= at && at <= site.range.end;
4098
4827
  var opsCache = /* @__PURE__ */ new WeakMap();
4099
- function opsOf(site) {
4828
+ var contextCache = /* @__PURE__ */ new WeakMap();
4829
+ function contextOf(a) {
4830
+ let context = contextCache.get(a.file);
4831
+ if (!context) {
4832
+ context = registrationContext(a.code, a.pairs, a.bodies);
4833
+ contextCache.set(a.file, context);
4834
+ }
4835
+ return context;
4836
+ }
4837
+ function opsOf(site, files) {
4100
4838
  const cached = opsCache.get(site);
4101
4839
  if (cached) return cached;
4102
4840
  const { a, name } = site;
4103
4841
  let ops;
4104
4842
  if (site.framework === "express") ops = { registrations: expressRegistrations(a, name), uses: expressUses(a, name), registers: [] };
4105
- else if (site.framework === "hono") ops = { ...honoOps(a, name, site.chainFrom), registers: [] };
4843
+ else if (site.framework === "hono") ops = { ...honoOps(a, name, site.chainFrom, files, site.openapi), registers: [] };
4106
4844
  else ops = fastifyOps(a, name);
4845
+ const context = contextOf(a);
4107
4846
  const result = {
4108
- registrations: ops.registrations.filter((reg) => within(site, reg.at)),
4109
- uses: ops.uses.filter((use) => within(site, use.at)),
4110
- registers: ops.registers.filter((reg) => within(site, reg.at))
4847
+ registrations: ops.registrations.filter((reg) => within(site, reg.at) && context.reachable(reg.at)),
4848
+ uses: ops.uses.filter((use) => within(site, use.at) && context.reachable(use.at)),
4849
+ registers: ops.registers.filter((reg) => within(site, reg.at) && context.reachable(reg.at)),
4850
+ batchGaps: (ops.batchGaps ?? []).filter((at) => within(site, at))
4111
4851
  };
4112
4852
  opsCache.set(site, result);
4113
4853
  return result;
4114
4854
  }
4115
4855
  function generalMiddleware(site, before, files) {
4116
- const args = opsOf(site).uses.filter((use) => !use.mount && use.at < before && (use.prefix === null || site.framework !== "hono" && use.prefix === "/" || /^\/?\*$/.test(use.prefix))).flatMap((use) => use.args).filter((arg) => site.framework !== "express" || mountedFile(site.a, arg, files) === null);
4856
+ const args = opsOf(site, files).uses.filter((use) => !use.mount && use.at < before && contextOf(site.a).covers(use.at, Number.isFinite(before) ? before : (site.range?.end ?? site.a.code.length) - 1) && (use.prefix === null || site.framework !== "hono" && use.prefix === "/" || /^\/?\*$/.test(use.prefix))).flatMap((use) => use.args).filter((arg) => site.framework !== "express" || mountedFile(site.a, arg, files) === null);
4117
4857
  return toRefs(site.a, args);
4118
4858
  }
4119
4859
  function passedSites(site, files) {
4120
4860
  const { a } = site;
4121
4861
  const found = [];
4122
4862
  const receive = (range, index, at) => {
4123
- if (!range) return;
4863
+ if (!range || !contextOf(a).reachable(at)) return;
4124
4864
  const target = analyse(range.file);
4125
4865
  const name = paramNamesOf(target, range)[index];
4126
4866
  if (!name) return;
@@ -4128,6 +4868,7 @@ function passedSites(site, files) {
4128
4868
  a: target,
4129
4869
  name,
4130
4870
  framework: site.framework,
4871
+ openapi: Boolean(site.openapi),
4131
4872
  range: { start: range.start, end: range.end },
4132
4873
  prefix: site.prefix,
4133
4874
  root: site.root ?? site,
@@ -4177,7 +4918,7 @@ function normalizePath(path) {
4177
4918
  function registeredSites(site, files) {
4178
4919
  const { a } = site;
4179
4920
  const found = [];
4180
- for (const reg of opsOf(site).registers) {
4921
+ for (const reg of opsOf(site, files).registers) {
4181
4922
  const options = entriesOf(a, reg.options);
4182
4923
  const prefixArg = options.get("prefix")?.value;
4183
4924
  const prefix = joinPath(site.prefix, (prefixArg && pathOf(a, prefixArg)) ?? "");
@@ -4236,24 +4977,50 @@ function serverIndex(files) {
4236
4977
  const cached = indexCache.get(files);
4237
4978
  if (cached) return cached;
4238
4979
  const sites = [];
4980
+ const registrationGaps = /* @__PURE__ */ new Set();
4981
+ const factoryGaps = /* @__PURE__ */ new Map();
4982
+ const factories = projectFactoryResolver(files, factoryGaps);
4239
4983
  const seen = /* @__PURE__ */ new Set();
4240
4984
  const add = (site) => {
4241
- const key = `${site.a.file.path}\0${site.name}\0${site.range?.start ?? -1}\0${site.prefix}`;
4985
+ if (site.inherited.length > 256) {
4986
+ registrationGaps.add(site.a.file);
4987
+ site.inherited = [];
4988
+ }
4989
+ const root = site.root ?? site;
4990
+ const identity2 = createHash2("sha256").update(JSON.stringify([
4991
+ site.a.file.path,
4992
+ site.name,
4993
+ site.framework,
4994
+ site.range?.start ?? -1,
4995
+ site.prefix,
4996
+ Boolean(site.openapi),
4997
+ root.a.file.path,
4998
+ root.name,
4999
+ root.range?.start ?? -1,
5000
+ root.prefix
5001
+ ]));
5002
+ for (const ref of site.inherited) identity2.update(JSON.stringify([ref.file.path, ref.at]));
5003
+ const key = identity2.digest("hex");
4242
5004
  if (seen.has(key)) return false;
5005
+ if (sites.length >= 4096) {
5006
+ registrationGaps.add(site.a.file);
5007
+ return false;
5008
+ }
4243
5009
  seen.add(key);
4244
5010
  sites.push(site);
4245
5011
  return true;
4246
5012
  };
4247
5013
  for (const file of files) {
4248
- if (!/\.[mc]?[jt]sx?$/.test(file.path) || !/\b(?:express|hono|fastify)\b/.test(file.content)) continue;
5014
+ if (!/\.[mc]?[jt]sx?$/.test(file.path)) continue;
4249
5015
  const a = analyse(file);
4250
5016
  const names = frameworkNamesOf(a);
4251
- if (!names) continue;
4252
- for (const instance of instancesOf(a, names)) {
5017
+ const direct = names ? instancesOf(a, names) : [];
5018
+ for (const instance of [...direct, ...factories(a, new Set(direct.map((item) => item.name)))]) {
4253
5019
  add({
4254
5020
  a,
4255
5021
  name: instance.name,
4256
5022
  framework: instance.framework,
5023
+ openapi: Boolean(instance.openapi),
4257
5024
  range: null,
4258
5025
  prefix: instance.prefix,
4259
5026
  inherited: [],
@@ -4274,15 +5041,22 @@ function serverIndex(files) {
4274
5041
  const shadowed = (site, at) => Boolean(site.weak) && sites.some((other) => other !== site && !other.weak && other.a === site.a && other.name === site.name && other.range !== null && within(other, at));
4275
5042
  const byRouterFile = /* @__PURE__ */ new Map();
4276
5043
  const byRoot = /* @__PURE__ */ new Map();
5044
+ const batchGaps = /* @__PURE__ */ new Map();
4277
5045
  for (const site of sites) {
4278
5046
  const { a } = site;
4279
- const { registrations, uses } = opsOf(site);
5047
+ const { registrations, uses, batchGaps: gaps } = opsOf(site, files);
5048
+ for (const at of gaps ?? []) if (!shadowed(site, at) && !batchGaps.has(a.file)) batchGaps.set(a.file, at);
4280
5049
  const routes = byRouterFile.get(a.file) ?? [];
4281
5050
  for (const reg of registrations) {
4282
5051
  if (shadowed(site, reg.at)) continue;
4283
- const handler = reg.handlerRange ?? (reg.handler ? handlerOf(a, reg.handler, files) : null);
4284
- if (!handler) continue;
4285
- const before = uses.filter((use) => !use.mount && use.at < reg.at && pathMatches(use.prefix, reg.path, site.framework)).flatMap((use) => use.args.filter((arg) => site.framework !== "express" || mountedFile(a, arg, files) === null));
5052
+ const handler = reg.handlerRange ?? (reg.handler ? handlerOf(reg.handler.file ? analyse(reg.handler.file) : a, reg.handler, files) : null);
5053
+ const handlerSource = handler ? analyse(handler.file) : null;
5054
+ const knownBatchHandler = !reg.batch || reg.handlerRange !== null || handler && handlerSource && (handler.arrow || FUNCTION_EXPRESSION.test(handlerSource.code.slice(handler.start, handler.end)) || handlerSource.bodies.some((body) => handler.start <= body.start && body.end <= handler.end));
5055
+ if (!handler || !knownBatchHandler) {
5056
+ if (reg.batch && !batchGaps.has(a.file)) batchGaps.set(a.file, reg.at);
5057
+ continue;
5058
+ }
5059
+ const before = uses.filter((use) => !use.mount && use.at < reg.at && contextOf(a).covers(use.at, reg.at) && pathMatches(use.prefix, reg.path, site.framework)).flatMap((use) => use.args.filter((arg) => site.framework !== "express" || mountedFile(a, arg, files) === null));
4286
5060
  const url = reg.path === null ? "(dynamic path)" : joinPath(site.prefix, reg.path);
4287
5061
  const callbacks = flatten(a, reg.middleware);
4288
5062
  const root = site.root ?? site;
@@ -4301,7 +5075,7 @@ function serverIndex(files) {
4301
5075
  };
4302
5076
  add2(handler, callbacks);
4303
5077
  callbacks.forEach((callback, index2) => {
4304
- const range = handlerOf(a, callback, files);
5078
+ const range = handlerOf(callback.file ? analyse(callback.file) : a, callback, files);
4305
5079
  if (range) add2(range, callbacks.slice(0, index2));
4306
5080
  });
4307
5081
  }
@@ -4311,19 +5085,23 @@ function serverIndex(files) {
4311
5085
  const root = sites.find((other) => other.a === a && other.range === null && other.name === arg.text && !other.root);
4312
5086
  return root ? byRoot.get(root) : void 0;
4313
5087
  };
5088
+ const fileRoutes = (file) => [.../* @__PURE__ */ new Set([
5089
+ ...byRouterFile.get(file) ?? [],
5090
+ ...sites.filter((other) => other.a.file === file && other.range === null && !other.root).flatMap((other) => byRoot.get(other) ?? [])
5091
+ ])];
4314
5092
  for (const site of sites) {
4315
5093
  if (site.framework === "fastify") continue;
4316
5094
  const { a } = site;
4317
- const uses = opsOf(site).uses;
5095
+ const uses = opsOf(site, files).uses;
4318
5096
  for (const use of uses) {
4319
5097
  if (site.framework === "hono" && !use.mount) continue;
4320
5098
  use.args.forEach((arg, index2) => {
4321
5099
  const target = mountedFile(a, arg, files);
4322
- const routes = target && target !== a.file ? byRouterFile.get(target) : localRoutes(a, arg);
5100
+ const routes = target && target !== a.file ? fileRoutes(target) : localRoutes(a, arg);
4323
5101
  if (!routes || routes.length === 0) return;
4324
5102
  const isMiddleware = (x) => mountedFile(a, x, files) === null && localRoutes(a, x) === void 0;
4325
5103
  const covers2 = (other) => site.framework === "hono" ? other.prefix === null || /\*$/.test(other.prefix) && pathMatches(other.prefix, use.prefix, "hono") : pathMatches(other.prefix, use.prefix, site.framework);
4326
- const earlier = uses.filter((other) => !other.mount && other.at < use.at && covers2(other)).flatMap((other) => site.framework === "hono" ? other.args : other.args.filter(isMiddleware));
5104
+ const earlier = uses.filter((other) => !other.mount && other.at < use.at && contextOf(a).covers(other.at, use.at) && covers2(other)).flatMap((other) => site.framework === "hono" ? other.args : other.args.filter(isMiddleware));
4327
5105
  const middleware = [...site.inherited, ...toRefs(a, earlier), ...toRefs(a, use.args.slice(0, index2).filter(isMiddleware))];
4328
5106
  const prefix = joinPath(site.prefix, use.prefix ?? "/");
4329
5107
  for (const route of routes) {
@@ -4341,13 +5119,27 @@ function serverIndex(files) {
4341
5119
  byHandlerFile.set(route.file, list);
4342
5120
  }
4343
5121
  }
4344
- const index = { byHandlerFile };
5122
+ for (const site of sites) if (contextOf(site.a).limited) registrationGaps.add(site.a.file);
5123
+ const index = { byHandlerFile, batchGaps, factoryGaps, registrationGaps };
4345
5124
  indexCache.set(files, index);
4346
5125
  return index;
4347
5126
  }
4348
5127
  function nodeRoutesFor(file, files) {
4349
5128
  return serverIndex(files).byHandlerFile.get(file) ?? [];
4350
5129
  }
5130
+ function reportOpenapiBatchCoverage(ctx) {
5131
+ for (const file of serverIndex(ctx.files).registrationGaps) {
5132
+ ctx.reportIncomplete("engine/route-registration", `${file.path}: Route registration context reached a documented scope or graph limit. Conditional or truncated inherited middleware was not accepted as protection.`);
5133
+ }
5134
+ for (const [file, at] of serverIndex(ctx.files).factoryGaps) {
5135
+ const line = file.content.slice(0, at).split("\n").length;
5136
+ ctx.reportIncomplete("engine/router-factories", `${file.path}:${line}: Project router-factory analysis reached a resolution, expression, candidate or module-configuration limit. See the documented bounds; simplify the wrapper or scan its direct route entry separately.`);
5137
+ }
5138
+ for (const [file, at] of serverIndex(ctx.files).batchGaps) {
5139
+ const line = file.content.slice(0, at).split("\n").length;
5140
+ ctx.reportIncomplete("engine/openapi-routes", `${file.path}:${line}: OpenAPI batch routes were not fully resolved. Use static entries and resolvable handlers; limits are 8 spread levels and 256 entries per call.`);
5141
+ }
5142
+ }
4351
5143
  function serverRoutesOf(file, files) {
4352
5144
  const route = routeOf(file);
4353
5145
  if (route) return [route];
@@ -4425,7 +5217,8 @@ function middlewareDefinition(ref, files) {
4425
5217
  const local = member === null ? findFunction(a, root) : null;
4426
5218
  if (local) return { name: callee, range: local, spec: null, inline };
4427
5219
  const target = importOf(a, root, files);
4428
- const range = target ? importedFunction(target, member) ?? (member ? propertyValue(analyse(target.file), member) : null) : null;
5220
+ const exported = member ?? (target && target.name !== "default" ? target.name : null);
5221
+ const range = target ? importedFunction(target, member) ?? (member ? propertyValue(analyse(target.file), member) : null) ?? (exported ? exportedMember(target.file, exported, files, 0) : null) : null;
4429
5222
  return { name: callee, range, spec: null, inline };
4430
5223
  }
4431
5224
  function propertyValue(a, name) {
@@ -5381,7 +6174,7 @@ var IMPORT_SPEC = /(?:from|import|require)\s*\(?\s*['"]([^'"]+)['"]/g;
5381
6174
  function normalizeSpec(spec, fromPath) {
5382
6175
  if (spec.startsWith(".")) {
5383
6176
  return {
5384
- key: moduleKey(posix.normalize(posix.join(posix.dirname(fromPath), spec))),
6177
+ key: moduleKey(posix2.normalize(posix2.join(posix2.dirname(fromPath), spec))),
5385
6178
  alias: false
5386
6179
  };
5387
6180
  }
@@ -6008,13 +6801,13 @@ function quotedValue(text) {
6008
6801
  function matcherEntry(text) {
6009
6802
  const trimmed = text.trimStart();
6010
6803
  if (trimmed.startsWith("{")) {
6011
- const object = sliceDelimited(trimmed, "{", "}");
6012
- if (object === null) return null;
6013
- if (topLevelPropertyStart(object, "has") !== null || topLevelPropertyStart(object, "missing") !== null) {
6804
+ const object2 = sliceDelimited(trimmed, "{", "}");
6805
+ if (object2 === null) return null;
6806
+ if (topLevelPropertyStart(object2, "has") !== null || topLevelPropertyStart(object2, "missing") !== null) {
6014
6807
  return { conditional: true };
6015
6808
  }
6016
- const source = topLevelPropertyStart(object, "source");
6017
- const pattern2 = source === null ? null : quotedValue(object.slice(source));
6809
+ const source = topLevelPropertyStart(object2, "source");
6810
+ const pattern2 = source === null ? null : quotedValue(object2.slice(source));
6018
6811
  return pattern2 === null ? null : { pattern: pattern2 };
6019
6812
  }
6020
6813
  const pattern = quotedValue(trimmed);
@@ -8552,7 +9345,106 @@ function shouldRunRule(id, only, skip) {
8552
9345
  }
8553
9346
 
8554
9347
  // src/engine.ts
8555
- import { createHash as createHash2 } from "crypto";
9348
+ import { createHash as createHash3 } from "crypto";
9349
+
9350
+ // src/diagnostics.ts
9351
+ var ScanInputError = class extends TypeError {
9352
+ code = "INVALID_ARGUMENT";
9353
+ };
9354
+ function diagnosticCodeOf(error) {
9355
+ if (error.kind === "crashed") return "RULE_EXECUTION_FAILED";
9356
+ if (["engine/openapi-routes", "engine/router-factories", "engine/route-registration"].includes(error.ruleId)) return "ROUTE_UNRESOLVED";
9357
+ if (error.ruleId === "engine/findings-limit") return "FINDINGS_LIMIT";
9358
+ if (error.ruleId.startsWith("gitleak/")) return "GIT_CHECK_INCOMPLETE";
9359
+ return "ANALYSIS_INCOMPLETE";
9360
+ }
9361
+
9362
+ // src/scan-control.ts
9363
+ var ScanCancelledError = class extends Error {
9364
+ code = "SCAN_CANCELLED";
9365
+ constructor() {
9366
+ super("Scan cancelled.");
9367
+ this.name = "AbortError";
9368
+ }
9369
+ };
9370
+ var ScanProgressError = class extends Error {
9371
+ code = "PROGRESS_CALLBACK_FAILED";
9372
+ constructor(cause) {
9373
+ super("Scan progress callback failed.", { cause });
9374
+ }
9375
+ };
9376
+ function checkScanCancelled(signal) {
9377
+ if (signal?.aborted) throw new ScanCancelledError();
9378
+ }
9379
+ async function waitForProgress(value, signal) {
9380
+ if (!signal) {
9381
+ await value;
9382
+ return;
9383
+ }
9384
+ let onAbort = () => {
9385
+ };
9386
+ const aborted = new Promise((_, reject) => {
9387
+ onAbort = () => reject(new ScanCancelledError());
9388
+ signal.addEventListener("abort", onAbort, { once: true });
9389
+ if (signal.aborted) onAbort();
9390
+ });
9391
+ try {
9392
+ await Promise.race([Promise.resolve(value), aborted]);
9393
+ } finally {
9394
+ signal.removeEventListener("abort", onAbort);
9395
+ }
9396
+ }
9397
+ function createScanControl(options) {
9398
+ const { signal, onProgress } = options;
9399
+ return {
9400
+ active: signal !== void 0 || onProgress !== void 0,
9401
+ check: () => checkScanCancelled(signal),
9402
+ async checkpoint(progress) {
9403
+ checkScanCancelled(signal);
9404
+ await new Promise((resolve3) => setImmediate(resolve3));
9405
+ checkScanCancelled(signal);
9406
+ if (onProgress) {
9407
+ try {
9408
+ await waitForProgress(onProgress(Object.freeze({ ...progress })), signal);
9409
+ } catch (error) {
9410
+ if (signal?.aborted || error instanceof ScanCancelledError) throw new ScanCancelledError();
9411
+ throw new ScanProgressError(error);
9412
+ }
9413
+ }
9414
+ checkScanCancelled(signal);
9415
+ }
9416
+ };
9417
+ }
9418
+
9419
+ // src/exclusions.ts
9420
+ var MAX_EXCLUSIONS = 64;
9421
+ var MAX_EXCLUSION_LENGTH = 512;
9422
+ var SEGMENT = String.raw`[^\s/\\](?:[^/\\]*[^\s/\\])?`;
9423
+ var EXCLUSION_PATH_PATTERN = String.raw`^(?![!])(?!.*[:*?\[\]{}\u0000-\u001f\u007f-\u009f\u2028\u2029])(?!(?:.*[/\\])?\.{1,2}(?:[/\\]|$))` + SEGMENT + String.raw`(?:[/\\]` + SEGMENT + String.raw`)*[/\\]?$`;
9424
+ var PATH = new RegExp(EXCLUSION_PATH_PATTERN);
9425
+ function isExclusionPath(value) {
9426
+ return typeof value === "string" && value.length > 0 && value.length <= MAX_EXCLUSION_LENGTH && PATH.test(value);
9427
+ }
9428
+ function createExclusions(values = []) {
9429
+ if (values.length > MAX_EXCLUSIONS || !values.every(isExclusionPath)) throw new TypeError("Exclusions must be at most 64 literal relative paths without traversal, wildcards or control characters.");
9430
+ const requested = [...new Set(values.map((value) => value.replace(/\\/g, "/").replace(/\/$/, "")))];
9431
+ const matched = /* @__PURE__ */ new Set();
9432
+ return {
9433
+ requested,
9434
+ matches(path) {
9435
+ const normalized = path.replace(/\\/g, "/").replace(/\/$/, "");
9436
+ let excluded = false;
9437
+ for (const prefix of requested) if (normalized === prefix || normalized.startsWith(prefix + "/")) {
9438
+ matched.add(prefix);
9439
+ excluded = true;
9440
+ }
9441
+ return excluded;
9442
+ },
9443
+ matched: () => [...matched].sort()
9444
+ };
9445
+ }
9446
+
9447
+ // src/engine.ts
8556
9448
  var SEVERITY_ORDER = { P0: 0, P1: 1, P2: 2 };
8557
9449
  var CONFIDENCE_ORDER = { certain: 0, likely: 1 };
8558
9450
  function dedupe(findings) {
@@ -8581,7 +9473,7 @@ function sanitize(findings, files) {
8581
9473
  const file = f.file === null ? void 0 : byPath.get(f.file);
8582
9474
  const source = f.line === null ? file?.content : file?.lines[f.line - 1];
8583
9475
  return source === void 0 ? {} : {
8584
- sourceFingerprint: createHash2("sha256").update(source.trim(), "utf8").digest("hex")
9476
+ sourceFingerprint: createHash3("sha256").update(source.trim(), "utf8").digest("hex")
8585
9477
  };
8586
9478
  };
8587
9479
  return findings.map((f) => ({
@@ -8616,9 +9508,9 @@ function sanitizeSkippedForOutput(items) {
8616
9508
  }));
8617
9509
  }
8618
9510
  function downgradeExampleContext(findings, files) {
8619
- const examples = new Set(files.filter((f) => f.isExampleContext).map((f) => f.path));
9511
+ const examples2 = new Set(files.filter((f) => f.isExampleContext).map((f) => f.path));
8620
9512
  return findings.map(
8621
- (f) => f.file !== null && examples.has(f.file) ? { ...f, confidence: "likely" } : f
9513
+ (f) => f.file !== null && examples2.has(f.file) ? { ...f, confidence: "likely" } : f
8622
9514
  );
8623
9515
  }
8624
9516
  function suppressIgnoredLines(findings, files) {
@@ -8671,20 +9563,36 @@ function sortFindings(findings) {
8671
9563
  }
8672
9564
  async function scan(root, options = {}) {
8673
9565
  const started = Date.now();
9566
+ const exclusions = createExclusions(options.exclude);
9567
+ const control = createScanControl(options);
9568
+ control.check();
9569
+ const progress = {
9570
+ phase: "discovery",
9571
+ filesCompleted: 0,
9572
+ filesTotal: null,
9573
+ projectRulesCompleted: 0,
9574
+ projectRulesTotal: null
9575
+ };
9576
+ if (control.active) await control.checkpoint(progress);
8674
9577
  const gitExecutable = resolveGitExecutable(root);
8675
9578
  const git2 = detectGitRepo(root, gitExecutable);
8676
9579
  const honorIgnoreMarkers = options.honorIgnoreMarkers !== false;
8677
- const { files, skipped, ignored, vendored } = collectFiles(root, git2 === "repo", gitExecutable, {}, honorIgnoreMarkers);
9580
+ const { files, skipped, ignored, vendored } = collectFiles(root, git2 === "repo", gitExecutable, {}, honorIgnoreMarkers, exclusions.matches);
8678
9581
  const findings = [];
8679
9582
  const errors = [];
8680
9583
  const fileRules = FILE_RULES.filter((rule2) => shouldRunRule(rule2.id, options.only ?? [], options.skip ?? []));
8681
9584
  const projectRules = PROJECT_RULES.filter((rule2) => shouldRunRule(rule2.id, options.only ?? [], options.skip ?? []));
9585
+ progress.filesTotal = files.length;
9586
+ progress.projectRulesTotal = projectRules.length;
9587
+ progress.phase = "files";
9588
+ if (control.active) await control.checkpoint(progress);
8682
9589
  const incompleteSeen = /* @__PURE__ */ new Set();
8683
9590
  const ctx = {
8684
9591
  root,
8685
9592
  files,
8686
9593
  git: git2,
8687
9594
  gitExecutable,
9595
+ excludePath: exclusions.matches,
8688
9596
  // 统一记录不完整状态,避免重复提示。
8689
9597
  reportIncomplete: (ruleId, message) => {
8690
9598
  if (incompleteSeen.has(`${ruleId} ${message}`)) return;
@@ -8693,6 +9601,7 @@ async function scan(root, options = {}) {
8693
9601
  }
8694
9602
  };
8695
9603
  for (const file of files) {
9604
+ control.check();
8696
9605
  for (const rule2 of fileRules) {
8697
9606
  try {
8698
9607
  if (!rule2.appliesTo(file)) continue;
@@ -8701,13 +9610,29 @@ async function scan(root, options = {}) {
8701
9610
  errors.push({ ruleId: rule2.id, file: file.path, message: messageOf(err), kind: "crashed" });
8702
9611
  }
8703
9612
  }
9613
+ progress.filesCompleted++;
9614
+ if (control.active && progress.filesCompleted % 32 === 0) await control.checkpoint(progress);
8704
9615
  }
9616
+ if (control.active) await control.checkpoint(progress);
8705
9617
  for (const rule2 of projectRules) {
9618
+ progress.phase = rule2.id.startsWith("gitleak/") ? "history" : "project";
9619
+ if (control.active) await control.checkpoint(progress);
8706
9620
  try {
8707
9621
  findings.push(...await rule2.check(ctx));
8708
9622
  } catch (err) {
8709
9623
  errors.push({ ruleId: rule2.id, file: null, message: messageOf(err), kind: "crashed" });
8710
9624
  }
9625
+ control.check();
9626
+ progress.projectRulesCompleted++;
9627
+ }
9628
+ progress.phase = "finalize";
9629
+ if (control.active) await control.checkpoint(progress);
9630
+ if ([...fileRules, ...projectRules].some((rule2) => /^(?:api|auth|injection|ssrf|redirect|webhook)\//.test(rule2.id))) {
9631
+ try {
9632
+ reportOpenapiBatchCoverage(ctx);
9633
+ } catch (err) {
9634
+ errors.push({ ruleId: "engine/openapi-routes", file: null, message: messageOf(err), kind: "crashed" });
9635
+ }
8711
9636
  }
8712
9637
  const deduped = dedupe(downgradeExampleContext(findings, files));
8713
9638
  const { kept, ignored: ignoredFindings } = honorIgnoreMarkers ? suppressIgnoredLines(deduped, files) : { kept: deduped, ignored: [] };
@@ -8723,12 +9648,14 @@ async function scan(root, options = {}) {
8723
9648
  );
8724
9649
  return false;
8725
9650
  });
8726
- return {
9651
+ const result = {
9652
+ ...exclusions.requested.length ? { exclusions: { requested: exclusions.requested.map(clean), matched: exclusions.matched().map(clean) } } : {},
8727
9653
  findings: sanitize(bounded, files),
8728
9654
  filesScanned: files.length,
8729
9655
  durationMs: Date.now() - started,
8730
9656
  errors: errors.map((e) => ({
8731
9657
  ...e,
9658
+ code: e.code ?? diagnosticCodeOf(e),
8732
9659
  file: e.file === null ? null : clean(e.file),
8733
9660
  message: clean(e.message)
8734
9661
  })),
@@ -8746,14 +9673,113 @@ async function scan(root, options = {}) {
8746
9673
  // 主动忽略不影响完整性;错误、跳过或零文件扫描均标记为未完成。
8747
9674
  partial: errors.length > 0 || skipped.length > 0 || files.length === 0
8748
9675
  };
9676
+ progress.phase = "complete";
9677
+ if (control.active) await control.checkpoint(progress);
9678
+ return result;
8749
9679
  }
8750
9680
  function messageOf(err) {
8751
9681
  if (err instanceof Error) return err.message;
8752
9682
  return String(err);
8753
9683
  }
8754
9684
 
9685
+ // src/rules/examples.ts
9686
+ var publicEnv = {
9687
+ context: "Server-only environment value",
9688
+ before: "NEXT_PUBLIC_SERVER_TOKEN=<private value>",
9689
+ after: "SERVER_TOKEN=<server-only value>",
9690
+ limitation: "Use the value only in server code. Rotate any exposed credential; renaming does not revoke it."
9691
+ };
9692
+ var auth = {
9693
+ context: "Supabase route with a request-scoped session client and a separate admin client",
9694
+ before: "await admin.from('items').delete().eq('id', id);",
9695
+ after: "const { data: { user }, error } = await sessionClient.auth.getUser();\nif (error || !user) return new Response(null, { status: 401 });\nif (!await canDelete(user.id, id)) return new Response(null, { status: 403 });\nawait admin.from('items').delete().eq('id', id);",
9696
+ limitation: "Bind sessionClient to the caller. Implement canDelete with actual resource ownership or permissions; authentication alone is insufficient."
9697
+ };
9698
+ var firestore = {
9699
+ context: "Firestore document ownership",
9700
+ before: "match /users/{uid} { allow read, write: if true; }",
9701
+ after: "match /users/{uid} {\n allow read, write: if request.auth != null && request.auth.uid == uid;\n}",
9702
+ limitation: "Firestore syntax only. Review field validation and overlapping rules; adapt ownership to the data model."
9703
+ };
9704
+ var cors = {
9705
+ context: "Express cors middleware with credentialed access from one trusted site",
9706
+ before: "app.use(cors({ origin: true, credentials: true }));",
9707
+ after: "app.use(cors({ origin: 'https://app.example.com', credentials: true }));",
9708
+ limitation: "Replace the example origin with an exact trusted origin. CORS does not replace authentication, authorization or CSRF controls."
9709
+ };
9710
+ var examples = {
9711
+ "api/admin-db-access-without-auth": auth,
9712
+ "api/db-write-without-auth": auth,
9713
+ "exposure/private-name-in-public-env": publicEnv,
9714
+ "exposure/secret-in-public-env": publicEnv,
9715
+ "exposure/supabase-service-role-in-client": {
9716
+ ...publicEnv,
9717
+ context: "Supabase admin credential",
9718
+ limitation: "Never ship an admin key to the client. Use a publishable/anon key with appropriate RLS for client access; rotate exposed admin credentials."
9719
+ },
9720
+ "cors/reflected-origin-with-credentials": cors,
9721
+ "cors/wildcard-with-credentials": { ...cors, before: "app.use(cors({ origin: '*', credentials: true }));" },
9722
+ "firebase/open-rules": firestore,
9723
+ "firebase/test-mode-rules": { ...firestore, before: "allow read, write: if request.time < timestamp.date(2030, 1, 1);" },
9724
+ "supabase/rls-not-enabled": {
9725
+ context: "Supabase table with a UUID owner_id column",
9726
+ before: "CREATE TABLE public.notes (id uuid PRIMARY KEY, owner_id uuid NOT NULL, body text);",
9727
+ after: "ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY;\nCREATE POLICY owner_access ON public.notes TO authenticated\nUSING ((SELECT auth.uid()) = owner_id)\nWITH CHECK ((SELECT auth.uid()) = owner_id);",
9728
+ limitation: "Apply ownership checks appropriate to the application. Review grants and other policies; service-role access bypasses RLS."
9729
+ },
9730
+ "auth/unverified-session": {
9731
+ context: "Server-side Supabase identity verification",
9732
+ before: "const { data: { session } } = await supabase.auth.getSession();\nif (!session) return new Response(null, { status: 401 });",
9733
+ after: "const { data: { user }, error } = await supabase.auth.getUser();\nif (error || !user) return new Response(null, { status: 401 });",
9734
+ limitation: "Use a client bound to the current request. Verified identity still requires resource-specific authorization."
9735
+ },
9736
+ "injection/sql": {
9737
+ context: "PostgreSQL driver with bound parameters",
9738
+ before: 'await db.query("SELECT * FROM users WHERE id = " + id);',
9739
+ after: "await db.query('SELECT * FROM users WHERE id = $1', [id]);",
9740
+ limitation: "Use the parameter syntax of your actual driver. Parameters do not substitute table names or authorize access."
9741
+ },
9742
+ "injection/command": {
9743
+ context: "A fixed executable with separately validated arguments",
9744
+ before: "exec('tool --input ' + requestValue);",
9745
+ after: "execFile(TRUSTED_EXECUTABLE, ['--input', validatedValue], { shell: false });",
9746
+ limitation: "Set TRUSTED_EXECUTABLE to a trusted absolute path. Validate argument meaning and filesystem access; never let the caller choose the executable."
9747
+ },
9748
+ "redirect/open": {
9749
+ context: "Named destinations on a fixed application origin",
9750
+ before: "return Response.redirect(requestedUrl);",
9751
+ after: "const path = destination === 'settings' ? '/settings' : '/dashboard';\nreturn Response.redirect(new URL(path, APP_ORIGIN));",
9752
+ limitation: "APP_ORIGIN must be trusted configuration, not a request header. Do not replace the fixed paths with caller-controlled URLs."
9753
+ },
9754
+ "ssrf/request-url": {
9755
+ context: "Named server resources instead of caller-provided hosts",
9756
+ before: "return fetch(requestedUrl);",
9757
+ after: "if (resource !== 'status') return new Response(null, { status: 400 });\nreturn fetch('https://api.example.com/status', { redirect: 'error' });",
9758
+ limitation: "Use a destination you control. Review DNS and egress restrictions; a URL allowlist alone is not a complete SSRF defense."
9759
+ },
9760
+ "webhook/unverified-signature": {
9761
+ context: "Stripe webhook using the original request-body bytes",
9762
+ before: "const event = JSON.parse(rawBody);\nawait handle(event);",
9763
+ after: "let event;\ntry { event = stripe.webhooks.constructEvent(rawBody, signature, SERVER_SIGNING_SECRET); }\ncatch { return new Response(null, { status: 400 }); }\nawait handle(event);",
9764
+ limitation: "Preserve raw bytes and use the endpoint signing secret. Verification must finish before side effects; implement replay/idempotency handling separately."
9765
+ }
9766
+ };
9767
+ function fixExampleFor(ruleId) {
9768
+ if (SECRET_PATTERNS.some((pattern) => !pattern.publicByDesign && ruleId === `secrets/hardcoded/${pattern.id}`)) return {
9769
+ context: "A credential used only by a server runtime",
9770
+ before: "const apiKey = '<private value>';",
9771
+ after: "const apiKey = process.env.SERVICE_API_KEY;\nif (!apiKey) throw new Error('Server credential is missing');",
9772
+ limitation: "Use trusted server-side configuration, never a public/client environment prefix. Rotate the old credential and review history and deployed bundles."
9773
+ };
9774
+ if (!Object.hasOwn(examples, ruleId)) return null;
9775
+ return { ...examples[ruleId] };
9776
+ }
9777
+
8755
9778
  // src/rules/catalog.ts
8756
- var rule = (id, name, severity, confidence, scope, limitation) => ({ id, name, severity, confidence, scope, limitation, reportsFindings: true });
9779
+ var rule = (id, name, severity, confidence, scope, limitation) => {
9780
+ const example = fixExampleFor(id);
9781
+ return { id, name, severity, confidence, scope, limitation, reportsFindings: true, ...example ? { example } : {} };
9782
+ };
8757
9783
  var RULE_CATALOG = [
8758
9784
  rule(
8759
9785
  "api/admin-db-access-without-auth",
@@ -8768,8 +9794,8 @@ var RULE_CATALOG = [
8768
9794
  "Database write without a recognized auth guard",
8769
9795
  "P1",
8770
9796
  "likely",
8771
- "Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, Astro, Express, Hono, and Fastify.",
8772
- "Session-scoped clients and indirect guards may need manual review. In Express, Hono, and Fastify routes, writes in called project functions are followed two levels deep; file-based routes report writes in the route file only. Hono app.openapi() routes are not analysed."
9797
+ "Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, Astro, Express, Hono (including OpenAPIHono.openapi and openapiRoutes), and Fastify.",
9798
+ "Session-scoped clients and indirect guards may need manual review. In Express, Hono, and Fastify routes, writes in called project functions are followed two levels deep; file-based routes report writes in the route file only. OpenAPI configuration follows constants and static ESM imports/re-exports within eight resolution steps. Unresolved configuration does not suppress findings; unresolved batch entries or handlers mark coverage incomplete. OpenAPI security declarations are not authentication, and arbitrary module side effects are not modelled."
8773
9799
  ),
8774
9800
  rule(
8775
9801
  "auth/unverified-session",
@@ -8945,10 +9971,7 @@ function verdictOf(findings) {
8945
9971
  return { blocking, minor, unsure };
8946
9972
  }
8947
9973
 
8948
- // src/index.ts
8949
- function listRules() {
8950
- return RULE_CATALOG.map((rule2) => ({ ...rule2 }));
8951
- }
9974
+ // src/summary.ts
8952
9975
  function summarize(result) {
8953
9976
  const verdict = verdictOf(result.findings);
8954
9977
  const findings = result.changeView?.totalFindings ?? result.findings.length;
@@ -8962,37 +9985,69 @@ function summarize(result) {
8962
9985
  exitCode: blocking > 0 ? 1 : findings > 0 ? 2 : partial ? 3 : 0
8963
9986
  };
8964
9987
  }
9988
+
9989
+ // src/build-info.ts
9990
+ var VERSION = true ? "0.8.0" : "0.0.0-dev";
9991
+ var identity = typeof define_CANSHIP_BUILD_INFO_default === "object" ? define_CANSHIP_BUILD_INFO_default : { version: VERSION, channel: "development", revision: null, dirty: null };
9992
+ function getBuildInfo() {
9993
+ return { ...identity };
9994
+ }
9995
+ function getCapabilities() {
9996
+ return {
9997
+ staticScan: { network: false, projectCodeExecution: false, projectWrites: false },
9998
+ fileOutputs: "explicit-only",
9999
+ onlineValidation: true,
10000
+ onlineValidationPolicy: { cliOnly: true, defaultEnabled: false, confirmation: "plan-digest", credentials: false, responseBodiesRetained: false }
10001
+ };
10002
+ }
10003
+
10004
+ // src/index.ts
10005
+ function listRules() {
10006
+ return RULE_CATALOG.map((rule2) => ({ ...rule2, ...rule2.example ? { example: { ...rule2.example } } : {} }));
10007
+ }
8965
10008
  async function scan2(root, options = {}) {
8966
- if (typeof root !== "string" || root.length === 0) throw new TypeError("Scan root must be a non-empty path.");
10009
+ if (typeof root !== "string" || root.length === 0) throw new ScanInputError("Scan root must be a non-empty path.");
8967
10010
  if (options === null || typeof options !== "object" || Array.isArray(options)) {
8968
- throw new TypeError("Scan options must be an object.");
10011
+ throw new ScanInputError("Scan options must be an object.");
8969
10012
  }
8970
- const allowed = /* @__PURE__ */ new Set(["only", "skip", "honorIgnoreMarkers", "noExcerpts"]);
8971
- if (Object.keys(options).some((key) => !allowed.has(key))) throw new TypeError("Unknown scan option.");
10013
+ const allowed = /* @__PURE__ */ new Set(["only", "skip", "honorIgnoreMarkers", "noExcerpts", "signal", "onProgress", "exclude"]);
10014
+ if (Object.keys(options).some((key) => !allowed.has(key))) throw new ScanInputError("Unknown scan option.");
10015
+ if (options.signal !== void 0 && (options.signal === null || typeof options.signal !== "object" || typeof options.signal.aborted !== "boolean" || typeof options.signal.addEventListener !== "function" || typeof options.signal.removeEventListener !== "function")) throw new ScanInputError("Expected an AbortSignal.");
10016
+ if (options.onProgress !== void 0 && typeof options.onProgress !== "function") throw new ScanInputError("Expected a progress callback.");
10017
+ if (options.exclude !== void 0 && (!Array.isArray(options.exclude) || options.exclude.length > MAX_EXCLUSIONS || !options.exclude.every(isExclusionPath))) throw new ScanInputError("Expected at most 64 literal relative exclusion paths.");
8972
10018
  for (const name of ["honorIgnoreMarkers", "noExcerpts"]) {
8973
- if (options[name] !== void 0 && typeof options[name] !== "boolean") throw new TypeError("Expected a boolean scan option.");
10019
+ if (options[name] !== void 0 && typeof options[name] !== "boolean") throw new ScanInputError("Expected a boolean scan option.");
8974
10020
  }
8975
10021
  for (const name of ["only", "skip"]) {
8976
10022
  const values = options[name];
8977
10023
  if (values !== void 0 && (!Array.isArray(values) || [...values].some((value) => typeof value !== "string" || !isKnownSelector(value)))) {
8978
- throw new TypeError("Rule selectors must be an array of known IDs or namespaces.");
10024
+ throw new ScanInputError("Rule selectors must be an array of known IDs or namespaces.");
8979
10025
  }
8980
10026
  }
8981
- if (options.only?.length && options.skip?.length) throw new TypeError("only and skip are mutually exclusive.");
10027
+ if (options.only?.length && options.skip?.length) throw new ScanInputError("only and skip are mutually exclusive.");
10028
+ checkScanCancelled(options.signal);
8982
10029
  const directory = resolve2(root);
8983
10030
  try {
8984
10031
  if (!statSync3(directory).isDirectory()) throw new Error("not a directory");
8985
10032
  } catch {
8986
- throw new Error("Scan root must be an accessible directory.");
10033
+ throw Object.assign(new Error("Scan root must be an accessible directory."), { code: "SCAN_ROOT_UNAVAILABLE" });
8987
10034
  }
10035
+ const omitExcerpts = options.noExcerpts === true;
8988
10036
  const result = await scan(directory, {
8989
10037
  only: [...options.only ?? []],
8990
10038
  skip: [...options.skip ?? []],
8991
- honorIgnoreMarkers: options.honorIgnoreMarkers ?? true
10039
+ honorIgnoreMarkers: options.honorIgnoreMarkers ?? true,
10040
+ exclude: [...options.exclude ?? []],
10041
+ ...options.signal === void 0 ? {} : { signal: options.signal },
10042
+ ...options.onProgress === void 0 ? {} : { onProgress: options.onProgress }
8992
10043
  });
8993
- return options.noExcerpts ? { ...result, findings: result.findings.map((finding) => ({ ...finding, excerpt: null })) } : result;
10044
+ return omitExcerpts ? { ...result, findings: result.findings.map((finding) => ({ ...finding, excerpt: null })) } : result;
8994
10045
  }
8995
10046
  export {
10047
+ ScanCancelledError,
10048
+ ScanProgressError,
10049
+ getBuildInfo,
10050
+ getCapabilities,
8996
10051
  listRules,
8997
10052
  scan2 as scan,
8998
10053
  summarize