checkpointer 0.1.0 → 0.2.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/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
  }
@@ -223,6 +287,22 @@ var Metadata = class _Metadata {
223
287
  unshipped() {
224
288
  return this.data.checkpoints.filter((cp) => cp.seq > this.data.shippedSeq && !cp.auto);
225
289
  }
290
+ // Returns seq ranges [from, to] that were abandoned by a restore. A restore
291
+ // to checkpoint X means everything between X+1 and the before-restore
292
+ // checkpoint is "set aside" — those checkpoints still exist for recovery but
293
+ // are not part of the current line of work.
294
+ abandonedRanges() {
295
+ const ranges = [];
296
+ for (const cp of this.data.checkpoints) {
297
+ if (cp.auto && cp.name.startsWith("before-restore-") && cp.restoredToSeq !== void 0) {
298
+ ranges.push({ from: cp.restoredToSeq + 1, to: cp.seq - 1 });
299
+ }
300
+ }
301
+ return ranges;
302
+ }
303
+ isAbandoned(seq) {
304
+ return this.abandonedRanges().some((r) => seq >= r.from && seq <= r.to);
305
+ }
226
306
  };
227
307
 
228
308
  // src/core/paths.ts
@@ -346,7 +426,10 @@ function withLock(file, fn) {
346
426
  continue;
347
427
  }
348
428
  if (Date.now() > deadline) {
349
- 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
+ );
350
433
  }
351
434
  sleep(RETRY_MS);
352
435
  }
