checkpointer 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5,6 +5,8 @@ import { Command } from "commander";
5
5
  import pc3 from "picocolors";
6
6
 
7
7
  // src/actions.ts
8
+ import { writeFileSync as writeFileSync4 } from "fs";
9
+ import { resolve } from "path";
8
10
  import pc2 from "picocolors";
9
11
 
10
12
  // src/core/store.ts
@@ -42,8 +44,37 @@ var CheckpointerError = class extends Error {
42
44
  function fail(message, code) {
43
45
  throw new CheckpointerError(message, code);
44
46
  }
47
+ var GENERIC_EXIT = 1;
48
+ var EXIT_CODES = {
49
+ // 3 — environment not ready
50
+ not_initialized: 3,
51
+ not_a_repo: 3,
52
+ lock_held: 3,
53
+ corrupt_metadata: 3,
54
+ unsupported_meta_version: 3,
55
+ skill_missing: 3,
56
+ // 4 — bad input
57
+ no_match: 4,
58
+ name_taken: 4,
59
+ invalid_status: 4,
60
+ missing_message: 4,
61
+ auto_target: 4,
62
+ bad_path: 4,
63
+ // 5 — safety refusal
64
+ broken_refusal: 5,
65
+ detached_head: 5,
66
+ abandoned_target: 5,
67
+ // 6 — benign no-op
68
+ nothing_to_ship: 6,
69
+ nothing_to_commit: 6,
70
+ no_sources: 6
71
+ };
72
+ function exitCodeFor(code) {
73
+ return EXIT_CODES[code] ?? GENERIC_EXIT;
74
+ }
45
75
 
46
76
  // src/core/git.ts
77
+ var EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
47
78
  var IDENTITY = [
48
79
  "-c",
49
80
  "user.name=checkpointer",
@@ -82,6 +113,21 @@ var ShadowGit = class {
82
113
  stageAll() {
83
114
  this.capture(["add", "-A"], "stage");
84
115
  }
116
+ // Paths staged as git submodule links (mode 160000). `git add -A` records a
117
+ // gitlink for any nested repo, but the shadow store never holds that repo's
118
+ // objects — so a snapshot would be a dangling pointer and a restore would
119
+ // leave the directory empty. Callers detect these to warn and drop them.
120
+ stagedGitlinks() {
121
+ const out = this.capture(["ls-files", "-s"], "ls-files");
122
+ if (!out) return [];
123
+ return out.split("\n").filter((line) => line.startsWith("160000 ")).map((line) => line.split(" ").slice(1).join(" ")).filter(Boolean);
124
+ }
125
+ // Drop paths from the index without touching the working tree, so the
126
+ // recorded tree contains neither a populated copy nor a dangling gitlink.
127
+ unstage(paths) {
128
+ if (!paths.length) return;
129
+ this.capture(["rm", "--cached", "-f", "-q", "--ignore-unmatch", "--", ...paths], "rm");
130
+ }
85
131
  commit(message) {
86
132
  const args = [...IDENTITY, "commit", "-q", "--allow-empty", "-m", message];
87
133
  const result = this.run(args);
@@ -109,6 +155,18 @@ var ShadowGit = class {
109
155
  const out = this.capture(["ls-tree", "-r", "--name-only", sha], "ls-tree");
110
156
  return out ? out.split("\n") : [];
111
157
  }
158
+ // Paths whose staged content differs from the given commit/tree. Call after
159
+ // stageAll() so the index reflects the working tree (excludes already
160
+ // applied), then this never reports excluded files like .env.
161
+ changedFiles(sha) {
162
+ const out = this.capture(["diff-index", "--cached", "--name-only", sha], "diff-index");
163
+ return out ? out.split("\n") : [];
164
+ }
165
+ // The tree object the current index would commit to. Lets callers compare
166
+ // working-tree state to a checkpoint without making a commit.
167
+ writeTree() {
168
+ return this.capture(["write-tree"], "write-tree");
169
+ }
112
170
  diffStat(from, to) {
113
171
  return this.capture(["diff", "--stat", from, to], "diff");
114
172
  }
@@ -132,11 +190,17 @@ function read(file, root) {
132
190
  try {
133
191
  parsed = JSON.parse(readFileSync(file, "utf8"));
134
192
  } catch {
135
- fail(`checkpoint metadata at ${file} is corrupt (invalid JSON)`);
193
+ fail(`checkpoint metadata at ${file} is corrupt (invalid JSON)`, "corrupt_metadata");
136
194
  }
137
195
  const data = parsed;
196
+ if (typeof data?.version === "number" && data.version > 1) {
197
+ fail(
198
+ `checkpoint metadata at ${file} was written by a newer checkpointer (schema v${data.version}) \u2014 upgrade checkpointer`,
199
+ "unsupported_meta_version"
200
+ );
201
+ }
138
202
  if (data?.version !== 1 || !Array.isArray(data.checkpoints) || typeof data.shippedSeq !== "number") {
139
- fail(`checkpoint metadata at ${file} is invalid or from an unsupported version`);
203
+ fail(`checkpoint metadata at ${file} is invalid or unreadable`, "corrupt_metadata");
140
204
  }
141
205
  return data;
142
206
  }
@@ -362,7 +426,10 @@ function withLock(file, fn) {
362
426
  continue;
363
427
  }
364
428
  if (Date.now() > deadline) {
365
- fail("another checkpointer operation is in progress \u2014 try again in a moment");
429
+ fail(
430
+ `another checkpointer operation is in progress (lock: ${file}) \u2014 try again in a moment`,
431
+ "lock_held"
432
+ );
366
433
  }
367
434
  sleep(RETRY_MS);
368
435
  }
@@ -418,8 +485,15 @@ var Store = class _Store {
418
485
  return withLock(this.paths.lockFile, () => {
419
486
  this.meta.reload();
420
487
  writeExcludes(this.paths);
421
- const seq = this.meta.nextSeq();
488
+ this.git.stageAll();
489
+ const nestedRepos = this.git.stagedGitlinks();
490
+ if (nestedRepos.length) this.git.unstage(nestedRepos);
422
491
  const auto = input.auto ?? false;
492
+ const latest = this.meta.latest();
493
+ if (!auto && !input.allowEmpty && input.restoredToSeq === void 0 && input.name === void 0 && input.message === void 0 && input.intent === null && latest && !latest.auto && input.status === latest.status && this.git.writeTree() === this.git.treeOf(latest.sha)) {
494
+ return { ...latest, noop: true, ...nestedRepos.length ? { nestedRepos } : {} };
495
+ }
496
+ const seq = this.meta.nextSeq();
423
497
  let name;
424
498
  if (input.name === void 0) {
425
499
  name = this.meta.uniqueName(`cp-${seq}`);
@@ -431,7 +505,6 @@ var Store = class _Store {
431
505
  }
432
506
  name = input.name;
433
507
  }
434
- this.git.stageAll();
435
508
  const message = input.message ?? name;
436
509
  const sha = this.git.commit(`[${seq}] ${name}: ${message}`);
437
510
  const checkpoint = {
@@ -449,9 +522,21 @@ var Store = class _Store {
449
522
  ...input.restoredToSeq !== void 0 ? { restoredToSeq: input.restoredToSeq } : {}
450
523
  };
451
524
  this.meta.add(checkpoint);
452
- return checkpoint;
525
+ return nestedRepos.length ? { ...checkpoint, nestedRepos } : checkpoint;
453
526
  });
454
527
  }
528
+ // Whether the working tree differs from a checkpoint, without committing.
529
+ // Defaults to the latest checkpoint of any kind, so the answer predicts
530
+ // whether `save` would create a new checkpoint or be a no-op. Read-only:
531
+ // stages into the shadow index (applying excludes) but writes no commit.
532
+ changed(ref) {
533
+ const against = ref ? this.meta.require(ref) : this.meta.latest();
534
+ this.git.stageAll();
535
+ const nested = this.git.stagedGitlinks();
536
+ if (nested.length) this.git.unstage(nested);
537
+ const files = against ? this.git.changedFiles(against.sha) : this.git.changedFiles(EMPTY_TREE);
538
+ return { changed: files.length > 0, against, files };
539
+ }
455
540
  setStatus(seq, status2) {
456
541
  withLock(this.paths.lockFile, () => {
457
542
  this.meta.reload();
@@ -485,24 +570,41 @@ function resolveIdentity(root, overrides = {}) {
485
570
  const author = overrides.author ?? process.env.CHECKPOINTER_AUTHOR ?? gitUser(root) ?? (agent ? agent : "you");
486
571
  return { author, agent, session, intent };
487
572
  }
488
- function gitUser(root) {
489
- const result = run("git", ["config", "user.name"], { cwd: root });
490
- const name = result.stdout.trim();
491
- return result.status === 0 && name ? name : null;
573
+ function gitUserName(root) {
574
+ return gitConfig(root, "user.name");
575
+ }
576
+ function gitUserEmail(root) {
577
+ return gitConfig(root, "user.email");
492
578
  }
579
+ function gitConfig(root, key) {
580
+ const result = run("git", ["config", key], { cwd: root });
581
+ const value = result.stdout.trim();
582
+ return result.status === 0 && value ? value : null;
583
+ }
584
+ var gitUser = gitUserName;
493
585
 
494
586
  // src/core/ship.ts
495
587
  function planShip(store, uptoRef) {
496
- const upto = uptoRef ? store.meta.require(uptoRef) : latestOrFail(store);
588
+ const upto = uptoRef ? store.meta.require(uptoRef) : latestLiveOrFail(store);
497
589
  if (upto.auto) {
498
590
  fail(`'${upto.name}' is an auto safety checkpoint \u2014 ship to a manual checkpoint instead`, "auto_target");
499
591
  }
592
+ if (store.meta.isAbandoned(upto.seq)) {
593
+ fail(
594
+ `'${upto.name}' was set aside by a restore \u2014 it is not part of the current line of work.
595
+ Restore it first if you want to ship it, or ship a live checkpoint instead.`,
596
+ "abandoned_target"
597
+ );
598
+ }
500
599
  const all = store.meta.all();
501
600
  const shippedSeq = store.meta.shippedSeq();
502
601
  const from = all.find((cp) => cp.seq === shippedSeq) ?? null;
503
602
  const included = all.filter(
504
603
  (cp) => cp.seq > shippedSeq && cp.seq <= upto.seq && !cp.auto && !store.meta.isAbandoned(cp.seq)
505
604
  );
605
+ if (!included.length) {
606
+ fail(`nothing to ship \u2014 no unshipped checkpoints up to ${upto.name}`, "nothing_to_ship");
607
+ }
506
608
  return {
507
609
  upto,
508
610
  from,
@@ -518,10 +620,19 @@ function executeShip(store, plan, message, author) {
518
620
  fail(`nothing to ship \u2014 no unshipped checkpoints up to ${plan.upto.name}`, "nothing_to_ship");
519
621
  }
520
622
  const root = store.paths.root;
623
+ const parent = run("git", ["rev-parse", "--verify", "-q", "HEAD"], { cwd: root }).stdout.trim();
624
+ if (parent) {
625
+ const onBranch = run("git", ["symbolic-ref", "-q", "HEAD"], { cwd: root }).status === 0;
626
+ if (!onBranch) {
627
+ fail(
628
+ "HEAD is detached \u2014 checkpointer won't ship here, the commit could be lost on checkout.\nCheck out a branch first (e.g. 'git switch -c my-feature'), then ship.",
629
+ "detached_head"
630
+ );
631
+ }
632
+ }
521
633
  const fetch = run("git", ["fetch", "--no-tags", "-q", store.git.path, "refs/heads/checkpoints"], { cwd: root });
522
634
  if (fetch.status !== 0) fail(`could not read checkpoints into the repo: ${fetch.stderr.trim()}`);
523
635
  const tree = store.git.treeOf(plan.upto.sha);
524
- const parent = run("git", ["rev-parse", "--verify", "-q", "HEAD"], { cwd: root }).stdout.trim();
525
636
  if (parent) {
526
637
  const headTree = run("git", ["rev-parse", "HEAD^{tree}"], { cwd: root }).stdout.trim();
527
638
  if (headTree === tree) {
@@ -530,10 +641,17 @@ function executeShip(store, plan, message, author) {
530
641
  }
531
642
  const args = ["commit-tree", tree, "-m", message];
532
643
  if (parent) args.push("-p", parent);
533
- const built = run("git", args, {
534
- cwd: root,
535
- env: { ...process.env, GIT_AUTHOR_NAME: author, GIT_COMMITTER_NAME: author }
536
- });
644
+ const env = {
645
+ ...process.env,
646
+ GIT_AUTHOR_NAME: author,
647
+ GIT_COMMITTER_NAME: author
648
+ };
649
+ const isHuman = author === gitUserName(root) && gitUserEmail(root) !== null;
650
+ if (!isHuman) {
651
+ env.GIT_AUTHOR_EMAIL = "checkpointer@local";
652
+ env.GIT_COMMITTER_EMAIL = "checkpointer@local";
653
+ }
654
+ const built = run("git", args, { cwd: root, env });
537
655
  if (built.status !== 0) fail(`git commit-tree failed: ${built.stderr.trim()}`);
538
656
  const commit = built.stdout.trim();
539
657
  const update = run("git", ["update-ref", "HEAD", commit], { cwd: root });
@@ -543,10 +661,350 @@ function executeShip(store, plan, message, author) {
543
661
  return { commit, message };
544
662
  });
545
663
  }
546
- function latestOrFail(store) {
547
- const latest = store.meta.latestManual();
548
- if (!latest) fail("no checkpoints to ship \u2014 run 'checkpointer save' first", "nothing_to_ship");
549
- return latest;
664
+ function latestLiveOrFail(store) {
665
+ const live = [...store.meta.all()].reverse().find((cp) => !cp.auto && !store.meta.isAbandoned(cp.seq));
666
+ if (!live) fail("no checkpoints to ship \u2014 run 'checkpointer save' first", "nothing_to_ship");
667
+ return live;
668
+ }
669
+
670
+ // src/core/analyze.ts
671
+ import { existsSync as existsSync4, readdirSync } from "fs";
672
+ import { join as join3, relative, sep } from "path";
673
+ import ts from "typescript";
674
+ var SOURCE_EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
675
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
676
+ "node_modules",
677
+ ".git",
678
+ "dist",
679
+ "build",
680
+ "out",
681
+ "coverage",
682
+ ".next",
683
+ ".nuxt",
684
+ ".cache",
685
+ "vendor"
686
+ ]);
687
+ function scanSources(root) {
688
+ const found = [];
689
+ const walk = (dir) => {
690
+ let entries;
691
+ try {
692
+ entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
693
+ } catch {
694
+ return;
695
+ }
696
+ for (const entry of entries) {
697
+ const full = join3(dir, entry.name);
698
+ if (entry.isDirectory()) {
699
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
700
+ walk(full);
701
+ } else if (entry.isFile()) {
702
+ if (entry.name.endsWith(".d.ts")) continue;
703
+ const dot = entry.name.lastIndexOf(".");
704
+ if (dot >= 0 && SOURCE_EXTS.has(entry.name.slice(dot))) found.push(full);
705
+ }
706
+ }
707
+ };
708
+ walk(root);
709
+ return found;
710
+ }
711
+ function collectFiles(root) {
712
+ const configPath = join3(root, "tsconfig.json");
713
+ if (existsSync4(configPath)) {
714
+ const config = ts.readConfigFile(configPath, ts.sys.readFile);
715
+ if (!config.error) {
716
+ const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root);
717
+ if (parsed.fileNames.length) {
718
+ return { files: parsed.fileNames, options: { ...parsed.options, allowJs: true, noEmit: true } };
719
+ }
720
+ }
721
+ }
722
+ return {
723
+ files: scanSources(root),
724
+ options: { allowJs: true, noEmit: true, target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }
725
+ };
726
+ }
727
+ function rel(root, file) {
728
+ return relative(root, file).split(sep).join("/");
729
+ }
730
+ function isFunctionLike(node) {
731
+ return ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isGetAccessor(node) || ts.isSetAccessor(node);
732
+ }
733
+ function describeCallable(node) {
734
+ if (ts.isFunctionDeclaration(node)) {
735
+ return node.name ? { name: node.name.text, kind: "function", commentNode: node } : null;
736
+ }
737
+ if (ts.isConstructorDeclaration(node)) {
738
+ const cls = node.parent;
739
+ const clsName = ts.isClassLike(cls) && cls.name ? cls.name.text : "(anonymous class)";
740
+ return { name: `${clsName}.constructor`, kind: "constructor", commentNode: node };
741
+ }
742
+ if (ts.isMethodDeclaration(node) || ts.isGetAccessor(node) || ts.isSetAccessor(node)) {
743
+ const cls = node.parent;
744
+ const clsName = ts.isClassLike(cls) && cls.name ? cls.name.text : "";
745
+ const member = node.name.getText();
746
+ return { name: clsName ? `${clsName}.${member}` : member, kind: "method", commentNode: node };
747
+ }
748
+ const parent = node.parent;
749
+ if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
750
+ const stmt = parent.parent.parent;
751
+ return { name: parent.name.text, kind: "function", commentNode: ts.isVariableStatement(stmt) ? stmt : node };
752
+ }
753
+ if ((ts.isPropertyAssignment(parent) || ts.isPropertyDeclaration(parent)) && (ts.isIdentifier(parent.name) || ts.isStringLiteral(parent.name))) {
754
+ const member = parent.name.text;
755
+ if (ts.isPropertyDeclaration(parent) && ts.isClassLike(parent.parent) && parent.parent.name) {
756
+ return { name: `${parent.parent.name.text}.${member}`, kind: "method", commentNode: parent };
757
+ }
758
+ return { name: member, kind: "function", commentNode: parent };
759
+ }
760
+ return null;
761
+ }
762
+ function extractDescription(commentNode, sf) {
763
+ const full = sf.getFullText();
764
+ const ranges = ts.getLeadingCommentRanges(full, commentNode.getFullStart());
765
+ if (!ranges?.length) return "";
766
+ const tail = full.slice(ranges[ranges.length - 1].end, commentNode.getStart());
767
+ if ((tail.match(/\n/g) ?? []).length > 1) return "";
768
+ const group = [ranges[ranges.length - 1]];
769
+ for (let i = ranges.length - 2; i >= 0; i--) {
770
+ const gap = full.slice(ranges[i].end, ranges[i + 1].pos);
771
+ if ((gap.match(/\n/g) ?? []).length > 1) break;
772
+ group.unshift(ranges[i]);
773
+ }
774
+ const raw = group.map((r) => full.slice(r.pos, r.end)).join("\n");
775
+ const cleaned = raw.split("\n").map(
776
+ (line) => line.replace(/^\s*\/\*\*?/, "").replace(/\*\/\s*$/, "").replace(/^\s*\*\s?/, "").replace(/^\s*\/\/\s?/, "").trimEnd()
777
+ ).join("\n").trim();
778
+ const summary = cleaned.split("\n").filter((line) => !line.trimStart().startsWith("@")).join(" ").replace(/\s+/g, " ").trim();
779
+ return summary.length > 280 ? summary.slice(0, 277) + "\u2026" : summary;
780
+ }
781
+ function jsdocTags(node) {
782
+ const params = /* @__PURE__ */ new Map();
783
+ let returns = "";
784
+ for (const tag2 of ts.getJSDocTags(node)) {
785
+ const text = typeof tag2.comment === "string" ? tag2.comment : ts.getTextOfJSDocComment(tag2.comment) ?? "";
786
+ if (ts.isJSDocParameterTag(tag2)) {
787
+ params.set(tag2.name.getText(), text.replace(/^[-–\s]+/, "").trim());
788
+ } else if (tag2.tagName.text === "returns" || tag2.tagName.text === "return") {
789
+ returns = text.replace(/^[-–\s]+/, "").trim();
790
+ }
791
+ }
792
+ return { params, returns };
793
+ }
794
+ function clampType(t) {
795
+ const flat = t.replace(/\s+/g, " ").trim();
796
+ return flat.length > 80 ? flat.slice(0, 77) + "\u2026" : flat;
797
+ }
798
+ function signatureOf(node, name, checker) {
799
+ const docs = jsdocTags(node);
800
+ const params = node.parameters.map((p) => {
801
+ const pname = p.name.getText();
802
+ let type = "";
803
+ if (p.type) {
804
+ type = p.type.getText();
805
+ } else {
806
+ try {
807
+ type = checker.typeToString(checker.getTypeAtLocation(p));
808
+ } catch {
809
+ type = "";
810
+ }
811
+ }
812
+ return {
813
+ name: pname,
814
+ type: clampType(type),
815
+ optional: !!p.questionToken || !!p.initializer || !!p.dotDotDotToken,
816
+ doc: docs.params.get(pname.replace(/[.{}[\]]/g, "").split(/[,:]/)[0].trim()) ?? docs.params.get(pname) ?? ""
817
+ };
818
+ });
819
+ let returns = "";
820
+ try {
821
+ const sig = checker.getSignatureFromDeclaration(node);
822
+ if (sig) returns = clampType(checker.typeToString(checker.getReturnTypeOfSignature(sig)));
823
+ } catch {
824
+ returns = "";
825
+ }
826
+ if (!returns && node.type) returns = clampType(node.type.getText());
827
+ const paramStr = params.map((p) => `${p.name}${p.optional ? "?" : ""}: ${p.type || "any"}`).join(", ");
828
+ const signature = `${name}(${paramStr})${returns ? `: ${returns}` : ""}`;
829
+ return { params, returns, signature };
830
+ }
831
+ function sourceOf(node, sf) {
832
+ const text = node.getText(sf);
833
+ const MAX = 8e3;
834
+ return text.length > MAX ? text.slice(0, MAX) + "\n/* \u2026 truncated \u2026 */" : text;
835
+ }
836
+ function resolveCallee(expr, checker, declToId) {
837
+ let symbol = checker.getSymbolAtLocation(expr.expression);
838
+ if (!symbol) return null;
839
+ if (symbol.flags & ts.SymbolFlags.Alias) {
840
+ try {
841
+ symbol = checker.getAliasedSymbol(symbol);
842
+ } catch {
843
+ }
844
+ }
845
+ for (const decl of symbol.declarations ?? []) {
846
+ const id = declToId.get(decl);
847
+ if (id) return id;
848
+ }
849
+ if (ts.isNewExpression(expr)) {
850
+ for (const decl of symbol.declarations ?? []) {
851
+ if (ts.isClassLike(decl)) {
852
+ for (const member of decl.members) {
853
+ if (ts.isConstructorDeclaration(member)) {
854
+ const id = declToId.get(member);
855
+ if (id) return id;
856
+ }
857
+ }
858
+ }
859
+ }
860
+ }
861
+ return null;
862
+ }
863
+ function analyzeProject(root) {
864
+ const { files, options } = collectFiles(root);
865
+ if (!files.length) {
866
+ fail(
867
+ `no TypeScript/JavaScript sources found under ${root} to graph. Today 'graph' analyzes TS/JS (it resolves calls via the TypeScript compiler); support for more languages is planned. Your other commands (save/restore/ship) work in any repo.`,
868
+ "no_sources"
869
+ );
870
+ }
871
+ const program2 = ts.createProgram(files, options);
872
+ const checker = program2.getTypeChecker();
873
+ const sources = program2.getSourceFiles().filter((sf) => !sf.isDeclarationFile && !sf.fileName.includes("node_modules"));
874
+ const nodes = /* @__PURE__ */ new Map();
875
+ const declToId = /* @__PURE__ */ new Map();
876
+ const moduleId = /* @__PURE__ */ new Map();
877
+ const makeId = (base) => {
878
+ if (!nodes.has(base)) return base;
879
+ for (let n = 2; ; n++) if (!nodes.has(`${base}~${n}`)) return `${base}~${n}`;
880
+ };
881
+ for (const sf of sources) {
882
+ const file = rel(root, sf.fileName);
883
+ moduleId.set(sf, `${file}::<module>`);
884
+ const discover = (node) => {
885
+ if (isFunctionLike(node)) {
886
+ const info2 = describeCallable(node);
887
+ if (info2) {
888
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart());
889
+ const id = makeId(`${file}#${info2.name}`);
890
+ const modifiers = ts.canHaveModifiers(node) ? ts.getCombinedModifierFlags(node) : ts.ModifierFlags.None;
891
+ const { params, returns, signature } = signatureOf(node, info2.name, checker);
892
+ nodes.set(id, {
893
+ id,
894
+ name: info2.name,
895
+ kind: info2.kind,
896
+ file,
897
+ line: line + 1,
898
+ description: extractDescription(info2.commentNode, sf),
899
+ signature,
900
+ exported: (modifiers & ts.ModifierFlags.Export) !== 0,
901
+ async: (modifiers & ts.ModifierFlags.Async) !== 0,
902
+ params,
903
+ returns,
904
+ returnDoc: jsdocTags(node).returns,
905
+ code: sourceOf(info2.commentNode, sf)
906
+ });
907
+ declToId.set(node, id);
908
+ if (ts.isVariableDeclaration(node.parent)) declToId.set(node.parent, id);
909
+ if (ts.isPropertyAssignment(node.parent) || ts.isPropertyDeclaration(node.parent)) {
910
+ declToId.set(node.parent, id);
911
+ }
912
+ }
913
+ }
914
+ ts.forEachChild(node, discover);
915
+ };
916
+ discover(sf);
917
+ }
918
+ const edgeSet = /* @__PURE__ */ new Set();
919
+ const edges = [];
920
+ const addEdge = (from, to) => {
921
+ if (from === to) return;
922
+ const key = `${from}\0${to}`;
923
+ if (edgeSet.has(key)) return;
924
+ edgeSet.add(key);
925
+ edges.push({ from, to });
926
+ };
927
+ for (const sf of sources) {
928
+ const visit = (node, scope) => {
929
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
930
+ const target = resolveCallee(node, checker, declToId);
931
+ if (target) addEdge(scope, target);
932
+ }
933
+ const nextScope = isFunctionLike(node) ? declToId.get(node) ?? scope : scope;
934
+ ts.forEachChild(node, (child) => visit(child, nextScope));
935
+ };
936
+ visit(sf, moduleId.get(sf));
937
+ }
938
+ const usedModules = new Set(edges.filter((e) => e.from.endsWith("::<module>")).map((e) => e.from));
939
+ for (const sf of sources) {
940
+ const id = moduleId.get(sf);
941
+ if (!usedModules.has(id)) continue;
942
+ const file = rel(root, sf.fileName);
943
+ nodes.set(id, {
944
+ id,
945
+ name: `${file} (top-level)`,
946
+ kind: "module",
947
+ file,
948
+ line: 1,
949
+ description: "Top-level module code \u2014 runs on import.",
950
+ signature: `${file} (top-level)`,
951
+ exported: false,
952
+ async: false,
953
+ params: [],
954
+ returns: "",
955
+ returnDoc: "",
956
+ code: ""
957
+ });
958
+ }
959
+ const liveEdges = edges.filter((e) => nodes.has(e.from) && nodes.has(e.to));
960
+ const hasIncoming = new Set(liveEdges.map((e) => e.to));
961
+ const roots = [...nodes.keys()].filter((id) => !hasIncoming.has(id)).sort();
962
+ return {
963
+ root,
964
+ fileCount: sources.length,
965
+ nodes: [...nodes.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line),
966
+ edges: liveEdges,
967
+ roots
968
+ };
969
+ }
970
+
971
+ // src/core/graph-server.ts
972
+ import { createServer } from "http";
973
+ import { spawn } from "child_process";
974
+ import { platform } from "os";
975
+ function serveGraph(html, opts) {
976
+ return new Promise((resolve2, reject) => {
977
+ const server = createServer((req, res) => {
978
+ if (req.url === "/favicon.ico") {
979
+ res.writeHead(204).end();
980
+ return;
981
+ }
982
+ res.writeHead(200, {
983
+ "content-type": "text/html; charset=utf-8",
984
+ "cache-control": "no-store"
985
+ });
986
+ res.end(html);
987
+ });
988
+ server.on("error", reject);
989
+ server.listen(opts.port ?? 0, "127.0.0.1", () => {
990
+ const addr = server.address();
991
+ const port = typeof addr === "object" && addr ? addr.port : opts.port;
992
+ const url = `http://127.0.0.1:${port}/`;
993
+ if (opts.open !== false) openBrowser(url);
994
+ resolve2({ url, close: () => server.close() });
995
+ });
996
+ });
997
+ }
998
+ function openBrowser(url) {
999
+ const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
1000
+ const args = platform() === "win32" ? ["/c", "start", "", url] : [url];
1001
+ try {
1002
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
1003
+ child.on("error", () => {
1004
+ });
1005
+ child.unref();
1006
+ } catch {
1007
+ }
550
1008
  }
551
1009
 
552
1010
  // src/ui/format.ts
@@ -587,8 +1045,319 @@ function heading(text) {
587
1045
  return pc.bold(pc.underline(text));
588
1046
  }
589
1047
  var ok = (text) => pc.green(text);
1048
+ var warn = (text) => pc.yellow(text);
590
1049
  var dim = (text) => pc.dim(text);
591
1050
 
1051
+ // src/ui/graph-html.ts
1052
+ function renderGraphHtml(graph2, meta) {
1053
+ const data = JSON.stringify(graph2).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
1054
+ const projectName = graph2.root.split("/").filter(Boolean).at(-1) ?? "project";
1055
+ return `<!DOCTYPE html>
1056
+ <html lang="en">
1057
+ <head>
1058
+ <meta charset="utf-8" />
1059
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1060
+ <title>${escapeHtml(projectName)} \xB7 function graph</title>
1061
+ <style>${CSS}</style>
1062
+ </head>
1063
+ <body>
1064
+ <header>
1065
+ <div class="title">
1066
+ <span class="logo">\u2387</span>
1067
+ <div>
1068
+ <h1>${escapeHtml(projectName)}</h1>
1069
+ <div class="sub"><span id="stat-fn">0</span> functions \xB7 <span id="stat-file">${graph2.fileCount}</span> files \xB7 <span id="stat-edge">0</span> calls \xB7 generated ${escapeHtml(meta.generatedAt)}</div>
1070
+ </div>
1071
+ </div>
1072
+ <div class="search">
1073
+ <input id="q" type="search" placeholder="Search a function\u2026 (\u2191\u2193 to move, \u21B5 to focus)" autocomplete="off" spellcheck="false" />
1074
+ <div id="results"></div>
1075
+ </div>
1076
+ </header>
1077
+ <main>
1078
+ <section id="pane-tree" class="pane">
1079
+ <div class="pane-head">
1080
+ <h2>Call tree</h2>
1081
+ <div class="pane-actions">
1082
+ <button id="btn-expand">Expand all</button>
1083
+ <button id="btn-collapse">Collapse</button>
1084
+ </div>
1085
+ </div>
1086
+ <div class="hint">Top-down from entrypoints. Click a row to expand the functions it calls. \u25B8 has callees.</div>
1087
+ <div id="tree" class="tree"></div>
1088
+ </section>
1089
+ <section id="pane-focus" class="pane hidden">
1090
+ <div class="pane-head">
1091
+ <h2 id="focus-title">Function</h2>
1092
+ <button id="btn-back" title="Back to full tree">\u2715 close</button>
1093
+ </div>
1094
+ <div id="focus-meta" class="focus-meta"></div>
1095
+ <div class="focus-cols">
1096
+ <div class="focus-col">
1097
+ <h3>Ancestors <span class="muted">\u2014 who reaches this (up to entrypoints)</span></h3>
1098
+ <div id="ancestors" class="tree"></div>
1099
+ </div>
1100
+ <div class="focus-col">
1101
+ <h3>Descendants <span class="muted">\u2014 what this calls</span></h3>
1102
+ <div id="descendants" class="tree"></div>
1103
+ </div>
1104
+ </div>
1105
+ </section>
1106
+ </main>
1107
+ <script id="graph-data" type="application/json">${data}</script>
1108
+ <script>${JS}</script>
1109
+ </body>
1110
+ </html>`;
1111
+ }
1112
+ function escapeHtml(s) {
1113
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1114
+ }
1115
+ var CSS = `
1116
+ :root{
1117
+ --bg:#0d1117;--panel:#161b22;--panel2:#1c2330;--line:#283040;--fg:#e6edf3;--muted:#8b949e;
1118
+ --accent:#58a6ff;--green:#3fb950;--yellow:#d29922;--purple:#bc8cff;--row:#21262d;
1119
+ }
1120
+ *{box-sizing:border-box}
1121
+ body{margin:0;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--bg);color:var(--fg)}
1122
+ header{position:sticky;top:0;z-index:5;background:linear-gradient(180deg,#0d1117,#0d1117f0);border-bottom:1px solid var(--line);padding:14px 20px;display:flex;gap:24px;align-items:flex-start;flex-wrap:wrap}
1123
+ .title{display:flex;gap:12px;align-items:center}
1124
+ .logo{font-size:26px;color:var(--accent)}
1125
+ h1{margin:0;font-size:17px;font-weight:600}
1126
+ .sub{color:var(--muted);font-size:12px}
1127
+ .search{position:relative;flex:1;min-width:280px;max-width:520px}
1128
+ #q{width:100%;padding:9px 12px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;color:var(--fg);font:inherit;outline:none}
1129
+ #q:focus{border-color:var(--accent)}
1130
+ #results{position:absolute;top:42px;left:0;right:0;background:var(--panel);border:1px solid var(--line);border-radius:8px;max-height:340px;overflow:auto;display:none;box-shadow:0 12px 30px #0008;z-index:6}
1131
+ #results.open{display:block}
1132
+ .result{padding:7px 12px;cursor:pointer;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:10px}
1133
+ .result:last-child{border-bottom:none}
1134
+ .result.active,.result:hover{background:var(--row)}
1135
+ .result .nm{color:var(--accent)}
1136
+ .result .fl{color:var(--muted);font-size:11px;white-space:nowrap}
1137
+ main{padding:18px 20px;display:block}
1138
+ .pane{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:18px}
1139
+ .pane.hidden{display:none}
1140
+ .pane-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}
1141
+ .pane-head h2{margin:0;font-size:14px;font-weight:600}
1142
+ .pane-actions button,#btn-back{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:6px;padding:5px 10px;cursor:pointer;font:inherit;font-size:12px}
1143
+ .pane-actions button:hover,#btn-back:hover{border-color:var(--accent)}
1144
+ .hint{color:var(--muted);font-size:12px;margin-bottom:10px}
1145
+ .tree{font-size:13px}
1146
+ .node{margin:0}
1147
+ .row{display:flex;align-items:baseline;gap:8px;padding:3px 6px;border-radius:6px;cursor:pointer;white-space:nowrap;overflow:hidden}
1148
+ .row:hover{background:var(--row)}
1149
+ .tw{width:14px;display:inline-block;color:var(--muted);flex:0 0 auto;text-align:center}
1150
+ .tw.leaf{color:transparent}
1151
+ .kind{font-size:10px;padding:1px 5px;border-radius:4px;flex:0 0 auto;text-transform:uppercase;letter-spacing:.04em}
1152
+ .kind.function{background:#1f6feb33;color:var(--accent)}
1153
+ .kind.method{background:#bc8cff22;color:var(--purple)}
1154
+ .kind.constructor{background:#d2992222;color:var(--yellow)}
1155
+ .kind.module{background:#8b949e22;color:var(--muted)}
1156
+ .nm{color:var(--fg);font-weight:500;flex:0 0 auto}
1157
+ .async{color:var(--yellow);font-size:11px;flex:0 0 auto}
1158
+ .desc{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis}
1159
+ .loc{color:#6e7681;font-size:11px;flex:0 0 auto;margin-left:auto;padding-left:12px}
1160
+ .children{margin-left:16px;border-left:1px solid var(--line);padding-left:4px}
1161
+ .children.collapsed{display:none}
1162
+ .cycle{color:var(--yellow);font-size:11px}
1163
+ .empty{color:var(--muted);font-style:italic;padding:6px}
1164
+ .focus-meta{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:8px 0 14px}
1165
+ .focus-meta .d{color:var(--muted);margin-top:6px}
1166
+ .fline{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1167
+ .sig{margin:8px 0 4px;padding:8px 10px;background:var(--bg);border:1px solid var(--line);border-radius:6px;color:var(--accent);font-size:12.5px;white-space:pre-wrap;word-break:break-word;overflow-x:auto}
1168
+ table.params{width:100%;border-collapse:collapse;margin:8px 0 4px;font-size:12px}
1169
+ table.params th{text-align:left;color:var(--muted);font-weight:500;padding:3px 8px;border-bottom:1px solid var(--line);text-transform:uppercase;letter-spacing:.04em;font-size:10px}
1170
+ table.params td{padding:3px 8px;border-bottom:1px solid var(--line);vertical-align:top}
1171
+ table.params tr:last-child td{border-bottom:none}
1172
+ .pn{color:var(--fg);white-space:nowrap}
1173
+ .pn .opt{color:var(--yellow)}
1174
+ .pt{color:var(--green);white-space:pre-wrap;word-break:break-word}
1175
+ .pd{color:var(--muted)}
1176
+ .ret{margin:6px 0 2px;font-size:12px}
1177
+ .ret .rk{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;font-size:10px;margin-right:6px}
1178
+ .codewrap{margin-top:10px}
1179
+ .codebtn{background:var(--panel2);border:1px solid var(--line);color:var(--accent);border-radius:6px;padding:4px 10px;cursor:pointer;font:inherit;font-size:12px}
1180
+ .codebtn:hover{border-color:var(--accent)}
1181
+ .code{margin:8px 0 0;padding:10px 12px;background:var(--bg);border:1px solid var(--line);border-radius:6px;font-size:12px;line-height:1.45;overflow:auto;max-height:480px;white-space:pre;tab-size:2}
1182
+ .code.hidden{display:none}
1183
+ .focus-cols{display:grid;grid-template-columns:1fr 1fr;gap:16px}
1184
+ @media(max-width:820px){.focus-cols{grid-template-columns:1fr}}
1185
+ .focus-col h3{font-size:12px;font-weight:600;margin:0 0 8px;color:var(--fg)}
1186
+ .muted{color:var(--muted);font-weight:400}
1187
+ mark{background:#d2992255;color:#fff;border-radius:2px;padding:0 1px}
1188
+ `;
1189
+ var JS = String.raw`
1190
+ const GRAPH = JSON.parse(document.getElementById('graph-data').textContent);
1191
+ const byId = new Map(GRAPH.nodes.map(n => [n.id, n]));
1192
+ const callees = new Map(), callers = new Map();
1193
+ for (const n of GRAPH.nodes){ callees.set(n.id, []); callers.set(n.id, []); }
1194
+ for (const e of GRAPH.edges){
1195
+ if (callees.has(e.from)) callees.get(e.from).push(e.to);
1196
+ if (callers.has(e.to)) callers.get(e.to).push(e.from);
1197
+ }
1198
+ for (const m of [callees, callers]) for (const [k,v] of m) m.set(k, [...new Set(v)].sort((a,b)=>label(a).localeCompare(label(b))));
1199
+
1200
+ document.getElementById('stat-fn').textContent = GRAPH.nodes.length;
1201
+ document.getElementById('stat-edge').textContent = GRAPH.edges.length;
1202
+
1203
+ function label(id){ const n = byId.get(id); return n ? n.name : id; }
1204
+ function esc(s){ return s.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
1205
+
1206
+ // Build one tree row. \`dir\` is 'down' (callees) or 'up' (callers). \`path\` is the
1207
+ // set of ancestor ids on the current branch, to detect and stop cycles.
1208
+ function makeNode(id, dir, path){
1209
+ const n = byId.get(id);
1210
+ const wrap = document.createElement('div'); wrap.className = 'node';
1211
+ const row = document.createElement('div'); row.className = 'row';
1212
+ const kids = (dir === 'down' ? callees : callers).get(id) || [];
1213
+ const isCycle = path.has(id);
1214
+ const hasKids = kids.length > 0 && !isCycle;
1215
+
1216
+ const tw = document.createElement('span');
1217
+ tw.className = 'tw' + (hasKids ? '' : ' leaf');
1218
+ tw.textContent = hasKids ? '▸' : '•';
1219
+ row.appendChild(tw);
1220
+
1221
+ const kind = document.createElement('span'); kind.className = 'kind ' + n.kind; kind.textContent = n.kind; row.appendChild(kind);
1222
+ const nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = n.name; row.appendChild(nm);
1223
+ if (n.async){ const a = document.createElement('span'); a.className='async'; a.textContent='async'; row.appendChild(a); }
1224
+ if (isCycle){ const c=document.createElement('span'); c.className='cycle'; c.textContent='↻ cycle'; row.appendChild(c); }
1225
+ if (n.description){ const d=document.createElement('span'); d.className='desc'; d.textContent='— '+n.description; row.appendChild(d); }
1226
+ const loc=document.createElement('span'); loc.className='loc'; loc.textContent=n.file+':'+n.line; row.appendChild(loc);
1227
+ wrap.appendChild(row);
1228
+
1229
+ const childBox = document.createElement('div'); childBox.className = 'children collapsed';
1230
+ wrap.appendChild(childBox);
1231
+ let built = false;
1232
+ const toggle = () => {
1233
+ if (!hasKids){ openFocus(id); return; }
1234
+ if (!built){
1235
+ const nextPath = new Set(path); nextPath.add(id);
1236
+ for (const k of kids) childBox.appendChild(makeNode(k, dir, nextPath));
1237
+ built = true;
1238
+ }
1239
+ const collapsed = childBox.classList.toggle('collapsed');
1240
+ tw.textContent = collapsed ? '▸' : '▾';
1241
+ };
1242
+ row.addEventListener('click', (e) => { if (e.metaKey || e.ctrlKey){ openFocus(id); return; } toggle(); });
1243
+ row._expand = () => { if (hasKids && childBox.classList.contains('collapsed')) toggle(); };
1244
+ return wrap;
1245
+ }
1246
+
1247
+ function renderTree(container, ids, dir){
1248
+ container.innerHTML = '';
1249
+ if (!ids.length){ container.innerHTML = '<div class="empty">none</div>'; return; }
1250
+ for (const id of ids) container.appendChild(makeNode(id, dir, new Set()));
1251
+ }
1252
+
1253
+ const treeEl = document.getElementById('tree');
1254
+ renderTree(treeEl, GRAPH.roots.length ? GRAPH.roots : GRAPH.nodes.map(n=>n.id), 'down');
1255
+
1256
+ document.getElementById('btn-expand').onclick = () => {
1257
+ // Expand a bounded number of levels to avoid runaway DOM on huge graphs.
1258
+ for (let depth=0; depth<6; depth++){
1259
+ treeEl.querySelectorAll('.row').forEach(r => r._expand && r._expand());
1260
+ }
1261
+ };
1262
+ document.getElementById('btn-collapse').onclick = () => renderTree(treeEl, GRAPH.roots.length ? GRAPH.roots : GRAPH.nodes.map(n=>n.id), 'down');
1263
+
1264
+ // ---- Focus view: ancestors + descendants of one function ----
1265
+ function openFocus(id){
1266
+ const n = byId.get(id);
1267
+ document.getElementById('pane-tree').classList.add('hidden');
1268
+ const pane = document.getElementById('pane-focus'); pane.classList.remove('hidden');
1269
+ document.getElementById('focus-title').textContent = n.name;
1270
+ const m = document.getElementById('focus-meta');
1271
+ const cers = (callers.get(id)||[]).length, cees = (callees.get(id)||[]).length;
1272
+
1273
+ let html = '<div class="fline"><span class="kind '+n.kind+'">'+n.kind+'</span>'
1274
+ + (n.async?' <span class="async">async</span>':'')
1275
+ + ' <span class="loc">'+esc(n.file)+':'+n.line+'</span>'
1276
+ + ' · '+cers+' caller(s), '+cees+' callee(s)</div>';
1277
+ if (n.signature) html += '<pre class="sig">'+esc(n.signature)+'</pre>';
1278
+ html += n.description ? '<div class="d">'+esc(n.description)+'</div>'
1279
+ : '<div class="d muted">no written description — signature derived from types</div>';
1280
+
1281
+ // Parameter table: every param shows its resolved type; the doc column is
1282
+ // filled from JSDoc @param when the author wrote one.
1283
+ if (n.params && n.params.length){
1284
+ html += '<table class="params"><thead><tr><th>param</th><th>type</th><th>notes</th></tr></thead><tbody>'
1285
+ + n.params.map(p =>
1286
+ '<tr><td class="pn">'+esc(p.name)+(p.optional?'<span class="opt">?</span>':'')+'</td>'
1287
+ + '<td class="pt">'+esc(p.type||'any')+'</td>'
1288
+ + '<td class="pd">'+(p.doc?esc(p.doc):'<span class="muted">—</span>')+'</td></tr>').join('')
1289
+ + '</tbody></table>';
1290
+ }
1291
+ if (n.returns){
1292
+ html += '<div class="ret"><span class="rk">returns</span> <span class="pt">'+esc(n.returns)+'</span>'
1293
+ + (n.returnDoc?' <span class="pd">— '+esc(n.returnDoc)+'</span>':'')+'</div>';
1294
+ }
1295
+ // View-code toggle: reveals the function's own source, fetched from nothing —
1296
+ // it's embedded in the graph data.
1297
+ if (n.code){
1298
+ html += '<div class="codewrap"><button id="btn-code" class="codebtn">▸ view code</button>'
1299
+ + '<pre id="codeblock" class="code hidden"></pre></div>';
1300
+ }
1301
+ m.innerHTML = html;
1302
+ if (n.code){
1303
+ const btn = document.getElementById('btn-code'), block = document.getElementById('codeblock');
1304
+ block.textContent = n.code;
1305
+ btn.onclick = () => {
1306
+ const hidden = block.classList.toggle('hidden');
1307
+ btn.textContent = (hidden?'▸':'▾') + ' view code';
1308
+ };
1309
+ }
1310
+ renderTree(document.getElementById('ancestors'), callers.get(id)||[], 'up');
1311
+ renderTree(document.getElementById('descendants'), callees.get(id)||[], 'down');
1312
+ window.scrollTo({top:0,behavior:'smooth'});
1313
+ }
1314
+ document.getElementById('btn-back').onclick = () => {
1315
+ document.getElementById('pane-focus').classList.add('hidden');
1316
+ document.getElementById('pane-tree').classList.remove('hidden');
1317
+ };
1318
+
1319
+ // ---- Search ----
1320
+ const q = document.getElementById('q'), results = document.getElementById('results');
1321
+ let active = -1, hits = [];
1322
+ function search(term){
1323
+ term = term.trim().toLowerCase();
1324
+ if (!term){ results.classList.remove('open'); hits=[]; return; }
1325
+ hits = GRAPH.nodes.filter(n => n.name.toLowerCase().includes(term) || n.file.toLowerCase().includes(term)).slice(0,50);
1326
+ active = hits.length ? 0 : -1;
1327
+ results.innerHTML = hits.map((n,i) =>
1328
+ '<div class="result'+(i===active?' active':'')+'" data-id="'+esc(n.id)+'">'
1329
+ + '<span class="nm">'+hl(n.name,term)+'</span><span class="fl">'+esc(n.file)+':'+n.line+'</span></div>').join('')
1330
+ || '<div class="result"><span class="fl">no matches</span></div>';
1331
+ results.classList.add('open');
1332
+ results.querySelectorAll('.result[data-id]').forEach(el =>
1333
+ el.onclick = () => { pick(el.getAttribute('data-id')); });
1334
+ }
1335
+ function hl(s,t){ const i=s.toLowerCase().indexOf(t); if(i<0)return esc(s); return esc(s.slice(0,i))+'<mark>'+esc(s.slice(i,i+t.length))+'</mark>'+esc(s.slice(i+t.length)); }
1336
+ function pick(id){ results.classList.remove('open'); q.value=''; openFocus(id); }
1337
+ q.addEventListener('input', () => search(q.value));
1338
+ q.addEventListener('keydown', (e) => {
1339
+ if (!results.classList.contains('open')) return;
1340
+ if (e.key==='ArrowDown'){ e.preventDefault(); active=Math.min(active+1,hits.length-1); search(q.value); }
1341
+ else if (e.key==='ArrowUp'){ e.preventDefault(); active=Math.max(active-1,0); search(q.value); }
1342
+ else if (e.key==='Enter'){ e.preventDefault(); if(hits[active]) pick(hits[active].id); }
1343
+ else if (e.key==='Escape'){ results.classList.remove('open'); }
1344
+ });
1345
+ document.addEventListener('click', (e) => { if(!e.target.closest('.search')) results.classList.remove('open'); });
1346
+ document.addEventListener('keydown', (e) => { if(e.key==='/' && document.activeElement!==q){ e.preventDefault(); q.focus(); } });
1347
+ `;
1348
+
1349
+ // src/ui/json.ts
1350
+ var SCHEMA = "checkpointer/v1";
1351
+ function okEnvelope(data) {
1352
+ return { schema: SCHEMA, ok: true, data };
1353
+ }
1354
+ function errEnvelope(message, code) {
1355
+ return { schema: SCHEMA, ok: false, error: { message, code } };
1356
+ }
1357
+ function writeJson(value) {
1358
+ process.stdout.write(JSON.stringify(value, null, 2) + "\n");
1359
+ }
1360
+
592
1361
  // src/actions.ts
593
1362
  function parseStatus(value, fallback) {
594
1363
  if (!value) return fallback;
@@ -599,11 +1368,18 @@ function parseStatus(value, fallback) {
599
1368
  }
600
1369
  function emit(json2, data, render) {
601
1370
  if (json2) {
602
- process.stdout.write(JSON.stringify(data, null, 2) + "\n");
1371
+ writeJson(okEnvelope(data));
603
1372
  } else {
604
1373
  render();
605
1374
  }
606
1375
  }
1376
+ function warnNestedRepos(nested) {
1377
+ if (!nested?.length) return;
1378
+ process.stderr.write(
1379
+ pc2.yellow(`warning: ${nested.length} nested git repo(s) cannot be checkpointed and were skipped:
1380
+ `) + nested.map((p) => ` - ${p}`).join("\n") + "\n" + pc2.dim(" their contents are left on disk untouched, but a restore will not recreate them\n")
1381
+ );
1382
+ }
607
1383
  function init(opts = {}) {
608
1384
  const root = resolveRoot();
609
1385
  requireGitRepo(root);
@@ -629,9 +1405,19 @@ function save(name, opts) {
629
1405
  name,
630
1406
  message: opts.message,
631
1407
  status: parseStatus(opts.status, "wip"),
1408
+ allowEmpty: opts.allowEmpty,
632
1409
  ...id
633
1410
  });
634
- emit(opts.json ?? false, cp, () => {
1411
+ const noop = cp.noop ?? false;
1412
+ const nestedRepos = cp.nestedRepos ?? [];
1413
+ warnNestedRepos(cp.nestedRepos);
1414
+ emit(opts.json ?? false, { ...cp, noop, nestedRepos }, () => {
1415
+ if (noop) {
1416
+ console.log(
1417
+ `${dim("\xB7 no change")} \u2014 reusing ${pc2.cyan(`#${cp.seq}`)} ${pc2.bold(cp.name)} ${dim("(pass --allow-empty to force)")}`
1418
+ );
1419
+ return;
1420
+ }
635
1421
  console.log(`${ok("\u2713 saved")} ${pc2.cyan(`#${cp.seq}`)} ${pc2.bold(cp.name)} ${statusBadge(cp.status)}`);
636
1422
  if (cp.intent) console.log(dim(` intent: ${cp.intent}`));
637
1423
  });
@@ -692,11 +1478,39 @@ function restore(ref, opts = {}) {
692
1478
  const target = store.meta.require(ref);
693
1479
  const id = resolveIdentity(store.paths.root);
694
1480
  const safety = store.restore(target, id.author);
695
- emit(opts.json ?? false, { restored: target.name, safety: safety.name }, () => {
1481
+ warnNestedRepos(safety.nestedRepos);
1482
+ emit(opts.json ?? false, { restored: target.name, safety: safety.name, nestedRepos: safety.nestedRepos ?? [] }, () => {
696
1483
  console.log(`${ok("\u2713 restored")} working tree to ${pc2.bold(target.name)} ${pc2.cyan(`#${target.seq}`)}`);
697
1484
  console.log(dim(` current state was auto-saved as '${safety.name}' (#${safety.seq}) \u2014 restore it to undo`));
698
1485
  });
699
1486
  }
1487
+ function changed(ref, opts = {}) {
1488
+ const root = resolveRoot();
1489
+ requireGitRepo(root);
1490
+ if (!Store.isInitialized(root)) {
1491
+ emit(
1492
+ opts.json ?? false,
1493
+ { changed: true, against: null, fileCount: 0, files: [] },
1494
+ () => console.log(`${warn("changed")} ${dim("(no checkpoints yet)")}`)
1495
+ );
1496
+ return;
1497
+ }
1498
+ const store = Store.open(root);
1499
+ const { changed: changed2, against, files } = store.changed(ref);
1500
+ emit(
1501
+ opts.json ?? false,
1502
+ { changed: changed2, against: against ? { seq: against.seq, name: against.name } : null, fileCount: files.length, files },
1503
+ () => {
1504
+ if (changed2) {
1505
+ const since = against ? ` since ${pc2.bold(against.name)}` : "";
1506
+ console.log(`${warn("changed")}${dim(since)} ${dim(`(${files.length} file(s))`)}`);
1507
+ } else {
1508
+ console.log(`${dim("unchanged")}${against ? dim(` since ${against.name}`) : ""}`);
1509
+ }
1510
+ }
1511
+ );
1512
+ if (!opts.json && !changed2) process.exitCode = 100;
1513
+ }
700
1514
  function tag(ref, status2, opts = {}) {
701
1515
  const store = Store.open();
702
1516
  const cp = store.meta.require(ref);
@@ -776,6 +1590,33 @@ function renderPlan(plan) {
776
1590
  console.log(dim("\n" + (plan.diffStat || "(no changes)")));
777
1591
  console.log(dim('\nrun without --dry-run and with -m "message" to commit'));
778
1592
  }
1593
+ async function graph(opts) {
1594
+ const root = opts.root ? resolve(opts.root) : resolveRoot();
1595
+ const g = analyzeProject(root);
1596
+ if (opts.json) {
1597
+ writeJson(okEnvelope(g));
1598
+ return;
1599
+ }
1600
+ const html = renderGraphHtml(g, { generatedAt: (/* @__PURE__ */ new Date()).toISOString() });
1601
+ if (opts.out) {
1602
+ const file = resolve(opts.out);
1603
+ writeFileSync4(file, html);
1604
+ console.log(`${ok("\u2713 wrote graph")} ${pc2.bold(file)}`);
1605
+ console.log(dim(` ${g.nodes.length} function(s), ${g.edges.length} call edge(s) across ${g.fileCount} file(s)`));
1606
+ console.log(dim(` open it in any browser \u2014 it's fully self-contained`));
1607
+ return;
1608
+ }
1609
+ const port = opts.port !== void 0 ? Number(opts.port) : void 0;
1610
+ if (port !== void 0 && (!Number.isInteger(port) || port < 0 || port > 65535)) {
1611
+ fail(`invalid --port '${opts.port}' (expected 0\u201365535)`, "bad_path");
1612
+ }
1613
+ const { url } = await serveGraph(html, { port, open: opts.open });
1614
+ console.log(`${ok("\u2713 function graph")} for ${pc2.bold(root)}`);
1615
+ console.log(` ${g.nodes.length} function(s), ${g.edges.length} call edge(s) across ${g.fileCount} file(s)`);
1616
+ console.log(` serving at ${pc2.cyan(url)}`);
1617
+ console.log(dim(opts.open === false ? " open the URL in your browser" : " opened in your browser"));
1618
+ console.log(dim(" press Ctrl-C to stop"));
1619
+ }
779
1620
  function info(opts = {}) {
780
1621
  const root = resolveRoot();
781
1622
  const store = Store.isInitialized(root) ? Store.open(root) : null;
@@ -796,28 +1637,28 @@ function info(opts = {}) {
796
1637
  }
797
1638
 
798
1639
  // src/skill.ts
799
- import { cpSync, existsSync as existsSync4, mkdirSync as mkdirSync4, rmSync } from "fs";
1640
+ import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync4, rmSync } from "fs";
800
1641
  import { homedir as homedir2 } from "os";
801
- import { dirname as dirname2, join as join3 } from "path";
1642
+ import { dirname as dirname2, join as join4 } from "path";
802
1643
  import { fileURLToPath } from "url";
803
1644
  function packagedSkillDir() {
804
1645
  const here = dirname2(fileURLToPath(import.meta.url));
805
1646
  const candidates = [
806
- join3(here, "..", "skills", "checkpointer"),
807
- join3(here, "..", "..", "skills", "checkpointer")
1647
+ join4(here, "..", "skills", "checkpointer"),
1648
+ join4(here, "..", "..", "skills", "checkpointer")
808
1649
  ];
809
- const found = candidates.find((dir) => existsSync4(join3(dir, "SKILL.md")));
810
- if (!found) fail("bundled skill not found \u2014 reinstall the package");
1650
+ const found = candidates.find((dir) => existsSync5(join4(dir, "SKILL.md")));
1651
+ if (!found) fail("bundled skill not found \u2014 reinstall the package", "skill_missing");
811
1652
  return found;
812
1653
  }
813
1654
  function installSkill(opts = {}) {
814
1655
  const source = packagedSkillDir();
815
- const target = opts.dir ?? join3(homedir2(), ".claude", "skills", "checkpointer");
1656
+ const target = opts.dir ?? join4(homedir2(), ".claude", "skills", "checkpointer");
816
1657
  mkdirSync4(dirname2(target), { recursive: true });
817
1658
  rmSync(target, { recursive: true, force: true });
818
1659
  cpSync(source, target, { recursive: true });
819
1660
  if (opts.json) {
820
- process.stdout.write(JSON.stringify({ installed: true, target }, null, 2) + "\n");
1661
+ writeJson(okEnvelope({ installed: true, target }));
821
1662
  return;
822
1663
  }
823
1664
  console.log(`${ok("\u2713 installed")} checkpointer skill to ${target}`);
@@ -826,14 +1667,14 @@ function installSkill(opts = {}) {
826
1667
  function skillPath(opts = {}) {
827
1668
  const source = packagedSkillDir();
828
1669
  if (opts.json) {
829
- process.stdout.write(JSON.stringify({ path: source }, null, 2) + "\n");
1670
+ writeJson(okEnvelope({ path: source }));
830
1671
  return;
831
1672
  }
832
1673
  console.log(source);
833
1674
  }
834
1675
 
835
1676
  // src/cli.ts
836
- var VERSION = "0.1.0";
1677
+ var VERSION = true ? "0.2.1" : "0.0.0-dev";
837
1678
  var program = new Command();
838
1679
  program.name("checkpointer").description(
839
1680
  "Git-isolated checkpoints that humans and AI agents share.\n\nSave working states during a long task, revert when something breaks,\nthen bundle a range of checkpoints into one clean commit on your repo.\nYour project's real git history is never touched until you 'ship'."
@@ -858,12 +1699,16 @@ Docs: https://github.com/checkpointer/checkpointer`
858
1699
  );
859
1700
  var json = ["--json", "output machine-readable JSON"];
860
1701
  program.command("init").description("initialize checkpointing for this project").option(...json).action((opts) => init(opts));
861
- program.command("save [name]").description("snapshot the working tree as a checkpoint").option("-m, --message <text>", "describe what this checkpoint contains").option("-s, --status <status>", "working | wip | broken (default: wip)").option("--intent <text>", "why this checkpoint is being made (useful for agents)").option("--author <name>", "override the author").option("--agent <name>", "agent making the checkpoint (or set CHECKPOINTER_AGENT)").option("--session <id>", "agent session id (or set CHECKPOINTER_SESSION)").option(...json).addHelpText(
1702
+ program.command("save [name]").description("snapshot the working tree as a checkpoint").option("-m, --message <text>", "describe what this checkpoint contains").option("-s, --status <status>", "working | wip | broken (default: wip)").option("--intent <text>", "why this checkpoint is being made (useful for agents)").option("--allow-empty", "save even if nothing changed since the latest checkpoint").option("--author <name>", "override the author").option("--agent <name>", "agent making the checkpoint (or set CHECKPOINTER_AGENT)").option("--session <id>", "agent session id (or set CHECKPOINTER_SESSION)").option(...json).addHelpText(
862
1703
  "after",
863
1704
  '\nExamples:\n $ checkpointer save\n $ checkpointer save before-refactor --intent "extracting auth module"\n $ checkpointer save v1 --status working -m "first working version"'
864
1705
  ).action((name, opts) => save(name, opts));
865
1706
  program.command("list").alias("ls").description("list all checkpoints").option(...json).action((opts) => list(opts));
866
1707
  program.command("status").description("show how many checkpoints are unshipped").option(...json).action((opts) => status(opts));
1708
+ program.command("changed [id]").description("probe whether the working tree differs from a checkpoint (exit 100 if unchanged)").option(...json).addHelpText(
1709
+ "after",
1710
+ "\nExit codes: 0 = changed, 100 = unchanged. With --json it always exits 0;\nread the 'changed' field instead. Defaults to the latest checkpoint.\n\nExamples:\n $ checkpointer changed && checkpointer save # save only when something changed\n $ checkpointer changed login --json"
1711
+ ).action((id, opts) => changed(id, opts));
867
1712
  program.command("show <id>").description("show details and tracked files of a checkpoint").option(...json).action((id, opts) => show(id, opts));
868
1713
  program.command("diff <a> [b]").description("diff two checkpoints, or one checkpoint vs the working tree").option(...json).addHelpText(
869
1714
  "after",
@@ -882,6 +1727,10 @@ Examples:
882
1727
  $ checkpointer ship -m "Add login flow"
883
1728
  $ checkpointer ship --upto v1 -m "Ship first version"`
884
1729
  ).action((opts) => ship(opts));
1730
+ program.command("graph").description("visualize the project's functions and their call graph in a browser").option("--out <file>", "write a standalone HTML file instead of serving it").option("--port <n>", "port to serve on (default: a free ephemeral port)").option("--no-open", "don't auto-open the browser; just print the URL").option("--root <path>", "project to analyze (default: current git repo)").option(...json).addHelpText(
1731
+ "after",
1732
+ "\nBuilds a top-down call tree from a TypeScript/JavaScript project: click a\nfunction to expand what it calls, search any function, and view its\nancestors (callers up to entrypoints) and descendants.\n\nExamples:\n $ checkpointer graph serve + open in browser\n $ checkpointer graph --no-open print the localhost URL only\n $ checkpointer graph --out graph.html save a shareable file\n $ checkpointer graph --json emit the raw graph data"
1733
+ ).action((opts) => graph(opts));
885
1734
  program.command("info").description("show storage location and excluded paths").option(...json).action((opts) => info(opts));
886
1735
  var skill = program.command("skill").description("manage the Claude / agent skill");
887
1736
  skill.command("install").description("install the skill into ~/.claude/skills so Claude Code can use checkpointer").option("--dir <path>", "install to a custom directory").option(...json).action((opts) => installSkill(opts));
@@ -892,11 +1741,11 @@ async function main() {
892
1741
  } catch (error) {
893
1742
  if (error instanceof CheckpointerError) {
894
1743
  if (process.argv.includes("--json")) {
895
- process.stdout.write(JSON.stringify({ error: error.message, code: error.code }) + "\n");
1744
+ writeJson(errEnvelope(error.message, error.code));
896
1745
  } else {
897
1746
  process.stderr.write(pc3.red("error: ") + error.message + "\n");
898
1747
  }
899
- process.exit(1);
1748
+ process.exit(exitCodeFor(error.code));
900
1749
  }
901
1750
  throw error;
902
1751
  }