@mafianacho/nacholens 0.0.0-stage → 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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.de.md +46 -0
- package/README.fr.md +46 -0
- package/README.ja.md +46 -0
- package/README.ko.md +46 -0
- package/README.md +45 -2
- package/README.zh.md +46 -0
- package/demo/shop/v1/README.md +7 -0
- package/demo/shop/v1/api.js +78 -0
- package/demo/shop/v1/db.js +38 -0
- package/demo/shop/v1/gateway.js +50 -0
- package/demo/shop/v1/mock-external.js +17 -0
- package/demo/shop/v1/public/app.js +32 -0
- package/demo/shop/v1/public/format.js +2 -0
- package/demo/shop/v1/public/index.html +13 -0
- package/demo/shop/v1/public/styles.css +4 -0
- package/demo/shop/v2/README.md +7 -0
- package/demo/shop/v2/api.js +88 -0
- package/demo/shop/v2/db.js +38 -0
- package/demo/shop/v2/gateway.js +50 -0
- package/demo/shop/v2/mock-external.js +17 -0
- package/demo/shop/v2/public/app.js +33 -0
- package/demo/shop/v2/public/format.js +3 -0
- package/demo/shop/v2/public/index.html +13 -0
- package/demo/shop/v2/public/styles.css +5 -0
- package/docs/guide.ko.md +97 -0
- package/docs/guide.md +142 -0
- package/docs/model-schema.md +137 -0
- package/docs/review.md +78 -0
- package/examples/demo-shop.artifact.html +1535 -0
- package/examples/demo-shop.artifact.json +7 -0
- package/examples/demo-shop.html +1535 -0
- package/examples/external/README.md +12 -0
- package/examples/external/full-stack-fastapi-template/LICENSE +21 -0
- package/examples/external/full-stack-fastapi-template/changes.json +56 -0
- package/examples/external/full-stack-fastapi-template/full-stack-fastapi-template-backend.html +1535 -0
- package/models/demo-shop/changes.auto.json +121 -0
- package/models/demo-shop/changes.example.json +16 -0
- package/models/demo-shop/intent.example.json +75 -0
- package/models/demo-shop/model.json +3151 -0
- package/package.json +60 -4
- package/skills/nacholens/SKILL.md +235 -0
- package/src/cli.js +377 -0
- package/src/demo.js +42 -0
- package/src/diff.js +254 -0
- package/src/gitio.js +67 -0
- package/src/install.js +94 -0
- package/src/model.js +434 -0
- package/src/page.js +87 -0
- package/src/player/fonts/FONTLOG.md +30 -0
- package/src/player/fonts/OFL-Anton.txt +93 -0
- package/src/player/fonts/OFL-IBMPlex.txt +94 -0
- package/src/player/fonts/anton-400.woff2 +0 -0
- package/src/player/fonts/nacho-mono-500.woff2 +0 -0
- package/src/player/fonts/nacho-mono-600.woff2 +0 -0
- package/src/player/fonts/nacho-sans-400.woff2 +0 -0
- package/src/player/fonts/nacho-sans-500.woff2 +0 -0
- package/src/player/fonts/nacho-sans-600.woff2 +0 -0
- package/src/player/fonts/nacho-sans-700.woff2 +0 -0
- package/src/player/player.css +318 -0
- package/src/player/player.js +872 -0
- package/src/player/review.css +49 -0
- package/src/player/review.js +230 -0
- package/src/player/template.html +58 -0
- package/src/review.js +321 -0
- package/src/scan.js +255 -0
- package/src/template.js +36 -0
- package/src/verify.js +90 -0
package/src/diff.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Change-overlay extraction: git diff (base..head) x model evidence (file:lines) -> changes.json.
|
|
2
|
+
//
|
|
3
|
+
// Rules (documented in README):
|
|
4
|
+
// * A step is CHANGED when any of its evidence ranges overlaps a changed line range.
|
|
5
|
+
// * A component is CHANGED when its own evidence overlaps, or when it is an endpoint of a changed
|
|
6
|
+
// step/link and its code lives in the repo (kind client|gateway|service). External systems and
|
|
7
|
+
// datastores are never marked just because a step talks to them.
|
|
8
|
+
// * Change kind: "added" when every evidence range of the item is wholly new code, "removed" when
|
|
9
|
+
// every evidence file was deleted, otherwise "modified".
|
|
10
|
+
// * Changed files that touch no step evidence are reported as `unmapped` (never dropped silently).
|
|
11
|
+
// Tests, docs and lockfiles are listed under `ignored` with the reason.
|
|
12
|
+
// * Model lines refer to model.source.commit. When head differs, head-side line numbers are
|
|
13
|
+
// translated to that commit with a second diff (head -> model commit).
|
|
14
|
+
import { collectEvidence } from "./verify.js";
|
|
15
|
+
|
|
16
|
+
/** Parse `git diff --unified=0` output. Content lines are skipped by hunk counters, so a removed
|
|
17
|
+
* line that itself starts with "-- " can never be mistaken for a file header. */
|
|
18
|
+
export function parseDiff(text) {
|
|
19
|
+
const files = [];
|
|
20
|
+
let cur = null, remOld = 0, remNew = 0;
|
|
21
|
+
for (const line of text.split("\n")) {
|
|
22
|
+
if (cur && (remOld > 0 || remNew > 0) && /^[-+\\]/.test(line)) {
|
|
23
|
+
if (line[0] === "-") remOld--; else if (line[0] === "+") remNew--;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (line.startsWith("diff --git ")) {
|
|
27
|
+
cur = { path: null, oldPath: null, status: "M", binary: false, hunks: [] };
|
|
28
|
+
files.push(cur);
|
|
29
|
+
const m = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
|
30
|
+
if (m) cur.path = m[2];
|
|
31
|
+
remOld = remNew = 0;
|
|
32
|
+
} else if (!cur) continue;
|
|
33
|
+
else if (line.startsWith("new file mode")) cur.status = "A";
|
|
34
|
+
else if (line.startsWith("deleted file mode")) cur.status = "D";
|
|
35
|
+
else if (line.startsWith("--- ")) { const p = line.slice(4); if (p.startsWith("a/")) cur.oldPath = p.slice(2); }
|
|
36
|
+
else if (line.startsWith("+++ ")) { const p = line.slice(4); if (p.startsWith("b/")) cur.path = p.slice(2); }
|
|
37
|
+
else if (line.startsWith("Binary files")) cur.binary = true;
|
|
38
|
+
else if (line.startsWith("@@")) {
|
|
39
|
+
const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
40
|
+
if (!m) continue;
|
|
41
|
+
const h = {
|
|
42
|
+
oldStart: +m[1], oldCount: m[2] === undefined ? 1 : +m[2],
|
|
43
|
+
newStart: +m[3], newCount: m[4] === undefined ? 1 : +m[4],
|
|
44
|
+
};
|
|
45
|
+
cur.hunks.push(h);
|
|
46
|
+
remOld = h.oldCount; remNew = h.newCount;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const f of files) if (!f.path) f.path = f.oldPath;
|
|
50
|
+
return files.filter((f) => f.path);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const overlap = (a, b) => a[0] <= b[1] && b[0] <= a[1];
|
|
54
|
+
|
|
55
|
+
/** Returns mapRange(range) translating head-side lines to the model commit's lines. */
|
|
56
|
+
export function makeMapper(hunks) {
|
|
57
|
+
const hs = [...hunks].sort((a, b) => a.oldStart - b.oldStart);
|
|
58
|
+
const shiftBefore = (l) => {
|
|
59
|
+
let s = 0;
|
|
60
|
+
for (const h of hs) {
|
|
61
|
+
const lastOld = h.oldCount === 0 ? h.oldStart : h.oldStart + h.oldCount - 1;
|
|
62
|
+
const before = h.oldCount === 0 ? h.oldStart < l : lastOld < l;
|
|
63
|
+
if (before) s += h.newCount - h.oldCount;
|
|
64
|
+
}
|
|
65
|
+
return s;
|
|
66
|
+
};
|
|
67
|
+
const mapLine = (l) => {
|
|
68
|
+
for (const h of hs)
|
|
69
|
+
if (h.oldCount > 0 && l >= h.oldStart && l <= h.oldStart + h.oldCount - 1)
|
|
70
|
+
return h.newCount > 0 ? [h.newStart, h.newStart + h.newCount - 1] : [Math.max(1, h.newStart), h.newStart + 1];
|
|
71
|
+
const v = l + shiftBefore(l);
|
|
72
|
+
return [v, v];
|
|
73
|
+
};
|
|
74
|
+
return (r) => [mapLine(r[0])[0], mapLine(r[1])[1]];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Notes written into changes.json (English; the player shows them as data). review.js matches COMPONENT_NOTE. */
|
|
78
|
+
export const COMPONENT_NOTE = "Component evidence code changed";
|
|
79
|
+
const EV_NOTE = { added: "Evidence code added", removed: "Evidence code removed", modified: "Evidence code changed" };
|
|
80
|
+
|
|
81
|
+
export const IGNORE_RULES = [
|
|
82
|
+
[/(^|\/)(tests?|__tests__|spec|e2e|fixtures)\//i, "test"],
|
|
83
|
+
[/\.(test|spec)\.[cm]?[jt]sx?$/i, "test"],
|
|
84
|
+
[/(^|\/)requirements[^/]*\.txt$/i, null], // a dependency manifest, not prose (kept: reported as config)
|
|
85
|
+
[/\.(md|mdx|txt|rst)$/i, "docs"],
|
|
86
|
+
[/(^|\/)docs?\//i, "docs"],
|
|
87
|
+
[/(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock|go\.sum|Gemfile\.lock|uv\.lock|Pipfile\.lock|pdm\.lock|composer\.lock|bun\.lockb?|flake\.lock|deno\.lock)$/, "lockfile"],
|
|
88
|
+
[/\.(png|jpe?g|gif|webp|ico|svg|woff2?|ttf)$/i, "asset"],
|
|
89
|
+
];
|
|
90
|
+
export function ignoreReason(path) {
|
|
91
|
+
for (const [re, why] of IGNORE_RULES) if (re.test(path)) return why; // first match wins; a null reason means "keep"
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Dependency manifests and CI/tooling files: what the code is built from, not a request path. */
|
|
96
|
+
const CONFIG_RE = /(^|\/)(pyproject\.toml|requirements[^/]*\.txt|Pipfile|go\.mod|Cargo\.toml|package\.json|\.pre-commit-config\.ya?ml|\.gitlab-ci\.ya?ml)$|(^|\/)\.github\/(workflows|dependabot)/i;
|
|
97
|
+
/**
|
|
98
|
+
* Stylesheets and dependency/CI files rarely change a request flow: reported, but kept apart from code
|
|
99
|
+
* in the warning and ignored by --strict ("style" and "config" are the soft categories).
|
|
100
|
+
*/
|
|
101
|
+
export const unmappedCategory = (path) => (/\.(css|scss|sass|less|styl)$/i.test(path) ? "style" : CONFIG_RE.test(path) ? "config" : "code");
|
|
102
|
+
export const isSoftCategory = (c) => c === "style" || c === "config";
|
|
103
|
+
|
|
104
|
+
const rangeText = (rs) => rs.map((r) => (r[0] === r[1] ? `${r[0]}` : `${r[0]}-${r[1]}`)).join(", ");
|
|
105
|
+
const clip = (s, n) => (s.length <= n ? s : s.slice(0, n - 1) + "…");
|
|
106
|
+
|
|
107
|
+
function mergeRanges(rs) {
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const r of [...rs].sort((a, b) => a[0] - b[0])) {
|
|
110
|
+
const last = out[out.length - 1];
|
|
111
|
+
if (last && r[0] <= last[1] + 1) last[1] = Math.max(last[1], r[1]); else out.push([r[0], r[1]]);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
const inside = (merged, r) => merged.some((m) => m[0] <= r[0] && r[1] <= m[1]);
|
|
116
|
+
|
|
117
|
+
export function generateOverlay({ model, diffText, toModelDiffText = null, meta = {} }) {
|
|
118
|
+
const files = parseDiff(diffText);
|
|
119
|
+
const toModel = new Map(toModelDiffText ? parseDiff(toModelDiffText).map((f) => [f.path, f]) : []);
|
|
120
|
+
const evidence = collectEvidence(model);
|
|
121
|
+
const ownerKey = (o) => (o.type === "step" ? `step:${o.uc}/${o.step}` : o.type === "link" ? `link:${o.a}|${o.b}` : o.type === "detail" ? `detail:${o.comp}|${o.kind}|${o.id}` : `component:${o.id}`);
|
|
122
|
+
|
|
123
|
+
// per owner: total evidence count + per-evidence state
|
|
124
|
+
const owners = new Map();
|
|
125
|
+
for (const ev of evidence) {
|
|
126
|
+
const k = ownerKey(ev.owner);
|
|
127
|
+
if (!owners.has(k)) owners.set(k, { owner: ev.owner, total: 0, hit: 0, added: 0, removed: 0, files: new Map() });
|
|
128
|
+
owners.get(k).total++;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ignored = [], unmapped = [];
|
|
132
|
+
for (const f of files) {
|
|
133
|
+
const why = ignoreReason(f.path);
|
|
134
|
+
if (why) { ignored.push({ file: f.path, reason: why }); continue; }
|
|
135
|
+
const whole = f.status === "A" || f.status === "D" || f.binary;
|
|
136
|
+
let ranges = [], addRanges = [];
|
|
137
|
+
if (!whole) {
|
|
138
|
+
const tf = toModel.get(f.path);
|
|
139
|
+
const map = tf && tf.status === "M" ? makeMapper(tf.hunks) : (r) => r;
|
|
140
|
+
for (const h of f.hunks) {
|
|
141
|
+
const r = h.newCount > 0 ? [h.newStart, h.newStart + h.newCount - 1] : [Math.max(1, h.newStart), h.newStart + 1];
|
|
142
|
+
const m = map(r);
|
|
143
|
+
ranges.push(m);
|
|
144
|
+
if (h.oldCount === 0 && h.newCount > 0) addRanges.push(m);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const merged = mergeRanges(addRanges);
|
|
148
|
+
let stepHit = false, anyHint = new Set();
|
|
149
|
+
for (const ev of evidence) {
|
|
150
|
+
if (ev.e.file !== f.path) continue;
|
|
151
|
+
const hit = whole || ranges.some((r) => overlap(r, ev.e.lines));
|
|
152
|
+
if (!hit) continue;
|
|
153
|
+
const o = owners.get(ownerKey(ev.owner));
|
|
154
|
+
o.hit++;
|
|
155
|
+
if (f.status === "D") o.removed++;
|
|
156
|
+
else if (f.status === "A" || inside(merged, ev.e.lines)) o.added++;
|
|
157
|
+
o.files.set(f.path, [...(o.files.get(f.path) ?? []), ev.e.lines]);
|
|
158
|
+
if (ev.owner.type === "step") stepHit = true; else anyHint.add(ev.owner.type === "link" ? `link ${ev.owner.a}–${ev.owner.b}` : ev.owner.type === "detail" ? `${ev.owner.comp} detail (${ev.owner.kind} ${ev.owner.id})` : `component ${ev.owner.id}`);
|
|
159
|
+
}
|
|
160
|
+
if (!stepHit)
|
|
161
|
+
unmapped.push({
|
|
162
|
+
file: f.path,
|
|
163
|
+
category: unmappedCategory(f.path),
|
|
164
|
+
status: f.status === "A" ? "added" : f.status === "D" ? "deleted" : "modified",
|
|
165
|
+
lines: whole ? "whole file" : clip(rangeText(mergeRanges(f.hunks.map((h) => (h.newCount > 0 ? [h.newStart, h.newStart + h.newCount - 1] : [Math.max(1, h.newStart), h.newStart + 1])))) + ` (${f.hunks.length} ${f.hunks.length === 1 ? "hunk" : "hunks"})`, 280),
|
|
166
|
+
hint: anyHint.size ? `Overlaps the evidence of ${[...anyHint].join(", ")}, but no step` : "Not in the evidence of any use-case step: add the path to the model, or confirm the change is unrelated",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const kindOf = (o) => (o.removed === o.total ? "removed" : o.added === o.total ? "added" : "modified");
|
|
171
|
+
const evText = (o) => [...o.files].map(([f, rs]) => `${f}:${rangeText(mergeRanges(rs))}`).join(", ");
|
|
172
|
+
|
|
173
|
+
// steps
|
|
174
|
+
const steps = [], stepSet = new Set();
|
|
175
|
+
const compById = new Map(model.components.map((c) => [c.id, c]));
|
|
176
|
+
const compChange = new Map();
|
|
177
|
+
const markComp = (id, change, note, strong) => {
|
|
178
|
+
const c = compById.get(id);
|
|
179
|
+
if (!c) return;
|
|
180
|
+
const prev = compChange.get(id);
|
|
181
|
+
if (prev && (prev.strong || !strong)) return;
|
|
182
|
+
compChange.set(id, { change, note, strong });
|
|
183
|
+
};
|
|
184
|
+
for (const u of model.useCases)
|
|
185
|
+
for (const s of u.steps) {
|
|
186
|
+
const o = owners.get(`step:${u.id}/${s.id}`);
|
|
187
|
+
if (!o || o.hit === 0) continue;
|
|
188
|
+
const change = kindOf(o);
|
|
189
|
+
steps.push({ useCase: u.id, step: s.id, change, note: clip(`${EV_NOTE[change]}: ${evText(o)}`, 300) });
|
|
190
|
+
stepSet.add(`${u.id}/${s.id}`);
|
|
191
|
+
for (const id of new Set([s.from, s.to]))
|
|
192
|
+
if (["client", "gateway", "service"].includes(compById.get(id)?.kind))
|
|
193
|
+
markComp(id, "modified", clip(`End of a changed step: ${u.id}/${s.id}`, 300), false);
|
|
194
|
+
}
|
|
195
|
+
for (const l of model.links) {
|
|
196
|
+
const o = owners.get(`link:${l.a}|${l.b}`);
|
|
197
|
+
if (o && o.hit)
|
|
198
|
+
for (const id of [l.a, l.b])
|
|
199
|
+
if (["client", "gateway", "service"].includes(compById.get(id)?.kind))
|
|
200
|
+
markComp(id, "modified", clip(`Evidence code of the link '${l.label}' changed: ${evText(o)}`, 300), false);
|
|
201
|
+
}
|
|
202
|
+
for (const c of model.components) {
|
|
203
|
+
const o = owners.get(`component:${c.id}`);
|
|
204
|
+
if (o && o.hit) markComp(c.id, kindOf(o), clip(`${COMPONENT_NOTE}: ${evText(o)}`, 300), true);
|
|
205
|
+
}
|
|
206
|
+
const components = model.components.filter((c) => compChange.has(c.id)).map((c) => ({ id: c.id, change: compChange.get(c.id).change, note: compChange.get(c.id).note }));
|
|
207
|
+
// drill-down items whose own evidence changed (shown inside the detail view; they do not badge the component)
|
|
208
|
+
const details = [];
|
|
209
|
+
for (const [k, o] of owners) {
|
|
210
|
+
if (!k.startsWith("detail:") || !o.hit) continue;
|
|
211
|
+
const change = kindOf(o);
|
|
212
|
+
details.push({ component: o.owner.comp, kind: o.owner.kind, id: o.owner.id, change, note: clip(`${EV_NOTE[change]}: ${evText(o)}`, 300) });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const short = (s) => (s ? String(s).slice(0, 7) : "?");
|
|
216
|
+
const subjects = (meta.subjects ?? []).map((s) => s.split("\n")[0]);
|
|
217
|
+
const title = subjects.length === 1 ? subjects[0] : subjects.length > 1 ? `${subjects.length} commits: ${subjects[0]} and more` : `Changes ${short(meta.base)}..${short(meta.head)}`;
|
|
218
|
+
const names = components.map((c) => c.id).join(", ") || "none";
|
|
219
|
+
const summary = `Generated from git diff: ${steps.length} changed steps, ${components.length} changed components (${names}), ${unmapped.length} changed files outside the model, ${ignored.length} ignored files.`;
|
|
220
|
+
return {
|
|
221
|
+
title: clip(title, 120),
|
|
222
|
+
summary: clip(summary, 600),
|
|
223
|
+
ref: clip(`${model.source.repo} ${short(meta.base)}..${short(meta.head)}`, 200),
|
|
224
|
+
components,
|
|
225
|
+
steps,
|
|
226
|
+
...(details.length ? { details } : {}),
|
|
227
|
+
unmapped,
|
|
228
|
+
ignored,
|
|
229
|
+
generated: {
|
|
230
|
+
by: "nacholens diff",
|
|
231
|
+
base: meta.base ?? null,
|
|
232
|
+
head: meta.head ?? null,
|
|
233
|
+
modelCommit: model.source.commit,
|
|
234
|
+
lineTranslation: !!toModelDiffText,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** git-backed wrapper. `repo` comes from openRepo(). */
|
|
240
|
+
export function overlayFromRepo(repo, model, baseRef, headRef) {
|
|
241
|
+
const base = repo.resolve(baseRef), head = repo.resolve(headRef);
|
|
242
|
+
const modelSha = repo.tryResolve(model.source.commit);
|
|
243
|
+
const warnings = [];
|
|
244
|
+
let toModelDiffText = null;
|
|
245
|
+
if (!modelSha) warnings.push(`Model commit ${model.source.commit} is not in the repository, so line numbers were compared without translation (they may be off).`);
|
|
246
|
+
else if (modelSha !== head) toModelDiffText = repo.diff(head, modelSha);
|
|
247
|
+
const overlay = generateOverlay({
|
|
248
|
+
model,
|
|
249
|
+
diffText: repo.diff(base, head),
|
|
250
|
+
toModelDiffText,
|
|
251
|
+
meta: { base, head, subjects: repo.subjects(base, head) },
|
|
252
|
+
});
|
|
253
|
+
return { overlay, warnings };
|
|
254
|
+
}
|
package/src/gitio.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Thin, read-only git access. Only fixed argv arrays reach git (no shell), refs are validated,
|
|
2
|
+
// and nothing here ever writes to the target repository.
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
const REF = /^[A-Za-z0-9][A-Za-z0-9._\/~^@{}:+-]*$/;
|
|
7
|
+
|
|
8
|
+
export function checkRef(ref, name = "ref") {
|
|
9
|
+
if (typeof ref !== "string" || !REF.test(ref) || ref.includes(".."))
|
|
10
|
+
throw new Error(`${name} 값이 올바르지 않습니다: ${JSON.stringify(ref)} (브랜치·태그·SHA 하나만 지정하세요. 범위(a..b)는 --base/--head 로 나눠 주세요)`);
|
|
11
|
+
return ref;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function git(repo, args) {
|
|
15
|
+
try {
|
|
16
|
+
return execFileSync("git", ["-c", "core.quotePath=false", "-C", repo, ...args], {
|
|
17
|
+
encoding: "utf8", maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"],
|
|
18
|
+
});
|
|
19
|
+
} catch (e) {
|
|
20
|
+
const msg = String(e.stderr || e.message).trim().split("\n")[0];
|
|
21
|
+
const err = new Error(`git ${args[0]} 실패: ${msg}`);
|
|
22
|
+
err.git = true;
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function openRepo(path) {
|
|
28
|
+
if (!path || !existsSync(path)) throw new Error(`저장소 경로가 없습니다: ${path}`);
|
|
29
|
+
try { git(path, ["rev-parse", "--git-dir"]); }
|
|
30
|
+
catch { throw new Error(`git 저장소가 아닙니다: ${path}`); }
|
|
31
|
+
const cache = new Map();
|
|
32
|
+
return {
|
|
33
|
+
path,
|
|
34
|
+
resolve(ref) {
|
|
35
|
+
checkRef(ref);
|
|
36
|
+
try { return git(path, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).trim(); }
|
|
37
|
+
catch { throw new Error(`저장소에서 '${ref}' 를 찾을 수 없습니다 (git fetch 가 필요할 수 있습니다)`); }
|
|
38
|
+
},
|
|
39
|
+
tryResolve(ref) { try { return this.resolve(ref); } catch { return null; } },
|
|
40
|
+
/** file content at a commit, or null when it does not exist there */
|
|
41
|
+
show(sha, file) {
|
|
42
|
+
const key = sha + ":" + file;
|
|
43
|
+
if (!cache.has(key)) {
|
|
44
|
+
let v = null;
|
|
45
|
+
try { v = git(path, ["show", `${sha}:${file}`]); } catch { v = null; }
|
|
46
|
+
cache.set(key, v);
|
|
47
|
+
}
|
|
48
|
+
return cache.get(key);
|
|
49
|
+
},
|
|
50
|
+
/** every tracked file path at a commit */
|
|
51
|
+
files(sha) { return git(path, ["ls-tree", "-r", "--name-only", sha]).split("\n").filter(Boolean); },
|
|
52
|
+
diff(base, head) {
|
|
53
|
+
return git(path, ["diff", "--no-renames", "--no-ext-diff", "--no-color", "--unified=0", base, head, "--"]);
|
|
54
|
+
},
|
|
55
|
+
subjects(base, head) {
|
|
56
|
+
const out = git(path, ["log", "--no-merges", "--format=%s", `${base}..${head}`]).trim();
|
|
57
|
+
return out ? out.split("\n").map((s) => s.trim()).filter(Boolean) : [];
|
|
58
|
+
},
|
|
59
|
+
remoteSlug() {
|
|
60
|
+
try {
|
|
61
|
+
const u = git(path, ["remote", "get-url", "origin"]).trim();
|
|
62
|
+
const m = /[:\/]([^\/:]+\/[^\/]+?)(?:\.git)?$/.exec(u);
|
|
63
|
+
return m ? m[1] : null;
|
|
64
|
+
} catch { return null; }
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// `nacholens install`: put the skill and a self-contained copy of the CLI where an agent will find it.
|
|
2
|
+
// --claude [--into <project>] [--global] <project>/.claude/skills/nacholens (or ~/.claude/skills/nacholens)
|
|
3
|
+
// --agents [--into <project>] <project>/.nacholens + a managed block in <project>/AGENTS.md
|
|
4
|
+
// --to <skills dir> <dir>/nacholens (any agent that reads SKILL.md folders)
|
|
5
|
+
// The installed folder holds SKILL.md and cli/ (src, demo, demo model). Nothing is downloaded; no dependencies.
|
|
6
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const ROOT = join(here, "..");
|
|
13
|
+
const BEGIN = "<!-- nacholens:begin -->", END = "<!-- nacholens:end -->";
|
|
14
|
+
|
|
15
|
+
function skillSource() {
|
|
16
|
+
for (const p of [join(ROOT, "skills", "nacholens", "SKILL.md"), join(ROOT, "..", "SKILL.md")]) if (existsSync(p)) return p;
|
|
17
|
+
throw new Error("SKILL.md 를 찾을 수 없습니다 (nacholens 저장소 또는 설치된 사본에서 실행하세요)");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function version() {
|
|
21
|
+
try { return JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version; } catch { /* vendored copy has its own */ }
|
|
22
|
+
try { return readFileSync(join(ROOT, "VERSION"), "utf8").trim(); } catch { return "unknown"; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeSkillDir(dir) {
|
|
26
|
+
mkdirSync(join(dir, "cli", "models", "demo-shop"), { recursive: true });
|
|
27
|
+
cpSync(skillSource(), join(dir, "SKILL.md"));
|
|
28
|
+
cpSync(join(ROOT, "src"), join(dir, "cli", "src"), { recursive: true });
|
|
29
|
+
cpSync(join(ROOT, "demo"), join(dir, "cli", "demo"), { recursive: true });
|
|
30
|
+
cpSync(join(ROOT, "models", "demo-shop", "model.json"), join(dir, "cli", "models", "demo-shop", "model.json"));
|
|
31
|
+
for (const f of ["LICENSE", "NOTICE"]) if (existsSync(join(ROOT, f))) cpSync(join(ROOT, f), join(dir, "cli", f));
|
|
32
|
+
writeFileSync(join(dir, "cli", "package.json"), JSON.stringify({ name: "nacholens-vendored", private: true, type: "module", version: version() }, null, 2) + "\n");
|
|
33
|
+
writeFileSync(join(dir, "cli", "VERSION"), version() + "\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function agentsBlock() {
|
|
37
|
+
return [
|
|
38
|
+
BEGIN,
|
|
39
|
+
"## nacholens (architecture flow viewer)",
|
|
40
|
+
"",
|
|
41
|
+
"When asked to show what changed in this repo's backend/infra without reading code, or to draw how a service works,",
|
|
42
|
+
"read `.nacholens/SKILL.md` and follow it. CLI: `node .nacholens/cli/src/cli.js` (Node >= 20, no install).",
|
|
43
|
+
END,
|
|
44
|
+
"",
|
|
45
|
+
].join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function upsertAgentsMd(file) {
|
|
49
|
+
const block = agentsBlock();
|
|
50
|
+
if (!existsSync(file)) { writeFileSync(file, block); return "created"; }
|
|
51
|
+
const text = readFileSync(file, "utf8");
|
|
52
|
+
const a = text.indexOf(BEGIN), b = text.indexOf(END);
|
|
53
|
+
if (a >= 0 && b > a) { writeFileSync(file, text.slice(0, a) + block.trimEnd() + text.slice(b + END.length)); return "updated"; }
|
|
54
|
+
writeFileSync(file, text.replace(/\s*$/, "\n\n") + block);
|
|
55
|
+
return "appended";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Returns { code: 0 | 2, dir } — 2 when the target exists and --force was not given. */
|
|
59
|
+
export function install(opt, env, log) {
|
|
60
|
+
const picks = ["claude", "agents", "to"].filter((k) => opt[k] !== undefined);
|
|
61
|
+
if (picks.length === 0) throw new Error("설치 대상이 필요합니다: --claude | --agents | --to <스킬 폴더>");
|
|
62
|
+
if (picks.length > 1) throw new Error("설치 대상은 하나만 고르세요: --claude | --agents | --to");
|
|
63
|
+
|
|
64
|
+
const project = () => {
|
|
65
|
+
const p = resolve(opt.into ?? process.cwd());
|
|
66
|
+
if (!existsSync(p) || !statSync(p).isDirectory()) throw new Error(`--into 는 존재하는 디렉터리여야 합니다: ${p}`);
|
|
67
|
+
return p;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let dir, after = null;
|
|
71
|
+
if (opt.claude !== undefined) {
|
|
72
|
+
dir = opt.global ? join(env.HOME ?? homedir(), ".claude", "skills", "nacholens") : join(project(), ".claude", "skills", "nacholens");
|
|
73
|
+
} else if (opt.agents !== undefined) {
|
|
74
|
+
dir = join(project(), ".nacholens");
|
|
75
|
+
after = () => `AGENTS.md ${upsertAgentsMd(join(dirname(dir), "AGENTS.md"))}`;
|
|
76
|
+
} else {
|
|
77
|
+
const base = resolve(opt.to);
|
|
78
|
+
mkdirSync(base, { recursive: true });
|
|
79
|
+
dir = join(base, "nacholens");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (existsSync(dir) && !opt.force) {
|
|
83
|
+
log(`이미 있습니다: ${dir} (덮어쓰려면 --force)`);
|
|
84
|
+
return { code: 2, dir };
|
|
85
|
+
}
|
|
86
|
+
rmSync(dir, { recursive: true, force: true });
|
|
87
|
+
writeSkillDir(dir);
|
|
88
|
+
log(`설치: ${dir}`);
|
|
89
|
+
log(` 스킬: ${join(dir, "SKILL.md")}`);
|
|
90
|
+
log(` CLI : node ${join(dir, "cli", "src", "cli.js")} help`);
|
|
91
|
+
if (after) log(` ${after()}`);
|
|
92
|
+
log("확인: 위 CLI 로 `demo` 를 실행해 보세요 (대상 저장소 없이 번들된 데모 앱으로 전체 흐름을 만듭니다).");
|
|
93
|
+
return { code: 0, dir };
|
|
94
|
+
}
|