@dev-tren/mapd 0.21.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 +21 -0
- package/MASTER_PROMPT.md +134 -0
- package/README.md +494 -0
- package/SETUP.md +108 -0
- package/UAT.md +77 -0
- package/package.json +56 -0
- package/src/adapters/github-app.js +79 -0
- package/src/agents/anthropicClient.js +18 -0
- package/src/agents/llm.js +196 -0
- package/src/agents/modelResolver.js +87 -0
- package/src/agents/provider.js +222 -0
- package/src/chat/commandRunner.js +86 -0
- package/src/chat/commands.js +275 -0
- package/src/chat/intent.js +87 -0
- package/src/chat/llmIntent.js +118 -0
- package/src/chat/repl.js +471 -0
- package/src/cli.js +1408 -0
- package/src/config/index.js +197 -0
- package/src/config/schema.js +119 -0
- package/src/core/assist.js +64 -0
- package/src/core/audit.js +63 -0
- package/src/core/changes.js +110 -0
- package/src/core/confidence.js +0 -0
- package/src/core/configLint.js +141 -0
- package/src/core/diagnose.js +262 -0
- package/src/core/docs.js +140 -0
- package/src/core/doctor.js +134 -0
- package/src/core/envFiles.js +43 -0
- package/src/core/events.js +53 -0
- package/src/core/evidence.js +212 -0
- package/src/core/findingScoring.js +20 -0
- package/src/core/fix.js +192 -0
- package/src/core/fixApply.js +172 -0
- package/src/core/frameworkEntries.js +247 -0
- package/src/core/gates.js +209 -0
- package/src/core/graph.js +467 -0
- package/src/core/grounding.js +235 -0
- package/src/core/handoff.js +157 -0
- package/src/core/importResolver.js +218 -0
- package/src/core/improve.js +226 -0
- package/src/core/integrate.js +169 -0
- package/src/core/intelligence.js +212 -0
- package/src/core/modernize.js +370 -0
- package/src/core/parseCache.js +64 -0
- package/src/core/parser.js +536 -0
- package/src/core/policy.js +65 -0
- package/src/core/polyglot.js +333 -0
- package/src/core/proc.js +25 -0
- package/src/core/reachability.js +543 -0
- package/src/core/regression.js +193 -0
- package/src/core/resolution.js +92 -0
- package/src/core/retry.js +61 -0
- package/src/core/review.js +219 -0
- package/src/core/score.js +338 -0
- package/src/core/security.js +0 -0
- package/src/core/session.js +143 -0
- package/src/core/solutions.js +254 -0
- package/src/core/staleness.js +45 -0
- package/src/core/testGuidance.js +226 -0
- package/src/core/theme.js +50 -0
- package/src/core/trace.js +151 -0
- package/src/core/verify.js +123 -0
- package/src/core/view.js +221 -0
- package/src/core/viewServer.js +88 -0
- package/src/core/watch.js +76 -0
- package/src/core/workspace.js +115 -0
- package/src/mcp/server.js +48 -0
- package/src/mcp/tools.js +423 -0
- package/src/server.js +84 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* solutions.js — `mapd solutions`: the "bigger picture" layer above raw
|
|
3
|
+
* findings. Two layers, deliberately separated:
|
|
4
|
+
*
|
|
5
|
+
* Layer 1 (buildSolutions, this file's core) is 100% deterministic and
|
|
6
|
+
* always complete on its own, with zero LLM/network involvement:
|
|
7
|
+
* - clusters related findings by real file overlap (Union-Find) — so
|
|
8
|
+
* e.g. 5 separate duplicate-function findings that all touch the same
|
|
9
|
+
* files surface as ONE problem, not 5 unrelated line items
|
|
10
|
+
* - scores each cluster's blast radius from the actual import graph and
|
|
11
|
+
* workflow membership already computed by graph.js — how many
|
|
12
|
+
* workflows does this touch, how depended-upon are its files
|
|
13
|
+
* - ranks clusters by (finding severity/priority) × blast radius
|
|
14
|
+
* Every fact in the output — file, workflow ID, suggestion — traces back to
|
|
15
|
+
* a real finding already in the review queue. Nothing here is invented.
|
|
16
|
+
*
|
|
17
|
+
* Layer 2 (narrateSolutions) is optional and additive: if a provider is
|
|
18
|
+
* configured, it asks the LLM to write a short "why this matters" paragraph
|
|
19
|
+
* per cluster — but it may ONLY rephrase/argue from the facts layer 1
|
|
20
|
+
* already produced. The response is mechanically verified afterward (every
|
|
21
|
+
* file path and workflow ID the narrative mentions must already appear in
|
|
22
|
+
* that cluster's own data); a narrative that fails verification is thrown
|
|
23
|
+
* away, never surfaced. Layer 1's output is always complete without layer 2.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { buildScoredGraph } from "./intelligence.js";
|
|
28
|
+
import { pending, loadQueue } from "./review.js";
|
|
29
|
+
import { loadFinding } from "./fix.js";
|
|
30
|
+
import { priorityOf, filesOf } from "./findingScoring.js";
|
|
31
|
+
import { checkReportFreshness } from "./staleness.js";
|
|
32
|
+
import { verifyGrounding } from "./grounding.js";
|
|
33
|
+
|
|
34
|
+
/** Iterative Union-Find (path compression, union by nothing fancy — inputs are small). */
|
|
35
|
+
function makeUnionFind(n) {
|
|
36
|
+
const parent = Array.from({ length: n }, (_, i) => i);
|
|
37
|
+
const find = (x) => {
|
|
38
|
+
let root = x;
|
|
39
|
+
while (parent[root] !== root) root = parent[root];
|
|
40
|
+
while (parent[x] !== root) { const next = parent[x]; parent[x] = root; x = next; }
|
|
41
|
+
return root;
|
|
42
|
+
};
|
|
43
|
+
const union = (a, b) => { const ra = find(a); const rb = find(b); if (ra !== rb) parent[ra] = rb; };
|
|
44
|
+
return { find, union };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Groups findings that share at least one file. Dependency- and
|
|
49
|
+
* architecture-tier findings are excluded from this step and always kept
|
|
50
|
+
* standalone: they're already whole-repo-scope facts by construction
|
|
51
|
+
* (orphan-cluster, cjs-in-esm-project, monolithic-workflow, etc. — see
|
|
52
|
+
* modernize.js's own tiering), so letting them act as file-overlap bridges
|
|
53
|
+
* would merge unrelated local findings into one meaningless mega-cluster
|
|
54
|
+
* just because both happen to touch a widely-shared file.
|
|
55
|
+
*/
|
|
56
|
+
function clusterByFileOverlap(resolved) {
|
|
57
|
+
const { find, union } = makeUnionFind(resolved.length);
|
|
58
|
+
const fileToIndices = new Map();
|
|
59
|
+
resolved.forEach((r, i) => {
|
|
60
|
+
for (const f of r.files) {
|
|
61
|
+
if (!fileToIndices.has(f)) fileToIndices.set(f, []);
|
|
62
|
+
fileToIndices.get(f).push(i);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
for (const indices of fileToIndices.values()) {
|
|
66
|
+
for (let k = 1; k < indices.length; k++) union(indices[0], indices[k]);
|
|
67
|
+
}
|
|
68
|
+
const groups = new Map();
|
|
69
|
+
resolved.forEach((r, i) => {
|
|
70
|
+
const root = find(i);
|
|
71
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
72
|
+
groups.get(root).push(r);
|
|
73
|
+
});
|
|
74
|
+
return [...groups.values()];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Blast radius of a file set: how many of the project's own workflows
|
|
79
|
+
* include at least one of these files, and how depended-upon the
|
|
80
|
+
* most-imported file among them is (import in-degree) — both read directly
|
|
81
|
+
* off graph.js's already-computed workflow membership and import edges,
|
|
82
|
+
* never estimated.
|
|
83
|
+
*/
|
|
84
|
+
function computeBlastRadius(graph, files) {
|
|
85
|
+
const fileSet = new Set(files);
|
|
86
|
+
const workflowsTouched = graph.workflows.filter((w) => w.files.some((f) => fileSet.has(f))).map((w) => w.id);
|
|
87
|
+
const inDegree = new Map();
|
|
88
|
+
for (const e of graph.importEdges) if (e.to) inDegree.set(e.to, (inDegree.get(e.to) ?? 0) + 1);
|
|
89
|
+
const maxImportInDegree = files.reduce((max, f) => Math.max(max, inDegree.get(f) ?? 0), 0);
|
|
90
|
+
const totalWorkflows = Math.max(1, graph.workflows.length);
|
|
91
|
+
const totalFiles = Math.max(1, graph.files.length);
|
|
92
|
+
const workflowShare = workflowsTouched.length / totalWorkflows;
|
|
93
|
+
const score = Math.min(1, workflowShare + maxImportInDegree / totalFiles);
|
|
94
|
+
return {
|
|
95
|
+
workflowsTouched,
|
|
96
|
+
workflowShare: Number(workflowShare.toFixed(3)),
|
|
97
|
+
maxImportInDegree,
|
|
98
|
+
score: Number(score.toFixed(3)),
|
|
99
|
+
method: "blast-radius-v1 (share of all workflows touched + max import in-degree, both normalized by repo size)",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Layer 1: fully deterministic, no provider required. */
|
|
104
|
+
export function buildSolutions(rootDir, { top = 5 } = {}) {
|
|
105
|
+
const abs = path.resolve(rootDir);
|
|
106
|
+
const graph = buildScoredGraph(abs);
|
|
107
|
+
const freshness = checkReportFreshness(abs);
|
|
108
|
+
const staleSet = new Set(freshness.staleReports);
|
|
109
|
+
const allItems = pending(loadQueue(abs)).filter((i) => i.source === "check" || i.source.startsWith("modernize-"));
|
|
110
|
+
// Same enforcement as handoff.js: findings from a stale report are excluded
|
|
111
|
+
// from clustering/ranking entirely (with the count disclosed), never mixed
|
|
112
|
+
// into an action plan with only a banner asking the reader to discount them.
|
|
113
|
+
const items = allItems.filter((i) => !staleSet.has(path.basename(i.file)));
|
|
114
|
+
const excludedStaleCount = allItems.length - items.length;
|
|
115
|
+
|
|
116
|
+
const resolved = items
|
|
117
|
+
.map((item) => {
|
|
118
|
+
const loaded = loadFinding(abs, item.id);
|
|
119
|
+
if (!loaded) return null;
|
|
120
|
+
const finding = loaded.finding;
|
|
121
|
+
return { item, finding, tier: finding.tier ?? "check", files: filesOf(finding), priority: priorityOf(finding, item) };
|
|
122
|
+
})
|
|
123
|
+
.filter(Boolean);
|
|
124
|
+
|
|
125
|
+
const standalone = resolved.filter((r) => r.tier === "dependency" || r.tier === "architecture");
|
|
126
|
+
const clusterable = resolved.filter((r) => r.tier !== "dependency" && r.tier !== "architecture");
|
|
127
|
+
const groups = [...standalone.map((r) => [r]), ...clusterByFileOverlap(clusterable)];
|
|
128
|
+
|
|
129
|
+
const solutions = groups
|
|
130
|
+
.map((group) => {
|
|
131
|
+
const files = [...new Set(group.flatMap((r) => r.files))];
|
|
132
|
+
const blastRadius = computeBlastRadius(graph, files);
|
|
133
|
+
const memberPriority = Math.max(...group.map((r) => r.priority));
|
|
134
|
+
const priority = Number((memberPriority * (0.5 + 0.5 * blastRadius.score)).toFixed(3));
|
|
135
|
+
return {
|
|
136
|
+
clusterId: group.map((r) => r.item.id).sort().join("+"),
|
|
137
|
+
kinds: [...new Set(group.map((r) => r.finding.kind ?? r.finding.rule))],
|
|
138
|
+
files,
|
|
139
|
+
priority,
|
|
140
|
+
blastRadius,
|
|
141
|
+
members: group.map((r) => ({
|
|
142
|
+
id: r.item.id, source: r.item.source, kind: r.finding.kind ?? r.finding.rule,
|
|
143
|
+
severity: r.finding.severity ?? null, detail: r.finding.detail,
|
|
144
|
+
files: r.files, suggestion: r.finding.suggestion ?? null,
|
|
145
|
+
})),
|
|
146
|
+
narrative: null,
|
|
147
|
+
};
|
|
148
|
+
})
|
|
149
|
+
.sort((a, b) => b.priority - a.priority)
|
|
150
|
+
.slice(0, top);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
root: abs, fileCount: graph.stats.fileCount, workflowCount: graph.workflows.length,
|
|
154
|
+
freshness, excludedStaleCount, solutions,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const NARRATE_SYSTEM_PROMPT =
|
|
159
|
+
"You will receive a JSON array of \"clusters\" — groups of related, already-verified static-analysis " +
|
|
160
|
+
"findings from a tool called Map'd, each with real file paths, workflow IDs, and numeric scores.\n\n" +
|
|
161
|
+
"Your ONLY job: for each cluster, write a short \"whyItMatters\" paragraph (2-4 sentences) explaining " +
|
|
162
|
+
"its priority, using ONLY the facts given.\n\n" +
|
|
163
|
+
"Strict rules:\n" +
|
|
164
|
+
"- Never mention a file path that is not in that cluster's \"files\" list.\n" +
|
|
165
|
+
"- Never mention a workflow ID that is not in that cluster's \"blastRadius.workflowsTouched\" list.\n" +
|
|
166
|
+
"- Never state a suggestion beyond what is already in that cluster's members' \"suggestion\" fields — " +
|
|
167
|
+
"you may combine or rephrase them, never invent a new one.\n" +
|
|
168
|
+
"- Never state a number that is not already present in the cluster's data.\n" +
|
|
169
|
+
"- If you are not confident you can do this without adding anything new, write \"\" for that cluster instead of guessing.\n\n" +
|
|
170
|
+
"Respond with ONLY a JSON array, same length and order as the input, each element: {\"whyItMatters\": \"...\"}";
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Layer 2: optional. `data` is buildSolutions' output. Every candidate
|
|
174
|
+
* narrative is mechanically checked against that same cluster's real data
|
|
175
|
+
* before being attached — a narrative that cites a file or workflow ID not
|
|
176
|
+
* already present in the cluster is discarded, never surfaced, regardless
|
|
177
|
+
* of how plausible the prose reads. Fails safe to the unmodified layer-1
|
|
178
|
+
* data on no provider, no response, malformed JSON, or a shape mismatch.
|
|
179
|
+
*/
|
|
180
|
+
export async function narrateSolutions(data, provider, { graph } = {}) {
|
|
181
|
+
if (!provider?.available?.() || !data.solutions.length) return data;
|
|
182
|
+
|
|
183
|
+
const payload = data.solutions.map((s) => ({
|
|
184
|
+
kinds: s.kinds, files: s.files, priority: s.priority, blastRadius: s.blastRadius,
|
|
185
|
+
members: s.members.map((m) => ({ kind: m.kind, severity: m.severity, detail: m.detail, suggestion: m.suggestion })),
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
let raw;
|
|
189
|
+
try { raw = await provider.complete(NARRATE_SYSTEM_PROMPT, JSON.stringify(payload), 2000); }
|
|
190
|
+
catch { return data; }
|
|
191
|
+
if (!raw) return data;
|
|
192
|
+
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
const jsonMatch = raw.match(/\[[\s\S]*\]/);
|
|
196
|
+
parsed = JSON.parse(jsonMatch ? jsonMatch[0] : raw);
|
|
197
|
+
} catch { return data; }
|
|
198
|
+
if (!Array.isArray(parsed) || parsed.length !== data.solutions.length) return data;
|
|
199
|
+
|
|
200
|
+
const solutions = data.solutions.map((s, i) => {
|
|
201
|
+
const text = parsed[i]?.whyItMatters;
|
|
202
|
+
if (typeof text !== "string" || !text.trim()) return s;
|
|
203
|
+
|
|
204
|
+
const check = verifyGrounding(text, { files: s.files, workflowIds: s.blastRadius.workflowsTouched, graph });
|
|
205
|
+
if (!check.grounded) {
|
|
206
|
+
const v = check.violations[0];
|
|
207
|
+
return { ...s, narrative: null, narrativeSkippedReason: `mentioned a ${v.type} ("${v.value}") not in this cluster's data` };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { ...s, narrative: text.trim() };
|
|
211
|
+
});
|
|
212
|
+
return { ...data, solutions };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Renders buildSolutions' (optionally narrateSolutions'd) data as plain text. */
|
|
216
|
+
export function renderSolutions(data) {
|
|
217
|
+
if (!data.solutions.length) {
|
|
218
|
+
// "no open findings" and "every finding was excluded as stale" are very
|
|
219
|
+
// different situations — say which one actually happened.
|
|
220
|
+
return data.excludedStaleCount > 0
|
|
221
|
+
? `All ${data.excludedStaleCount} open finding(s) come from stale report(s) (${data.freshness.staleReports.join(", ")}) that ` +
|
|
222
|
+
"predate a more recent source change — nothing current to synthesize. Re-run `mapd check`/`mapd modernize` first."
|
|
223
|
+
: "No open findings — nothing to synthesize. Run `mapd check` / `mapd modernize` first.";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const lines = [];
|
|
227
|
+
if (data.freshness?.stale && data.excludedStaleCount > 0) {
|
|
228
|
+
lines.push(`⚠ STALE REPORTS EXCLUDED: ${data.freshness.staleReports.join(", ")} predate a more recent source change. ` +
|
|
229
|
+
`${data.excludedStaleCount} finding(s) sourced from them were EXCLUDED from the solutions below (not just flagged). ` +
|
|
230
|
+
"Re-run `mapd check`/`mapd modernize` to refresh and include them.");
|
|
231
|
+
lines.push("");
|
|
232
|
+
} else if (data.freshness?.stale) {
|
|
233
|
+
// stale report(s) exist but contributed no open findings — one quiet line,
|
|
234
|
+
// not a warning banner about zero exclusions
|
|
235
|
+
lines.push(`note: ${data.freshness.staleReports.join(", ")} predate a more recent source change but contributed no open findings; re-run \`mapd check\`/\`mapd modernize\` to refresh.`);
|
|
236
|
+
lines.push("");
|
|
237
|
+
}
|
|
238
|
+
lines.push(`Map'd solutions — ${data.fileCount} files, ${data.workflowCount} workflows`, "");
|
|
239
|
+
data.solutions.forEach((s, i) => {
|
|
240
|
+
const header = `${i + 1}. ${s.kinds.join(" + ").toUpperCase()} (priority ${s.priority}, ${s.members.length} finding(s))`;
|
|
241
|
+
lines.push(header);
|
|
242
|
+
lines.push("-".repeat(header.length));
|
|
243
|
+
lines.push(`Blast radius: touches ${s.blastRadius.workflowsTouched.length} workflow(s) ` +
|
|
244
|
+
`(${(s.blastRadius.workflowShare * 100).toFixed(0)}% of all workflows), ` +
|
|
245
|
+
`most-depended-upon file has ${s.blastRadius.maxImportInDegree} importer(s).`);
|
|
246
|
+
if (s.blastRadius.workflowsTouched.length) lines.push(`Workflows: ${s.blastRadius.workflowsTouched.join(", ")}`);
|
|
247
|
+
lines.push(`Files: ${s.files.join(", ")}`);
|
|
248
|
+
if (s.narrative) lines.push(`Why it matters: ${s.narrative}`);
|
|
249
|
+
else if (s.narrativeSkippedReason) lines.push(`(narrative skipped — ${s.narrativeSkippedReason})`);
|
|
250
|
+
for (const m of s.members) lines.push(` - [${m.id}] ${m.kind}${m.severity ? ` (${m.severity})` : ""}: ${m.detail}`);
|
|
251
|
+
lines.push("");
|
|
252
|
+
});
|
|
253
|
+
return lines.join("\n").trimEnd();
|
|
254
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* staleness.js — reports from `mapd check`/`mapd modernize` are written to
|
|
3
|
+
* disk once and re-read by review.js, handoff.js, solutions.js, and chat's
|
|
4
|
+
* /findings and /review every time after that. Nothing previously compared
|
|
5
|
+
* a report's age against the current tree, so a finding that was already
|
|
6
|
+
* fixed (in this project or by an external agent) kept getting presented as
|
|
7
|
+
* current. That's a real form of overclaiming — stale data shown as current
|
|
8
|
+
* fact without disclosure — so this is checked and surfaced explicitly
|
|
9
|
+
* wherever those reports get read, never silently trusted.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { latestSourceMtime } from "./parser.js";
|
|
15
|
+
|
|
16
|
+
const REPORT_FILES = ["findings.json", "modernize-light.json", "modernize-medium.json", "modernize-heavy.json"];
|
|
17
|
+
|
|
18
|
+
function readJson(p) {
|
|
19
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compares each present report's `generatedAt` against the most recent
|
|
24
|
+
* source-file mtime in the project. Returns `{checked:false}` when no
|
|
25
|
+
* reports exist yet (nothing to be stale). Never mutates or deletes
|
|
26
|
+
* anything — purely informational, for callers to disclose.
|
|
27
|
+
*/
|
|
28
|
+
export function checkReportFreshness(rootDir) {
|
|
29
|
+
const abs = path.resolve(rootDir);
|
|
30
|
+
const dir = path.join(abs, ".mapd");
|
|
31
|
+
const reports = REPORT_FILES
|
|
32
|
+
.map((name) => ({ name, path: path.join(dir, name) }))
|
|
33
|
+
.filter((r) => fs.existsSync(r.path))
|
|
34
|
+
.map((r) => ({ ...r, data: readJson(r.path) }))
|
|
35
|
+
.filter((r) => r.data?.generatedAt);
|
|
36
|
+
|
|
37
|
+
if (!reports.length) return { checked: false, stale: false, staleReports: [] };
|
|
38
|
+
|
|
39
|
+
const latestSource = latestSourceMtime(abs);
|
|
40
|
+
const staleReports = reports
|
|
41
|
+
.filter((r) => new Date(r.data.generatedAt).getTime() < latestSource)
|
|
42
|
+
.map((r) => r.name);
|
|
43
|
+
|
|
44
|
+
return { checked: true, stale: staleReports.length > 0, staleReports, latestSourceMtime: latestSource };
|
|
45
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testGuidance.js — Test Guidance: make the testPresence signal honest.
|
|
3
|
+
*
|
|
4
|
+
* confidence.js credits a source file as "tested" if ANY file on a test path
|
|
5
|
+
* merely contains its basename as a substring (see hasTestFor). That is cheap
|
|
6
|
+
* to satisfy and easy to fool: an empty `foo.test.js`, or a `foobar.test.js`
|
|
7
|
+
* that never touches foo, both earn full credit.
|
|
8
|
+
*
|
|
9
|
+
* This module re-derives that relationship from real structure — the parsed
|
|
10
|
+
* imports and exports already on every graph node — and separates:
|
|
11
|
+
* tested-real a matching test imports THIS module AND references ≥1 export
|
|
12
|
+
* tested-shallow a matching test imports the module XOR references an export
|
|
13
|
+
* tested-nameonly the basename rule credits it, but no import/symbol link
|
|
14
|
+
* exists → padding: it inflates the score without evidence
|
|
15
|
+
* untested no test matches by the basename rule at all
|
|
16
|
+
*
|
|
17
|
+
* It never rewrites the score here (that would move every baseline). It reports
|
|
18
|
+
* how much of testPresence is real vs padding, so the inflation is visible —
|
|
19
|
+
* and `mapd score` can be hardened as an explicit, separate decision.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
/** The LITERAL rules confidence.js uses — imported nowhere else, mirrored here so the report can quote them exactly. */
|
|
25
|
+
export const TEST_PATH_RE = /(\.test\.|\.spec\.|__tests__\/|tests?\/)/;
|
|
26
|
+
export const SRC_EXT_RE = /\.(js|ts|jsx|tsx|mjs|cjs|py|go|rs|rb|java|php)$/;
|
|
27
|
+
|
|
28
|
+
const basenameKey = (file) => path.posix.basename(file).replace(SRC_EXT_RE, "");
|
|
29
|
+
const stripExt = (file) => file.replace(SRC_EXT_RE, "");
|
|
30
|
+
|
|
31
|
+
/** Resolve a relative import from `importer` to a repo-relative path without extension; null for bare/external. */
|
|
32
|
+
function resolveImport(importer, source) {
|
|
33
|
+
if (!source || !source.startsWith(".")) return null;
|
|
34
|
+
return stripExt(path.posix.normalize(path.posix.join(path.posix.dirname(importer), source)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const normName = (s) => s.toLowerCase().replace(/[-_]/g, "");
|
|
38
|
+
const GENERIC_TEST_TOKENS = new Set(["test", "tests", "spec", "specs", "__tests__", "e2e", "unit", "integration"]);
|
|
39
|
+
|
|
40
|
+
/** Name tokens a test file answers to: its path segments/words plus its whole stem (logger-redaction → logger, redaction, loggerredaction). */
|
|
41
|
+
function testNameTokens(testFile) {
|
|
42
|
+
const stem = stripExt(testFile);
|
|
43
|
+
const tokens = new Set();
|
|
44
|
+
for (const seg of stem.split("/")) {
|
|
45
|
+
const words = seg.split(".").filter((w) => !GENERIC_TEST_TOKENS.has(w.toLowerCase()));
|
|
46
|
+
if (!words.length) continue;
|
|
47
|
+
tokens.add(normName(words.join("")));
|
|
48
|
+
for (const w of words.join("-").split(/[-_]/)) if (w && !GENERIC_TEST_TOKENS.has(w.toLowerCase())) tokens.add(normName(w));
|
|
49
|
+
}
|
|
50
|
+
return tokens;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const QUALITY_RANK = { real: 3, shallow: 2, nameonly: 1 };
|
|
54
|
+
|
|
55
|
+
function suggestTestName(file, hasTopTestsDir) {
|
|
56
|
+
const ext = (file.match(SRC_EXT_RE)?.[0] ?? ".js").slice(1);
|
|
57
|
+
const testExt = ext === "mjs" || ext === "cjs" ? "js" : ext;
|
|
58
|
+
const base = basenameKey(file);
|
|
59
|
+
return hasTopTestsDir ? `tests/${base}.test.${testExt}` : `${path.posix.dirname(file)}/${base}.test.${testExt}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The canonical per-source test-credit classification, derived from real parsed
|
|
64
|
+
* imports/exports. Returns one record per non-test source file. This is the
|
|
65
|
+
* SINGLE definition of "is this file really tested" — confidence.js consumes it
|
|
66
|
+
* so the score and this report can never disagree.
|
|
67
|
+
*/
|
|
68
|
+
export function classifyTestCredit(graph) {
|
|
69
|
+
const files = graph.files ?? [];
|
|
70
|
+
const testFiles = files.filter((f) => TEST_PATH_RE.test(f.file));
|
|
71
|
+
const srcFiles = files.filter((f) => !TEST_PATH_RE.test(f.file));
|
|
72
|
+
const wfFiles = new Set(graph.workflows.flatMap((w) => w.files)); // only these move testPresence
|
|
73
|
+
const hasTopTestsDir = files.some((f) => /^tests?\//.test(f.file));
|
|
74
|
+
|
|
75
|
+
// Precompute each test file's resolved import targets + imported symbol names.
|
|
76
|
+
const testMeta = testFiles.map((t) => {
|
|
77
|
+
const targets = new Set();
|
|
78
|
+
const names = new Set();
|
|
79
|
+
for (const imp of t.imports ?? []) {
|
|
80
|
+
// A type-only import exercises nothing at runtime, so it must not earn
|
|
81
|
+
// test credit — otherwise `import type { Finding }` reads as coverage.
|
|
82
|
+
if (imp.typeOnly) continue;
|
|
83
|
+
const r = resolveImport(t.file, imp.source);
|
|
84
|
+
if (r) targets.add(r);
|
|
85
|
+
for (const n of imp.names ?? []) names.add(n);
|
|
86
|
+
}
|
|
87
|
+
return { file: t.file, targets, names, nameTokens: testNameTokens(t.file) };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return srcFiles.map((s) => {
|
|
91
|
+
const key = basenameKey(s.file);
|
|
92
|
+
const sNoExt = stripExt(s.file);
|
|
93
|
+
const exportSet = new Set(s.exports ?? []);
|
|
94
|
+
|
|
95
|
+
// Credit a test that NAMES this file (tests/logger.test.ts) OR one that
|
|
96
|
+
// simply IMPORTS it. Gating on the filename alone missed real coverage:
|
|
97
|
+
// tests/setup.test.ts imports redactSecrets from src/logger.ts, exercises
|
|
98
|
+
// it properly, and was reported as untested purely because the test is not
|
|
99
|
+
// called "logger". An import is stronger evidence than a filename.
|
|
100
|
+
// Name matching is whole-token, not substring: a raw `includes` let src/a.js
|
|
101
|
+
// "match" tests/format.test.js and src/test.js match every test file.
|
|
102
|
+
const nameKey = normName(key);
|
|
103
|
+
const nameMatched = testMeta.filter((t) => t.nameTokens.has(nameKey) || t.targets.has(sNoExt));
|
|
104
|
+
|
|
105
|
+
const credits = nameMatched.map((t) => {
|
|
106
|
+
const pathLinked = t.targets.has(sNoExt);
|
|
107
|
+
const symbolLinked = exportSet.size > 0 && [...exportSet].some((e) => t.names.has(e));
|
|
108
|
+
const quality = pathLinked && symbolLinked ? "real"
|
|
109
|
+
: pathLinked || symbolLinked ? "shallow"
|
|
110
|
+
: "nameonly";
|
|
111
|
+
return { test: t.file, quality, pathLinked, symbolLinked };
|
|
112
|
+
}).sort((a, b) => QUALITY_RANK[b.quality] - QUALITY_RANK[a.quality]);
|
|
113
|
+
|
|
114
|
+
const best = credits[0];
|
|
115
|
+
const status = !nameMatched.length ? "untested"
|
|
116
|
+
: best.quality === "real" ? "tested-real"
|
|
117
|
+
: best.quality === "shallow" ? "tested-shallow"
|
|
118
|
+
: "tested-nameonly";
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
file: s.file,
|
|
122
|
+
inWorkflow: wfFiles.has(s.file),
|
|
123
|
+
exportCount: exportSet.size,
|
|
124
|
+
status,
|
|
125
|
+
credits,
|
|
126
|
+
suggestedTest: suggestTestName(s.file, hasTopTestsDir),
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Files that count toward the HONEST testPresence signal (real or shallow credit). */
|
|
132
|
+
export function honestlyTestedFiles(graph) {
|
|
133
|
+
return new Set(classifyTestCredit(graph)
|
|
134
|
+
.filter((s) => s.status === "tested-real" || s.status === "tested-shallow")
|
|
135
|
+
.map((s) => s.file));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Analyze every workflow source file's test relationship. Returns per-file
|
|
140
|
+
* classification + credit map + an honest-vs-current testPresence summary.
|
|
141
|
+
*/
|
|
142
|
+
export function analyzeTestCoverage(rootDir, graph) {
|
|
143
|
+
const perSource = classifyTestCredit(graph);
|
|
144
|
+
|
|
145
|
+
const wf = perSource.filter((s) => s.inWorkflow);
|
|
146
|
+
const count = (st) => wf.filter((s) => s.status === st).length;
|
|
147
|
+
const real = count("tested-real"), shallow = count("tested-shallow");
|
|
148
|
+
const nameOnly = count("tested-nameonly"), untested = count("untested");
|
|
149
|
+
const total = wf.length || 1;
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
rule: {
|
|
153
|
+
honest: 'a source file counts toward testPresence only if a matching test IMPORTS the module (path-linked) and/or REFERENCES one of its exports (symbol-linked) — real or shallow credit',
|
|
154
|
+
looseLegacy: 'the retired substring rule credited any test whose path merely contained the basename; name-only matches now earn nothing',
|
|
155
|
+
},
|
|
156
|
+
summary: {
|
|
157
|
+
workflowFiles: wf.length,
|
|
158
|
+
real, shallow, nameOnlyPadding: nameOnly, untested,
|
|
159
|
+
// testPresence is now the HONEST value and matches what confidence.js scores.
|
|
160
|
+
testPresence: Number(((real + shallow) / total).toFixed(3)),
|
|
161
|
+
// what the retired substring rule would have counted — kept only to show the gap.
|
|
162
|
+
looseRuleWouldCredit: Number(((real + shallow + nameOnly) / total).toFixed(3)),
|
|
163
|
+
paddingRejected: Number((nameOnly / total).toFixed(3)),
|
|
164
|
+
},
|
|
165
|
+
files: perSource,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── views ──────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/** Files that lower testPresence: untested, plus name-only padding (credited but unbacked). */
|
|
172
|
+
export function testGaps(analysis, { includeShallow = false } = {}) {
|
|
173
|
+
const wanted = new Set(["untested", "tested-nameonly", ...(includeShallow ? ["tested-shallow"] : [])]);
|
|
174
|
+
return analysis.files
|
|
175
|
+
.filter((s) => s.inWorkflow && wanted.has(s.status))
|
|
176
|
+
.sort((a, b) => a.status.localeCompare(b.status) || a.file.localeCompare(b.file));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The source→test crediting map. `paddingOnly` narrows to the anti-padding view. */
|
|
180
|
+
export function testCredit(analysis, { paddingOnly = false } = {}) {
|
|
181
|
+
return analysis.files.filter((s) => {
|
|
182
|
+
if (!s.credits.length && !paddingOnly) return s.inWorkflow; // untested workflow files still shown
|
|
183
|
+
if (paddingOnly) return s.status === "tested-nameonly";
|
|
184
|
+
return s.credits.length > 0;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── renderers ────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
export function renderTestGaps(analysis, gaps, theme) {
|
|
191
|
+
const { bold, dim, red, green, yellow, cyan } = theme;
|
|
192
|
+
const s = analysis.summary;
|
|
193
|
+
const lines = [`\n${bold("Test gaps")} — ${s.workflowFiles} workflow file(s)`];
|
|
194
|
+
lines.push(` testPresence (honest, scored): ${s.testPresence < 0.5 ? red(s.testPresence) : green(s.testPresence)}${s.paddingRejected > 0 ? dim(` (the retired substring rule would have inflated this to ${s.looseRuleWouldCredit})`) : ""}`);
|
|
195
|
+
lines.push(` ${green(`${s.real} real`)} · ${cyan(`${s.shallow} shallow`)} · ${yellow(`${s.nameOnlyPadding} name-only padding (earns nothing)`)} · ${red(`${s.untested} untested`)}`);
|
|
196
|
+
lines.push(dim(` rule: ${analysis.rule.honest}`));
|
|
197
|
+
if (!gaps.length) { lines.push(green("\n No gaps — every workflow file has a real or shallow test.")); return lines.join("\n"); }
|
|
198
|
+
const label = { "untested": red("untested"), "tested-nameonly": yellow("name-only padding"), "tested-shallow": cyan("shallow") };
|
|
199
|
+
for (const g of gaps) {
|
|
200
|
+
lines.push(`\n ${label[g.status] ?? g.status} ${bold(g.file)}${g.exportCount === 0 ? dim(" (no exports)") : ""}`);
|
|
201
|
+
if (g.status === "tested-nameonly") {
|
|
202
|
+
lines.push(dim(` credited by ${g.credits.map((c) => c.test).join(", ")} — but no import or export reference; the credit is a filename coincidence`));
|
|
203
|
+
} else if (g.status === "untested") {
|
|
204
|
+
lines.push(dim(` suggested: ${g.suggestedTest} (import ${g.file}, exercise ${g.exportCount ? "its exports" : "its behavior"})`));
|
|
205
|
+
} else {
|
|
206
|
+
lines.push(dim(` credited by ${g.credits[0].test} — ${g.credits[0].pathLinked ? "imports the module but references no export" : "references an export but doesn't import the module"}`));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return lines.join("\n");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function renderTestCredit(rows, theme, { paddingOnly = false } = {}) {
|
|
213
|
+
const { bold, dim, red, green, yellow, cyan } = theme;
|
|
214
|
+
const qColor = { real: green, shallow: cyan, nameonly: yellow };
|
|
215
|
+
const lines = [`\n${bold(paddingOnly ? "Test credit — padding suspects" : "Test credit")}`];
|
|
216
|
+
if (!rows.length) { lines.push(green(paddingOnly ? "\n No padding suspects — all credits are real or shallow." : "\n No credited files.")); return lines.join("\n"); }
|
|
217
|
+
for (const r of rows) {
|
|
218
|
+
if (!r.credits.length) { lines.push(`\n ${red("untested")} ${bold(r.file)} ${dim(`→ suggest ${r.suggestedTest}`)}`); continue; }
|
|
219
|
+
lines.push(`\n ${bold(r.file)} ${dim(`(${r.exportCount} export(s))`)}`);
|
|
220
|
+
for (const c of r.credits) {
|
|
221
|
+
const links = [c.pathLinked ? "imports module" : null, c.symbolLinked ? "uses export" : null].filter(Boolean).join(" + ") || "name match only";
|
|
222
|
+
lines.push(` ${qColor[c.quality](c.quality.padEnd(8))} ${c.test} ${dim(`(${links})`)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return lines.join("\n");
|
|
226
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* theme.js — minimal ANSI terminal styling for human-readable CLI output.
|
|
3
|
+
* No new dependency (plain escape codes). Respects NO_COLOR and non-TTY
|
|
4
|
+
* output (piped/redirected) by degrading to plain text automatically — never
|
|
5
|
+
* pollutes --json output or a file redirect with escape codes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function colorEnabled() {
|
|
9
|
+
if (process.env.NO_COLOR) return false;
|
|
10
|
+
if (process.env.FORCE_COLOR) return true;
|
|
11
|
+
return !!process.stdout.isTTY;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function wrap(code) {
|
|
15
|
+
return (text) => (colorEnabled() ? `\x1b[${code}m${text}\x1b[0m` : String(text));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const bold = wrap(1);
|
|
19
|
+
export const dim = wrap(2);
|
|
20
|
+
export const red = wrap(31);
|
|
21
|
+
export const green = wrap(32);
|
|
22
|
+
export const yellow = wrap(33);
|
|
23
|
+
export const blue = wrap(34);
|
|
24
|
+
export const magenta = wrap(35);
|
|
25
|
+
export const cyan = wrap(36);
|
|
26
|
+
|
|
27
|
+
/** Green when healthy, yellow when marginal, red when fragile — a quick visual cue, not a new signal. */
|
|
28
|
+
export function confidenceColor(score) {
|
|
29
|
+
if (score >= 0.8) return green;
|
|
30
|
+
if (score >= 0.5) return yellow;
|
|
31
|
+
return red;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wraps `items` (strings) into lines no wider than `width`, joined by `sep`, each subsequent line indented. */
|
|
35
|
+
export function wrapList(items, { width = 100, indent = " ", sep = ", " } = {}) {
|
|
36
|
+
if (!items.length) return "";
|
|
37
|
+
const lines = [];
|
|
38
|
+
let current = "";
|
|
39
|
+
for (const item of items) {
|
|
40
|
+
const candidate = current ? `${current}${sep}${item}` : item;
|
|
41
|
+
if (candidate.length > width && current) {
|
|
42
|
+
lines.push(current);
|
|
43
|
+
current = item;
|
|
44
|
+
} else {
|
|
45
|
+
current = candidate;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (current) lines.push(current);
|
|
49
|
+
return lines.map((l, i) => (i === 0 ? l : `${indent}${l}`)).join("\n");
|
|
50
|
+
}
|