@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/review.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// Review layer: three read-only views derived from the model + change overlay.
|
|
2
|
+
// compare (A3) before/after rows per use case
|
|
3
|
+
// intent (C1) planner expectations checked against the model (met | unmet | unknown)
|
|
4
|
+
// impact (C5) which other use cases a change can break, and whether tests mention them
|
|
5
|
+
// Everything here is a pure function of its inputs (model, changes, intent, test files). Nothing is
|
|
6
|
+
// guessed: an expectation without a mechanical check is `unknown`, an untestable claim stays that way.
|
|
7
|
+
import { ignoreReason, COMPONENT_NOTE } from "./diff.js";
|
|
8
|
+
|
|
9
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
10
|
+
const isStr = (v, max = 400) => typeof v === "string" && v.trim().length > 0 && v.length <= max;
|
|
11
|
+
const stepKey = (u, s) => `${u}/${s}`;
|
|
12
|
+
const pathOf = (st) => [st.from, ...(st.via ?? []), st.to];
|
|
13
|
+
const evText = (st) => (st.evidence ?? []).slice(0, 2).map((e) => `${e.file}:${e.lines[0]}${e.lines[1] !== e.lines[0] ? "-" + e.lines[1] : ""}`).join(", ");
|
|
14
|
+
|
|
15
|
+
/** ordered, consecutive-deduplicated list of the components a use case visits */
|
|
16
|
+
export function usePath(model, uc) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const st of uc.steps) for (const c of pathOf(st)) if (out[out.length - 1] !== c) out.push(c);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ───────────────────────────── A3: before / after ─────────────────────────────
|
|
23
|
+
|
|
24
|
+
const FLOW_FIELDS = ["kind", "from", "to", "label", "request", "response"];
|
|
25
|
+
const flowSig = (st) => FLOW_FIELDS.map((k) => st[k] ?? "").join("\u0001") + "\u0001" + (st.via ?? []).join(",");
|
|
26
|
+
const cell = (m, st) => ({
|
|
27
|
+
label: st.label, kind: st.kind, route: pathOf(st).map((c) => (m.components.find((x) => x.id === c)?.label ?? c).split("\n")[0]).join(" → "),
|
|
28
|
+
...(st.request ? { request: st.request } : {}), ...(st.response ? { response: st.response } : {}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Rows aligned by step id. With `beforeModel` (a model written against the base commit) the flow itself is
|
|
33
|
+
* compared; without it, the overlay decides: added = nothing before, removed = nothing after, modified = same flow,
|
|
34
|
+
* code changed (the old text is not known, and the row says so).
|
|
35
|
+
*/
|
|
36
|
+
export function buildCompare(model, changes = null, beforeModel = null) {
|
|
37
|
+
const cs = new Map((changes?.steps ?? []).map((s) => [stepKey(s.useCase, s.step), s]));
|
|
38
|
+
const mode = beforeModel ? "two-models" : "overlay";
|
|
39
|
+
const ids = [...model.useCases.map((u) => u.id), ...(beforeModel?.useCases ?? []).map((u) => u.id).filter((id) => !model.useCases.some((u) => u.id === id))];
|
|
40
|
+
const useCases = ids.map((id) => {
|
|
41
|
+
const a = model.useCases.find((u) => u.id === id), b = beforeModel ? beforeModel.useCases.find((u) => u.id === id) : a;
|
|
42
|
+
const A = a?.steps ?? [], B = b?.steps ?? [];
|
|
43
|
+
const rows = [];
|
|
44
|
+
if (beforeModel) {
|
|
45
|
+
const aIds = new Set(A.map((s) => s.id)), bIds = new Set(B.map((s) => s.id));
|
|
46
|
+
const commonB = B.filter((s) => aIds.has(s.id)).map((s) => s.id), commonA = A.filter((s) => bIds.has(s.id)).map((s) => s.id);
|
|
47
|
+
const orderMoved = new Set(commonA.filter((sid, i) => commonB[i] !== sid));
|
|
48
|
+
for (const sa of A) {
|
|
49
|
+
const sb = B.find((s) => s.id === sa.id);
|
|
50
|
+
const ch = cs.get(stepKey(id, sa.id));
|
|
51
|
+
if (!sb) { rows.push({ id: sa.id, status: "added", before: null, after: cell(model, sa), note: "This step is not in the model before the change." }); continue; }
|
|
52
|
+
const diffs = FLOW_FIELDS.filter((k) => (sa[k] ?? "") !== (sb[k] ?? "")).concat((sa.via ?? []).join(",") !== (sb.via ?? []).join(",") ? ["via"] : []);
|
|
53
|
+
const status = diffs.length ? "modified" : orderMoved.has(sa.id) ? "moved" : ch ? "modified" : "same";
|
|
54
|
+
rows.push({
|
|
55
|
+
id: sa.id, status, before: cell(beforeModel, sb), after: cell(model, sa),
|
|
56
|
+
...(diffs.length ? { fields: diffs, note: `Changed fields: ${diffs.join(", ")}` } : orderMoved.has(sa.id) ? { note: "The step order changed." } : ch ? { note: ch.note } : {}),
|
|
57
|
+
});
|
|
58
|
+
// removed before-steps are inserted right after their nearest earlier common step
|
|
59
|
+
const idxB = B.findIndex((s) => s.id === sa.id);
|
|
60
|
+
for (let k = idxB + 1; k < B.length && !aIds.has(B[k].id); k++) rows.push({ id: B[k].id, status: "removed", before: cell(beforeModel, B[k]), after: null, note: "This step is not in the model after the change." });
|
|
61
|
+
}
|
|
62
|
+
// before-steps that open the use case, before any common step
|
|
63
|
+
const lead = [];
|
|
64
|
+
for (let k = 0; k < B.length && !aIds.has(B[k].id); k++) lead.push({ id: B[k].id, status: "removed", before: cell(beforeModel, B[k]), after: null, note: "This step is not in the model after the change." });
|
|
65
|
+
rows.unshift(...lead);
|
|
66
|
+
} else {
|
|
67
|
+
for (const st of A) {
|
|
68
|
+
const ch = cs.get(stepKey(id, st.id));
|
|
69
|
+
if (!ch) rows.push({ id: st.id, status: "same", before: cell(model, st), after: cell(model, st) });
|
|
70
|
+
else if (ch.change === "added") rows.push({ id: st.id, status: "added", before: null, after: cell(model, st), note: ch.note });
|
|
71
|
+
else if (ch.change === "removed") rows.push({ id: st.id, status: "removed", before: cell(model, st), after: null, note: ch.note });
|
|
72
|
+
else rows.push({ id: st.id, status: "modified", before: cell(model, st), after: cell(model, st), note: `${ch.note} (the model has no earlier version, so the flow is shown unchanged)`, codeOnly: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// overlay mode: the "before" path is the after path without the steps this work added
|
|
76
|
+
let pathBefore, pathAfter = a ? usePath(model, a) : [];
|
|
77
|
+
if (beforeModel) pathBefore = b ? usePath(beforeModel, b) : [];
|
|
78
|
+
else pathBefore = a ? usePath(model, { ...a, steps: a.steps.filter((s) => cs.get(stepKey(id, s.id))?.change !== "added") }) : [];
|
|
79
|
+
const changed = rows.filter((r) => r.status !== "same").length;
|
|
80
|
+
return {
|
|
81
|
+
id, title: (a ?? b).title, trigger: (a ?? b).trigger, state: !a ? "removed" : !b ? "added" : changed ? "changed" : "same",
|
|
82
|
+
pathBefore, pathAfter,
|
|
83
|
+
pathLabels: Object.fromEntries([...model.components, ...(beforeModel?.components ?? [])].map((c) => [c.id, c.label.split("\n")[0]])),
|
|
84
|
+
rows, changedRows: changed,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
const sum = (st) => useCases.reduce((n, u) => n + u.rows.filter((r) => r.status === st).length, 0);
|
|
88
|
+
return { mode, useCases, summary: { useCases: useCases.filter((u) => u.state !== "same").length, added: sum("added"), removed: sum("removed"), modified: sum("modified"), moved: sum("moved") } };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ───────────────────────────── C1: intent check ─────────────────────────────
|
|
92
|
+
|
|
93
|
+
const CHECKS = ["step", "absent", "order"];
|
|
94
|
+
const MATCH_KEYS = ["kind", "from", "to", "with", "table", "op", "text"];
|
|
95
|
+
|
|
96
|
+
/** a matcher is a plain object; `text` is a list of alternatives joined by | (case-insensitive substring, no regex) */
|
|
97
|
+
function validateMatcher(m, w, errors, model) {
|
|
98
|
+
if (!isObj(m)) return errors.push(`${w}: must be an object with at least one of kind/from/to/with/table/op/text`);
|
|
99
|
+
for (const k of Object.keys(m)) if (!MATCH_KEYS.includes(k)) errors.push(`${w}.${k}: unknown condition (${MATCH_KEYS.join("|")})`);
|
|
100
|
+
if (!MATCH_KEYS.some((k) => m[k] !== undefined)) errors.push(`${w}: has no condition`);
|
|
101
|
+
if (m.kind !== undefined && !["request", "response", "call", "internal"].includes(m.kind)) errors.push(`${w}.kind: request|response|call|internal`);
|
|
102
|
+
for (const k of ["from", "to", "with"]) if (m[k] !== undefined && !model.components.some((c) => c.id === m[k])) errors.push(`${w}.${k}: component not in the model (${m[k]})`);
|
|
103
|
+
if (m.op !== undefined && !["read", "insert", "update", "upsert", "delete"].includes(m.op)) errors.push(`${w}.op: read|insert|update|upsert|delete`);
|
|
104
|
+
if (m.table !== undefined && !isStr(m.table, 80)) errors.push(`${w}.table: 1-80 characters`);
|
|
105
|
+
if (m.text !== undefined && !isStr(m.text, 120)) errors.push(`${w}.text: 1-120 characters (separate alternatives with |)`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function validateIntent(intent, model) {
|
|
109
|
+
const errors = [];
|
|
110
|
+
if (!isObj(intent)) return ["intent: must be an object"];
|
|
111
|
+
if (intent.schemaVersion !== 1) errors.push("intent.schemaVersion: 1");
|
|
112
|
+
if (!isStr(intent.title, 160)) errors.push("intent.title: 1-160 characters (what the work set out to build)");
|
|
113
|
+
if (intent.source !== undefined && !isStr(intent.source, 300)) errors.push("intent.source: 1-300 characters (where the plan lives)");
|
|
114
|
+
if (!Array.isArray(intent.expectations) || intent.expectations.length === 0 || intent.expectations.length > 60) return [...errors, "intent.expectations: an array of 1-60 items"];
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
intent.expectations.forEach((e, i) => {
|
|
117
|
+
const w = `intent.expectations[${i}]`;
|
|
118
|
+
if (!isObj(e)) return errors.push(`${w}: must be an object`);
|
|
119
|
+
if (!/^[a-z0-9][a-z0-9-]{0,40}$/.test(e.id ?? "")) errors.push(`${w}.id: lowercase letters, digits and - (1-41 characters)`);
|
|
120
|
+
else if (seen.has(e.id)) errors.push(`${w}.id: duplicate ${e.id}`);
|
|
121
|
+
seen.add(e.id);
|
|
122
|
+
if (!isStr(e.text, 300)) errors.push(`${w}.text: 1-300 characters (the planner's own words)`);
|
|
123
|
+
if (e.useCase !== undefined && !model.useCases.some((u) => u.id === e.useCase)) errors.push(`${w}.useCase: use case not in the model (${e.useCase})`);
|
|
124
|
+
if (e.check === undefined) return; // allowed: stays `unknown`
|
|
125
|
+
const c = e.check;
|
|
126
|
+
if (!isObj(c) || !CHECKS.includes(c.type)) return errors.push(`${w}.check.type: ${CHECKS.join("|")}`);
|
|
127
|
+
if (e.useCase === undefined) errors.push(`${w}.useCase: required when check is given`);
|
|
128
|
+
if (c.type === "order") {
|
|
129
|
+
for (const k of Object.keys(c)) if (!["type", "first", "then"].includes(k)) errors.push(`${w}.check.${k}: unknown field`);
|
|
130
|
+
validateMatcher(c.first, `${w}.check.first`, errors, model); validateMatcher(c.then, `${w}.check.then`, errors, model);
|
|
131
|
+
} else {
|
|
132
|
+
for (const k of Object.keys(c)) if (!["type", "match"].includes(k)) errors.push(`${w}.check.${k}: unknown field`);
|
|
133
|
+
validateMatcher(c.match, `${w}.check.match`, errors, model);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
return errors;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function stepMatches(model, st, m) {
|
|
140
|
+
if (m.kind !== undefined && st.kind !== m.kind) return false;
|
|
141
|
+
if (m.from !== undefined && st.from !== m.from) return false;
|
|
142
|
+
if (m.to !== undefined && st.to !== m.to) return false;
|
|
143
|
+
if (m.with !== undefined && !pathOf(st).includes(m.with)) return false;
|
|
144
|
+
if (m.table !== undefined || m.op !== undefined) {
|
|
145
|
+
const hit = (st.data ?? []).some((d) => (m.table === undefined || d.table === m.table) && (m.op === undefined || d.op === m.op));
|
|
146
|
+
if (!hit) return false;
|
|
147
|
+
}
|
|
148
|
+
if (m.text !== undefined) {
|
|
149
|
+
const hay = [st.label, st.narration, st.request, st.response, ...(st.data ?? []).map((d) => `${d.table} ${(d.columns ?? []).join(" ")}`)].filter(Boolean).join(" ").toLowerCase();
|
|
150
|
+
if (!m.text.toLowerCase().split("|").map((t) => t.trim()).filter(Boolean).some((t) => hay.includes(t))) return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Status per expectation: met (the model shows it, with the steps as proof), unmet (the use case exists but the
|
|
157
|
+
* model shows no such step / the opposite), unknown (no mechanical check, or nothing to compare).
|
|
158
|
+
* `touched` marks expectations whose proof steps were changed by this work, so the planner re-reads them.
|
|
159
|
+
* `uncovered` = changed steps that no expectation points at (candidates for scope drift; a human decides).
|
|
160
|
+
*/
|
|
161
|
+
export function checkIntent(model, intent, changes = null) {
|
|
162
|
+
const cs = new Map((changes?.steps ?? []).map((s) => [stepKey(s.useCase, s.step), s]));
|
|
163
|
+
const proofOf = (uc, st) => ({ useCase: uc.id, step: st.id, label: st.label, evidence: evText(st), change: cs.get(stepKey(uc.id, st.id))?.change ?? null });
|
|
164
|
+
const results = intent.expectations.map((e) => {
|
|
165
|
+
const base = { id: e.id, text: e.text, ...(e.useCase ? { useCase: e.useCase } : {}) };
|
|
166
|
+
if (!e.check) return { ...base, status: "unknown", reason: "No automatic check. Add a check that names the model step that proves it, or have a person confirm it.", proof: [] };
|
|
167
|
+
const uc = model.useCases.find((u) => u.id === e.useCase);
|
|
168
|
+
const find = (m) => uc.steps.filter((s) => stepMatches(model, s, m));
|
|
169
|
+
let status, reason, proof = [];
|
|
170
|
+
if (e.check.type === "step") {
|
|
171
|
+
const hit = find(e.check.match);
|
|
172
|
+
status = hit.length ? "met" : "unmet"; proof = hit.map((s) => proofOf(uc, s));
|
|
173
|
+
reason = hit.length ? `${hit.length} matching ${hit.length === 1 ? "step" : "steps"}.` : "No step of this use case matches.";
|
|
174
|
+
} else if (e.check.type === "absent") {
|
|
175
|
+
const hit = find(e.check.match);
|
|
176
|
+
status = hit.length ? "unmet" : "met"; proof = hit.map((s) => proofOf(uc, s));
|
|
177
|
+
reason = hit.length ? "A step that should not exist is there." : "No step matches (as intended).";
|
|
178
|
+
} else {
|
|
179
|
+
const f = find(e.check.first), t = find(e.check.then);
|
|
180
|
+
const idx = (s) => uc.steps.indexOf(s);
|
|
181
|
+
if (!f.length || !t.length) {
|
|
182
|
+
status = "unmet"; proof = [...f, ...t].map((s) => proofOf(uc, s));
|
|
183
|
+
reason = `This use case has no ${!f.length ? "step that should come first" : "step that should come later"}.`;
|
|
184
|
+
} else if (f.some((a) => t.every((b) => idx(a) < idx(b)))) {
|
|
185
|
+
status = "met"; proof = [...f, ...t].map((s) => proofOf(uc, s)); reason = "The step that should come first comes before the later one.";
|
|
186
|
+
} else {
|
|
187
|
+
status = "unmet"; proof = [...f, ...t].map((s) => proofOf(uc, s)); reason = "The order is reversed, or the earlier step is missing.";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { ...base, status, reason, proof, touched: proof.some((p) => p.change) };
|
|
191
|
+
});
|
|
192
|
+
const referenced = new Set(results.flatMap((r) => r.proof.map((p) => stepKey(p.useCase, p.step))));
|
|
193
|
+
// an expectation also claims its whole use case's changed steps when it has no check but names the use case
|
|
194
|
+
for (const e of intent.expectations) if (e.useCase && !e.check) for (const s of changes?.steps ?? []) if (s.useCase === e.useCase) referenced.add(stepKey(s.useCase, s.step));
|
|
195
|
+
const stepOf = (k) => { const [u, s] = k.split("/"); const uc = model.useCases.find((x) => x.id === u); return { uc, st: uc?.steps.find((x) => x.id === s) }; };
|
|
196
|
+
const uncovered = (changes?.steps ?? []).filter((s) => !referenced.has(stepKey(s.useCase, s.step))).map((s) => {
|
|
197
|
+
const { uc, st } = stepOf(stepKey(s.useCase, s.step));
|
|
198
|
+
return { useCase: s.useCase, step: s.step, label: st?.label ?? s.step, useCaseTitle: uc?.title ?? s.useCase, change: s.change, evidence: st ? evText(st) : "" };
|
|
199
|
+
});
|
|
200
|
+
const count = (k) => results.filter((r) => r.status === k).length;
|
|
201
|
+
return { title: intent.title, source: intent.source ?? null, results, uncovered, summary: { total: results.length, met: count("met"), unmet: count("unmet"), unknown: count("unknown"), touched: results.filter((r) => r.touched).length, uncovered: uncovered.length, changedSteps: (changes?.steps ?? []).length } };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ───────────────────────────── C5: regression map ─────────────────────────────
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Other use cases that share something the change touched:
|
|
208
|
+
* - table a changed step reads/writes a table another use case also uses
|
|
209
|
+
* - endpoint / part / wire a changed detail item (own evidence changed) that a step of another use case passes
|
|
210
|
+
* - component a component whose own evidence (start-up/config) changed and the use case passes through it
|
|
211
|
+
* `direct` = the use case holds a changed step itself. Components that are merely endpoints of a changed step
|
|
212
|
+
* are NOT used as a link (every use case passes the API: that would flag everything).
|
|
213
|
+
*/
|
|
214
|
+
export function buildImpact(model, changes = null, tests = null) {
|
|
215
|
+
const csteps = changes?.steps ?? [];
|
|
216
|
+
const changedKeys = new Set(csteps.map((s) => stepKey(s.useCase, s.step)));
|
|
217
|
+
const info = new Map(model.useCases.map((u) => [u.id, { direct: [], reasons: [] }]));
|
|
218
|
+
const stepOf = (uid, sid) => model.useCases.find((u) => u.id === uid)?.steps.find((s) => s.id === sid);
|
|
219
|
+
for (const c of csteps) info.get(c.useCase)?.direct.push({ step: c.step, change: c.change, label: stepOf(c.useCase, c.step)?.label ?? c.step });
|
|
220
|
+
|
|
221
|
+
// what the changed steps touch
|
|
222
|
+
const touchedTables = new Map(), touchedEndpoints = new Map();
|
|
223
|
+
for (const c of csteps) {
|
|
224
|
+
const st = stepOf(c.useCase, c.step);
|
|
225
|
+
for (const d of st?.data ?? []) touchedTables.set(`${d.store}.${d.table}`, [...(touchedTables.get(`${d.store}.${d.table}`) ?? []), { useCase: c.useCase, step: c.step, op: d.op }]);
|
|
226
|
+
for (const e of st?.endpoints ?? []) touchedEndpoints.set(e, [...(touchedEndpoints.get(e) ?? []), { useCase: c.useCase, step: c.step }]);
|
|
227
|
+
}
|
|
228
|
+
const detailHits = new Map((changes?.details ?? []).map((d) => [`${d.component}|${d.kind}|${d.id}`, d]));
|
|
229
|
+
const strongComps = new Set((changes?.components ?? []).filter((c) => c.note.startsWith(COMPONENT_NOTE)).map((c) => c.id));
|
|
230
|
+
|
|
231
|
+
for (const u of model.useCases) {
|
|
232
|
+
const r = info.get(u.id);
|
|
233
|
+
for (const st of u.steps) {
|
|
234
|
+
const here = { useCase: u.id, step: st.id, label: st.label };
|
|
235
|
+
const own = changedKeys.has(stepKey(u.id, st.id)); // a changed step already explains its own parts, wires and endpoints
|
|
236
|
+
for (const d of st.data ?? []) {
|
|
237
|
+
const k = `${d.store}.${d.table}`;
|
|
238
|
+
const src = (touchedTables.get(k) ?? []).filter((x) => x.useCase !== u.id);
|
|
239
|
+
if (src.length) r.reasons.push({ kind: "table", ref: k, ...here, text: `Does ${d.op} on table ${k}, the same table as the changed ${src.map((x) => `${x.useCase}/${x.step} (${x.op})`).join(", ")}.` });
|
|
240
|
+
if (!own && detailHits.has(`${d.store}|table|${d.table}`)) r.reasons.push({ kind: "table", ref: k, ...here, text: `The definition (schema) evidence of table ${k} changed in this work.` });
|
|
241
|
+
}
|
|
242
|
+
for (const e of st.endpoints ?? []) {
|
|
243
|
+
const src = (touchedEndpoints.get(e) ?? []).filter((x) => x.useCase !== u.id);
|
|
244
|
+
if (src.length) r.reasons.push({ kind: "endpoint", ref: e, ...here, text: `Calls the external endpoint ${e}, the same call as the changed ${src.map((x) => `${x.useCase}/${x.step}`).join(", ")}.` });
|
|
245
|
+
const [c, id] = e.split("/");
|
|
246
|
+
if (!own && detailHits.has(`${c}|endpoint|${id}`)) r.reasons.push({ kind: "endpoint", ref: e, ...here, text: `The definition evidence of endpoint ${e} changed in this work.` });
|
|
247
|
+
}
|
|
248
|
+
const trace = st.trace ?? [];
|
|
249
|
+
trace.forEach((p, i) => {
|
|
250
|
+
const [c, id] = p.split("/");
|
|
251
|
+
if (!own && detailHits.has(`${c}|part|${id}`)) r.reasons.push({ kind: "part", ref: p, ...here, text: `Passes the part ${p}, whose evidence code changed in this work.` });
|
|
252
|
+
const nx = trace[i + 1];
|
|
253
|
+
if (!own && nx && nx.split("/")[0] === c && detailHits.has(`${c}|wire|${id}>${nx.split("/")[1]}`)) r.reasons.push({ kind: "wire", ref: `${p}>${nx}`, ...here, text: `Passes the wire ${p} → ${nx}, whose evidence code changed in this work.` });
|
|
254
|
+
});
|
|
255
|
+
for (const c of new Set(pathOf(st))) if (!own && strongComps.has(c)) r.reasons.push({ kind: "component", ref: c, ...here, text: `The component ${c}'s own evidence code (start-up, configuration) changed in this work.` });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const testFiles = new Set(tests?.changedTests ?? []);
|
|
260
|
+
const items = [];
|
|
261
|
+
for (const u of model.useCases) {
|
|
262
|
+
const r = info.get(u.id);
|
|
263
|
+
// one reason per (kind, ref, step) is enough
|
|
264
|
+
const seen = new Set(), reasons = r.reasons.filter((x) => { const k = `${x.kind}|${x.ref}|${x.step}`; if (seen.has(k)) return false; seen.add(k); return true; });
|
|
265
|
+
if (!r.direct.length && !reasons.length) continue;
|
|
266
|
+
const found = tests?.byUseCase?.[u.id];
|
|
267
|
+
const t = !tests ? { state: "unknown", files: [], note: "The repository was not read, so tests were not looked for." }
|
|
268
|
+
: found?.length ? { state: found.some((f) => testFiles.has(f.file)) ? "covered-changed" : "covered", files: found, note: found.some((f) => testFiles.has(f.file)) ? "A test that mentions this path changed in this work too." : "A test mentions this path, but it did not change in this work." }
|
|
269
|
+
: { state: "none-found", files: [], note: "No test file mentions this path (a guess from path strings: tests that name it differently are missed)." };
|
|
270
|
+
items.push({
|
|
271
|
+
id: u.id, title: u.title, trigger: u.trigger, relation: r.direct.length ? "direct" : "indirect",
|
|
272
|
+
direct: r.direct, reasons, tests: t,
|
|
273
|
+
// what to re-check by hand: the steps that moved, or the steps that share the changed thing
|
|
274
|
+
recheck: [...new Set([...r.direct.map((d) => d.step), ...reasons.map((x) => x.step)])].map((sid) => ({ step: sid, label: stepOf(u.id, sid)?.label ?? sid })),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const rank = (i) => (i.relation === "direct" ? 0 : 2) + (i.tests.state === "none-found" ? 0 : i.tests.state === "unknown" ? 0 : 1);
|
|
278
|
+
items.sort((a, b) => rank(a) - rank(b));
|
|
279
|
+
const withoutTests = items.filter((i) => i.tests.state === "none-found").length;
|
|
280
|
+
return {
|
|
281
|
+
useCases: items,
|
|
282
|
+
summary: { direct: items.filter((i) => i.relation === "direct").length, indirect: items.filter((i) => i.relation === "indirect").length, withoutTests, changedSteps: changedKeys.size, testsKnown: !!tests },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** `/api/orders` style path tokens in a trigger line ("GET /api/auth/start → provider → GET /api/auth/callback" has two) */
|
|
287
|
+
export function triggerPaths(trigger) {
|
|
288
|
+
return [...new Set([...String(trigger ?? "").matchAll(/\/[A-Za-z0-9_\-./{}:]*[A-Za-z0-9_}]/g)].map((m) => m[0]))].filter((p) => p.length > 1);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Test files that mention a use case's route. `files` = [{path, text}] of test files at head;
|
|
293
|
+
* `changedTests` = test files the diff touched. A path counts at a boundary (`/api/orders` matches `/api/orders/1`
|
|
294
|
+
* and `"/api/orders"`, not `/api/orders-archive`). Route parameters (`:id`, `{id}`) cut the literal at the parameter.
|
|
295
|
+
*/
|
|
296
|
+
export function discoverTests(model, files, changedTests = []) {
|
|
297
|
+
const byUseCase = {};
|
|
298
|
+
for (const u of model.useCases) {
|
|
299
|
+
const paths = triggerPaths(u.trigger).map((p) => p.split(/\/[:{]/)[0]).filter((p) => p.length > 1);
|
|
300
|
+
const hits = [];
|
|
301
|
+
for (const f of files) {
|
|
302
|
+
const n = paths.filter((p) => { let i = f.text.indexOf(p); while (i >= 0) { const nx = f.text[i + p.length]; if (!nx || !/[A-Za-z0-9_-]/.test(nx)) return true; i = f.text.indexOf(p, i + 1); } return false; });
|
|
303
|
+
if (n.length) hits.push({ file: f.path, paths: n });
|
|
304
|
+
}
|
|
305
|
+
byUseCase[u.id] = hits;
|
|
306
|
+
}
|
|
307
|
+
return { byUseCase, changedTests };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export const isTestFile = (path) => ignoreReason(path) === "test" && !/(^|\/)fixtures\//i.test(path);
|
|
311
|
+
|
|
312
|
+
// ───────────────────────────── bundle ─────────────────────────────
|
|
313
|
+
|
|
314
|
+
export function buildReview(model, changes, { beforeModel = null, intent = null, tests = null } = {}) {
|
|
315
|
+
const review = {};
|
|
316
|
+
if (changes || beforeModel) review.compare = buildCompare(model, changes, beforeModel);
|
|
317
|
+
if (intent) review.intent = checkIntent(model, intent, changes);
|
|
318
|
+
if (changes) review.impact = buildImpact(model, changes, tests);
|
|
319
|
+
return Object.keys(review).length ? review : null;
|
|
320
|
+
}
|
|
321
|
+
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// Candidate finder for drafting a model. Output is a list of *leads* with file:line, not facts:
|
|
2
|
+
// the author must read the code and write evidence themselves. Read-only; uses `git ls-files`.
|
|
3
|
+
//
|
|
4
|
+
// Stacks understood: Node (Express, Hono, Fastify, Koa, Next, NestJS), Python (FastAPI, Flask, Django),
|
|
5
|
+
// Go (net/http, chi, gin, echo), Java (Spring annotations), plus infra files (nginx, Caddy, Ingress, compose, wrangler).
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
const SKIP = /(^|\/)(node_modules|dist|build|out|\.git|vendor|coverage|\.wrangler|\.next|\.astro|\.venv|venv|__pycache__|target)\//;
|
|
11
|
+
const CODE = /\.(m?[jt]sx?|py|go|rs|java|kt|rb|php|cs|sql|toml|ya?ml|jsonc?|astro|vue|svelte|conf|hcl)$|(^|\/)Caddyfile$/i;
|
|
12
|
+
const TESTISH = /(^|\/)(tests?|__tests__|e2e|fixtures)\/|\.(test|spec)\.|_test\.go$|(^|\/)test_[^/]+\.py$/i;
|
|
13
|
+
const CONFIG_NAMES = /(^|\/)(wrangler\.(toml|jsonc?)|docker-compose[^/]*\.ya?ml|compose\.ya?ml|Dockerfile[^/]*|serverless\.ya?ml|vercel\.json|fly\.toml|netlify\.toml|_routes\.json|_worker\.js|openapi\.(json|ya?ml)|package\.json|go\.mod|requirements[^/]*\.txt|pyproject\.toml|Caddyfile|nginx[^/]*\.conf|[^/]+\.tf)$/;
|
|
14
|
+
|
|
15
|
+
/* ---------- stack ---------- */
|
|
16
|
+
const LANG_BY_EXT = { js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "javascript", ts: "typescript", tsx: "typescript", py: "python", go: "go", java: "java", kt: "kotlin", rb: "ruby", rs: "rust", php: "php", cs: "csharp" };
|
|
17
|
+
// package name (npm / pip / go module fragment) -> label
|
|
18
|
+
const LIBS = {
|
|
19
|
+
express: "express", hono: "hono", fastify: "fastify", koa: "koa", next: "next", "@nestjs/core": "nestjs", "@hapi/hapi": "hapi", restify: "restify", nuxt: "nuxt", "@sveltejs/kit": "sveltekit", "itty-router": "itty-router",
|
|
20
|
+
pg: "pg", mysql2: "mysql2", "better-sqlite3": "better-sqlite3", sqlite3: "sqlite3", mongoose: "mongoose", mongodb: "mongodb", ioredis: "ioredis", redis: "redis", "@prisma/client": "prisma", "drizzle-orm": "drizzle", typeorm: "typeorm", sequelize: "sequelize", knex: "knex", "@libsql/client": "libsql", axios: "axios", got: "got",
|
|
21
|
+
fastapi: "fastapi", flask: "flask", django: "django", starlette: "starlette", aiohttp: "aiohttp", tornado: "tornado", sanic: "sanic", bottle: "bottle",
|
|
22
|
+
sqlalchemy: "sqlalchemy", psycopg2: "psycopg2", "psycopg2-binary": "psycopg2", psycopg: "psycopg", asyncpg: "asyncpg", pymysql: "pymysql", pymongo: "pymongo", httpx: "httpx", requests: "requests",
|
|
23
|
+
"gin-gonic/gin": "gin", "labstack/echo": "echo", "go-chi/chi": "chi", "gorilla/mux": "gorilla-mux", "gofiber/fiber": "fiber", "gorm.io/gorm": "gorm", "jackc/pgx": "pgx", "lib/pq": "pq", "redis/go-redis": "go-redis", "go-redis/redis": "go-redis",
|
|
24
|
+
};
|
|
25
|
+
const IMPORT_LIBS = [
|
|
26
|
+
/(?:require\(\s*|from\s+|import\s+)["'](express|hono|fastify|koa|next|@nestjs\/core|pg|mysql2|better-sqlite3|sqlite3|mongoose|mongodb|ioredis|redis|axios|got)["']/g,
|
|
27
|
+
/^\s*(?:from|import)\s+(fastapi|flask|django|starlette|aiohttp|sqlalchemy|psycopg2?|asyncpg|pymysql|pymongo|httpx|requests)\b/gm,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// standard-library servers (no framework to name)
|
|
31
|
+
const STD_SERVERS = [
|
|
32
|
+
[/(?:from\s+|require\(\s*)["'](?:node:)?https?["']/, "node:http", /\.(m?[jt]sx?)$/],
|
|
33
|
+
[/^\s*"net\/http"|^\s*import\s+"net\/http"|\bhttp\.NewServeMux\(|\bhttp\.ListenAndServe\(/m, "net/http", /\.go$/],
|
|
34
|
+
[/^\s*(?:from|import)\s+http\.server\b/m, "http.server", /\.py$/],
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function detectManifestLibs(file, text, add) {
|
|
38
|
+
const base = file.slice(file.lastIndexOf("/") + 1);
|
|
39
|
+
if (base === "package.json") {
|
|
40
|
+
try {
|
|
41
|
+
const j = JSON.parse(text);
|
|
42
|
+
for (const k of Object.keys({ ...j.dependencies, ...j.devDependencies, ...j.peerDependencies })) if (LIBS[k]) add(LIBS[k]);
|
|
43
|
+
} catch { /* not valid json */ }
|
|
44
|
+
} else if (/^requirements.*\.txt$|^pyproject\.toml$|^Pipfile$/.test(base)) {
|
|
45
|
+
for (const ln of text.split("\n")) {
|
|
46
|
+
const m = /^\s*["']?([A-Za-z0-9_.-]+)/.exec(ln);
|
|
47
|
+
if (m && LIBS[m[1].toLowerCase()]) add(LIBS[m[1].toLowerCase()]);
|
|
48
|
+
for (const k of Object.keys(LIBS)) if (/^[a-z0-9_-]+$/.test(k) && new RegExp(`["']${k}[\\s>=<~!\\]"']`, "i").test(ln)) add(LIBS[k]);
|
|
49
|
+
}
|
|
50
|
+
} else if (base === "go.mod") {
|
|
51
|
+
for (const [k, v] of Object.entries(LIBS)) if ((k.includes("/") || k.includes(".")) && text.includes(k)) add(v);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ---------- route leads ---------- */
|
|
56
|
+
const METHODS = "get|post|put|patch|delete|all|options|head";
|
|
57
|
+
const ROUTER_NAME = /^(app|router|api|server|fastify|hono|koa|route|routes|r|e|g|mux|engine|group|v\d+|\w*Router|\w*[Aa]pp|\w*Routes?)$/;
|
|
58
|
+
const RE_JS_ROUTE = new RegExp(`([A-Za-z_$][\\w$]*)\\.(${METHODS})\\(\\s*(["'\`])(\\/[^"'\`]*)\\3`, "i");
|
|
59
|
+
const RE_ROUTE_CALL = /\.route\(\s*["'`](\/[^"'`]*)["'`]/;
|
|
60
|
+
const RE_GO_HANDLE = /\.(?:HandleFunc|Handle)\(\s*"([^"]+)"/;
|
|
61
|
+
const RE_GO_VERB = /\.(Get|Post|Put|Patch|Delete|Options|Head|GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|ANY)\(\s*"(\/[^"]*)"/;
|
|
62
|
+
const RE_PY_DECO = /@[\w.]+\.(get|post|put|patch|delete|route|api_route|websocket)\(\s*["']([^"']*)["']/;
|
|
63
|
+
const RE_PY_METHODS = /methods\s*=\s*\[([^\]]*)\]/;
|
|
64
|
+
const RE_DJANGO = /\b(?:re_)?path\(\s*r?["']([^"']*)["']\s*,/;
|
|
65
|
+
const RE_SPRING = /@(Get|Post|Put|Patch|Delete|Request)Mapping\(\s*(?:value\s*=\s*)?["']([^"']+)["']/;
|
|
66
|
+
const RE_NEST = /@(Get|Post|Put|Patch|Delete|All|Options|Head)\(\s*(?:["'`]([^"'`]*)["'`])?\s*\)/;
|
|
67
|
+
|
|
68
|
+
// hand-written routers: a string literal that is exactly "METHOD /path" (switch/case, key === "GET /x", route tables)
|
|
69
|
+
const RE_VERB_LITERAL = /["'`](GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) (\/[^\s"'`]*)["'`]/;
|
|
70
|
+
|
|
71
|
+
function routeFromLine(file, ln, fileHasMethod = false) {
|
|
72
|
+
return routeByFramework(file, ln) ?? literalRoute(file, ln) ?? dispatchRoute(file, ln, fileHasMethod);
|
|
73
|
+
}
|
|
74
|
+
// one handler that branches on the HTTP method (Go stdlib, Node http, Python http.server / hand-rolled)
|
|
75
|
+
function dispatchRoute(file, ln, fileHasMethod) {
|
|
76
|
+
let m;
|
|
77
|
+
if (file.endsWith(".go") && (m = /\bcase\s+http\.Method(Get|Post|Put|Patch|Delete|Head|Options)\b|\.Method\s*==\s*http\.Method(Get|Post|Put|Patch|Delete)\b/.exec(ln)))
|
|
78
|
+
return { method: (m[1] ?? m[2]).toUpperCase(), path: "(method dispatch in one handler)" };
|
|
79
|
+
if (/\.(m?[jt]sx?)$/.test(file) && fileHasMethod && (m = /\bcase\s+["'](GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)["']\s*:/.exec(ln)))
|
|
80
|
+
return { method: m[1], path: "(method dispatch in one handler)" };
|
|
81
|
+
if (file.endsWith(".py") && (m = /\.method\s*(?:==|in)\s*[\(\[]?\s*["'](GET|POST|PUT|PATCH|DELETE)["']/.exec(ln)))
|
|
82
|
+
return { method: m[1], path: "(method dispatch in one handler)" };
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function literalRoute(file, ln) {
|
|
86
|
+
if (!/\.(m?[jt]sx?|py|go|java|kt|rb|php|cs)$/.test(file)) return null;
|
|
87
|
+
const m = RE_VERB_LITERAL.exec(ln);
|
|
88
|
+
return m ? { method: m[1], path: m[2] } : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function routeByFramework(file, ln) {
|
|
92
|
+
const isGo = file.endsWith(".go"), isPy = file.endsWith(".py"), isJs = /\.(m?[jt]sx?|vue|svelte|astro)$/.test(file);
|
|
93
|
+
let m;
|
|
94
|
+
if (isPy) {
|
|
95
|
+
if ((m = RE_PY_DECO.exec(ln))) {
|
|
96
|
+
const verb = m[1].toLowerCase();
|
|
97
|
+
let method = verb === "route" || verb === "api_route" ? "ANY" : verb.toUpperCase();
|
|
98
|
+
const ms = RE_PY_METHODS.exec(ln);
|
|
99
|
+
if (ms) method = ms[1].replace(/["'\s]/g, "").toUpperCase() || method;
|
|
100
|
+
return { method, path: m[2] };
|
|
101
|
+
}
|
|
102
|
+
if (/urlpatterns|path\(|re_path\(/.test(ln) && (m = RE_DJANGO.exec(ln)) && /urls?\.py$|urlpatterns/.test(file + ln)) return { method: "ANY", path: m[1] };
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (isGo) {
|
|
106
|
+
if ((m = RE_GO_HANDLE.exec(ln))) {
|
|
107
|
+
const mm = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\/.*)$/.exec(m[1]);
|
|
108
|
+
return mm ? { method: mm[1], path: mm[2] } : { method: "ANY", path: m[1] };
|
|
109
|
+
}
|
|
110
|
+
if ((m = RE_GO_VERB.exec(ln))) return { method: m[1].toUpperCase(), path: m[2] };
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (file.endsWith(".java") || file.endsWith(".kt")) {
|
|
114
|
+
return (m = RE_SPRING.exec(ln)) ? { method: m[1] === "Request" ? "ANY" : m[1].toUpperCase(), path: m[2] } : null;
|
|
115
|
+
}
|
|
116
|
+
if (isJs) {
|
|
117
|
+
if ((m = RE_NEST.exec(ln))) return { method: m[1].toUpperCase(), path: m[2] ?? "" };
|
|
118
|
+
if ((m = RE_JS_ROUTE.exec(ln)) && ROUTER_NAME.test(m[1])) return { method: m[2].toUpperCase(), path: m[4] };
|
|
119
|
+
if ((m = RE_ROUTE_CALL.exec(ln))) return { method: "ANY", path: m[1] };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Next.js file-based routes: pages/api/** and app/**\/route.(ts|js). Returns [{method,path,line}] */
|
|
125
|
+
function nextRoutes(file, text) {
|
|
126
|
+
let m = /(?:^|\/)pages\/api\/(.+)\.(?:[mc]?[jt]sx?)$/.exec(file);
|
|
127
|
+
if (m) return [{ method: "ANY", path: "/api/" + m[1].replace(/\/?index$/, ""), line: 1 }];
|
|
128
|
+
m = /(?:^|\/)app\/(.+)\/route\.(?:[mc]?[jt]sx?)$/.exec(file) ?? /(?:^|\/)app\/()route\.(?:[mc]?[jt]sx?)$/.exec(file);
|
|
129
|
+
if (!m) return [];
|
|
130
|
+
const dir = ("/" + (m[1] ?? "")).replace(/\/\([^/)]+\)/g, "");
|
|
131
|
+
const out = [];
|
|
132
|
+
text.split("\n").forEach((ln, i) => {
|
|
133
|
+
const e = /export\s+(?:async\s+)?(?:function|const)\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/.exec(ln);
|
|
134
|
+
if (e) out.push({ method: e[1], path: dir === "" ? "/" : dir, line: i + 1 });
|
|
135
|
+
});
|
|
136
|
+
return out.length ? out : [{ method: "ANY", path: dir || "/", line: 1 }];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ---------- other leads ---------- */
|
|
140
|
+
const GATEWAY = [
|
|
141
|
+
/\bproxy_pass\b/, /\breverse_proxy\b/, /\bNewSingleHostReverseProxy\b|\bReverseProxy\b/, /\bcreateProxyMiddleware\b|\bhttp-proxy\b|\bhttpProxy\b|\bproxyPass\b/,
|
|
142
|
+
/^\s*kind:\s*Ingress\b|\bIngressRoute\b/, /\bimage:\s*["']?(?:[\w.\/-]*\/)?(?:traefik|nginx|envoyproxy|haproxy|kong|caddy)\b/,
|
|
143
|
+
/\brewrites\s*\(|\bdestination:\s*["']https?:\/\//, /\baws_api_gateway|AWS::ApiGateway|\bapigateway\b/i,
|
|
144
|
+
/\bfetch\(\s*[\w.$]+\s*\+\s*(?:req|request)\.(?:url|path)/, /\bconst\s+upstream\b|\bupstream\s*=/,
|
|
145
|
+
];
|
|
146
|
+
const DATA = [
|
|
147
|
+
/\.(prepare|query|execute|exec)\(\s*[`"']\s*(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER)\b/i,
|
|
148
|
+
/\b(new Pool|createPool|createClient|MongoClient|sqlite3?\.connect|redis\.)/,
|
|
149
|
+
/(?:require\(\s*|from\s+|import\s+)["'](pg|mysql2?|better-sqlite3|sqlite3|mongoose|mongodb|ioredis|redis|@prisma\/client|drizzle-orm[^"']*|typeorm|sequelize|knex|@libsql\/client|node:sqlite)["']/,
|
|
150
|
+
/\bnew\s+(Pool|Client|PrismaClient|Redis|DatabaseSync|MongoClient|Sequelize)\(/,
|
|
151
|
+
/^\s*(?:from|import)\s+(sqlalchemy|psycopg2?|asyncpg|pymysql|pymongo|redis|sqlite3|motor|peewee|tortoise|databases)\b/,
|
|
152
|
+
/\bcreate_engine\(|\bredis\.(Redis|from_url|StrictRedis)\(|\bsqlite3\.connect\(/,
|
|
153
|
+
/\bsql\.Open\(|\bgorm\.Open\(|\bpgxpool\.New\(|\bpgx\.Connect\(|\bredis\.NewClient\(|\bmongo\.Connect\(|\bsqlx\.(Connect|Open)\(/,
|
|
154
|
+
/^\s*image:\s*["']?(?:[\w.\/-]*\/)?(postgres|mysql|mariadb|mongo|redis|memcached|cassandra|clickhouse|elasticsearch|opensearch|rabbitmq|kafka)\b/,
|
|
155
|
+
];
|
|
156
|
+
// binding keys only count inside config files (otherwise `services: Foo[]` type fields match)
|
|
157
|
+
const DATA_CONFIG = /["']?\b(d1_databases|kv_namespaces|r2_buckets|queues|durable_objects|hyperdrive|services)\b["']?\s*[:=\[]/;
|
|
158
|
+
const CONFIG_FILE = /\.(toml|jsonc?|ya?ml)$/i;
|
|
159
|
+
const CLIENT = [
|
|
160
|
+
/\bfetch\(/, /\baxios(?:\.\w+)?\(/, /\bgot(?:\.(?:get|post|put|patch|delete))?\(\s*["'`\w]/,
|
|
161
|
+
/\brequests\.(get|post|put|patch|delete|request|Session)\b/, /\bhttpx\.(get|post|put|patch|delete|request|AsyncClient|Client)\b/, /\baiohttp\.ClientSession\b|\burllib\.request\.urlopen\b/,
|
|
162
|
+
/\bhttp\.(Get|Post|NewRequest|NewRequestWithContext)\(/, /\b(?:RestTemplate|WebClient|HttpClient)\b/, /Net::HTTP/,
|
|
163
|
+
];
|
|
164
|
+
const ENV_NAME = "[A-Z][A-Z0-9_]{1,63}";
|
|
165
|
+
const ENV_READ = [
|
|
166
|
+
new RegExp(`process\\.env\\.(${ENV_NAME})`, "g"), new RegExp(`process\\.env\\[["'](${ENV_NAME})["']\\]`, "g"),
|
|
167
|
+
new RegExp(`os\\.environ(?:\\.get)?[\\[(]\\s*["'](${ENV_NAME})["']`, "g"), new RegExp(`os\\.getenv\\(\\s*["'](${ENV_NAME})["']`, "g"),
|
|
168
|
+
new RegExp(`os\\.Getenv\\(\\s*"(${ENV_NAME})"`, "g"), new RegExp(`Deno\\.env\\.get\\(\\s*["'](${ENV_NAME})["']`, "g"),
|
|
169
|
+
];
|
|
170
|
+
const ENV_ADDRESS = /(URL|URI|HOST|ENDPOINT|ADDR|DSN|ORIGIN|UPSTREAM|BROKER|BACKEND)$|^(DATABASE|DB|REDIS|MONGO|POSTGRES|MYSQL|API|UPSTREAM)_/;
|
|
171
|
+
const ENV_SECRET = /(TOKEN|SECRET|PASSWORD|PASSWD|KEY|CREDENTIAL)/;
|
|
172
|
+
const URLRE = /https?:\/\/([A-Za-z0-9.-]+\.[A-Za-z]{2,})(?::\d+)?[^\s"'`)<>]*/g;
|
|
173
|
+
const NOISE_HOSTS = /(^|\.)(w3\.org|example\.(com|org)|localhost|schema\.org|json-schema\.org|github\.com\/actions|npmjs\.org|mozilla\.org|apache\.org|opensource\.org|creativecommons\.org)$/i;
|
|
174
|
+
|
|
175
|
+
const PER_FILE_CAP = 4; // one big file must not hide the rest of the repo
|
|
176
|
+
// migration history is schema, not data access; these files also carry URLs that are not outbound calls
|
|
177
|
+
const NOISY_DATA_DIR = /(^|\/)(migrations?|alembic\/versions|db\/migrate|versions)\//i;
|
|
178
|
+
const NOISY_HOST_FILE = /\.(sql|md|mdx|lock|json|svg)$|(^|\/)(\.github|migrations?|alembic|docs?)\/|(^|\/)Dockerfile|\.gen\.[a-z]+$|pre-commit/i;
|
|
179
|
+
|
|
180
|
+
const clip = (s, n = 140) => { s = s.trim().replace(/\s+/g, " "); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
|
|
181
|
+
|
|
182
|
+
export function scanRepo(repoPath, { limit = 60 } = {}) {
|
|
183
|
+
const list = execFileSync("git", ["-C", repoPath, "ls-files", "-z"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 })
|
|
184
|
+
.split("\0").filter(Boolean);
|
|
185
|
+
const out = { stack: { languages: [], frameworks: [] }, configs: [], routes: [], gateways: [], data: [], outbound: [], outboundEnv: [], clients: [], stats: { files: list.length, scanned: 0 } };
|
|
186
|
+
const hosts = new Map(), envs = new Map(), langCount = {}, libs = new Set();
|
|
187
|
+
for (const f of list) {
|
|
188
|
+
if (SKIP.test(f)) continue;
|
|
189
|
+
const ext = f.slice(f.lastIndexOf(".") + 1).toLowerCase();
|
|
190
|
+
if (!TESTISH.test(f) && LANG_BY_EXT[ext]) langCount[LANG_BY_EXT[ext]] = (langCount[LANG_BY_EXT[ext]] ?? 0) + 1;
|
|
191
|
+
const isConfig = CONFIG_NAMES.test(f) && !/package-lock/.test(f);
|
|
192
|
+
if (isConfig) out.configs.push({ file: f, line: 1, text: f });
|
|
193
|
+
if (!CODE.test(f) && !isConfig) continue;
|
|
194
|
+
if (TESTISH.test(f)) continue;
|
|
195
|
+
let text;
|
|
196
|
+
try { if (statSync(join(repoPath, f)).size > 400_000) continue; text = readFileSync(join(repoPath, f), "utf8"); } catch { continue; }
|
|
197
|
+
out.stats.scanned++;
|
|
198
|
+
if (isConfig) detectManifestLibs(f, text, (l) => libs.add(l));
|
|
199
|
+
for (const [re, label, only] of STD_SERVERS) if (only.test(f) && re.test(text)) libs.add(label);
|
|
200
|
+
for (const re of IMPORT_LIBS) for (const m of text.matchAll(re)) if (LIBS[m[1].toLowerCase()]) libs.add(LIBS[m[1].toLowerCase()]);
|
|
201
|
+
for (const r of nextRoutes(f, text)) out.routes.push({ file: f, line: r.line, method: r.method, path: r.path, text: `${r.method} ${r.path} (file-based route)` });
|
|
202
|
+
|
|
203
|
+
const lines = text.split("\n");
|
|
204
|
+
const fileHasMethod = /\.method\b|\bMethod\b/.test(text);
|
|
205
|
+
const noData = NOISY_DATA_DIR.test(f), noHosts = NOISY_HOST_FILE.test(f);
|
|
206
|
+
const perFile = { data: 0, clients: 0, gateways: 0 };
|
|
207
|
+
lines.forEach((ln, i) => {
|
|
208
|
+
if (ln.length > 600) return;
|
|
209
|
+
const rt = routeFromLine(f, ln, fileHasMethod);
|
|
210
|
+
if (rt) out.routes.push({ file: f, line: i + 1, method: rt.method, path: rt.path, text: clip(ln) });
|
|
211
|
+
const isComment = /^\s*(\/\/|#|\*|\/\*|<!--)/.test(ln);
|
|
212
|
+
if (perFile.gateways < PER_FILE_CAP && GATEWAY.some((re) => re.test(ln))) { out.gateways.push({ file: f, line: i + 1, text: clip(ln) }); perFile.gateways++; }
|
|
213
|
+
if (!noData && perFile.data < PER_FILE_CAP && (DATA.some((re) => re.test(ln)) || (CONFIG_FILE.test(f) && DATA_CONFIG.test(ln)))) { out.data.push({ file: f, line: i + 1, text: clip(ln) }); perFile.data++; }
|
|
214
|
+
if (perFile.clients < PER_FILE_CAP && !isComment && CLIENT.some((re) => re.test(ln))) { out.clients.push({ file: f, line: i + 1, text: clip(ln) }); perFile.clients++; }
|
|
215
|
+
for (const re of ENV_READ) for (const m of ln.matchAll(re)) {
|
|
216
|
+
const name = m[1];
|
|
217
|
+
if (ENV_SECRET.test(name) || !ENV_ADDRESS.test(name) || envs.has(name)) continue;
|
|
218
|
+
envs.set(name, { file: f, line: i + 1, name, text: clip(ln) });
|
|
219
|
+
}
|
|
220
|
+
if (!noHosts && !isComment)
|
|
221
|
+
for (const m of ln.matchAll(URLRE)) {
|
|
222
|
+
const h = m[1].toLowerCase();
|
|
223
|
+
if (NOISE_HOSTS.test(h)) continue;
|
|
224
|
+
if (!hosts.has(h)) hosts.set(h, { file: f, line: i + 1, text: clip(ln), host: h });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
out.outbound = [...hosts.values()];
|
|
229
|
+
out.outboundEnv = [...envs.values()];
|
|
230
|
+
out.stack.languages = Object.entries(langCount).sort((a, b) => b[1] - a[1]).map(([k]) => k);
|
|
231
|
+
out.stack.frameworks = [...libs].sort();
|
|
232
|
+
for (const k of ["configs", "routes", "gateways", "data", "outbound", "outboundEnv", "clients"]) {
|
|
233
|
+
out[k + "Total"] = out[k].length;
|
|
234
|
+
out[k] = out[k].slice(0, limit);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatScan(r) {
|
|
240
|
+
const line = (x) => ` ${x.file}:${x.line} ${x.host ? `[${x.host}] ` : x.name ? `[${x.name}] ` : ""}${x.text}`;
|
|
241
|
+
const sec = (title, hint, items, total) =>
|
|
242
|
+
[`## ${title} (${total}${total > items.length ? `, 상위 ${items.length}개 표시` : ""}) — ${hint}`, ...items.map(line), ""].join("\n");
|
|
243
|
+
return [
|
|
244
|
+
`# nacholens scan — 후보 목록 (사실이 아닙니다. 코드를 직접 읽고 근거를 쓰세요)`,
|
|
245
|
+
`파일 ${r.stats.files}개 중 ${r.stats.scanned}개 검사`, "",
|
|
246
|
+
`## 스택 — 언어: ${r.stack.languages.join(", ") || "(없음)"} · 프레임워크/라이브러리: ${r.stack.frameworks.join(", ") || "(없음)"}`, "",
|
|
247
|
+
sec("설정/배포 파일", "게이트웨이·바인딩·컨테이너 구성을 확인", r.configs, r.configsTotal),
|
|
248
|
+
sec("라우트 후보", "유즈케이스의 진입 요청(trigger)을 고르는 단서", r.routes, r.routesTotal),
|
|
249
|
+
sec("게이트웨이 후보", "프록시·리버스 프록시·Ingress·리라이트 — gateway 컴포넌트와 via 의 단서", r.gateways, r.gatewaysTotal),
|
|
250
|
+
sec("데이터 저장소 후보", "datastore 컴포넌트와 call 단계의 단서 (드라이버 import·연결·SQL·compose 이미지)", r.data, r.dataTotal),
|
|
251
|
+
sec("바깥으로 나가는 호스트", "external 컴포넌트 후보 (호스트별 첫 위치)", r.outbound, r.outboundTotal),
|
|
252
|
+
sec("환경변수로 주입되는 주소", "주소가 코드에 없고 설정으로 들어오는 external/내부 호출 (이름만, 값은 보지 않음)", r.outboundEnv, r.outboundEnvTotal),
|
|
253
|
+
sec("HTTP 클라이언트 호출", "외부/내부 서비스를 부르는 코드 위치 — call 단계의 단서", r.clients, r.clientsTotal),
|
|
254
|
+
].join("\n");
|
|
255
|
+
}
|
package/src/template.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Empty model template for a repo that has none yet. Every value an author must supply starts
|
|
2
|
+
// with "TODO"; validateModel reports each one with its JSON path, so the error output is the checklist.
|
|
3
|
+
export function makeTemplate({ slug = null, commit, name = null }) {
|
|
4
|
+
const id = (name ?? "my-product").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^[^a-z]+/, "") || "my-product";
|
|
5
|
+
const ev = (what) => [{ file: `TODO: ${what} 가 있는 파일 (저장소 기준 상대 경로)`, lines: [1, 1], contains: `TODO: 그 줄 범위에 실제로 있는 문자열(선택, 지우면 검사 생략)` }];
|
|
6
|
+
return {
|
|
7
|
+
schemaVersion: 1,
|
|
8
|
+
product: {
|
|
9
|
+
id,
|
|
10
|
+
name: `TODO: 제품 이름`,
|
|
11
|
+
description: `TODO: 한 줄 설명 — 사용자가 무엇을 하는 제품이고 요청이 어디를 거치는지`,
|
|
12
|
+
},
|
|
13
|
+
source: { repo: slug ?? `TODO: owner/repo`, commit },
|
|
14
|
+
components: [
|
|
15
|
+
{ id: "client", kind: "client", label: "TODO: 클라이언트 이름\nTODO: 부제", detail: "TODO: 이 컴포넌트가 하는 일 (코드에서 확인한 것만)", evidence: ev("클라이언트가 API 를 호출하는 코드") },
|
|
16
|
+
{ id: "gateway", kind: "gateway", label: "TODO: 게이트웨이 이름\nTODO: 부제", detail: "TODO: 요청을 어디로 넘기는지 (게이트웨이가 없으면 이 컴포넌트와 연결·via 를 지우세요)", evidence: ev("게이트웨이/프록시 설정·코드") },
|
|
17
|
+
{ id: "server", kind: "service", label: "TODO: 서버 이름\nTODO: 부제", detail: "TODO: 서버가 하는 일", evidence: ev("요청 핸들러 진입점") },
|
|
18
|
+
],
|
|
19
|
+
links: [
|
|
20
|
+
{ a: "client", b: "gateway", label: "TODO: 어떤 통신인지(예: HTTPS /api/*)", evidence: ev("연결을 보여주는 코드·설정") },
|
|
21
|
+
{ a: "gateway", b: "server", label: "TODO: 어떤 통신인지(예: 서비스 바인딩)", evidence: ev("연결을 보여주는 코드·설정") },
|
|
22
|
+
],
|
|
23
|
+
useCases: [
|
|
24
|
+
{
|
|
25
|
+
id: "first-use-case",
|
|
26
|
+
title: "TODO: 유즈케이스 이름 (사용자가 하는 행동, 예: 관리자 로그인)",
|
|
27
|
+
summary: "TODO: 한두 문장 요약",
|
|
28
|
+
trigger: "TODO: 진입 요청 (예: GET /api/session)",
|
|
29
|
+
steps: [
|
|
30
|
+
{ id: "request", kind: "request", from: "client", via: ["gateway"], to: "server", label: "TODO: 짧은 제목", narration: "TODO: 사람이 읽는 문장 — 무슨 일이 일어나는가", request: "TODO: 요청 요약", evidence: ev("이 단계를 보여주는 코드") },
|
|
31
|
+
{ id: "response", kind: "response", from: "server", via: ["gateway"], to: "client", label: "TODO: 짧은 제목", narration: "TODO: 사람이 읽는 문장", response: "TODO: 응답 요약", evidence: ev("응답을 만드는 코드") },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|