@dansyuqri/spark-joy 0.2.1 → 0.3.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 +4 -4
- package/dist/app/assets/index-CDcwMAja.js +8 -0
- package/dist/app/assets/index-pgxDJMuQ.css +1 -0
- package/dist/app/index.html +2 -2
- package/dist/cli.js +111 -40
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/plugin/skills/spark-joy/SKILL.md +25 -9
- package/dist/app/assets/index-ChCdW9F8.css +0 -1
- package/dist/app/assets/index-_XxERk0m.js +0 -8
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg:#fbfaf8;--panel:#fff;--ink:#1d1d1f;--muted:#6b6b72;--line:#e4e2dd;--accent:#c2410c;--accent-ink:#fff;--add-bg:#e9f8ec;--add-ink:#116329;--del-bg:#fdeceb;--del-ink:#a40e26;--hunk-bg:#f1f0fb;--note-bg:#fff8e5;--done:#15803d;--partial:#b45309;--todo:#9ca3af;--mono:ui-monospace, "SF Mono", Menlo, Consolas, monospace;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial;--bg:#151518;--panel:#1c1c20;--ink:#ececef;--muted:#9a9aa3;--line:#2e2e34;--accent:#fb923c;--accent-ink:#1a1a1a;--add-bg:#12301c;--add-ink:#7ee2a0;--del-bg:#3a1619;--del-ink:#ff9aa5;--hunk-bg:#23233a;--note-bg:#2f2a18;--done:#4ade80;--partial:#fbbf24;--todo:#6b7280}}*{box-sizing:border-box}body{background:var(--bg);color:var(--ink);margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}code,kbd{font-family:var(--mono);font-size:.92em}kbd{border:1px solid var(--line);border-radius:4px;padding:0 4px}button{font:inherit;border:1px solid var(--line);background:var(--panel);color:var(--ink);cursor:pointer;border-radius:6px;padding:6px 12px}button:hover:not(:disabled){border-color:var(--muted)}button:disabled{opacity:.4;cursor:default}button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);font-weight:600}textarea{font:inherit;border:1px solid var(--line);background:var(--panel);width:100%;min-height:70px;color:var(--ink);resize:vertical;border-radius:6px;padding:8px}.muted{color:var(--muted)}.small{font-size:12px}.warn{color:var(--partial)}.pre{white-space:pre-wrap}.empty{max-width:640px;margin:80px auto;padding:0 16px}.topbar{z-index:10;background:var(--panel);border-bottom:1px solid var(--line);align-items:center;gap:20px;padding:10px 20px;display:flex;position:sticky;top:0}.brand{white-space:nowrap;font-weight:700}.spark{color:var(--accent)}.run{white-space:nowrap;text-overflow:ellipsis;flex:1;min-width:0;overflow:hidden}.tabs{gap:4px;display:flex}.tabs a{color:var(--muted);border-radius:6px;padding:4px 10px}.tabs a.on{background:var(--hunk-bg);color:var(--ink);font-weight:600}.progress{font-variant-numeric:tabular-nums;align-items:center;gap:8px;display:flex}.progress .bar{background:var(--line);border-radius:3px;width:120px;height:6px;overflow:hidden}.progress .bar div{background:var(--done);height:100%;transition:width .2s}.saving{width:70px;color:var(--muted);font-size:12px}.saving.error{color:var(--del-ink)}.status{vertical-align:-2px;justify-content:center;align-self:center;width:1.2em;display:inline-flex}.status.done{color:var(--done)}.status.partial{color:var(--partial)}.status.todo{color:var(--todo)}.badge{border:1px solid var(--line);color:var(--muted);white-space:nowrap;border-radius:10px;margin-left:6px;padding:0 6px;font-size:11px;display:inline-block}.risk{vertical-align:middle;color:#fff;white-space:nowrap;border-radius:9px;margin-left:8px;padding:0 6px;font-size:11px;font-weight:600;line-height:17px;display:inline-block}.risk-high{background:var(--del-ink)}.risk-med{background:var(--partial)}.issues{border:1px solid var(--line);background:var(--panel);border-radius:8px;margin:12px 0;padding:8px 12px}.issues.has-errors{border-color:var(--del-ink)}.issues li.error{color:var(--del-ink)}.issues li.warning{color:var(--partial)}.page{max-width:1200px;padding:24px 32px 80px}.overview{max-width:800px;font-size:15px}.strategies{grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:16px;display:grid}.strategy{background:var(--panel);border:1px solid var(--line);border-radius:10px;flex-direction:column;padding:16px;display:flex}.strategy.on{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}.strategy h3{margin:0 0 4px}.strategy .stats{color:var(--muted);font-size:12px}.chunk-list{flex:1;padding-left:0;list-style:none}.chunk-list .n{min-width:1.6em;color:var(--muted);font-variant-numeric:tabular-nums}.chunk-list li{align-items:center;gap:4px;padding:3px 0;display:flex}.chunk-list li .title{flex:1}.chunk-list .branch{color:var(--muted);font-size:11px;display:block}.strategy .hint{color:var(--muted);margin:10px 0 0;font-size:12px}.size{font-variant-numeric:tabular-nums;color:var(--muted);margin-left:6px;font-size:12px}.features li{margin-bottom:6px}.review{grid-template-columns:300px 1fr;align-items:start;display:grid}.sidebar{border-right:1px solid var(--line);max-height:calc(100vh - 53px);padding:16px 8px 16px 16px;position:sticky;top:53px;overflow:auto}.sidebar ol{margin:8px 0;padding:0;list-style:none}.sidebar li a{color:var(--ink);border-radius:6px;align-items:baseline;gap:6px;padding:5px 8px;display:flex}.sidebar li a:hover{background:var(--hunk-bg);text-decoration:none}.sidebar li.on a{background:var(--hunk-bg);font-weight:600}.sidebar li.skim{border-top:1px dashed var(--line);margin-top:8px;padding-top:8px}.sidebar .n{color:var(--muted);font-variant-numeric:tabular-nums;min-width:1.4em}.sidebar .title{flex:1}.chunk-head h1{margin:0 0 2px;font-size:20px}.chunk-head .intent{max-width:820px;margin:6px 0 0;font-size:15px}.focus{max-width:820px;color:var(--muted);margin:6px 0 0;padding-left:18px}.diff{margin-top:16px}.file{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin-bottom:14px;overflow:hidden}.file>summary{cursor:pointer;border-bottom:1px solid var(--line);align-items:center;gap:4px;padding:8px 12px;display:flex}.file:not([open])>summary{border-bottom:none}.file .path{font-family:var(--mono);font-weight:600}.file .counts{font-family:var(--mono);margin-left:auto;font-size:12px}.add{color:var(--add-ink)}.del{color:var(--del-ink)}.file table{border-collapse:collapse;width:100%;font-family:var(--mono);table-layout:fixed;font-size:12.5px}.file col.no-col{width:52px}.file td.no{text-align:right;color:var(--muted);-webkit-user-select:none;user-select:none;cursor:pointer;vertical-align:top;padding:0 8px}.file td.no:hover{color:var(--accent)}.file td.code{white-space:pre-wrap;word-break:break-all;padding:0 8px}.sign{width:1.2em;color:var(--muted);-webkit-user-select:none;user-select:none;display:inline-block}tr.add td{background:var(--add-bg)}tr.del td{background:var(--del-bg)}tr.meta td{color:var(--muted);font-style:italic}tr.hunk-header td{background:var(--hunk-bg);color:var(--muted);padding:3px 8px}.hid{color:var(--ink);font-weight:700}tr.placeholder td{color:var(--muted);padding:8px;font-family:system-ui,sans-serif}tr.note-row td{background:var(--bg);padding:6px 12px 6px 112px}.line-note{background:var(--note-bg);border:1px solid var(--line);border-radius:6px;padding:8px 10px;font-family:system-ui,sans-serif;font-size:13px}.line-note .text{white-space:pre-wrap;cursor:text}.line-note .actions{gap:6px;margin-top:6px;display:flex}.line-note .actions button{padding:2px 8px;font-size:12px}.chunk-foot{background:var(--bg);border-top:1px solid var(--line);align-items:flex-end;gap:8px;padding:10px 0;display:flex;position:sticky;bottom:0}.chunk-foot textarea{flex:1;height:34px;min-height:34px;padding:6px 8px;transition:height .1s}.chunk-foot textarea:focus,.chunk-foot textarea:not(:placeholder-shown){height:80px}.totals{margin:0 0 8px;font-size:18px}.chunk-refs a{font-variant-numeric:tabular-nums;margin-left:8px;font-size:12px}.features{padding:0;list-style:none}.note-group{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin-bottom:10px;padding:10px 16px}.note-group p{margin:6px 0}.export textarea{min-height:300px;font-family:var(--mono);font-size:12px}@media (width<=900px){.review{grid-template-columns:1fr}.sidebar{border-right:none;border-bottom:1px solid var(--line);max-height:none;position:static}.page{padding:16px}.run{display:none}}.file{scroll-margin-top:64px}.map-page{flex-direction:column;height:calc(100vh - 53px);display:flex}.crumbs{color:var(--muted);padding:10px 20px;font-size:13px}.map-canvas{cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none;flex:1;position:relative;overflow:hidden}.map-canvas:active{cursor:grabbing}.empty-map{padding:20px}.zoom-in{transform-box:fill-box;animation:.22s ease-out zoom-in}@keyframes zoom-in{0%{opacity:0}}.node{cursor:pointer}.node rect{fill:var(--panel);stroke:var(--line);stroke-width:1.2px}.node:hover rect{stroke:var(--accent)}.node.neighbour rect{fill:var(--bg);stroke-dasharray:4 3}.node.neighbour text{opacity:.7}.node text{fill:var(--ink);font-size:13px}.node .title{font-weight:600}.node.file .title{font-family:var(--mono);font-size:12.5px}.node .n{fill:var(--muted);font-weight:400}.node .meta{fill:var(--muted);font-size:11px}.node .status.done{color:var(--done)}.node .status.partial{color:var(--partial)}.node .status.todo{color:var(--todo)}.node .risk-high{fill:var(--del-ink)}.node .risk-med{fill:var(--partial)}.edge path{fill:none;stroke:var(--muted);opacity:.7;transition:opacity .15s}.edge.link path{stroke-dasharray:5 4}.edge.faded{opacity:.15}.arrowhead{fill:var(--muted)}.edge-label rect{fill:var(--bg);stroke:var(--line)}.edge-label text{fill:var(--ink);font-size:11px}tr.gap-row td,tr.foreign-row td{background:var(--hunk-bg);color:var(--muted);padding:2px 8px;font-size:12px}tr.gap-row button{margin-right:6px;padding:1px 8px;font-size:12px}tr.line.expanded td.code,tr.line.expanded td.no{opacity:.75}tr.line.expanded td.no{cursor:default}.file summary .view-file{margin-left:8px;padding:1px 8px;font-size:12px}.risk-reason{border-left:3px solid var(--partial);margin:4px 0 0;padding-left:8px;font-size:13px}.risk-reason-high{border-left-color:var(--del-ink)}.rubric{color:var(--muted);margin:0 0 12px;font-size:13px}.rubric p{margin:4px 0}.rubric .risk{margin:0 6px 0 0}
|
package/dist/app/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>spark-joy review</title>
|
|
7
7
|
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CDcwMAja.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-pgxDJMuQ.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// cli/index.ts
|
|
2
|
-
import { copyFile, mkdir as mkdir3, mkdtemp, readFile as
|
|
2
|
+
import { copyFile, mkdir as mkdir3, mkdtemp, readFile as readFile6, rm as rm2, writeFile as writeFile3 } from "fs/promises";
|
|
3
3
|
import { homedir, tmpdir } from "os";
|
|
4
|
-
import { join as
|
|
4
|
+
import { join as join6, relative } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { parseArgs } from "util";
|
|
7
7
|
|
|
@@ -66,6 +66,8 @@ var Chunk = z.object({
|
|
|
66
66
|
dependsOn: z.array(z.string()).default([]),
|
|
67
67
|
reviewFocus: z.array(z.string()).default([]),
|
|
68
68
|
risk: Risk.default("low"),
|
|
69
|
+
// Why this chunk is med or high risk, against the agreed rubric.
|
|
70
|
+
riskReason: z.string().min(1).optional(),
|
|
69
71
|
// Split mode: the branch this chunk becomes.
|
|
70
72
|
branch: z.string().min(1).optional()
|
|
71
73
|
});
|
|
@@ -119,6 +121,23 @@ var ReviewState = z.object({
|
|
|
119
121
|
updatedAt: z.string().optional()
|
|
120
122
|
});
|
|
121
123
|
var emptyState = () => ReviewState.parse({});
|
|
124
|
+
var RUBRIC_FILE = ".spark-joy.md";
|
|
125
|
+
function parseRubric(text) {
|
|
126
|
+
const rubric = { high: [], med: [] };
|
|
127
|
+
let inRisk = false;
|
|
128
|
+
for (const line of text.split(/\r?\n/)) {
|
|
129
|
+
const heading = /^#{1,6}\s+(.*)$/.exec(line);
|
|
130
|
+
if (heading) {
|
|
131
|
+
inRisk = /^risk$/i.test(heading[1].trim());
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const m = inRisk && /^\s*[-*]\s*(high|med|medium)\s*:\s*(.+)$/i.exec(line);
|
|
135
|
+
if (!m) continue;
|
|
136
|
+
const items = m[2].split(",").map((s) => s.trim()).filter(Boolean);
|
|
137
|
+
rubric[m[1].toLowerCase() === "high" ? "high" : "med"].push(...items);
|
|
138
|
+
}
|
|
139
|
+
return rubric.high.length || rubric.med.length ? rubric : void 0;
|
|
140
|
+
}
|
|
122
141
|
|
|
123
142
|
// shared/derive.ts
|
|
124
143
|
var isIgnorable = (h) => h.flags.some((f) => IGNORABLE_FLAGS.includes(f));
|
|
@@ -188,7 +207,8 @@ var BUDGET = {
|
|
|
188
207
|
focusWords: 8,
|
|
189
208
|
featureTitle: 4,
|
|
190
209
|
featureDescription: 8,
|
|
191
|
-
linkLabel: 3
|
|
210
|
+
linkLabel: 3,
|
|
211
|
+
riskReason: 8
|
|
192
212
|
};
|
|
193
213
|
var PADDING = [
|
|
194
214
|
[/,\s*not\b|\bnot just\b|\brather than\b|\binstead of\b/i, "drop the contrast; say only what it does"],
|
|
@@ -264,6 +284,10 @@ function validatePlan(raw, hunks, budget = CHUNK_LINE_BUDGET) {
|
|
|
264
284
|
if (c.hunks.length === 0) error(cw, "chunk has no hunks");
|
|
265
285
|
tooLong(cw, "title", c.title, BUDGET.chunkTitle);
|
|
266
286
|
tooLong(cw, "intent", c.intent, BUDGET.intent);
|
|
287
|
+
if (c.risk === "low") {
|
|
288
|
+
if (c.riskReason) warn(cw, "riskReason on a low-risk chunk; drop it");
|
|
289
|
+
} else if (!c.riskReason) error(cw, `${c.risk} risk needs a riskReason: which rubric item, and where`);
|
|
290
|
+
else tooLong(cw, "riskReason", c.riskReason, BUDGET.riskReason);
|
|
267
291
|
if (c.reviewFocus.length > BUDGET.focusItems)
|
|
268
292
|
error(cw, `${c.reviewFocus.length} reviewFocus items; max ${BUDGET.focusItems}`);
|
|
269
293
|
c.reviewFocus.forEach((q, n) => tooLong(cw, `reviewFocus ${n + 1}`, q, BUDGET.focusWords));
|
|
@@ -667,6 +691,8 @@ function computeLinks(hunks) {
|
|
|
667
691
|
|
|
668
692
|
// cli/lib/git.ts
|
|
669
693
|
import { execFile } from "child_process";
|
|
694
|
+
import { lstat, readFile } from "fs/promises";
|
|
695
|
+
import { join } from "path";
|
|
670
696
|
import { promisify } from "util";
|
|
671
697
|
var exec = promisify(execFile);
|
|
672
698
|
async function git(cwd, args, input, env) {
|
|
@@ -734,31 +760,39 @@ async function generatedByAttr(cwd, files) {
|
|
|
734
760
|
}
|
|
735
761
|
return result;
|
|
736
762
|
}
|
|
763
|
+
async function fileText(root, meta, path) {
|
|
764
|
+
const source = meta.source ?? (meta.working ? "working" : "pr");
|
|
765
|
+
if (source === "working" || source === "uncommitted") {
|
|
766
|
+
if ((await lstat(join(root, path))).isSymbolicLink()) throw new Error(`${path} is a symlink`);
|
|
767
|
+
return readFile(join(root, path), "utf8");
|
|
768
|
+
}
|
|
769
|
+
return git(root, ["show", `${source === "staged" ? "" : meta.headSha}:${path}`]);
|
|
770
|
+
}
|
|
737
771
|
|
|
738
772
|
// cli/lib/runs.ts
|
|
739
|
-
import { mkdir, readFile, readdir, rename, stat, writeFile } from "fs/promises";
|
|
740
|
-
import { join } from "path";
|
|
773
|
+
import { mkdir, readFile as readFile2, readdir, rename, stat, writeFile } from "fs/promises";
|
|
774
|
+
import { join as join2 } from "path";
|
|
741
775
|
import { z as z2 } from "zod";
|
|
742
776
|
var RUNS_DIR = ".spark-joy";
|
|
743
777
|
var slug = (s) => s.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "run";
|
|
744
778
|
function runPaths(root, name) {
|
|
745
|
-
const dir =
|
|
779
|
+
const dir = join2(root, RUNS_DIR, name);
|
|
746
780
|
return {
|
|
747
781
|
name,
|
|
748
782
|
dir,
|
|
749
|
-
hunks:
|
|
750
|
-
meta:
|
|
751
|
-
plan:
|
|
752
|
-
state:
|
|
753
|
-
review:
|
|
754
|
-
links:
|
|
755
|
-
prs:
|
|
783
|
+
hunks: join2(dir, "hunks.json"),
|
|
784
|
+
meta: join2(dir, "meta.json"),
|
|
785
|
+
plan: join2(dir, "plan.json"),
|
|
786
|
+
state: join2(dir, "review-state.json"),
|
|
787
|
+
review: join2(dir, "review.md"),
|
|
788
|
+
links: join2(dir, "links.json"),
|
|
789
|
+
prs: join2(dir, "prs.md")
|
|
756
790
|
};
|
|
757
791
|
}
|
|
758
792
|
async function ensureRunsDir(root) {
|
|
759
|
-
const dir =
|
|
793
|
+
const dir = join2(root, RUNS_DIR);
|
|
760
794
|
await mkdir(dir, { recursive: true });
|
|
761
|
-
await writeFile(
|
|
795
|
+
await writeFile(join2(dir, ".gitignore"), "*\n");
|
|
762
796
|
}
|
|
763
797
|
var exists = (p) => stat(p).then(() => true, () => false);
|
|
764
798
|
async function resolveRun(root, name, branch) {
|
|
@@ -774,11 +808,11 @@ async function resolveRun(root, name, branch) {
|
|
|
774
808
|
return runPaths(root, runs[0].name);
|
|
775
809
|
}
|
|
776
810
|
async function listRuns(root) {
|
|
777
|
-
const dir =
|
|
811
|
+
const dir = join2(root, RUNS_DIR);
|
|
778
812
|
const names = await readdir(dir).catch(() => []);
|
|
779
813
|
const runs = [];
|
|
780
814
|
for (const name of names) {
|
|
781
|
-
const s = await stat(
|
|
815
|
+
const s = await stat(join2(dir, name, "hunks.json")).catch(() => void 0);
|
|
782
816
|
if (s) runs.push({ name, mtime: s.mtimeMs });
|
|
783
817
|
}
|
|
784
818
|
return runs.sort((a, b) => b.mtime - a.mtime);
|
|
@@ -789,7 +823,7 @@ async function writeJson(path, data) {
|
|
|
789
823
|
await rename(tmp, path);
|
|
790
824
|
}
|
|
791
825
|
async function readJson(path) {
|
|
792
|
-
return JSON.parse(await
|
|
826
|
+
return JSON.parse(await readFile2(path, "utf8"));
|
|
793
827
|
}
|
|
794
828
|
var readHunks = async (p) => z2.array(Hunk).parse(await readJson(p.hunks));
|
|
795
829
|
var readMeta = async (p) => Meta.parse(await readJson(p.meta));
|
|
@@ -938,7 +972,8 @@ function exportMarkdown(meta, hunks, plan, state) {
|
|
|
938
972
|
for (const c of view.chunks) {
|
|
939
973
|
const status = hunkStatus(c.hunks, reviewed);
|
|
940
974
|
const changed = status === "partial" ? " \xB7 changed since review" : "";
|
|
941
|
-
|
|
975
|
+
const risk = c.chunk.risk !== "low" ? ` \xB7 ${c.chunk.risk}: ${c.chunk.riskReason ?? "unexplained"}` : "";
|
|
976
|
+
out.push(`- ${MARK[status]} ${c.index + 1}. ${c.chunk.title} (${c.size})${risk}${changed}`);
|
|
942
977
|
const note = state.chunkNotes[chunkNoteKey(view.strategy.id, c.chunk.id)]?.trim();
|
|
943
978
|
if (note) out.push(...note.split("\n").map((l) => ` > ${l}`));
|
|
944
979
|
for (const n of notesInHunks(c.hunks, state.lineNotes))
|
|
@@ -1038,10 +1073,19 @@ function formatLinks(hunks, links) {
|
|
|
1038
1073
|
return [...pairs].sort(([a], [b]) => a.localeCompare(b)).map(([pair, items]) => [pair, ...items.map((i) => ` ${i}`)].join("\n")).join("\n");
|
|
1039
1074
|
}
|
|
1040
1075
|
|
|
1076
|
+
// cli/lib/rubric.ts
|
|
1077
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
1078
|
+
import { join as join3 } from "path";
|
|
1079
|
+
async function readRubric(root) {
|
|
1080
|
+
const text = await readFile3(join3(root, RUBRIC_FILE), "utf8").catch(() => void 0);
|
|
1081
|
+
return text === void 0 ? void 0 : parseRubric(text);
|
|
1082
|
+
}
|
|
1083
|
+
var formatRubric = (r) => [r.high.length ? `high: ${r.high.join(", ")}` : "", r.med.length ? `med: ${r.med.join(", ")}` : ""].filter(Boolean).join("\n");
|
|
1084
|
+
|
|
1041
1085
|
// cli/lib/server.ts
|
|
1042
1086
|
import { createServer } from "http";
|
|
1043
|
-
import { readFile as
|
|
1044
|
-
import { extname as extname2, join as
|
|
1087
|
+
import { readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
|
|
1088
|
+
import { extname as extname2, join as join4, normalize, sep } from "path";
|
|
1045
1089
|
var TYPES = {
|
|
1046
1090
|
".html": "text/html; charset=utf-8",
|
|
1047
1091
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -1051,6 +1095,7 @@ var TYPES = {
|
|
|
1051
1095
|
".map": "application/json"
|
|
1052
1096
|
};
|
|
1053
1097
|
var MAX_BODY = 20 * 1024 * 1024;
|
|
1098
|
+
var MAX_FILE = 2 * 1024 * 1024;
|
|
1054
1099
|
async function body(req) {
|
|
1055
1100
|
const chunks = [];
|
|
1056
1101
|
let size = 0;
|
|
@@ -1073,6 +1118,7 @@ async function loadRun(paths) {
|
|
|
1073
1118
|
readState(paths),
|
|
1074
1119
|
readLinks(paths)
|
|
1075
1120
|
]);
|
|
1121
|
+
const rubric = await readRubric(meta.repoRoot);
|
|
1076
1122
|
const validation = raw === void 0 ? void 0 : validatePlan(raw, hunks);
|
|
1077
1123
|
return {
|
|
1078
1124
|
meta,
|
|
@@ -1081,7 +1127,8 @@ async function loadRun(paths) {
|
|
|
1081
1127
|
issues: validation?.issues ?? [],
|
|
1082
1128
|
hasPlan: raw !== void 0,
|
|
1083
1129
|
links,
|
|
1084
|
-
state
|
|
1130
|
+
state,
|
|
1131
|
+
rubric
|
|
1085
1132
|
};
|
|
1086
1133
|
}
|
|
1087
1134
|
async function serve(opts) {
|
|
@@ -1097,6 +1144,19 @@ async function serve(opts) {
|
|
|
1097
1144
|
return send(res, 403, { error: "cross-origin request refused" });
|
|
1098
1145
|
}
|
|
1099
1146
|
if (url.pathname === "/api/run" && req.method === "GET") return send(res, 200, await loadRun(paths));
|
|
1147
|
+
if (url.pathname === "/api/file" && req.method === "GET") {
|
|
1148
|
+
const path = url.searchParams.get("path") ?? "";
|
|
1149
|
+
const [meta, hunks] = await Promise.all([readMeta(paths), readHunks(paths)]);
|
|
1150
|
+
const hs = hunks.filter((h) => h.file === path);
|
|
1151
|
+
if (!hs.length || hs[0].status === "deleted" || hs.some((h) => h.flags.includes("binary")))
|
|
1152
|
+
return send(res, 404, { error: "no text for that file" });
|
|
1153
|
+
const text = await fileText(meta.repoRoot, meta, path).catch(() => void 0);
|
|
1154
|
+
if (text === void 0) return send(res, 404, { error: "no text for that file" });
|
|
1155
|
+
if (text.length > MAX_FILE || text.includes("\0")) return send(res, 404, { error: "file too large or binary" });
|
|
1156
|
+
const lines = text.split("\n");
|
|
1157
|
+
if (lines.at(-1) === "") lines.pop();
|
|
1158
|
+
return send(res, 200, { lines: lines.map((l) => l.replace(/\r$/, "")) });
|
|
1159
|
+
}
|
|
1100
1160
|
if (url.pathname === "/api/state" && req.method === "PUT") {
|
|
1101
1161
|
const state = ReviewState.parse(await body(req));
|
|
1102
1162
|
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1113,14 +1173,14 @@ async function serve(opts) {
|
|
|
1113
1173
|
if (url.pathname.startsWith("/api/")) return send(res, 404, { error: "not found" });
|
|
1114
1174
|
if (req.method !== "GET") return send(res, 405, { error: "method not allowed" });
|
|
1115
1175
|
const rel = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, "");
|
|
1116
|
-
let file =
|
|
1176
|
+
let file = join4(appDir, rel);
|
|
1117
1177
|
if (!file.startsWith(appDir + sep) && file !== appDir) return send(res, 403, { error: "forbidden" });
|
|
1118
1178
|
let data;
|
|
1119
1179
|
try {
|
|
1120
|
-
data = await
|
|
1180
|
+
data = await readFile4(file);
|
|
1121
1181
|
} catch {
|
|
1122
|
-
file =
|
|
1123
|
-
data = await
|
|
1182
|
+
file = join4(appDir, "index.html");
|
|
1183
|
+
data = await readFile4(file);
|
|
1124
1184
|
}
|
|
1125
1185
|
res.writeHead(200, { "content-type": TYPES[extname2(file)] ?? "application/octet-stream" });
|
|
1126
1186
|
res.end(data);
|
|
@@ -1153,8 +1213,8 @@ async function serve(opts) {
|
|
|
1153
1213
|
|
|
1154
1214
|
// cli/lib/stack.ts
|
|
1155
1215
|
import { execFile as execFile2 } from "child_process";
|
|
1156
|
-
import { lstat, readFile as
|
|
1157
|
-
import { join as
|
|
1216
|
+
import { lstat as lstat2, readFile as readFile5, readlink, rm } from "fs/promises";
|
|
1217
|
+
import { join as join5 } from "path";
|
|
1158
1218
|
import { promisify as promisify2 } from "util";
|
|
1159
1219
|
var execP = promisify2(execFile2);
|
|
1160
1220
|
var ZERO = "0".repeat(40);
|
|
@@ -1222,9 +1282,9 @@ async function targetEntry(root, meta, path) {
|
|
|
1222
1282
|
const m = /^(\d+) ([0-9a-f]+) 0\t/.exec(await git(root, ["ls-files", "-s", "-z", "--", path]));
|
|
1223
1283
|
if (m) e = { mode: m[1], sha: m[2] };
|
|
1224
1284
|
} else {
|
|
1225
|
-
const full =
|
|
1226
|
-
const st = await
|
|
1227
|
-
const content = st.isSymbolicLink() ? await readlink(full) : await
|
|
1285
|
+
const full = join5(root, path);
|
|
1286
|
+
const st = await lstat2(full);
|
|
1287
|
+
const content = st.isSymbolicLink() ? await readlink(full) : await readFile5(full);
|
|
1228
1288
|
const mode = st.isSymbolicLink() ? "120000" : st.mode & 73 ? "100755" : "100644";
|
|
1229
1289
|
const sha = st.isSymbolicLink() ? (await git(root, ["hash-object", "-w", "--stdin"], content)).trim() : (await git(root, ["hash-object", "-w", "--", path])).trim();
|
|
1230
1290
|
e = { mode, sha };
|
|
@@ -1330,7 +1390,7 @@ async function createBranches(root, stack, force) {
|
|
|
1330
1390
|
async function checkStack(root, stack, cmd, tmp) {
|
|
1331
1391
|
const results = [];
|
|
1332
1392
|
for (const pr of stack.prs) {
|
|
1333
|
-
const dir =
|
|
1393
|
+
const dir = join5(tmp, `pr-${pr.n}`);
|
|
1334
1394
|
await git(root, ["worktree", "add", "--detach", "--quiet", dir, pr.commit]);
|
|
1335
1395
|
try {
|
|
1336
1396
|
const r = await execP(cmd, { cwd: dir, shell: true, maxBuffer: 64 * 1024 * 1024 }).then(
|
|
@@ -1352,6 +1412,7 @@ function prsMarkdown(meta, stack) {
|
|
|
1352
1412
|
out.push(`## ${pr.n}. ${c.title}`, "");
|
|
1353
1413
|
out.push(`\`${pr.branch}\` \u2192 \`${i === 0 ? target : stack.prs[i - 1].branch}\` \xB7 ${pr.lines} lines`, "");
|
|
1354
1414
|
out.push(c.intent, "");
|
|
1415
|
+
if (c.risk !== "low") out.push(`**${c.risk === "high" ? "High" : "Medium"} risk:** ${c.riskReason ?? "unexplained"}`, "");
|
|
1355
1416
|
for (const q of c.reviewFocus) out.push(`- ${q}`);
|
|
1356
1417
|
if (c.reviewFocus.length) out.push("");
|
|
1357
1418
|
const deps = c.dependsOn.map((d) => stack.prs.find((p) => p.chunk.id === d)?.n).filter((n) => n && n < pr.n - 1);
|
|
@@ -1376,6 +1437,7 @@ Commands:
|
|
|
1376
1437
|
show <ids...> Print hunks in full; accepts h003, h003..h010, or a parent id like h012
|
|
1377
1438
|
--file <path> Print every hunk of a file
|
|
1378
1439
|
links Cross-file links: which hunk uses a name another hunk defines
|
|
1440
|
+
rubric Print the agreed risk rubric (.spark-joy.md at the repo root)
|
|
1379
1441
|
validate Check plan.json against the hunks
|
|
1380
1442
|
--json Machine-readable output
|
|
1381
1443
|
serve Open the review app
|
|
@@ -1430,7 +1492,7 @@ async function main(argv, cwd = process.cwd()) {
|
|
|
1430
1492
|
});
|
|
1431
1493
|
const [cmd, ...rest] = positionals;
|
|
1432
1494
|
if (values.version) {
|
|
1433
|
-
const pkg = JSON.parse(await
|
|
1495
|
+
const pkg = JSON.parse(await readFile6(join6(PKG_ROOT, "package.json"), "utf8"));
|
|
1434
1496
|
console.log(pkg.version);
|
|
1435
1497
|
return 0;
|
|
1436
1498
|
}
|
|
@@ -1451,6 +1513,7 @@ async function main(argv, cwd = process.cwd()) {
|
|
|
1451
1513
|
if (r.untracked.length)
|
|
1452
1514
|
console.log(`note: ${r.untracked.length} untracked file(s) are not included; \`git add -N <file>\` to include them`);
|
|
1453
1515
|
if (await readPlanRaw(r.paths)) console.log("note: plan.json already exists and may be stale; run `spark-joy validate`");
|
|
1516
|
+
console.log(await readRubric(r.meta.repoRoot) ? `risk rubric: ${RUBRIC_FILE}` : "no risk rubric yet");
|
|
1454
1517
|
return 0;
|
|
1455
1518
|
}
|
|
1456
1519
|
case "outline": {
|
|
@@ -1471,11 +1534,19 @@ async function main(argv, cwd = process.cwd()) {
|
|
|
1471
1534
|
console.log(formatLinks(await readHunks(p), await readLinks(p)));
|
|
1472
1535
|
return 0;
|
|
1473
1536
|
}
|
|
1537
|
+
case "rubric": {
|
|
1538
|
+
const rubric = await readRubric(await repoRoot(cwd));
|
|
1539
|
+
console.log(rubric ? formatRubric(rubric) : `no risk rubric yet; agree one with the user and write ${RUBRIC_FILE}`);
|
|
1540
|
+
return 0;
|
|
1541
|
+
}
|
|
1474
1542
|
case "validate": {
|
|
1475
1543
|
const p = await run(cwd, values.run);
|
|
1476
1544
|
const raw = await readPlanRaw(p);
|
|
1477
1545
|
if (raw === void 0) throw new Error(`no plan yet; write ${p.plan}`);
|
|
1478
1546
|
const result = validatePlan(raw, await readHunks(p));
|
|
1547
|
+
const risky = result.plan?.strategies.some((s) => s.chunks.some((c) => c.risk !== "low"));
|
|
1548
|
+
if (risky && !await readRubric(await repoRoot(cwd)))
|
|
1549
|
+
result.issues.push({ level: "warning", where: "risk", message: `no risk rubric; agree one with the user and write ${RUBRIC_FILE}` });
|
|
1479
1550
|
if (values.json) {
|
|
1480
1551
|
console.log(JSON.stringify({ ok: result.ok, issues: result.issues }, null, 2));
|
|
1481
1552
|
} else {
|
|
@@ -1489,7 +1560,7 @@ async function main(argv, cwd = process.cwd()) {
|
|
|
1489
1560
|
case "serve": {
|
|
1490
1561
|
const p = await run(cwd, values.run);
|
|
1491
1562
|
const port = values.port ? Number(values.port) : 4848;
|
|
1492
|
-
const s = await serve({ paths: p, appDir:
|
|
1563
|
+
const s = await serve({ paths: p, appDir: join6(PKG_ROOT, "dist", "app"), port });
|
|
1493
1564
|
console.log(`spark-joy reviewing ${p.name} at ${s.url} (Ctrl+C to stop)`);
|
|
1494
1565
|
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
1495
1566
|
await s.close();
|
|
@@ -1524,13 +1595,13 @@ async function main(argv, cwd = process.cwd()) {
|
|
|
1524
1595
|
hunks,
|
|
1525
1596
|
plan: result.plan,
|
|
1526
1597
|
prefix,
|
|
1527
|
-
indexFile:
|
|
1598
|
+
indexFile: join6(p.dir, "split.index")
|
|
1528
1599
|
});
|
|
1529
1600
|
for (const pr of stack.prs)
|
|
1530
1601
|
console.log(`${String(pr.n).padStart(2)} ${pr.branch} ${pr.lines} lines, ${pr.files} files ${pr.chunk.title}`);
|
|
1531
1602
|
console.log(`${stack.prs.length} PRs; the last one matches your changes exactly`);
|
|
1532
1603
|
if (values.check !== void 0) {
|
|
1533
|
-
const tmp = await mkdtemp(
|
|
1604
|
+
const tmp = await mkdtemp(join6(tmpdir(), "spark-joy-check-"));
|
|
1534
1605
|
try {
|
|
1535
1606
|
const results = await checkStack(meta.repoRoot, stack, values.check, tmp);
|
|
1536
1607
|
for (const r of results) {
|
|
@@ -1555,9 +1626,9 @@ nothing created; add --apply to create the branches`);
|
|
|
1555
1626
|
return 0;
|
|
1556
1627
|
}
|
|
1557
1628
|
case "install-skill": {
|
|
1558
|
-
const skill =
|
|
1629
|
+
const skill = join6(PKG_ROOT, "plugin", "skills", "spark-joy", "SKILL.md");
|
|
1559
1630
|
if (values.print) {
|
|
1560
|
-
process.stdout.write(await
|
|
1631
|
+
process.stdout.write(await readFile6(skill, "utf8"));
|
|
1561
1632
|
return 0;
|
|
1562
1633
|
}
|
|
1563
1634
|
const homes = { claude: ".claude", agents: ".agents" };
|
|
@@ -1566,9 +1637,9 @@ nothing created; add --apply to create the branches`);
|
|
|
1566
1637
|
if (bad) throw new Error(`unknown agent ${bad}; use claude or agents`);
|
|
1567
1638
|
const root = values.project ? await repoRoot(cwd) : homedir();
|
|
1568
1639
|
for (const a of agents) {
|
|
1569
|
-
const dir =
|
|
1640
|
+
const dir = join6(root, homes[a], "skills", "spark-joy");
|
|
1570
1641
|
await mkdir3(dir, { recursive: true });
|
|
1571
|
-
await copyFile(skill,
|
|
1642
|
+
await copyFile(skill, join6(dir, "SKILL.md"));
|
|
1572
1643
|
console.log(`installed skill to ${dir}`);
|
|
1573
1644
|
}
|
|
1574
1645
|
console.log('ask your agent to "review this PR in chunks" or "split my changes into PRs"');
|