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.
Files changed (152) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-63ZCQDXH.js +6 -0
  5. package/dist/chunk-6FUWMLWO.js +1 -0
  6. package/dist/chunk-7CHDNTH6.js +1 -0
  7. package/dist/chunk-ACOMNDUL.js +185 -0
  8. package/dist/chunk-AEVPET7X.js +2 -0
  9. package/dist/chunk-AGGIYXN7.js +2 -0
  10. package/dist/chunk-BHVBO4SC.js +1 -0
  11. package/dist/chunk-FPXVDP32.js +8 -0
  12. package/dist/chunk-GIUYIKH3.js +109 -0
  13. package/dist/chunk-GQCAJ56I.js +5 -0
  14. package/dist/chunk-GXB7RZHH.js +1 -0
  15. package/dist/chunk-IGEY3AWF.js +1 -0
  16. package/dist/chunk-ILDDDLRC.js +1 -0
  17. package/dist/chunk-JFYVRWJI.js +2 -0
  18. package/dist/chunk-MVLCGU3G.js +1 -0
  19. package/dist/chunk-N345KWWK.js +1 -0
  20. package/dist/chunk-NBQFPCRT.js +1 -0
  21. package/dist/chunk-OIPSLIZE.js +5 -0
  22. package/dist/chunk-ORJ2QZBG.js +3 -0
  23. package/dist/chunk-PCALBZOO.js +2 -0
  24. package/dist/chunk-PDNBGUYP.js +2 -0
  25. package/dist/chunk-S656RE4A.js +1 -0
  26. package/dist/chunk-TSEKPGK4.js +1 -0
  27. package/dist/chunk-U6CI6KXN.js +34 -0
  28. package/dist/chunk-UTYFLLSM.js +1 -0
  29. package/dist/chunk-V3R4FYTJ.js +1 -0
  30. package/dist/chunk-V656XVXV.js +8 -0
  31. package/dist/chunk-WKUJ7CFO.js +1 -0
  32. package/dist/chunk-WOT6VMZA.js +1 -0
  33. package/dist/chunk-WUPU6LTD.js +4 -0
  34. package/dist/chunk-WX3V5P7U.js +5 -0
  35. package/dist/chunk-ZKZ6WCI3.js +2 -0
  36. package/dist/main.js +94 -536
  37. package/package.json +15 -62
  38. package/dist/account-credential.js +0 -136
  39. package/dist/account-credential.test.js +0 -38
  40. package/dist/activity.js +0 -124
  41. package/dist/agent-defs.js +0 -237
  42. package/dist/agent-limiter.js +0 -68
  43. package/dist/agents.js +0 -554
  44. package/dist/anim.js +0 -361
  45. package/dist/args.js +0 -228
  46. package/dist/at-file.js +0 -28
  47. package/dist/brand-extension.js +0 -87
  48. package/dist/brand.js +0 -76
  49. package/dist/browser.js +0 -58
  50. package/dist/cache-report.js +0 -139
  51. package/dist/checkpoint.js +0 -437
  52. package/dist/claims.js +0 -167
  53. package/dist/cli-auth.js +0 -142
  54. package/dist/cli-checkin.js +0 -116
  55. package/dist/cli-credentials.js +0 -254
  56. package/dist/clipboard.js +0 -149
  57. package/dist/collapse.js +0 -129
  58. package/dist/commands.js +0 -301
  59. package/dist/compaction-guard.js +0 -81
  60. package/dist/context-extension.js +0 -17
  61. package/dist/dcp/commands.js +0 -172
  62. package/dist/dcp/compress-state.js +0 -139
  63. package/dist/dcp/compress.js +0 -329
  64. package/dist/dcp/dcp-extension.js +0 -288
  65. package/dist/dcp/dedup.js +0 -93
  66. package/dist/dcp/ids.js +0 -189
  67. package/dist/dcp/index.js +0 -25
  68. package/dist/dcp/prompts.js +0 -68
  69. package/dist/dcp/protected-content.js +0 -221
  70. package/dist/dcp/protected.js +0 -161
  71. package/dist/dcp/prune.js +0 -418
  72. package/dist/dcp/state.js +0 -176
  73. package/dist/device-id.js +0 -90
  74. package/dist/device-id.test.js +0 -23
  75. package/dist/errors.js +0 -63
  76. package/dist/extensions.js +0 -48
  77. package/dist/fatal.js +0 -35
  78. package/dist/feed.js +0 -498
  79. package/dist/fs-atomic.js +0 -54
  80. package/dist/gateway-errors.js +0 -90
  81. package/dist/git.js +0 -230
  82. package/dist/goal-extension.js +0 -88
  83. package/dist/goal.js +0 -222
  84. package/dist/hooks-extension.js +0 -176
  85. package/dist/hooks.js +0 -443
  86. package/dist/howtofix-account.js +0 -715
  87. package/dist/hud-line.js +0 -153
  88. package/dist/hwid.js +0 -130
  89. package/dist/interrupt.js +0 -60
  90. package/dist/json-events.js +0 -50
  91. package/dist/level-style.js +0 -112
  92. package/dist/loop-guard.js +0 -95
  93. package/dist/lsp-tools.js +0 -32
  94. package/dist/lsp.js +0 -347
  95. package/dist/mcp-config.js +0 -85
  96. package/dist/mcp-lifecycle.js +0 -147
  97. package/dist/model-catalog.js +0 -217
  98. package/dist/model-resolve.js +0 -65
  99. package/dist/notes.js +0 -78
  100. package/dist/oauth-login.js +0 -568
  101. package/dist/paths.js +0 -61
  102. package/dist/permissions.js +0 -298
  103. package/dist/plan-mode.js +0 -363
  104. package/dist/preset-command.js +0 -227
  105. package/dist/preset-extension.js +0 -35
  106. package/dist/preset-mcp.js +0 -292
  107. package/dist/presets.js +0 -727
  108. package/dist/prompt-files.js +0 -103
  109. package/dist/prompt-text.js +0 -41
  110. package/dist/proxy.js +0 -137
  111. package/dist/quality/ast.js +0 -196
  112. package/dist/quality/cognitive.js +0 -83
  113. package/dist/quality/cyclomatic.js +0 -30
  114. package/dist/quality/halstead.js +0 -83
  115. package/dist/quality/index.js +0 -18
  116. package/dist/quality/length.js +0 -3
  117. package/dist/quality/maintainability.js +0 -15
  118. package/dist/quality/nesting.js +0 -57
  119. package/dist/quality/report.js +0 -803
  120. package/dist/quality/smells.js +0 -67
  121. package/dist/quality-tool.js +0 -63
  122. package/dist/read-only-commands.js +0 -597
  123. package/dist/repo-map.js +0 -292
  124. package/dist/rtk.js +0 -193
  125. package/dist/sandbox.js +0 -105
  126. package/dist/schema.js +0 -214
  127. package/dist/session-lifecycle.js +0 -19
  128. package/dist/session-retention.js +0 -111
  129. package/dist/session.js +0 -446
  130. package/dist/sessions.js +0 -100
  131. package/dist/settings.js +0 -148
  132. package/dist/skills.js +0 -35
  133. package/dist/slop-tool.js +0 -96
  134. package/dist/spend-cap.js +0 -43
  135. package/dist/subagent.js +0 -171
  136. package/dist/tar.js +0 -146
  137. package/dist/theme.js +0 -79
  138. package/dist/thinking.js +0 -45
  139. package/dist/todo.js +0 -114
  140. package/dist/tool-loadout.js +0 -97
  141. package/dist/tool-render.js +0 -769
  142. package/dist/tools.js +0 -176
  143. package/dist/ui.js +0 -4751
  144. package/dist/update.js +0 -533
  145. package/dist/usage-segment.js +0 -69
  146. package/dist/web-tools.js +0 -46
  147. package/dist/web.js +0 -265
  148. package/dist/welcome.js +0 -179
  149. package/dist/workflow-engine.js +0 -246
  150. package/dist/workflow-progress.js +0 -446
  151. package/dist/workflow-view.js +0 -195
  152. package/dist/workflow.js +0 -349
@@ -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
- }