@@ -402,8 +485,15 @@ var Store = class _Store {
402
485
  return withLock(this.paths.lockFile, () => {
403
486
  this.meta.reload();
404
487
  writeExcludes(this.paths);
405
- const seq = this.meta.nextSeq();
488
+ this.git.stageAll();
489
+ const nestedRepos = this.git.stagedGitlinks();
490
+ if (nestedRepos.length) this.git.unstage(nestedRepos);
406
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();
407
497
  let name;
408
498
  if (input.name === void 0) {
409
499
  name = this.meta.uniqueName(`cp-${seq}`);
@@ -415,7 +505,6 @@ var Store = class _Store {
415
505
  }
416
506
  name = input.name;
417
507
  }
418
- this.git.stageAll();
419
508
  const message = input.message ?? name;
420
509
  const sha = this.git.commit(`[${seq}] ${name}: ${message}`);
421
510
  const checkpoint = {
@@ -429,12 +518,25 @@ var Store = class _Store {
429
518
  session: input.session,
430
519
  intent: input.intent,
431
520
  auto,
432
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
521
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
522
+ ...input.restoredToSeq !== void 0 ? { restoredToSeq: input.restoredToSeq } : {}
433
523
  };
434
524
  this.meta.add(checkpoint);
435
- return checkpoint;
525
+ return nestedRepos.length ? { ...checkpoint, nestedRepos } : checkpoint;
436
526
  });
437
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
+ }
438
540
  setStatus(seq, status2) {
439
541
  withLock(this.paths.lockFile, () => {
440
542
  this.meta.reload();
@@ -451,7 +553,8 @@ var Store = class _Store {
451
553
  agent: null,
452
554
  session: null,
453
555
  intent: null,
454
- auto: true
556
+ auto: true,
557
+ restoredToSeq: target.seq
455
558
  });
456
559
  this.git.restoreTree(target.sha);
457
560
  return safety;
@@ -467,22 +570,41 @@ function resolveIdentity(root, overrides = {}) {
467
570
  const author = overrides.author ?? process.env.CHECKPOINTER_AUTHOR ?? gitUser(root) ?? (agent ? agent : "you");
468
571
  return { author, agent, session, intent };
469
572
  }
470
- function gitUser(root) {
471
- const result = run("git", ["config", "user.name"], { cwd: root });
472
- const name = result.stdout.trim();
473
- 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");
474
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;
475
585
 
476
586
  // src/core/ship.ts
477
587
  function planShip(store, uptoRef) {
478
- const upto = uptoRef ? store.meta.require(uptoRef) : latestOrFail(store);
588
+ const upto = uptoRef ? store.meta.require(uptoRef) : latestLiveOrFail(store);
479
589
  if (upto.auto) {
480
590
  fail(`'${upto.name}' is an auto safety checkpoint \u2014 ship to a manual checkpoint instead`, "auto_target");
481
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
+ }
482
599
  const all = store.meta.all();
483
600
  const shippedSeq = store.meta.shippedSeq();
484
601
  const from = all.find((cp) => cp.seq === shippedSeq) ?? null;
485
- const included = all.filter((cp) => cp.seq > shippedSeq && cp.seq <= upto.seq && !cp.auto);
602
+ const included = all.filter(
603
+ (cp) => cp.seq > shippedSeq && cp.seq <= upto.seq && !cp.auto && !store.meta.isAbandoned(cp.seq)
604
+ );
605
+ if (!included.length) {
606
+ fail(`nothing to ship \u2014 no unshipped checkpoints up to ${upto.name}`, "nothing_to_ship");
607
+ }
486
608
  return {
487
609
  upto,
488
610
  from,
@@ -498,10 +620,19 @@ function executeShip(store, plan, message, author) {
498
620
  fail(`nothing to ship \u2014 no unshipped checkpoints up to ${plan.upto.name}`, "nothing_to_ship");
499
621
  }
500
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
+ }
501
633
  const fetch = run("git", ["fetch", "--no-tags", "-q", store.git.path, "refs/heads/checkpoints"], { cwd: root });
502
634
  if (fetch.status !== 0) fail(`could not read checkpoints into the repo: ${fetch.stderr.trim()}`);
503
635
  const tree = store.git.treeOf(plan.upto.sha);
504
- const parent = run("git", ["rev-parse", "--verify", "-q", "HEAD"], { cwd: root }).stdout.trim();
505
636
  if (parent) {
506
637
  const headTree = run("git", ["rev-parse", "HEAD^{tree}"], { cwd: root }).stdout.trim();
507
638
  if (headTree === tree) {
@@ -510,10 +641,17 @@ function executeShip(store, plan, message, author) {
510
641
  }
511
642
  const args = ["commit-tree", tree, "-m", message];
512
643
  if (parent) args.push("-p", parent);
513
- const built = run("git", args, {
514
- cwd: root,
515
- env: { ...process.env, GIT_AUTHOR_NAME: author, GIT_COMMITTER_NAME: author }
516
- });
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 });
517
655
  if (built.status !== 0) fail(`git commit-tree failed: ${built.stderr.trim()}`);
518
656
  const commit = built.stdout.trim();
519
657
  const update = run("git", ["update-ref", "HEAD", commit], { cwd: root });
@@ -523,14 +661,357 @@ function executeShip(store, plan, message, author) {
523
661
  return { commit, message };
524
662
  });
525
663
  }
526
- function latestOrFail(store) {
527
- const latest = store.meta.latestManual();
528
- if (!latest) fail("no checkpoints to ship \u2014 run 'checkpointer save' first", "nothing_to_ship");
529
- 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
+ }
530
1008
  }
531
1009
 
532
1010
  // src/ui/format.ts
533
1011
  import pc from "picocolors";
1012
+ function restoreDivider(targetName) {
1013
+ return pc.dim(` ${"\u2500".repeat(20)} restored to '${targetName}' \u2014 history above set aside ${"\u2500".repeat(20)}`);
1014
+ }
534
1015
  function statusBadge(status2) {
535
1016
  switch (status2) {
536
1017
  case "working":
@@ -564,8 +1045,319 @@ function heading(text) {
564
1045
  return pc.bold(pc.underline(text));
565
1046
  }
566
1047
  var ok = (text) => pc.green(text);
1048
+ var warn = (text) => pc.yellow(text);
567
1049
  var dim = (text) => pc.dim(text);
568
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
+
569
1361
  // src/actions.ts
570
1362
  function parseStatus(value, fallback) {
571
1363
  if (!value) return fallback;
@@ -576,11 +1368,18 @@ function parseStatus(value, fallback) {
576
1368
  }
577
1369
  function emit(json2, data, render) {
578
1370
  if (json2) {
579
- process.stdout.write(JSON.stringify(data, null, 2) + "\n");
1371
+ writeJson(okEnvelope(data));
580
1372
  } else {
581
1373
  render();
582
1374
  }
583
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
+ }
584
1383
  function init(opts = {}) {
585
1384
  const root = resolveRoot();
586
1385
  requireGitRepo(root);
@@ -606,9 +1405,19 @@ function save(name, opts) {
606
1405
  name,
607
1406
  message: opts.message,
608
1407
  status: parseStatus(opts.status, "wip"),
1408
+ allowEmpty: opts.allowEmpty,
609
1409
  ...id
610
1410
  });
611
- 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
+ }
612
1421
  console.log(`${ok("\u2713 saved")} ${pc2.cyan(`#${cp.seq}`)} ${pc2.bold(cp.name)} ${statusBadge(cp.status)}`);
613
1422
  if (cp.intent) console.log(dim(` intent: ${cp.intent}`));
614
1423
  });
@@ -622,7 +1431,13 @@ function list(opts = {}) {
622
1431
  return;
623
1432
  }
624
1433
  const shipped = store.meta.shippedSeq();
625
- for (const cp of checkpoints) console.log(checkpointLine(cp, shipped));
1434
+ for (const cp of checkpoints) {
1435
+ console.log(checkpointLine(cp, shipped));
1436
+ if (cp.auto && cp.name.startsWith("before-restore-") && cp.restoredToSeq !== void 0) {
1437
+ const target = store.meta.find(`#${cp.restoredToSeq}`);
1438
+ if (target) console.log(restoreDivider(target.name));
1439
+ }
1440
+ }
626
1441
  const legend = [];
627
1442
  if (shipped > 0) legend.push("\u2713 shipped to git");
628
1443
  if (checkpoints.some((cp) => cp.auto)) legend.push("\xB7 auto safety checkpoint");
@@ -663,11 +1478,39 @@ function restore(ref, opts = {}) {
663
1478
  const target = store.meta.require(ref);
664
1479
  const id = resolveIdentity(store.paths.root);
665
1480
  const safety = store.restore(target, id.author);
666
- 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 ?? [] }, () => {
667
1483
  console.log(`${ok("\u2713 restored")} working tree to ${pc2.bold(target.name)} ${pc2.cyan(`#${target.seq}`)}`);
668
1484
  console.log(dim(` current state was auto-saved as '${safety.name}' (#${safety.seq}) \u2014 restore it to undo`));
669
1485
  });
670
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
+ }
671
1514
  function tag(ref, status2, opts = {}) {
672
1515
  const store = Store.open();
673
1516
  const cp = store.meta.require(ref);
@@ -747,6 +1590,33 @@ function renderPlan(plan) {
747
1590
  console.log(dim("\n" + (plan.diffStat || "(no changes)")));
748
1591
  console.log(dim('\nrun without --dry-run and with -m "message" to commit'));
749
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
+ }
750
1620
  function info(opts = {}) {
751
1621
  const root = resolveRoot();
752
1622
  const store = Store.isInitialized(root) ? Store.open(root) : null;
@@ -767,28 +1637,28 @@ function info(opts = {}) {
767
1637
  }
768
1638
 
769
1639
  // src/skill.ts
770
- import { cpSync, existsSync as existsSync4, mkdirSync as mkdirSync4, rmSync } from "fs";
1640
+ import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync4, rmSync } from "fs";
771
1641
  import { homedir as homedir2 } from "os";
772
- import { dirname as dirname2, join as join3 } from "path";
1642
+ import { dirname as dirname2, join as join4 } from "path";
773
1643
  import { fileURLToPath } from "url";
774
1644
  function packagedSkillDir() {
775
1645
  const here = dirname2(fileURLToPath(import.meta.url));
776
1646
  const candidates = [
777
- join3(here, "..", "skills", "checkpointer"),
778
- join3(here, "..", "..", "skills", "checkpointer")
1647
+ join4(here, "..", "skills", "checkpointer"),
1648
+ join4(here, "..", "..", "skills", "checkpointer")
779
1649
  ];
780
- const found = candidates.find((dir) => existsSync4(join3(dir, "SKILL.md")));
781
- 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");
782
1652
  return found;
783
1653
  }
784
1654
  function installSkill(opts = {}) {
785
1655
  const source = packagedSkillDir();
786
- const target = opts.dir ?? join3(homedir2(), ".claude", "skills", "checkpointer");
1656
+ const target = opts.dir ?? join4(homedir2(), ".claude", "skills", "checkpointer");
787
1657
  mkdirSync4(dirname2(target), { recursive: true });
788
1658
  rmSync(target, { recursive: true, force: true });
789
1659
  cpSync(source, target, { recursive: true });
790
1660
  if (opts.json) {
791
- process.stdout.write(JSON.stringify({ installed: true, target }, null, 2) + "\n");
1661
+ writeJson(okEnvelope({ installed: true, target }));
792
1662
  return;
793
1663
  }
794
1664
  console.log(`${ok("\u2713 installed")} checkpointer skill to ${target}`);
@@ -797,14 +1667,14 @@ function installSkill(opts = {}) {
797
1667
  function skillPath(opts = {}) {
798
1668
  const source = packagedSkillDir();
799
1669
  if (opts.json) {
800
- process.stdout.write(JSON.stringify({ path: source }, null, 2) + "\n");
1670
+ writeJson(okEnvelope({ path: source }));
801
1671
  return;
802
1672
  }
803
1673
  console.log(source);
804
1674
  }
805
1675
 
806
1676
  // src/cli.ts
807
- var VERSION = "0.1.0";
1677
+ var VERSION = true ? "0.2.0" : "0.0.0-dev";
808
1678
  var program = new Command();
809
1679
  program.name("checkpointer").description(
810
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'."
@@ -829,12 +1699,16 @@ Docs: https://github.com/checkpointer/checkpointer`
829
1699
  );
830
1700
  var json = ["--json", "output machine-readable JSON"];
831
1701
  program.command("init").description("initialize checkpointing for this project").option(...json).action((opts) => init(opts));
832
- 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(
833
1703
  "after",
834
1704
  '\nExamples:\n $ checkpointer save\n $ checkpointer save before-refactor --intent "extracting auth module"\n $ checkpointer save v1 --status working -m "first working version"'
835
1705
  ).action((name, opts) => save(name, opts));
836
1706
  program.command("list").alias("ls").description("list all checkpoints").option(...json).action((opts) => list(opts));
837
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));
838
1712
  program.command("show <id>").description("show details and tracked files of a checkpoint").option(...json).action((id, opts) => show(id, opts));
839
1713
  program.command("diff <a> [b]").description("diff two checkpoints, or one checkpoint vs the working tree").option(...json).addHelpText(
840
1714
  "after",
@@ -853,6 +1727,10 @@ Examples:
853
1727
  $ checkpointer ship -m "Add login flow"
854
1728
  $ checkpointer ship --upto v1 -m "Ship first version"`
855
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));
856
1734
  program.command("info").description("show storage location and excluded paths").option(...json).action((opts) => info(opts));
857
1735
  var skill = program.command("skill").description("manage the Claude / agent skill");
858
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));
@@ -863,11 +1741,11 @@ async function main() {
863
1741
  } catch (error) {
864
1742
  if (error instanceof CheckpointerError) {
865
1743
  if (process.argv.includes("--json")) {
866
- process.stdout.write(JSON.stringify({ error: error.message, code: error.code }) + "\n");
1744
+ writeJson(errEnvelope(error.message, error.code));
867
1745
  } else {
868
1746
  process.stderr.write(pc3.red("error: ") + error.message + "\n");
869
1747
  }
870
- process.exit(1);
1748
+ process.exit(exitCodeFor(error.code));
871
1749
  }
872
1750
  throw error;
873
1751
  }