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/README.md +81 -4
- package/dist/cli.js +918 -40
- package/package.json +6 -4
- package/skills/checkpointer/SKILL.md +35 -2
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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) :
|
|
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(
|
|
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
|
|
514
|
-
|
|
515
|
-
|
|
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
|
|
527
|
-
const
|
|
528
|
-
if (!
|
|
529
|
-
return
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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 => ({'&':'&','<':'<','>':'>','"':'"'}[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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
778
|
-
|
|
1647
|
+
join4(here, "..", "skills", "checkpointer"),
|
|
1648
|
+
join4(here, "..", "..", "skills", "checkpointer")
|
|
779
1649
|
];
|
|
780
|
-
const found = candidates.find((dir) =>
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
1748
|
+
process.exit(exitCodeFor(error.code));
|
|
871
1749
|
}
|
|
872
1750
|
throw error;
|
|
873
1751
|
}
|