@stupify/cli 0.0.14 → 0.0.16
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/README.md +4 -1
- package/dist/analysis.js +3 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/doctor.d.ts +14 -2
- package/dist/doctor.js +12 -0
- package/dist/git.d.ts +6 -1
- package/dist/git.js +71 -1
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +18 -0
- package/dist/render.d.ts +3 -0
- package/dist/render.js +165 -21
- package/dist/sem-provider.js +3 -0
- package/dist/stupify.js +106 -13
- package/dist/trace.d.ts +2 -0
- package/dist/trace.js +22 -0
- package/dist/types.d.ts +13 -0
- package/package.json +6 -4
- package/src/analysis.ts +3 -0
- package/src/constants.ts +1 -1
- package/src/doctor.ts +27 -1
- package/src/git.ts +76 -2
- package/src/hooks.ts +17 -0
- package/src/render.ts +196 -22
- package/src/sem-provider.ts +6 -3
- package/src/stupify.ts +113 -23
- package/src/trace.ts +23 -0
- package/src/types.ts +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stupify contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Local-only diagnostic CLI for checking whether AI is making you dumber.
|
|
4
4
|
|
|
5
|
+
Released under the MIT License.
|
|
6
|
+
|
|
5
7
|
Stupify has one analysis path:
|
|
6
8
|
|
|
7
9
|
```text
|
|
@@ -48,7 +50,8 @@ stupify --staged --max-search-input-tokens 24000
|
|
|
48
50
|
```
|
|
49
51
|
|
|
50
52
|
The package is prepared for the public `@stupify` npm scope. Publishing should
|
|
51
|
-
|
|
53
|
+
use the repository release workflow so npm receives Trusted Publishing
|
|
54
|
+
provenance. See the repository release docs.
|
|
52
55
|
|
|
53
56
|
This iteration intentionally does not run findings audit, validators, judges,
|
|
54
57
|
baselines, hosted LLM APIs, GitHub integration, dashboards, or repo-wide
|
package/dist/analysis.js
CHANGED
|
@@ -74,6 +74,9 @@ function uncheckedSearchMatches(value, contexts) {
|
|
|
74
74
|
reason: match.reason ?? "",
|
|
75
75
|
proof: sourcePointer(context),
|
|
76
76
|
snapshot: sourceSnapshot(context),
|
|
77
|
+
filePath: context.filePath,
|
|
78
|
+
entityName: context.entityName,
|
|
79
|
+
entityKind: context.entityKind,
|
|
77
80
|
}];
|
|
78
81
|
});
|
|
79
82
|
}
|
package/dist/constants.d.ts
CHANGED
package/dist/constants.js
CHANGED
package/dist/doctor.d.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
import type { CliUi } from "./ui.ts";
|
|
2
|
+
type CheckStatus = "ok" | "missing" | "info";
|
|
3
|
+
type DoctorCheck = Readonly<{
|
|
4
|
+
label: string;
|
|
5
|
+
status: CheckStatus;
|
|
6
|
+
detail: string;
|
|
7
|
+
required?: boolean;
|
|
8
|
+
}>;
|
|
9
|
+
export type DoctorResult = Readonly<{
|
|
2
10
|
exitCode: number;
|
|
3
11
|
text: string;
|
|
4
|
-
|
|
12
|
+
checks: readonly DoctorCheck[];
|
|
13
|
+
}>;
|
|
14
|
+
export declare function runDoctor(): Promise<DoctorResult>;
|
|
15
|
+
export declare function renderDoctorToUi(result: DoctorResult, ui: CliUi): void;
|
|
16
|
+
export {};
|
package/dist/doctor.js
CHANGED
|
@@ -19,8 +19,20 @@ export async function runDoctor() {
|
|
|
19
19
|
return {
|
|
20
20
|
exitCode: requiredMissing ? 1 : 0,
|
|
21
21
|
text: renderDoctor(checks),
|
|
22
|
+
checks,
|
|
22
23
|
};
|
|
23
24
|
}
|
|
25
|
+
export function renderDoctorToUi(result, ui) {
|
|
26
|
+
const missingRequired = result.checks.filter((check) => check.required && check.status === "missing");
|
|
27
|
+
if (missingRequired.length > 0) {
|
|
28
|
+
ui.error(`Doctor found ${missingRequired.length} missing required dependency.`);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
ui.success("Doctor checks complete.");
|
|
32
|
+
}
|
|
33
|
+
ui.note(result.checks.map((check) => `${icon(check.status)} ${check.label}: ${check.detail}`).join("\n"), "Doctor");
|
|
34
|
+
ui.note("Local-only. Stupify does not upload source, diffs, filenames, repo URLs, commit messages, author names, or private package names.", "Privacy");
|
|
35
|
+
}
|
|
24
36
|
async function gitCheck() {
|
|
25
37
|
try {
|
|
26
38
|
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { maxBuffer: 1024 * 1024 });
|
package/dist/git.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type NetDiff, type SourceRange, type StagedDiff } from "./types.ts";
|
|
1
|
+
import { type BlameSummary, type NetDiff, type SourceRange, type StagedDiff } from "./types.ts";
|
|
2
2
|
export declare function netDiffSince(since: string): Promise<NetDiff>;
|
|
3
3
|
export declare function netDiffForCommit(commit: string): Promise<NetDiff>;
|
|
4
4
|
export declare function netDiffForRecentCommits(count: number): Promise<NetDiff>;
|
|
@@ -10,3 +10,8 @@ export declare function stagedDiff(): Promise<StagedDiff>;
|
|
|
10
10
|
export declare function gitRoot(): Promise<string>;
|
|
11
11
|
export declare function gitPath(pathspec: string): Promise<string>;
|
|
12
12
|
export declare function gitUserLabel(): Promise<string>;
|
|
13
|
+
export declare function blameEntity(input: Readonly<{
|
|
14
|
+
filePath: string;
|
|
15
|
+
entityName: string;
|
|
16
|
+
rev: string;
|
|
17
|
+
}>): Promise<BlameSummary | null>;
|
package/dist/git.js
CHANGED
|
@@ -98,6 +98,23 @@ export async function gitUserLabel() {
|
|
|
98
98
|
return `${name} <${email}>`;
|
|
99
99
|
return name || email || "working tree";
|
|
100
100
|
}
|
|
101
|
+
export async function blameEntity(input) {
|
|
102
|
+
try {
|
|
103
|
+
const { stdout } = await execFileAsync("git", [
|
|
104
|
+
"blame",
|
|
105
|
+
"--line-porcelain",
|
|
106
|
+
"-L",
|
|
107
|
+
`:${input.entityName}`,
|
|
108
|
+
input.rev,
|
|
109
|
+
"--",
|
|
110
|
+
input.filePath,
|
|
111
|
+
], { maxBuffer: 16 * 1024 * 1024 });
|
|
112
|
+
return summarizeBlame(stdout);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
101
118
|
async function netDiff(base, target, label, id) {
|
|
102
119
|
const [text, stats, shortBase, shortTarget] = await Promise.all([
|
|
103
120
|
diff(base, target),
|
|
@@ -115,11 +132,12 @@ async function netDiff(base, target, label, id) {
|
|
|
115
132
|
};
|
|
116
133
|
}
|
|
117
134
|
async function sourceRange(base, target, label, id) {
|
|
118
|
-
const [stats, shortBase, shortTarget, committers] = await Promise.all([
|
|
135
|
+
const [stats, shortBase, shortTarget, committers, commitSubjects] = await Promise.all([
|
|
119
136
|
diffStats(base, target),
|
|
120
137
|
shortCommit(base),
|
|
121
138
|
shortCommit(target),
|
|
122
139
|
committersForRange(base, target),
|
|
140
|
+
commitSubjectsForRange(base, target),
|
|
123
141
|
]);
|
|
124
142
|
return {
|
|
125
143
|
id: id ?? sourceId(`net:${shortBase}..${shortTarget}`),
|
|
@@ -127,6 +145,7 @@ async function sourceRange(base, target, label, id) {
|
|
|
127
145
|
base,
|
|
128
146
|
target,
|
|
129
147
|
committers,
|
|
148
|
+
commitSubjects,
|
|
130
149
|
stats,
|
|
131
150
|
};
|
|
132
151
|
}
|
|
@@ -150,6 +169,17 @@ async function committersForRange(base, target) {
|
|
|
150
169
|
return [];
|
|
151
170
|
}
|
|
152
171
|
}
|
|
172
|
+
async function commitSubjectsForRange(base, target) {
|
|
173
|
+
try {
|
|
174
|
+
const { stdout } = await execFileAsync("git", ["log", "--format=%s", `${base}..${target}`], {
|
|
175
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
176
|
+
});
|
|
177
|
+
return uniqueLines(stdout);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
153
183
|
function uniqueLines(value) {
|
|
154
184
|
const seen = new Set();
|
|
155
185
|
const lines = [];
|
|
@@ -296,3 +326,43 @@ async function commitMessage(commit) {
|
|
|
296
326
|
function firstLine(value) {
|
|
297
327
|
return value.trim().split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
298
328
|
}
|
|
329
|
+
function summarizeBlame(output) {
|
|
330
|
+
const entries = new Map();
|
|
331
|
+
let currentCommit = "";
|
|
332
|
+
let currentAuthor = "";
|
|
333
|
+
let currentSubject = "";
|
|
334
|
+
for (const line of output.split(/\r?\n/)) {
|
|
335
|
+
const header = /^([0-9a-f]{40})\s+/.exec(line);
|
|
336
|
+
if (header?.[1]) {
|
|
337
|
+
currentCommit = header[1];
|
|
338
|
+
currentAuthor = "";
|
|
339
|
+
currentSubject = "";
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (line.startsWith("author ")) {
|
|
343
|
+
currentAuthor = line.slice("author ".length).trim();
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (line.startsWith("summary ")) {
|
|
347
|
+
currentSubject = line.slice("summary ".length).trim();
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (!line.startsWith("\t") || !currentCommit)
|
|
351
|
+
continue;
|
|
352
|
+
const previous = entries.get(currentCommit);
|
|
353
|
+
entries.set(currentCommit, {
|
|
354
|
+
commit: currentCommit,
|
|
355
|
+
author: currentAuthor || previous?.author || "unknown author",
|
|
356
|
+
subject: currentSubject || previous?.subject || currentCommit.slice(0, 7),
|
|
357
|
+
count: (previous?.count ?? 0) + 1,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const [best] = [...entries.values()].sort((a, b) => b.count - a.count);
|
|
361
|
+
if (!best)
|
|
362
|
+
return null;
|
|
363
|
+
return {
|
|
364
|
+
commit: best.commit.slice(0, 7),
|
|
365
|
+
author: best.author,
|
|
366
|
+
subject: best.subject,
|
|
367
|
+
};
|
|
368
|
+
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { HookAction } from "./types.ts";
|
|
2
|
+
import type { CliUi } from "./ui.ts";
|
|
2
3
|
export declare function runHookCommand(action: HookAction): Promise<string>;
|
|
4
|
+
export declare function renderHookResultToUi(result: string, ui: CliUi): void;
|
|
3
5
|
export declare function hookSnippet(): string;
|
package/dist/hooks.js
CHANGED
|
@@ -14,6 +14,24 @@ export async function runHookCommand(action) {
|
|
|
14
14
|
return installHook();
|
|
15
15
|
return uninstallHook();
|
|
16
16
|
}
|
|
17
|
+
export function renderHookResultToUi(result, ui) {
|
|
18
|
+
const [firstLine = "Stupify hook: no status returned", ...rest] = result.split(/\r?\n/);
|
|
19
|
+
if (firstLine.includes("not installed")) {
|
|
20
|
+
ui.info(firstLine);
|
|
21
|
+
}
|
|
22
|
+
else if (firstLine.includes("installed") || firstLine.includes("updated") || firstLine.includes("uninstalled")) {
|
|
23
|
+
ui.success(firstLine);
|
|
24
|
+
}
|
|
25
|
+
else if (firstLine.includes("existing non-Stupify")) {
|
|
26
|
+
ui.warn(firstLine);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
ui.info(firstLine);
|
|
30
|
+
}
|
|
31
|
+
const detail = rest.join("\n").trim();
|
|
32
|
+
if (detail)
|
|
33
|
+
ui.note(detail, "Hook");
|
|
34
|
+
}
|
|
17
35
|
export function hookSnippet() {
|
|
18
36
|
return managedBlock("stupify --staged");
|
|
19
37
|
}
|
package/dist/render.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
import type { SearchCommand, SearchRunJson } from "./types.ts";
|
|
2
|
+
import { type CliUi } from "./ui.ts";
|
|
2
3
|
export declare function renderSearchRun(run: SearchRunJson, command: SearchCommand): string;
|
|
4
|
+
export declare function renderSearchRunToUi(run: SearchRunJson, command: SearchCommand, ui: CliUi): void;
|
|
5
|
+
export declare function renderSearchHumanText(run: SearchRunJson, command: SearchCommand): string;
|
|
3
6
|
export declare function helpText(): string;
|
package/dist/render.js
CHANGED
|
@@ -3,16 +3,43 @@ import { format } from "./ui.js";
|
|
|
3
3
|
export function renderSearchRun(run, command) {
|
|
4
4
|
if (command.json)
|
|
5
5
|
return JSON.stringify(run, null, 2);
|
|
6
|
+
return renderSearchHumanText(run, command);
|
|
7
|
+
}
|
|
8
|
+
export function renderSearchRunToUi(run, command, ui) {
|
|
9
|
+
if (command.json) {
|
|
10
|
+
ui.writeStdout(renderSearchRun(run, command));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
|
|
14
|
+
ui.warn("Search skipped: input is too large for precise local search.");
|
|
15
|
+
ui.note(oversizedText(run, command), "Skipped");
|
|
16
|
+
ui.outro("Warn-only. Nothing blocked.");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
|
|
20
|
+
ui.success("Search complete: no search targets found.");
|
|
21
|
+
ui.note(cleanSummaryText(run), "Summary");
|
|
22
|
+
ui.outro("No judgment-offload signals found.");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (run.matches.length === 0) {
|
|
26
|
+
ui.success("Search complete: no judgment-offload signals found.");
|
|
27
|
+
ui.note(cleanSummaryText(run), "Summary");
|
|
28
|
+
ui.outro("Warn-only. Nothing blocked.");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
ui.warn(format.warn(format.heading("AI SLOP DETECTED")));
|
|
32
|
+
ui.note(matchSummaryText(run, command), "Summary");
|
|
33
|
+
for (const group of groupMatchesByFile(run.matches)) {
|
|
34
|
+
ui.note(renderMatchGroup(group, run), group.filePath);
|
|
35
|
+
}
|
|
36
|
+
ui.outro(summaryLine(run));
|
|
37
|
+
}
|
|
38
|
+
export function renderSearchHumanText(run, command) {
|
|
6
39
|
if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
|
|
7
|
-
return `${format.heading("Search
|
|
8
|
-
${
|
|
9
|
-
|
|
10
|
-
${format.heading("Limit:")}
|
|
11
|
-
${run.stats.inputTokenCap ?? "unknown"} tokens
|
|
12
|
-
Stupify skipped the search rather than review truncated context.
|
|
13
|
-
Nothing was blocked.
|
|
14
|
-
${format.heading("Try:")}
|
|
15
|
-
rerun with ${sourceHint(command)} --max-search-input-tokens ${Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2)}`;
|
|
40
|
+
return `${format.heading("Search skipped")}
|
|
41
|
+
${oversizedText(run, command)}
|
|
42
|
+
Warn-only. Nothing blocked.`;
|
|
16
43
|
}
|
|
17
44
|
if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
|
|
18
45
|
return `${format.heading("Search complete.")}
|
|
@@ -24,18 +51,12 @@ ${format.success("No search targets found.")}`;
|
|
|
24
51
|
${format.label("Patterns:")} ${run.patterns.join(", ")}
|
|
25
52
|
${format.success("No judgment-offload signals found.")}`;
|
|
26
53
|
}
|
|
54
|
+
const groups = groupMatchesByFile(run.matches);
|
|
27
55
|
return `${slopHeading()}
|
|
28
|
-
${
|
|
29
|
-
|
|
30
|
-
${run.matches.map((match, index) => `${index + 1}. ${format.label(match.patternId)}
|
|
31
|
-
${match.reason}
|
|
32
|
-
|
|
33
|
-
\`\`\`
|
|
34
|
-
${match.snapshot ?? match.proof}
|
|
35
|
-
\`\`\`
|
|
36
|
-
${format.muted(match.proof)}
|
|
56
|
+
${matchSummaryText(run, command)}
|
|
37
57
|
|
|
38
|
-
${
|
|
58
|
+
${groups.map((group) => `${format.heading(group.filePath)}
|
|
59
|
+
${renderMatchGroup(group, run)}`).join("\n\n")}
|
|
39
60
|
${format.muted(summaryLine(run))}`;
|
|
40
61
|
}
|
|
41
62
|
export function helpText() {
|
|
@@ -85,6 +106,127 @@ Not included:
|
|
|
85
106
|
Findings audit, validators, judges, baselines, sharing, hosted server calls, GitHub, dashboards, or repo-wide crawling.
|
|
86
107
|
`;
|
|
87
108
|
}
|
|
109
|
+
function oversizedText(run, command) {
|
|
110
|
+
const targetLimit = Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2);
|
|
111
|
+
return [
|
|
112
|
+
`Size: ~${run.stats.inputTokens ?? "unknown"} tokens`,
|
|
113
|
+
`Limit: ${run.stats.inputTokenCap ?? "unknown"} tokens`,
|
|
114
|
+
"Stupify skipped the search rather than review truncated context.",
|
|
115
|
+
`Try: ${sourceHint(command)} --max-search-input-tokens ${targetLimit}`,
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
function cleanSummaryText(run) {
|
|
119
|
+
return [
|
|
120
|
+
`Patterns: ${run.patterns.join(", ")}`,
|
|
121
|
+
run.stats.filesChanged === undefined ? null : `Diff: ${run.stats.filesChanged} files, ${run.stats.entitiesScanned ?? 0} changed entities`,
|
|
122
|
+
].filter(Boolean).join("\n");
|
|
123
|
+
}
|
|
124
|
+
function matchSummaryText(run, command) {
|
|
125
|
+
const fileCount = groupMatchesByFile(run.matches).length;
|
|
126
|
+
const fileNoun = fileCount === 1 ? "file" : "files";
|
|
127
|
+
return [
|
|
128
|
+
`${run.matches.length} ${signalNoun(run.matches.length)} across ${fileCount} ${fileNoun}`,
|
|
129
|
+
`${committerLabel(run)} · ${sourceLabel(command)}`,
|
|
130
|
+
"Warn-only. Nothing blocked.",
|
|
131
|
+
"",
|
|
132
|
+
patternSummaryLine(run),
|
|
133
|
+
].filter((line) => line !== null).join("\n");
|
|
134
|
+
}
|
|
135
|
+
function patternSummaryLine(run) {
|
|
136
|
+
const counts = new Map();
|
|
137
|
+
for (const match of run.matches)
|
|
138
|
+
counts.set(patternLabel(match), (counts.get(patternLabel(match)) ?? 0) + 1);
|
|
139
|
+
return [...counts.entries()].map(([patternName, count]) => `${patternName} ${count}`).join(" · ");
|
|
140
|
+
}
|
|
141
|
+
function groupMatchesByFile(matches) {
|
|
142
|
+
const groups = new Map();
|
|
143
|
+
for (const match of matches) {
|
|
144
|
+
const filePath = proofFilePath(match.proof);
|
|
145
|
+
const group = groups.get(filePath) ?? [];
|
|
146
|
+
group.push(match);
|
|
147
|
+
groups.set(filePath, group);
|
|
148
|
+
}
|
|
149
|
+
return [...groups.entries()].map(([filePath, groupedMatches]) => ({
|
|
150
|
+
filePath,
|
|
151
|
+
matches: groupedMatches,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
function renderMatchGroup(group, run) {
|
|
155
|
+
return group.matches.map((match, index) => {
|
|
156
|
+
const lines = [
|
|
157
|
+
matchHeadline(match, run, index),
|
|
158
|
+
match.reason,
|
|
159
|
+
match.snapshot ? `\n\`\`\`\n${match.snapshot}\n\`\`\`` : null,
|
|
160
|
+
format.muted(`${proofDetail(match.proof)}${commitSubjectSuffix(run)}`),
|
|
161
|
+
match.checkWhy ?? "This pattern may indicate judgment-offload.",
|
|
162
|
+
];
|
|
163
|
+
return lines.filter(Boolean).join("\n");
|
|
164
|
+
}).join("\n\n");
|
|
165
|
+
}
|
|
166
|
+
function matchHeadline(match, run, index) {
|
|
167
|
+
return `${index + 1}. ${format.label(patternLabel(match))}: ${headlineArgs(match)} -- ${matchBlameLabel(match, run)}`;
|
|
168
|
+
}
|
|
169
|
+
function patternLabel(match) {
|
|
170
|
+
return titleCase(match.patternName ?? match.patternId.replace(/_/g, " "));
|
|
171
|
+
}
|
|
172
|
+
function headlineArgs(match) {
|
|
173
|
+
const destination = entityNameFromProof(match.proof);
|
|
174
|
+
const source = firstBacktickedToken(match.reason) ?? firstLikelySource(match.reason, destination);
|
|
175
|
+
if (source && destination && source !== destination)
|
|
176
|
+
return `${codeLabel(source)} -> ${codeLabel(destination)}`;
|
|
177
|
+
if (destination)
|
|
178
|
+
return codeLabel(destination);
|
|
179
|
+
return codeLabel(match.targetId);
|
|
180
|
+
}
|
|
181
|
+
function matchBlameLabel(match, run) {
|
|
182
|
+
return match.blame ? blameSummaryLabel(match.blame) : runLevelBlameLabel(run);
|
|
183
|
+
}
|
|
184
|
+
function blameSummaryLabel(blame) {
|
|
185
|
+
return `${blame.author} (${blame.subject})`;
|
|
186
|
+
}
|
|
187
|
+
function runLevelBlameLabel(run) {
|
|
188
|
+
const author = committerLabel(run);
|
|
189
|
+
const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
|
|
190
|
+
return subject ? `${author} (${subject})` : author;
|
|
191
|
+
}
|
|
192
|
+
function commitSubjectSuffix(run) {
|
|
193
|
+
const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
|
|
194
|
+
return subject ? ` · commit: ${subject}` : "";
|
|
195
|
+
}
|
|
196
|
+
function firstHumanSubject(subjects) {
|
|
197
|
+
return subjects.map((subject) => subject.trim()).find(Boolean);
|
|
198
|
+
}
|
|
199
|
+
function codeLabel(value) {
|
|
200
|
+
return `\`${value}\``;
|
|
201
|
+
}
|
|
202
|
+
function titleCase(value) {
|
|
203
|
+
return value.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
|
|
204
|
+
}
|
|
205
|
+
function entityNameFromProof(proof) {
|
|
206
|
+
const parts = proof.split("::");
|
|
207
|
+
return parts[2] || parts[1] || undefined;
|
|
208
|
+
}
|
|
209
|
+
function firstBacktickedToken(value) {
|
|
210
|
+
const match = /`([^`]+)`/.exec(value);
|
|
211
|
+
return cleanToken(match?.[1]);
|
|
212
|
+
}
|
|
213
|
+
function firstLikelySource(value, destination) {
|
|
214
|
+
const tokens = [...value.matchAll(/\b[A-Z][A-Za-z0-9_]*(?:\[[^\]]+\])?\b/g)]
|
|
215
|
+
.map((match) => cleanToken(match[0]))
|
|
216
|
+
.filter((token) => Boolean(token));
|
|
217
|
+
return tokens.find((token) => token !== destination && token !== "The");
|
|
218
|
+
}
|
|
219
|
+
function cleanToken(value) {
|
|
220
|
+
const token = value?.trim().replace(/[.,;:]+$/, "");
|
|
221
|
+
return token || undefined;
|
|
222
|
+
}
|
|
223
|
+
function proofFilePath(proof) {
|
|
224
|
+
return proof.split("::")[0] || proof;
|
|
225
|
+
}
|
|
226
|
+
function proofDetail(proof) {
|
|
227
|
+
const [, ...rest] = proof.split("::");
|
|
228
|
+
return rest.length > 0 ? `::${rest.join("::")}` : proof;
|
|
229
|
+
}
|
|
88
230
|
function sourceHint(command) {
|
|
89
231
|
if (command.kind === "staged")
|
|
90
232
|
return "--staged";
|
|
@@ -146,6 +288,8 @@ function sinceLabel(since) {
|
|
|
146
288
|
return `last ${count} ${unit}s`;
|
|
147
289
|
}
|
|
148
290
|
function summaryLine(run) {
|
|
149
|
-
|
|
150
|
-
|
|
291
|
+
return `${run.matches.length} ${signalNoun(run.matches.length)}. Warn-only. Nothing blocked.`;
|
|
292
|
+
}
|
|
293
|
+
function signalNoun(count) {
|
|
294
|
+
return count === 1 ? "signal" : "signals";
|
|
151
295
|
}
|
package/dist/sem-provider.js
CHANGED
|
@@ -36,6 +36,7 @@ function emptyChangeSet(label, stats, committers) {
|
|
|
36
36
|
base: label,
|
|
37
37
|
target: label,
|
|
38
38
|
committers,
|
|
39
|
+
commitSubjects: undefined,
|
|
39
40
|
contextCwd: process.cwd(),
|
|
40
41
|
cleanup: async () => undefined,
|
|
41
42
|
changes: [],
|
|
@@ -75,6 +76,7 @@ async function semChangeSetFromPatch(patch, debugSem, label = "stdin", committer
|
|
|
75
76
|
base: label,
|
|
76
77
|
target: label,
|
|
77
78
|
committers,
|
|
79
|
+
commitSubjects: undefined,
|
|
78
80
|
stats: { filesChanged: 0, additions: 0, deletions: 0 },
|
|
79
81
|
}),
|
|
80
82
|
contextCwd: process.cwd(),
|
|
@@ -199,6 +201,7 @@ function normalizeSemDiff(value, range) {
|
|
|
199
201
|
base: range.base,
|
|
200
202
|
target: range.target,
|
|
201
203
|
committers: range.committers,
|
|
204
|
+
commitSubjects: range.commitSubjects,
|
|
202
205
|
contextCwd: process.cwd(),
|
|
203
206
|
cleanup: async () => undefined,
|
|
204
207
|
changes,
|