howtofixcli 0.7.7 → 0.7.9
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/dist/chunk-27TRKD65.js +2 -0
- package/dist/chunk-3FYCFLNK.js +1 -0
- package/dist/chunk-56SEAGAQ.js +1 -0
- package/dist/chunk-63ZCQDXH.js +6 -0
- package/dist/chunk-6FUWMLWO.js +1 -0
- package/dist/chunk-7CHDNTH6.js +1 -0
- package/dist/chunk-ACOMNDUL.js +185 -0
- package/dist/chunk-AEVPET7X.js +2 -0
- package/dist/chunk-AGGIYXN7.js +2 -0
- package/dist/chunk-BHVBO4SC.js +1 -0
- package/dist/chunk-FPXVDP32.js +8 -0
- package/dist/chunk-GIUYIKH3.js +109 -0
- package/dist/chunk-GQCAJ56I.js +5 -0
- package/dist/chunk-GXB7RZHH.js +1 -0
- package/dist/chunk-IGEY3AWF.js +1 -0
- package/dist/chunk-ILDDDLRC.js +1 -0
- package/dist/chunk-JFYVRWJI.js +2 -0
- package/dist/chunk-MVLCGU3G.js +1 -0
- package/dist/chunk-N345KWWK.js +1 -0
- package/dist/chunk-NBQFPCRT.js +1 -0
- package/dist/chunk-OIPSLIZE.js +5 -0
- package/dist/chunk-ORJ2QZBG.js +3 -0
- package/dist/chunk-PCALBZOO.js +2 -0
- package/dist/chunk-PDNBGUYP.js +2 -0
- package/dist/chunk-S656RE4A.js +1 -0
- package/dist/chunk-TSEKPGK4.js +1 -0
- package/dist/chunk-U6CI6KXN.js +34 -0
- package/dist/chunk-UTYFLLSM.js +1 -0
- package/dist/chunk-V3R4FYTJ.js +1 -0
- package/dist/chunk-V656XVXV.js +8 -0
- package/dist/chunk-WKUJ7CFO.js +1 -0
- package/dist/chunk-WOT6VMZA.js +1 -0
- package/dist/chunk-WUPU6LTD.js +4 -0
- package/dist/chunk-WX3V5P7U.js +5 -0
- package/dist/chunk-ZKZ6WCI3.js +2 -0
- package/dist/main.js +94 -536
- package/package.json +15 -62
- package/dist/account-credential.js +0 -136
- package/dist/account-credential.test.js +0 -38
- package/dist/activity.js +0 -124
- package/dist/agent-defs.js +0 -237
- package/dist/agent-limiter.js +0 -68
- package/dist/agents.js +0 -554
- package/dist/anim.js +0 -361
- package/dist/args.js +0 -228
- package/dist/at-file.js +0 -28
- package/dist/brand-extension.js +0 -87
- package/dist/brand.js +0 -76
- package/dist/browser.js +0 -58
- package/dist/cache-report.js +0 -139
- package/dist/checkpoint.js +0 -437
- package/dist/claims.js +0 -167
- package/dist/cli-auth.js +0 -142
- package/dist/cli-checkin.js +0 -116
- package/dist/cli-credentials.js +0 -254
- package/dist/clipboard.js +0 -149
- package/dist/collapse.js +0 -129
- package/dist/commands.js +0 -301
- package/dist/compaction-guard.js +0 -81
- package/dist/context-extension.js +0 -17
- package/dist/dcp/commands.js +0 -172
- package/dist/dcp/compress-state.js +0 -139
- package/dist/dcp/compress.js +0 -329
- package/dist/dcp/dcp-extension.js +0 -288
- package/dist/dcp/dedup.js +0 -93
- package/dist/dcp/ids.js +0 -189
- package/dist/dcp/index.js +0 -25
- package/dist/dcp/prompts.js +0 -68
- package/dist/dcp/protected-content.js +0 -221
- package/dist/dcp/protected.js +0 -161
- package/dist/dcp/prune.js +0 -418
- package/dist/dcp/state.js +0 -176
- package/dist/device-id.js +0 -90
- package/dist/device-id.test.js +0 -23
- package/dist/errors.js +0 -63
- package/dist/extensions.js +0 -48
- package/dist/fatal.js +0 -35
- package/dist/feed.js +0 -498
- package/dist/fs-atomic.js +0 -54
- package/dist/gateway-errors.js +0 -90
- package/dist/git.js +0 -230
- package/dist/goal-extension.js +0 -88
- package/dist/goal.js +0 -222
- package/dist/hooks-extension.js +0 -176
- package/dist/hooks.js +0 -443
- package/dist/howtofix-account.js +0 -715
- package/dist/hud-line.js +0 -153
- package/dist/hwid.js +0 -130
- package/dist/interrupt.js +0 -60
- package/dist/json-events.js +0 -50
- package/dist/level-style.js +0 -112
- package/dist/loop-guard.js +0 -95
- package/dist/lsp-tools.js +0 -32
- package/dist/lsp.js +0 -347
- package/dist/mcp-config.js +0 -85
- package/dist/mcp-lifecycle.js +0 -147
- package/dist/model-catalog.js +0 -217
- package/dist/model-resolve.js +0 -65
- package/dist/notes.js +0 -78
- package/dist/oauth-login.js +0 -568
- package/dist/paths.js +0 -61
- package/dist/permissions.js +0 -298
- package/dist/plan-mode.js +0 -363
- package/dist/preset-command.js +0 -227
- package/dist/preset-extension.js +0 -35
- package/dist/preset-mcp.js +0 -292
- package/dist/presets.js +0 -727
- package/dist/prompt-files.js +0 -103
- package/dist/prompt-text.js +0 -41
- package/dist/proxy.js +0 -137
- package/dist/quality/ast.js +0 -196
- package/dist/quality/cognitive.js +0 -83
- package/dist/quality/cyclomatic.js +0 -30
- package/dist/quality/halstead.js +0 -83
- package/dist/quality/index.js +0 -18
- package/dist/quality/length.js +0 -3
- package/dist/quality/maintainability.js +0 -15
- package/dist/quality/nesting.js +0 -57
- package/dist/quality/report.js +0 -803
- package/dist/quality/smells.js +0 -67
- package/dist/quality-tool.js +0 -63
- package/dist/read-only-commands.js +0 -597
- package/dist/repo-map.js +0 -292
- package/dist/rtk.js +0 -193
- package/dist/sandbox.js +0 -105
- package/dist/schema.js +0 -214
- package/dist/session-lifecycle.js +0 -19
- package/dist/session-retention.js +0 -111
- package/dist/session.js +0 -446
- package/dist/sessions.js +0 -100
- package/dist/settings.js +0 -148
- package/dist/skills.js +0 -35
- package/dist/slop-tool.js +0 -96
- package/dist/spend-cap.js +0 -43
- package/dist/subagent.js +0 -171
- package/dist/tar.js +0 -146
- package/dist/theme.js +0 -79
- package/dist/thinking.js +0 -45
- package/dist/todo.js +0 -114
- package/dist/tool-loadout.js +0 -97
- package/dist/tool-render.js +0 -769
- package/dist/tools.js +0 -176
- package/dist/ui.js +0 -4751
- package/dist/update.js +0 -533
- package/dist/usage-segment.js +0 -69
- package/dist/web-tools.js +0 -46
- package/dist/web.js +0 -265
- package/dist/welcome.js +0 -179
- package/dist/workflow-engine.js +0 -246
- package/dist/workflow-progress.js +0 -446
- package/dist/workflow-view.js +0 -195
- package/dist/workflow.js +0 -349
package/dist/quality/report.js
DELETED
|
@@ -1,803 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The quality report — spec §2's ninth module: thresholds and config, the
|
|
3
|
-
* per-file analysis pipeline, baseline comparison, and the three renderers
|
|
4
|
-
* (human, JSON, SARIF).
|
|
5
|
-
*
|
|
6
|
-
* Two contracts carry the spec's §4 fix over `code-multivitals`:
|
|
7
|
-
*
|
|
8
|
-
* 1. **Attribution is by path, never by position.** `analyzeFiles` walks the
|
|
9
|
-
* `Map<path, ParsedFile>` that `parseFiles` guarantees is total and writes
|
|
10
|
-
* exactly one `FileReport` per requested path. A file that failed to parse
|
|
11
|
-
* becomes an explicit `{ ok: false }` entry with a `parse error` finding —
|
|
12
|
-
* one broken file can never shift another file's report, because nothing
|
|
13
|
-
* is paired by array index anywhere.
|
|
14
|
-
* 2. **Severities are ranks.** Every metric maps to `0` (ok), `1` (warn) or
|
|
15
|
-
* `2` (error) through the threshold table (§3); compound smells consume
|
|
16
|
-
* those ranks (see `smells.ts`), and `--strict` compares ranks against the
|
|
17
|
-
* baseline, so a config change that moves a threshold cannot manufacture a
|
|
18
|
-
* regression — both sides of a comparison are re-ranked with the same
|
|
19
|
-
* config.
|
|
20
|
-
*
|
|
21
|
-
* The score that `ci.failBelow` gates is the LOC-weighted mean function MI on
|
|
22
|
-
* the 0–100 SEI scale (§3.6): a repository is as maintainable as its code
|
|
23
|
-
* mass, not as its worst five-line function. An empty report scores 100.
|
|
24
|
-
*/
|
|
25
|
-
import { spawnSync } from "node:child_process";
|
|
26
|
-
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
27
|
-
import { extname, join, relative, resolve } from "node:path";
|
|
28
|
-
import { SyntaxKind } from "typescript/unstable/ast";
|
|
29
|
-
import { childNode, childNodes, hasModifier, nodeSpan, nodeText, parseFiles, walk } from "./ast.js";
|
|
30
|
-
import { cognitive } from "./cognitive.js";
|
|
31
|
-
import { cyclomatic } from "./cyclomatic.js";
|
|
32
|
-
import { functionLength } from "./length.js";
|
|
33
|
-
import { halsteadVolume } from "./halstead.js";
|
|
34
|
-
import { maintainabilityIndex } from "./maintainability.js";
|
|
35
|
-
import { nesting } from "./nesting.js";
|
|
36
|
-
import { SMELL_SUGGESTIONS, detectSmells } from "./smells.js";
|
|
37
|
-
export const RANK_LABELS = ["ok", "warn", "error"];
|
|
38
|
-
/** The per-function metric keys, in the order the human report prints them. */
|
|
39
|
-
export const FUNCTION_METRIC_ORDER = ["cyclomatic", "cognitive", "nesting", "length", "halstead", "mi", "parameters", "booleanParameters"];
|
|
40
|
-
/** The six metrics a baseline tracks (module limits are config-shaped, not code-shaped). */
|
|
41
|
-
export const BASELINE_METRIC_ORDER = ["cyclomatic", "cognitive", "nesting", "length", "halstead", "mi"];
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// Config
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
/**
|
|
46
|
-
* Defaults straight from spec §3. Halstead's warn line (500) is the spec's §6
|
|
47
|
-
* example; the error line (1000) is this repo's own — the spec gives no error
|
|
48
|
-
* tier for Halstead, so it is documented here rather than implied.
|
|
49
|
-
*/
|
|
50
|
-
export const DEFAULT_THRESHOLDS = {
|
|
51
|
-
cyclomatic: { warn: 15, error: 25 },
|
|
52
|
-
cognitive: { warn: 12, error: 20 },
|
|
53
|
-
nesting: { warn: 4, error: 5 },
|
|
54
|
-
length: { warn: 60, error: 120 },
|
|
55
|
-
halstead: { warn: 500, error: 1000 },
|
|
56
|
-
mi: { warn: 65, error: 20 },
|
|
57
|
-
fileLines: { warn: 400, error: 800 },
|
|
58
|
-
exports: { warn: 20, error: 30 },
|
|
59
|
-
parameters: { warn: 5, error: 8 },
|
|
60
|
-
};
|
|
61
|
-
export function defaultConfig() {
|
|
62
|
-
const thresholds = {
|
|
63
|
-
cyclomatic: { ...DEFAULT_THRESHOLDS.cyclomatic },
|
|
64
|
-
cognitive: { ...DEFAULT_THRESHOLDS.cognitive },
|
|
65
|
-
nesting: { ...DEFAULT_THRESHOLDS.nesting },
|
|
66
|
-
length: { ...DEFAULT_THRESHOLDS.length },
|
|
67
|
-
halstead: { ...DEFAULT_THRESHOLDS.halstead },
|
|
68
|
-
mi: { ...DEFAULT_THRESHOLDS.mi },
|
|
69
|
-
fileLines: { ...DEFAULT_THRESHOLDS.fileLines },
|
|
70
|
-
exports: { ...DEFAULT_THRESHOLDS.exports },
|
|
71
|
-
parameters: { ...DEFAULT_THRESHOLDS.parameters },
|
|
72
|
-
};
|
|
73
|
-
return { ignore: [], thresholds, ci: { failBelow: 65, mode: "advisory" } };
|
|
74
|
-
}
|
|
75
|
-
const THRESHOLD_KEYS = ["cyclomatic", "cognitive", "nesting", "length", "halstead", "mi", "fileLines", "exports", "parameters"];
|
|
76
|
-
function isFiniteNumber(value) {
|
|
77
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* `.howtofix/quality.json` over the defaults. A missing or broken file is "no
|
|
81
|
-
* decision", not an error: a typo'd JSON must not take the gate down, and a
|
|
82
|
-
* threshold that is not a finite number keeps its default instead of ranking
|
|
83
|
-
* every metric `NaN > NaN === false` (all ok).
|
|
84
|
-
*/
|
|
85
|
-
export function loadQualityConfig(cwd) {
|
|
86
|
-
const config = defaultConfig();
|
|
87
|
-
let parsed;
|
|
88
|
-
try {
|
|
89
|
-
parsed = JSON.parse(readFileSync(join(cwd, ".howtofix", "quality.json"), "utf8"));
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
return config;
|
|
93
|
-
}
|
|
94
|
-
if (typeof parsed !== "object" || parsed === null)
|
|
95
|
-
return config;
|
|
96
|
-
const raw = parsed;
|
|
97
|
-
if (typeof raw.enabled === "boolean")
|
|
98
|
-
config.enabled = raw.enabled;
|
|
99
|
-
if (Array.isArray(raw.ignore)) {
|
|
100
|
-
config.ignore = raw.ignore.filter((pattern) => typeof pattern === "string");
|
|
101
|
-
}
|
|
102
|
-
if (typeof raw.thresholds === "object" && raw.thresholds !== null) {
|
|
103
|
-
const given = raw.thresholds;
|
|
104
|
-
for (const key of THRESHOLD_KEYS) {
|
|
105
|
-
const pair = given[key];
|
|
106
|
-
if (typeof pair !== "object" || pair === null)
|
|
107
|
-
continue;
|
|
108
|
-
const bounds = pair;
|
|
109
|
-
if (isFiniteNumber(bounds.warn))
|
|
110
|
-
config.thresholds[key].warn = bounds.warn;
|
|
111
|
-
if (isFiniteNumber(bounds.error))
|
|
112
|
-
config.thresholds[key].error = bounds.error;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
if (typeof raw.ci === "object" && raw.ci !== null) {
|
|
116
|
-
const given = raw.ci;
|
|
117
|
-
if (isFiniteNumber(given.failBelow))
|
|
118
|
-
config.ci.failBelow = given.failBelow;
|
|
119
|
-
if (given.mode === "advisory" || given.mode === "blocking")
|
|
120
|
-
config.ci.mode = given.mode;
|
|
121
|
-
}
|
|
122
|
-
return config;
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* The three-layer switch, strongest layer first:
|
|
126
|
-
*
|
|
127
|
-
* 1. `HOWTOFIX_QUALITY` — `0`/`false` off, any other value on. Wins both ways.
|
|
128
|
-
* 2. `enabled` in `.howtofix/quality.json`.
|
|
129
|
-
* 3. `runtime` — the state a future `/quality off` passes in; consulted only
|
|
130
|
-
* when neither layer above decided.
|
|
131
|
-
* 4. Default: on.
|
|
132
|
-
*
|
|
133
|
-
* This switch governs the *invocations* (the CLI's advisory path, the tool,
|
|
134
|
-
* a slash command). The `--strict` CI path ignores it on purpose: an AST
|
|
135
|
-
* analysis with no side effects that anyone can switch off from inside an
|
|
136
|
-
* agent session is not a gate (spec §2).
|
|
137
|
-
*/
|
|
138
|
-
export function qualityEnabled(cwd, runtime) {
|
|
139
|
-
const env = process.env.HOWTOFIX_QUALITY;
|
|
140
|
-
if (env !== undefined) {
|
|
141
|
-
const value = env.trim().toLowerCase();
|
|
142
|
-
if (value === "0" || value === "false")
|
|
143
|
-
return { enabled: false, source: "env" };
|
|
144
|
-
return { enabled: true, source: "env" };
|
|
145
|
-
}
|
|
146
|
-
const configured = loadQualityConfig(cwd).enabled;
|
|
147
|
-
if (configured !== undefined)
|
|
148
|
-
return { enabled: configured, source: "config" };
|
|
149
|
-
if (runtime !== undefined)
|
|
150
|
-
return { enabled: runtime, source: "runtime" };
|
|
151
|
-
return { enabled: true, source: "default" };
|
|
152
|
-
}
|
|
153
|
-
// ---------------------------------------------------------------------------
|
|
154
|
-
// Ranking
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
function rankAbove(value, pair) {
|
|
157
|
-
if (value > pair.error)
|
|
158
|
-
return 2;
|
|
159
|
-
if (value > pair.warn)
|
|
160
|
-
return 1;
|
|
161
|
-
return 0;
|
|
162
|
-
}
|
|
163
|
-
function rankBelow(value, pair) {
|
|
164
|
-
if (value < pair.error)
|
|
165
|
-
return 2;
|
|
166
|
-
if (value < pair.warn)
|
|
167
|
-
return 1;
|
|
168
|
-
return 0;
|
|
169
|
-
}
|
|
170
|
-
/** Re-rank a stored value with the *current* thresholds (baseline comparisons). */
|
|
171
|
-
function rankOfBaselineMetric(metric, value, thresholds) {
|
|
172
|
-
switch (metric) {
|
|
173
|
-
case "cyclomatic":
|
|
174
|
-
return rankAbove(value, thresholds.cyclomatic);
|
|
175
|
-
case "cognitive":
|
|
176
|
-
return rankAbove(value, thresholds.cognitive);
|
|
177
|
-
case "nesting":
|
|
178
|
-
return rankAbove(value, thresholds.nesting);
|
|
179
|
-
case "length":
|
|
180
|
-
return rankAbove(value, thresholds.length);
|
|
181
|
-
case "halstead":
|
|
182
|
-
return rankAbove(value, thresholds.halstead);
|
|
183
|
-
case "mi":
|
|
184
|
-
return rankBelow(value, thresholds.mi);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
// ---------------------------------------------------------------------------
|
|
188
|
-
// Analysis
|
|
189
|
-
// ---------------------------------------------------------------------------
|
|
190
|
-
function boolParamNames(unit) {
|
|
191
|
-
const names = [];
|
|
192
|
-
for (const parameter of childNodes(unit.node, "parameters")) {
|
|
193
|
-
const type = childNode(parameter, "type");
|
|
194
|
-
if (type?.kind !== SyntaxKind.BooleanKeyword)
|
|
195
|
-
continue;
|
|
196
|
-
// A destructured parameter has no identifier; its shape is still worth naming.
|
|
197
|
-
names.push(nodeText(childNode(parameter, "name")) ?? "<pattern>");
|
|
198
|
-
}
|
|
199
|
-
return names;
|
|
200
|
-
}
|
|
201
|
-
function analyzeFunction(unit, sourceFile, path, config) {
|
|
202
|
-
const t = config.thresholds;
|
|
203
|
-
// The metric walkers take the function's BODY: their nested-function guard
|
|
204
|
-
// also fires on the root, so passing the function node itself would score 0.
|
|
205
|
-
// An ambient declaration has no body; there is nothing inside to measure.
|
|
206
|
-
const body = childNode(unit.node, "body") ?? unit.node;
|
|
207
|
-
const values = {
|
|
208
|
-
cyclomatic: cyclomatic(body),
|
|
209
|
-
cognitive: cognitive(body),
|
|
210
|
-
nesting: nesting(body),
|
|
211
|
-
length: functionLength(unit),
|
|
212
|
-
halstead: halsteadVolume(body),
|
|
213
|
-
mi: 0,
|
|
214
|
-
};
|
|
215
|
-
values.mi = maintainabilityIndex(values.halstead, values.cyclomatic, values.length);
|
|
216
|
-
const booleanNames = boolParamNames(unit);
|
|
217
|
-
const parameterCount = childNodes(unit.node, "parameters").length;
|
|
218
|
-
const metrics = {
|
|
219
|
-
cyclomatic: { value: values.cyclomatic, rank: rankAbove(values.cyclomatic, t.cyclomatic) },
|
|
220
|
-
cognitive: { value: values.cognitive, rank: rankAbove(values.cognitive, t.cognitive) },
|
|
221
|
-
nesting: { value: values.nesting, rank: rankAbove(values.nesting, t.nesting) },
|
|
222
|
-
length: { value: values.length, rank: rankAbove(values.length, t.length) },
|
|
223
|
-
halstead: { value: values.halstead, rank: rankAbove(values.halstead, t.halstead) },
|
|
224
|
-
mi: { value: values.mi, rank: rankBelow(values.mi, t.mi) },
|
|
225
|
-
parameters: { value: parameterCount, rank: rankAbove(parameterCount, t.parameters) },
|
|
226
|
-
booleanParameters: { value: booleanNames.length, rank: (booleanNames.length > 0 ? 1 : 0) },
|
|
227
|
-
};
|
|
228
|
-
const ranks = {
|
|
229
|
-
cyclomatic: metrics.cyclomatic.rank,
|
|
230
|
-
cognitive: metrics.cognitive.rank,
|
|
231
|
-
nesting: metrics.nesting.rank,
|
|
232
|
-
length: metrics.length.rank,
|
|
233
|
-
halstead: metrics.halstead.rank,
|
|
234
|
-
mi: metrics.mi.rank,
|
|
235
|
-
};
|
|
236
|
-
const span = nodeSpan(sourceFile, unit.node);
|
|
237
|
-
let worstRank = 0;
|
|
238
|
-
for (const key of FUNCTION_METRIC_ORDER)
|
|
239
|
-
if (metrics[key].rank > worstRank)
|
|
240
|
-
worstRank = metrics[key].rank;
|
|
241
|
-
return {
|
|
242
|
-
path,
|
|
243
|
-
name: unit.name,
|
|
244
|
-
startLine: span.startLine,
|
|
245
|
-
endLine: span.endLine,
|
|
246
|
-
startColumn: span.startColumn,
|
|
247
|
-
endColumn: span.endColumn,
|
|
248
|
-
metrics,
|
|
249
|
-
booleanParameterNames: booleanNames,
|
|
250
|
-
smells: detectSmells(values, ranks),
|
|
251
|
-
worstRank,
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* Count a file's exports: every statement carrying `export`, plus the specifiers
|
|
256
|
-
* of `export { a, b }` (a re-export of nothing exports nothing, `export *`
|
|
257
|
-
* counts as the one statement it is).
|
|
258
|
-
*/
|
|
259
|
-
function countExports(sourceFile) {
|
|
260
|
-
let total = 0;
|
|
261
|
-
walk(sourceFile, (node) => {
|
|
262
|
-
if (node.kind === SyntaxKind.ExportDeclaration) {
|
|
263
|
-
const clause = childNode(node, "exportClause");
|
|
264
|
-
const elements = clause ? childNodes(clause, "elements") : [];
|
|
265
|
-
total += elements.length > 0 ? elements.length : 1;
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
if (node.kind === SyntaxKind.ExportAssignment) {
|
|
269
|
-
total += 1;
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
if (hasModifier(node, SyntaxKind.ExportKeyword))
|
|
273
|
-
total += 1;
|
|
274
|
-
});
|
|
275
|
-
return total;
|
|
276
|
-
}
|
|
277
|
-
/** Physical lines: a trailing newline ends the last line, it does not open one. */
|
|
278
|
-
function fileLineCount(text) {
|
|
279
|
-
if (text.length === 0)
|
|
280
|
-
return 0;
|
|
281
|
-
let lines = 1;
|
|
282
|
-
for (let index = 0; index < text.length; index += 1) {
|
|
283
|
-
if (text.charCodeAt(index) === 10)
|
|
284
|
-
lines += 1;
|
|
285
|
-
}
|
|
286
|
-
if (text.charCodeAt(text.length - 1) === 10)
|
|
287
|
-
lines -= 1;
|
|
288
|
-
return lines;
|
|
289
|
-
}
|
|
290
|
-
const ZERO = { value: 0, rank: 0 };
|
|
291
|
-
function errorFileReport(path, error) {
|
|
292
|
-
return {
|
|
293
|
-
path,
|
|
294
|
-
ok: false,
|
|
295
|
-
error,
|
|
296
|
-
lines: 0,
|
|
297
|
-
metrics: { lines: ZERO, exports: ZERO, mi: { value: 100, rank: 0 } },
|
|
298
|
-
functions: [],
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
function pushMetricFindings(into, file, fn, span, metric, value, warnLine) {
|
|
302
|
-
if (value.rank === 0)
|
|
303
|
-
return;
|
|
304
|
-
into.push({
|
|
305
|
-
path: file.path,
|
|
306
|
-
metric,
|
|
307
|
-
detail: metric === "booleanParameters" ? (fn?.booleanParameterNames.join(", ") ?? String(value.value)) : `${value.value} (limit ${warnLine})`,
|
|
308
|
-
rank: value.rank,
|
|
309
|
-
line: span.line,
|
|
310
|
-
endLine: span.endLine,
|
|
311
|
-
startColumn: span.startColumn,
|
|
312
|
-
endColumn: span.endColumn,
|
|
313
|
-
...(fn ? { function: fn.name } : {}),
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* Analyze a batch of files into the Map-based report (spec §4).
|
|
318
|
-
*
|
|
319
|
-
* `paths` may contain anything; every key of the input appears exactly once in
|
|
320
|
-
* `report.files`, parsed or not. Findings follow the same order as the input,
|
|
321
|
-
* so the three renderers see one deterministic stream.
|
|
322
|
-
*/
|
|
323
|
-
export async function analyzeFiles(paths, config = defaultConfig()) {
|
|
324
|
-
const parsed = await parseFiles(paths);
|
|
325
|
-
const files = new Map();
|
|
326
|
-
const findings = [];
|
|
327
|
-
const t = config.thresholds;
|
|
328
|
-
for (const path of paths) {
|
|
329
|
-
const outcome = parsed.get(path);
|
|
330
|
-
if (!outcome) {
|
|
331
|
-
// `parseFiles` guarantees totality; if it ever regresses, fail visibly on
|
|
332
|
-
// this file rather than shifting the ones after it.
|
|
333
|
-
const report = errorFileReport(path, "internal: file missing from the parse result");
|
|
334
|
-
files.set(path, report);
|
|
335
|
-
findings.push({ path, metric: "parse error", detail: report.error ?? "", rank: 2, line: 1, endLine: 1, startColumn: 1, endColumn: 1 });
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
if (!outcome.ok) {
|
|
339
|
-
const report = errorFileReport(path, outcome.error);
|
|
340
|
-
files.set(path, report);
|
|
341
|
-
findings.push({ path, metric: "parse error", detail: outcome.error, rank: 2, line: 1, endLine: 1, startColumn: 1, endColumn: 1 });
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
const { sourceFile } = outcome;
|
|
345
|
-
const functions = outcome.functions.map((unit) => analyzeFunction(unit, sourceFile, path, config));
|
|
346
|
-
const lines = fileLineCount(sourceFile.text);
|
|
347
|
-
const exportCount = countExports(sourceFile);
|
|
348
|
-
const weight = functions.reduce((total, fn) => total + fn.metrics.length.value, 0);
|
|
349
|
-
const weightedMi = weight === 0 ? 100 : Math.round((functions.reduce((total, fn) => total + fn.metrics.mi.value * fn.metrics.length.value, 0) / weight) * 10) / 10;
|
|
350
|
-
const report = {
|
|
351
|
-
path,
|
|
352
|
-
ok: true,
|
|
353
|
-
lines,
|
|
354
|
-
metrics: {
|
|
355
|
-
lines: { value: lines, rank: rankAbove(lines, t.fileLines) },
|
|
356
|
-
exports: { value: exportCount, rank: rankAbove(exportCount, t.exports) },
|
|
357
|
-
mi: { value: weightedMi, rank: rankBelow(weightedMi, t.mi) },
|
|
358
|
-
},
|
|
359
|
-
functions,
|
|
360
|
-
};
|
|
361
|
-
files.set(path, report);
|
|
362
|
-
const fileSpan = { line: 1, endLine: Math.max(lines, 1), startColumn: 1, endColumn: 1 };
|
|
363
|
-
pushMetricFindings(findings, report, undefined, fileSpan, "file lines", report.metrics.lines, t.fileLines.warn);
|
|
364
|
-
pushMetricFindings(findings, report, undefined, fileSpan, "exports", report.metrics.exports, t.exports.warn);
|
|
365
|
-
for (const fn of functions) {
|
|
366
|
-
const fnSpan = { line: fn.startLine, endLine: fn.endLine, startColumn: fn.startColumn, endColumn: fn.endColumn };
|
|
367
|
-
for (const key of FUNCTION_METRIC_ORDER) {
|
|
368
|
-
pushMetricFindings(findings, report, fn, fnSpan, key, fn.metrics[key], warnLineOf(key, t));
|
|
369
|
-
}
|
|
370
|
-
for (const smell of fn.smells) {
|
|
371
|
-
findings.push({
|
|
372
|
-
path,
|
|
373
|
-
metric: "smell",
|
|
374
|
-
detail: `${smell.label}: ${smell.reason}`,
|
|
375
|
-
rank: 1,
|
|
376
|
-
line: fn.startLine,
|
|
377
|
-
endLine: fn.endLine,
|
|
378
|
-
startColumn: fn.startColumn,
|
|
379
|
-
endColumn: fn.endColumn,
|
|
380
|
-
function: fn.name,
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
return { files, findings };
|
|
386
|
-
}
|
|
387
|
-
// ---------------------------------------------------------------------------
|
|
388
|
-
// Aggregation
|
|
389
|
-
// ---------------------------------------------------------------------------
|
|
390
|
-
/** The score `ci.failBelow` gates: LOC-weighted mean function MI, 100 when empty. */
|
|
391
|
-
export function reportScore(report) {
|
|
392
|
-
let weight = 0;
|
|
393
|
-
let total = 0;
|
|
394
|
-
for (const file of report.files.values()) {
|
|
395
|
-
if (!file.ok)
|
|
396
|
-
continue;
|
|
397
|
-
for (const fn of file.functions) {
|
|
398
|
-
weight += fn.metrics.length.value;
|
|
399
|
-
total += fn.metrics.mi.value * fn.metrics.length.value;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
if (weight === 0)
|
|
403
|
-
return 100;
|
|
404
|
-
return Math.round((total / weight) * 10) / 10;
|
|
405
|
-
}
|
|
406
|
-
export function summarize(report) {
|
|
407
|
-
let errorFiles = 0;
|
|
408
|
-
let functions = 0;
|
|
409
|
-
let warn = 0;
|
|
410
|
-
let error = 0;
|
|
411
|
-
for (const file of report.files.values()) {
|
|
412
|
-
if (!file.ok)
|
|
413
|
-
errorFiles += 1;
|
|
414
|
-
functions += file.functions.length;
|
|
415
|
-
}
|
|
416
|
-
for (const finding of report.findings) {
|
|
417
|
-
if (finding.rank >= 2)
|
|
418
|
-
error += 1;
|
|
419
|
-
else
|
|
420
|
-
warn += 1;
|
|
421
|
-
}
|
|
422
|
-
return {
|
|
423
|
-
files: report.files.size,
|
|
424
|
-
errorFiles,
|
|
425
|
-
functions,
|
|
426
|
-
findings: report.findings.length,
|
|
427
|
-
warn,
|
|
428
|
-
error,
|
|
429
|
-
score: reportScore(report),
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
// ---------------------------------------------------------------------------
|
|
433
|
-
// Baseline
|
|
434
|
-
// ---------------------------------------------------------------------------
|
|
435
|
-
export function buildBaseline(report) {
|
|
436
|
-
const functions = new Map();
|
|
437
|
-
for (const file of report.files.values()) {
|
|
438
|
-
if (!file.ok)
|
|
439
|
-
continue;
|
|
440
|
-
const entries = new Map();
|
|
441
|
-
for (const fn of file.functions) {
|
|
442
|
-
entries.set(`${fn.name}@${fn.startLine}`, {
|
|
443
|
-
cyclomatic: fn.metrics.cyclomatic.value,
|
|
444
|
-
cognitive: fn.metrics.cognitive.value,
|
|
445
|
-
nesting: fn.metrics.nesting.value,
|
|
446
|
-
length: fn.metrics.length.value,
|
|
447
|
-
halstead: fn.metrics.halstead.value,
|
|
448
|
-
mi: fn.metrics.mi.value,
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
if (entries.size > 0)
|
|
452
|
-
functions.set(file.path, entries);
|
|
453
|
-
}
|
|
454
|
-
const serialized = Object.fromEntries([...functions].map(([path, entries]) => [path, Object.fromEntries(entries)]));
|
|
455
|
-
return { version: 1, functions: serialized };
|
|
456
|
-
}
|
|
457
|
-
export function loadBaseline(cwd) {
|
|
458
|
-
let parsed;
|
|
459
|
-
try {
|
|
460
|
-
parsed = JSON.parse(readFileSync(join(cwd, ".howtofix", "quality-baseline.json"), "utf8"));
|
|
461
|
-
}
|
|
462
|
-
catch {
|
|
463
|
-
return undefined;
|
|
464
|
-
}
|
|
465
|
-
if (typeof parsed !== "object" || parsed === null)
|
|
466
|
-
return undefined;
|
|
467
|
-
const raw = parsed;
|
|
468
|
-
if (raw.version !== 1 || typeof raw.functions !== "object" || raw.functions === null)
|
|
469
|
-
return undefined;
|
|
470
|
-
return { version: 1, functions: raw.functions };
|
|
471
|
-
}
|
|
472
|
-
export function saveBaseline(cwd, baseline) {
|
|
473
|
-
const dir = join(cwd, ".howtofix");
|
|
474
|
-
mkdirSync(dir, { recursive: true });
|
|
475
|
-
const file = join(dir, "quality-baseline.json");
|
|
476
|
-
writeFileSync(file, `${JSON.stringify(baseline, null, 2)}\n`);
|
|
477
|
-
return relative(cwd, file);
|
|
478
|
-
}
|
|
479
|
-
/**
|
|
480
|
-
* What changed against the baseline: a function that is new while over its
|
|
481
|
-
* threshold, or one whose metric rank rose. Stored values are re-ranked with
|
|
482
|
-
* the current config, so editing a threshold moves both sides together and
|
|
483
|
-
* cannot fake a regression.
|
|
484
|
-
*/
|
|
485
|
-
export function compareBaseline(baseline, report, config) {
|
|
486
|
-
const stored = new Map();
|
|
487
|
-
if (baseline) {
|
|
488
|
-
for (const [path, entries] of Object.entries(baseline.functions)) {
|
|
489
|
-
stored.set(path, new Map(Object.entries(entries)));
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
const regressions = [];
|
|
493
|
-
for (const file of report.files.values()) {
|
|
494
|
-
if (!file.ok)
|
|
495
|
-
continue;
|
|
496
|
-
const previous = stored.get(file.path);
|
|
497
|
-
for (const fn of file.functions) {
|
|
498
|
-
const key = `${fn.name}@${fn.startLine}`;
|
|
499
|
-
const before = previous?.get(key);
|
|
500
|
-
if (!before) {
|
|
501
|
-
if (fn.worstRank === 0)
|
|
502
|
-
continue;
|
|
503
|
-
const worst = BASELINE_METRIC_ORDER.reduce((best, metric) => (fn.metrics[metric].rank > fn.metrics[best].rank ? metric : best));
|
|
504
|
-
regressions.push({ path: file.path, name: fn.name, startLine: fn.startLine, kind: "new", metric: worst, to: fn.metrics[worst].value });
|
|
505
|
-
continue;
|
|
506
|
-
}
|
|
507
|
-
for (const metric of BASELINE_METRIC_ORDER) {
|
|
508
|
-
const from = before[metric];
|
|
509
|
-
if (!isFiniteNumber(from))
|
|
510
|
-
continue;
|
|
511
|
-
const rankFrom = rankOfBaselineMetric(metric, from, config.thresholds);
|
|
512
|
-
const rankTo = fn.metrics[metric].rank;
|
|
513
|
-
if (rankTo > rankFrom) {
|
|
514
|
-
regressions.push({
|
|
515
|
-
path: file.path,
|
|
516
|
-
name: fn.name,
|
|
517
|
-
startLine: fn.startLine,
|
|
518
|
-
kind: "worsened",
|
|
519
|
-
metric,
|
|
520
|
-
from,
|
|
521
|
-
to: fn.metrics[metric].value,
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
return regressions;
|
|
528
|
-
}
|
|
529
|
-
/**
|
|
530
|
-
* The `--strict` gate (spec §5, §7): parse errors, regressions against the
|
|
531
|
-
* baseline, and a score under `ci.failBelow`. Without a baseline every flagged
|
|
532
|
-
* function counts as new — the first CI run records one (`--update-baseline`)
|
|
533
|
-
* instead of the gate being impossible to satisfy.
|
|
534
|
-
*/
|
|
535
|
-
export function strictVerdict(report, config, baseline) {
|
|
536
|
-
const reasons = [];
|
|
537
|
-
for (const file of report.files.values()) {
|
|
538
|
-
if (!file.ok)
|
|
539
|
-
reasons.push(`${file.path}: parse error — ${file.error}`);
|
|
540
|
-
}
|
|
541
|
-
for (const regression of compareBaseline(baseline, report, config)) {
|
|
542
|
-
if (regression.kind === "new") {
|
|
543
|
-
reasons.push(`${regression.path}:${regression.startLine} ${regression.name} is new over threshold (${regression.metric} ${regression.to})`);
|
|
544
|
-
}
|
|
545
|
-
else {
|
|
546
|
-
reasons.push(`${regression.path}:${regression.startLine} ${regression.name} ${regression.metric} worsened ${regression.from} → ${regression.to}`);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
const score = reportScore(report);
|
|
550
|
-
if (score < config.ci.failBelow) {
|
|
551
|
-
reasons.push(`score ${score} is below ci.failBelow ${config.ci.failBelow}`);
|
|
552
|
-
}
|
|
553
|
-
return { failed: reasons.length > 0, reasons };
|
|
554
|
-
}
|
|
555
|
-
// ---------------------------------------------------------------------------
|
|
556
|
-
// Renderers
|
|
557
|
-
// ---------------------------------------------------------------------------
|
|
558
|
-
const FILE_MARKS = ["ok", "⚠", "✗"];
|
|
559
|
-
const METRIC_ROWS = [
|
|
560
|
-
["cyclomatic", "cyclomatic"],
|
|
561
|
-
["cognitive", "cognitive"],
|
|
562
|
-
["nesting", "nesting"],
|
|
563
|
-
["length", "length"],
|
|
564
|
-
["halstead", "halstead"],
|
|
565
|
-
["mi", "MI"],
|
|
566
|
-
["parameters", "parameters"],
|
|
567
|
-
["booleanParameters", "boolean params"],
|
|
568
|
-
];
|
|
569
|
-
function warnLineOf(metric, thresholds) {
|
|
570
|
-
if (metric === "mi")
|
|
571
|
-
return thresholds.mi.warn;
|
|
572
|
-
if (metric === "booleanParameters")
|
|
573
|
-
return thresholds.parameters.warn;
|
|
574
|
-
return thresholds[metric].warn;
|
|
575
|
-
}
|
|
576
|
-
function metricRow(indent, label, value, warnLine) {
|
|
577
|
-
return `${indent}${label.padEnd(15)} ${value.value} (limit ${warnLine}) ${RANK_LABELS[value.rank]}`;
|
|
578
|
-
}
|
|
579
|
-
function functionBlock(fn, config) {
|
|
580
|
-
const lines = [` ${fn.name.padEnd(30)} L${fn.startLine}-${fn.endLine}`];
|
|
581
|
-
for (const [key, label] of METRIC_ROWS) {
|
|
582
|
-
const value = fn.metrics[key];
|
|
583
|
-
if (value.rank === 0)
|
|
584
|
-
continue;
|
|
585
|
-
if (key === "booleanParameters") {
|
|
586
|
-
lines.push(` ${label.padEnd(15)} ${fn.booleanParameterNames.join(", ")} warn`);
|
|
587
|
-
continue;
|
|
588
|
-
}
|
|
589
|
-
lines.push(metricRow(" ", label, value, warnLineOf(key, config.thresholds)));
|
|
590
|
-
}
|
|
591
|
-
if (fn.smells.length > 0) {
|
|
592
|
-
lines.push(` ${"smells".padEnd(15)} ${fn.smells.map((smell) => smell.id).join(", ")}`);
|
|
593
|
-
for (const smell of fn.smells) {
|
|
594
|
-
lines.push(` → ${SMELL_SUGGESTIONS[smell.id] ?? smell.label}`);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
return lines;
|
|
598
|
-
}
|
|
599
|
-
function flaggedCount(fn) {
|
|
600
|
-
let count = fn.smells.length;
|
|
601
|
-
for (const key of FUNCTION_METRIC_ORDER)
|
|
602
|
-
if (fn.metrics[key].rank > 0)
|
|
603
|
-
count += 1;
|
|
604
|
-
return count;
|
|
605
|
-
}
|
|
606
|
-
function pickTop(report, top) {
|
|
607
|
-
const all = [];
|
|
608
|
-
for (const file of report.files.values()) {
|
|
609
|
-
for (const fn of file.functions)
|
|
610
|
-
if (fn.worstRank > 0)
|
|
611
|
-
all.push(fn);
|
|
612
|
-
}
|
|
613
|
-
all.sort((a, b) => {
|
|
614
|
-
if (a.worstRank !== b.worstRank)
|
|
615
|
-
return b.worstRank - a.worstRank;
|
|
616
|
-
const flagged = flaggedCount(b) - flaggedCount(a);
|
|
617
|
-
if (flagged !== 0)
|
|
618
|
-
return flagged;
|
|
619
|
-
const cc = b.metrics.cyclomatic.value - a.metrics.cyclomatic.value;
|
|
620
|
-
if (cc !== 0)
|
|
621
|
-
return cc;
|
|
622
|
-
return `${a.path}:${a.name}`.localeCompare(`${b.path}:${b.name}`);
|
|
623
|
-
});
|
|
624
|
-
const selected = new Map();
|
|
625
|
-
for (const fn of all.slice(0, top)) {
|
|
626
|
-
const bucket = selected.get(fn.path) ?? new Set();
|
|
627
|
-
bucket.add(`${fn.name}@${fn.startLine}`);
|
|
628
|
-
selected.set(fn.path, bucket);
|
|
629
|
-
}
|
|
630
|
-
return selected;
|
|
631
|
-
}
|
|
632
|
-
/**
|
|
633
|
-
* The §6 human report: one header per file, details only where something is
|
|
634
|
-
* flagged, names and line numbers always together, metric names in lowercase
|
|
635
|
-
* spaces, and a concrete next step under every compound smell. `top` narrows
|
|
636
|
-
* the listing to the N worst functions; the summary counts stay whole-report
|
|
637
|
-
* so `--json`/`--sarif` parity holds.
|
|
638
|
-
*/
|
|
639
|
-
export function formatHuman(report, config, options = {}) {
|
|
640
|
-
const summary = summarize(report);
|
|
641
|
-
const top = options.top && options.top > 0 ? pickTop(report, options.top) : undefined;
|
|
642
|
-
const shown = [...report.files.values()].filter((file) => !top || (top.get(file.path)?.size ?? 0) > 0);
|
|
643
|
-
const width = Math.max(40, ...shown.map((file) => file.path.length));
|
|
644
|
-
const out = [];
|
|
645
|
-
for (const file of shown) {
|
|
646
|
-
if (!file.ok) {
|
|
647
|
-
out.push(`${file.path.padEnd(width)} parse error`);
|
|
648
|
-
out.push(` ${file.error}`);
|
|
649
|
-
continue;
|
|
650
|
-
}
|
|
651
|
-
out.push(`${file.path.padEnd(width)} MI ${file.metrics.mi.value} ${FILE_MARKS[file.metrics.mi.rank]}`);
|
|
652
|
-
if (file.metrics.lines.rank > 0)
|
|
653
|
-
out.push(metricRow(" ", "file lines", file.metrics.lines, config.thresholds.fileLines.warn));
|
|
654
|
-
if (file.metrics.exports.rank > 0)
|
|
655
|
-
out.push(metricRow(" ", "exports", file.metrics.exports, config.thresholds.exports.warn));
|
|
656
|
-
for (const fn of file.functions) {
|
|
657
|
-
if (top && !top.get(file.path)?.has(`${fn.name}@${fn.startLine}`))
|
|
658
|
-
continue;
|
|
659
|
-
if (fn.worstRank === 0 && fn.smells.length === 0)
|
|
660
|
-
continue;
|
|
661
|
-
out.push(...functionBlock(fn, config));
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
if (out.length > 0)
|
|
665
|
-
out.push("");
|
|
666
|
-
const parseNote = summary.errorFiles > 0 ? `, ${summary.errorFiles} parse error(s)` : "";
|
|
667
|
-
out.push(`${summary.findings} findings in ${summary.files} files${parseNote} · ${summary.functions} functions · score ${summary.score}`);
|
|
668
|
-
return out.join("\n");
|
|
669
|
-
}
|
|
670
|
-
export function formatJson(report) {
|
|
671
|
-
const summary = summarize(report);
|
|
672
|
-
const files = [...report.files.values()].map((file) => ({
|
|
673
|
-
path: file.path,
|
|
674
|
-
ok: file.ok,
|
|
675
|
-
...(file.ok ? {} : { error: file.error }),
|
|
676
|
-
lines: file.lines,
|
|
677
|
-
metrics: file.metrics,
|
|
678
|
-
functions: file.functions,
|
|
679
|
-
}));
|
|
680
|
-
return JSON.stringify({ version: 1, score: summary.score, summary, files, findings: report.findings }, null, 2);
|
|
681
|
-
}
|
|
682
|
-
const SARIF_LEVELS = ["warning", "error"];
|
|
683
|
-
/** SARIF 2.1.0 for code scanning: one result per finding, regions included. */
|
|
684
|
-
export function formatSarif(report) {
|
|
685
|
-
const rules = new Map();
|
|
686
|
-
for (const finding of report.findings) {
|
|
687
|
-
if (!rules.has(finding.metric))
|
|
688
|
-
rules.set(finding.metric, finding.metric);
|
|
689
|
-
}
|
|
690
|
-
const run = {
|
|
691
|
-
tool: {
|
|
692
|
-
driver: {
|
|
693
|
-
name: "howtofix-quality",
|
|
694
|
-
// The canonical repository, not a contributor's personal fork: this URL
|
|
695
|
-
// is what GitHub code scanning shows next to every finding, so pointing
|
|
696
|
-
// it at a fork credits the wrong place and breaks for anyone without
|
|
697
|
-
// access to that account.
|
|
698
|
-
informationUri: "https://github.com/NetroIndonesia/HowToFix-CLI",
|
|
699
|
-
rules: [...rules].map(([id, name]) => ({ id, name, shortDescription: { text: name } })),
|
|
700
|
-
},
|
|
701
|
-
},
|
|
702
|
-
results: report.findings.map((finding) => ({
|
|
703
|
-
ruleId: finding.metric,
|
|
704
|
-
level: SARIF_LEVELS[finding.rank >= 2 ? 1 : 0],
|
|
705
|
-
message: { text: finding.function ? `${finding.function}: ${finding.detail}` : finding.detail },
|
|
706
|
-
locations: [
|
|
707
|
-
{
|
|
708
|
-
physicalLocation: {
|
|
709
|
-
artifactLocation: { uri: finding.path },
|
|
710
|
-
region: {
|
|
711
|
-
startLine: finding.line,
|
|
712
|
-
startColumn: finding.startColumn,
|
|
713
|
-
endLine: finding.endLine,
|
|
714
|
-
endColumn: finding.endColumn,
|
|
715
|
-
},
|
|
716
|
-
},
|
|
717
|
-
},
|
|
718
|
-
],
|
|
719
|
-
})),
|
|
720
|
-
};
|
|
721
|
-
return JSON.stringify({ version: "2.1.0", $schema: "https://json.schemastore.org/sarif-2.1.0.json", runs: [run] }, null, 2);
|
|
722
|
-
}
|
|
723
|
-
const TS_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
|
|
724
|
-
/** Never walked. `.howtofix` and friends start with a dot and are skipped on sight. */
|
|
725
|
-
const SKIP_DIRS = new Set(["node_modules", "dist", "coverage"]);
|
|
726
|
-
function walkForSources(dir, cwd, into) {
|
|
727
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
728
|
-
const full = join(dir, entry.name);
|
|
729
|
-
if (entry.isDirectory()) {
|
|
730
|
-
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
|
|
731
|
-
continue;
|
|
732
|
-
walkForSources(full, cwd, into);
|
|
733
|
-
continue;
|
|
734
|
-
}
|
|
735
|
-
if (entry.isFile() && TS_EXTENSIONS.has(extname(entry.name)))
|
|
736
|
-
into.push(relative(cwd, full));
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
function gitLines(cwd, args) {
|
|
740
|
-
const run = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
741
|
-
if (run.error || run.status !== 0) {
|
|
742
|
-
const detail = run.error ? run.error.message : run.stderr.trim() || `exit ${run.status}`;
|
|
743
|
-
throw new Error(`git ${args.join(" ")} failed: ${detail}`);
|
|
744
|
-
}
|
|
745
|
-
return run.stdout.split("\n").filter((line) => line.trim().length > 0);
|
|
746
|
-
}
|
|
747
|
-
function changedPaths(cwd, base) {
|
|
748
|
-
const diff = gitLines(cwd, ["diff", "--name-only", "--diff-filter=ACMR", base]);
|
|
749
|
-
const untracked = gitLines(cwd, ["ls-files", "--others", "--exclude-standard"]);
|
|
750
|
-
return [...diff, ...untracked];
|
|
751
|
-
}
|
|
752
|
-
/** `dist/**` matches below dist, `vendor` matches the directory and its files. */
|
|
753
|
-
function ignoredBy(relPath, pattern) {
|
|
754
|
-
if (!pattern.includes("*")) {
|
|
755
|
-
const prefix = pattern.endsWith("/") ? pattern : `${pattern}/`;
|
|
756
|
-
return relPath === pattern || relPath.startsWith(prefix);
|
|
757
|
-
}
|
|
758
|
-
const escaped = pattern
|
|
759
|
-
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
760
|
-
.replace(/\*\*/g, "\u0000")
|
|
761
|
-
.replace(/\*/g, "[^/]*")
|
|
762
|
-
.replace(/\u0000/g, ".*");
|
|
763
|
-
return new RegExp(`^${escaped}$`).test(relPath);
|
|
764
|
-
}
|
|
765
|
-
/**
|
|
766
|
-
* The batch to analyze: whole project, one target, or the changed set. Paths
|
|
767
|
-
* come back relative to `cwd` (sorted, deduplicated, ignore-filtered) — they
|
|
768
|
-
* are also the Map keys, so output paths stay readable and stable.
|
|
769
|
-
*/
|
|
770
|
-
export function collectTargetPaths(cwd, options, ignore = []) {
|
|
771
|
-
let candidates;
|
|
772
|
-
if (options.changes) {
|
|
773
|
-
candidates = changedPaths(cwd, options.base ?? "HEAD");
|
|
774
|
-
}
|
|
775
|
-
else if (options.target) {
|
|
776
|
-
const full = resolve(cwd, options.target);
|
|
777
|
-
const stats = statSync(full, { throwIfNoEntry: false });
|
|
778
|
-
if (!stats)
|
|
779
|
-
throw new Error(`no such path: ${options.target}`);
|
|
780
|
-
if (stats.isDirectory()) {
|
|
781
|
-
candidates = [];
|
|
782
|
-
walkForSources(full, cwd, candidates);
|
|
783
|
-
}
|
|
784
|
-
else {
|
|
785
|
-
if (!TS_EXTENSIONS.has(extname(full)))
|
|
786
|
-
throw new Error(`not a TypeScript source file: ${options.target}`);
|
|
787
|
-
candidates = [relative(cwd, full)];
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
else {
|
|
791
|
-
candidates = [];
|
|
792
|
-
walkForSources(cwd, cwd, candidates);
|
|
793
|
-
}
|
|
794
|
-
const kept = new Set();
|
|
795
|
-
for (const path of candidates) {
|
|
796
|
-
if (!TS_EXTENSIONS.has(extname(path)))
|
|
797
|
-
continue;
|
|
798
|
-
if (ignore.some((pattern) => ignoredBy(path, pattern)))
|
|
799
|
-
continue;
|
|
800
|
-
kept.add(path);
|
|
801
|
-
}
|
|
802
|
-
return [...kept].sort();
|
|
803
|
-
}
|