fallow 2.88.2 → 2.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fallow",
3
- "version": "2.88.2",
3
+ "version": "2.89.0",
4
4
  "description": "Deterministic codebase intelligence for TypeScript and JavaScript. Quality, risk, architecture, dependencies, duplication, and safe cleanup evidence for humans, CI, and agents. Optional runtime intelligence layer (Fallow Runtime) adds production execution evidence. Rust-native, sub-second, 96 framework plugins.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -83,13 +83,13 @@
83
83
  "@tanstack/intent": "0.0.41"
84
84
  },
85
85
  "optionalDependencies": {
86
- "@fallow-cli/darwin-arm64": "2.88.2",
87
- "@fallow-cli/darwin-x64": "2.88.2",
88
- "@fallow-cli/linux-x64-gnu": "2.88.2",
89
- "@fallow-cli/linux-arm64-gnu": "2.88.2",
90
- "@fallow-cli/linux-x64-musl": "2.88.2",
91
- "@fallow-cli/linux-arm64-musl": "2.88.2",
92
- "@fallow-cli/win32-arm64-msvc": "2.88.2",
93
- "@fallow-cli/win32-x64-msvc": "2.88.2"
86
+ "@fallow-cli/darwin-arm64": "2.89.0",
87
+ "@fallow-cli/darwin-x64": "2.89.0",
88
+ "@fallow-cli/linux-x64-gnu": "2.89.0",
89
+ "@fallow-cli/linux-arm64-gnu": "2.89.0",
90
+ "@fallow-cli/linux-x64-musl": "2.89.0",
91
+ "@fallow-cli/linux-arm64-musl": "2.89.0",
92
+ "@fallow-cli/win32-arm64-msvc": "2.89.0",
93
+ "@fallow-cli/win32-x64-msvc": "2.89.0"
94
94
  }
95
95
  }
@@ -14,6 +14,7 @@ const fs = require("node:fs");
14
14
 
15
15
  const { getPlatformPackage } = require("./platform-package");
16
16
  const { ensureVerified } = require("./lazy-verify");
17
+ const { isPreSigningVersion } = require("./verify-binary");
17
18
 
18
19
  function resolvePlatformPackageName() {
19
20
  if (process.platform !== "linux") {
@@ -29,13 +30,34 @@ function resolvePlatformPackageName() {
29
30
  }
30
31
 
31
32
  function isVersionQuery(argv) {
32
- // clap registers both --version and -V on the root command.
33
+ // The root command answers all three version flags (--version, -V, and the
34
+ // TS/JS-toolchain-style -v), so the verified-status line must be appended for
35
+ // every one of them, not just --version / -V.
33
36
  const tail = argv.slice(2);
34
37
  if (tail.length === 0) return false;
35
- return tail[0] === "--version" || tail[0] === "-V";
38
+ return tail[0] === "--version" || tail[0] === "-V" || tail[0] === "-v";
36
39
  }
37
40
 
38
- function describeVerified(result) {
41
+ // Signing status of the resolved CLI version, appended to the `verified:` line.
42
+ // Most informative on the `skipped` path: a fleet running with
43
+ // FALLOW_SKIP_BINARY_VERIFY can see whether the pinned version is even signable
44
+ // (pre-signing versions predate the 2.77.0 epoch and have no signature to
45
+ // verify). Best-effort: an unknown/unreadable version yields no annotation.
46
+ function describeSigning(version) {
47
+ if (typeof version !== "string" || version.length === 0) {
48
+ return "";
49
+ }
50
+ return isPreSigningVersion(version)
51
+ ? `; fallow ${version} unsigned (predates 2.77.0)`
52
+ : `; fallow ${version} signed`;
53
+ }
54
+
55
+ function describeVerified(result, version) {
56
+ const status = describeVerifiedStatus(result);
57
+ return `${status}${describeSigning(version)}`;
58
+ }
59
+
60
+ function describeVerifiedStatus(result) {
39
61
  if (result.skipped) {
40
62
  return `verified: skipped (${result.reason})`;
41
63
  }
@@ -79,9 +101,21 @@ function printVerifyError(verifyResult) {
79
101
  );
80
102
  }
81
103
 
82
- function writeVerifiedLineIfVersionQuery(verifyResult) {
104
+ function writeVerifiedLineIfVersionQuery(verifyResult, version) {
83
105
  if (isVersionQuery(process.argv)) {
84
- process.stdout.write(`${describeVerified(verifyResult)}\n`);
106
+ process.stdout.write(`${describeVerified(verifyResult, version)}\n`);
107
+ }
108
+ }
109
+
110
+ // Read the resolved CLI version from the platform package manifest (its version
111
+ // is released in lockstep with the CLI). Best-effort: never throws, so a
112
+ // missing/garbled manifest just omits the signing annotation on --version.
113
+ function readResolvedVersion(manifestPath) {
114
+ try {
115
+ const version = JSON.parse(fs.readFileSync(manifestPath, "utf8")).version;
116
+ return typeof version === "string" ? version : undefined;
117
+ } catch {
118
+ return undefined;
85
119
  }
86
120
  }
87
121
 
@@ -105,6 +139,7 @@ function guardBrokenStdout() {
105
139
  function runBinary(binaryBaseName) {
106
140
  guardBrokenStdout();
107
141
  const { pkg, manifestPath, platformPkgDir } = resolvePlatformPaths();
142
+ const resolvedVersion = readResolvedVersion(manifestPath);
108
143
 
109
144
  const binaryName = process.platform === "win32" ? `${binaryBaseName}.exe` : binaryBaseName;
110
145
  const binaryPath = path.join(platformPkgDir, binaryName);
@@ -126,11 +161,11 @@ function runBinary(binaryBaseName) {
126
161
  if (e.status === undefined) throw e;
127
162
  // Child has already written its --version line via inherited stdio;
128
163
  // append the verified line here only on a clean exit.
129
- if (e.status === 0) writeVerifiedLineIfVersionQuery(verifyResult);
164
+ if (e.status === 0) writeVerifiedLineIfVersionQuery(verifyResult, resolvedVersion);
130
165
  process.exit(e.status);
131
166
  }
132
167
 
133
- writeVerifiedLineIfVersionQuery(verifyResult);
168
+ writeVerifiedLineIfVersionQuery(verifyResult, resolvedVersion);
134
169
  }
135
170
 
136
171
  module.exports = {
@@ -37,3 +37,40 @@ test("guardBrokenStdout rethrows non-EPIPE stdout errors (exit 1)", () => {
37
37
  assert.match(res.stderr, /Error: write ENOSPC/, "the rethrown error reaches stderr");
38
38
  assert.doesNotMatch(res.stderr, /is not a function/, "guard must be present, not absent");
39
39
  });
40
+
41
+ test("isVersionQuery recognizes --version, -V, and -v as the first argument", () => {
42
+ const { isVersionQuery } = require(RUN_BINARY);
43
+ assert.equal(isVersionQuery(["node", "fallow", "--version"]), true);
44
+ assert.equal(isVersionQuery(["node", "fallow", "-V"]), true);
45
+ assert.equal(
46
+ isVersionQuery(["node", "fallow", "-v"]),
47
+ true,
48
+ "-v must append the verified line too",
49
+ );
50
+ assert.equal(isVersionQuery(["node", "fallow"]), false);
51
+ assert.equal(isVersionQuery(["node", "fallow", "dead-code"]), false);
52
+ assert.equal(
53
+ isVersionQuery(["node", "fallow", "dead-code", "-v"]),
54
+ false,
55
+ "-v only counts as the first arg",
56
+ );
57
+ });
58
+
59
+ test("describeVerified annotates the resolved version's signing status", () => {
60
+ const { describeVerified } = require(RUN_BINARY);
61
+ const ok = { ok: true, sentinelPath: "/c/s" };
62
+ // Signed-era version: appended as `signed`.
63
+ assert.match(
64
+ describeVerified(ok, "2.83.0"),
65
+ /verified: yes \(sentinel \/c\/s\); fallow 2\.83\.0 signed/,
66
+ );
67
+ // Pre-signing version: the fleet pre-flight signal, most useful on skip.
68
+ const skipped = { skipped: true, reason: "FALLOW_SKIP_BINARY_VERIFY is set" };
69
+ assert.match(
70
+ describeVerified(skipped, "2.76.0"),
71
+ /verified: skipped \(.*\); fallow 2\.76\.0 unsigned \(predates 2\.77\.0\)/,
72
+ );
73
+ // Unknown / unreadable version: no annotation, version line stays intact.
74
+ assert.equal(describeVerified(ok, undefined), "verified: yes (sentinel /c/s)");
75
+ assert.equal(describeVerified(ok, ""), "verified: yes (sentinel /c/s)");
76
+ });
@@ -81,6 +81,10 @@ function _verifyWithKey(binaryPath, rawPubKey) {
81
81
  signature = fs.readFileSync(sigPath);
82
82
  } catch (err) {
83
83
  if (err && err.code === "ENOENT") {
84
+ // Low-level result: the version-aware guidance is attached one layer up
85
+ // in verifyOneBinary{,Sync}, which knows the resolved platform-package
86
+ // version and can distinguish a pre-signing version from a >=2.77.0
87
+ // package whose signature is missing (a tampering signal). Refs #944.
84
88
  return { ok: false, code: "sig-missing", message: `signature not found at ${sigPath}` };
85
89
  }
86
90
  return {
@@ -389,16 +393,69 @@ function resolvePlatformPackageForVerify(opts) {
389
393
  };
390
394
  }
391
395
 
396
+ // Signed platform binaries ship from fallow 2.77.0 onward (the `.sig` staging
397
+ // step, the `files` entries, and this verifier all landed in #488, first
398
+ // released in 2.77.0). A resolved version below this epoch has no signature and
399
+ // never will (npm is immutable), so its missing-sig failure is expected and the
400
+ // fix is to upgrade the pin. A version at or above the epoch whose signature is
401
+ // absent is a different, alarming case (tampered or incomplete package).
402
+ const SIGNING_EPOCH = [2, 77, 0];
403
+
404
+ // True when `version` is a parseable semver strictly below the signing epoch.
405
+ // An unparsable / unknown version returns false so the caller uses the
406
+ // cautious (possible-tampering) message rather than telling the user to bump.
407
+ function isPreSigningVersion(version) {
408
+ if (typeof version !== "string") {
409
+ return false;
410
+ }
411
+ const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
412
+ if (!match) {
413
+ return false;
414
+ }
415
+ const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
416
+ for (let i = 0; i < SIGNING_EPOCH.length; i += 1) {
417
+ if (parts[i] < SIGNING_EPOCH[i]) {
418
+ return true;
419
+ }
420
+ if (parts[i] > SIGNING_EPOCH[i]) {
421
+ return false;
422
+ }
423
+ }
424
+ return false;
425
+ }
426
+
427
+ // Attach version-aware remediation to a `sig-missing` low-level result. Other
428
+ // failure codes pass through untouched. The split distinguishes a pre-signing
429
+ // version (no signature exists and never will, so the fix is to upgrade the
430
+ // pin) from a >=2.77.0 package whose signature is unexpectedly absent (a
431
+ // tampering signal). Each message gives the CONSTRUCTIVE fix only; the bypass
432
+ // escape hatch (FALLOW_SKIP_BINARY_VERIFY) is owned by the caller's trailer and
433
+ // SECURITY.md, deliberately not surfaced here so a tampering victim is never
434
+ // nudged to bypass and the env is not normalized in CI logs.
435
+ function describeSigMissing(result, version) {
436
+ if (result.code !== "sig-missing") {
437
+ return result;
438
+ }
439
+ const message = isPreSigningVersion(version)
440
+ ? `${result.message}. fallow ${version} predates signed binaries (signatures ship in 2.77.0 ` +
441
+ `and later), so this package has no signature to verify. Bump the \`fallow\` dependency in ` +
442
+ `your project's package.json to >=2.77.0 (for example \`npm install fallow@latest\`).`
443
+ : `${result.message}. fallow ${version} should be signed but its signature is missing; the ` +
444
+ `platform package may be tampered with or incomplete. Reinstall with \`npm install fallow@latest\` ` +
445
+ `and report it if it persists on a clean install.`;
446
+ return { ...result, message };
447
+ }
448
+
392
449
  // Verify one binary against its sig + expected SHA-256. Used by both the
393
450
  // sync and async verify-installed entry points; the digest provider may be
394
451
  // sync (returns string) or async (returns Promise<string>), and the loop body
395
452
  // awaits the value regardless. Keeps the outer functions a flat for-loop so
396
453
  // cyclomatic + cognitive complexity stays low.
397
- async function verifyOneBinary(target, dir, pkg, manifestPath, verifyFn, digestProvider) {
454
+ async function verifyOneBinary(target, dir, pkg, manifestPath, verifyFn, digestProvider, version) {
398
455
  const binaryPath = path.join(dir, target.binary);
399
456
  const sigResult = verifyFn(binaryPath);
400
457
  if (!sigResult.ok) {
401
- return { ...sigResult, binary: binaryPath, package: pkg };
458
+ return { ...describeSigMissing(sigResult, version), binary: binaryPath, package: pkg };
402
459
  }
403
460
  // Prefer the digest embedded in the platform package's package.json
404
461
  // (written at release time by `npm-prep`). Falling back to the GitHub
@@ -478,7 +535,15 @@ async function verifyInstalled(options) {
478
535
  const boundProvider = async (args) => digestProvider({ ...args, version });
479
536
 
480
537
  for (const target of binaryTargetsForPlatform(platformId)) {
481
- const result = await verifyOneBinary(target, dir, pkg, manifestPath, verify, boundProvider);
538
+ const result = await verifyOneBinary(
539
+ target,
540
+ dir,
541
+ pkg,
542
+ manifestPath,
543
+ verify,
544
+ boundProvider,
545
+ version,
546
+ );
482
547
  if (!result.ok) return result;
483
548
  }
484
549
  return { ok: true, package: pkg, version };
@@ -518,7 +583,15 @@ function verifyInstalledSync(options) {
518
583
  const { dir, manifestPath, pkg, version, platformId } = resolved;
519
584
 
520
585
  for (const target of binaryTargetsForPlatform(platformId)) {
521
- const result = verifyOneBinarySync(target, dir, pkg, manifestPath, verify, digestProvider);
586
+ const result = verifyOneBinarySync(
587
+ target,
588
+ dir,
589
+ pkg,
590
+ manifestPath,
591
+ verify,
592
+ digestProvider,
593
+ version,
594
+ );
522
595
  if (!result.ok) return result;
523
596
  }
524
597
  return { ok: true, package: pkg, version };
@@ -529,11 +602,11 @@ function verifyInstalledSync(options) {
529
602
  // actionable error pointing the user at `npm install fallow@latest` (since
530
603
  // there is no network fallback in lazy mode), and the digestProvider is
531
604
  // optional (tests inject one; production callers rely on the embedded digest).
532
- function verifyOneBinarySync(target, dir, pkg, manifestPath, verifyFn, digestProvider) {
605
+ function verifyOneBinarySync(target, dir, pkg, manifestPath, verifyFn, digestProvider, version) {
533
606
  const binaryPath = path.join(dir, target.binary);
534
607
  const sigResult = verifyFn(binaryPath);
535
608
  if (!sigResult.ok) {
536
- return { ...sigResult, binary: binaryPath, package: pkg };
609
+ return { ...describeSigMissing(sigResult, version), binary: binaryPath, package: pkg };
537
610
  }
538
611
  let expectedDigest = manifestPath ? readEmbeddedDigest(manifestPath, target.binary) : null;
539
612
  if (!expectedDigest && digestProvider) {
@@ -575,6 +648,8 @@ module.exports = {
575
648
  verifyInstalled,
576
649
  verifyInstalledSync,
577
650
  _verifyWithKey,
651
+ isPreSigningVersion,
652
+ describeSigMissing,
578
653
  normalizeDigest,
579
654
  readEmbeddedDigest,
580
655
  sha256Hex,
@@ -7,6 +7,8 @@ const path = require("node:path");
7
7
 
8
8
  const {
9
9
  _verifyWithKey,
10
+ isPreSigningVersion,
11
+ describeSigMissing,
10
12
  verifyBinaryAt,
11
13
  verifyDigestAt,
12
14
  verifyInstalled,
@@ -133,6 +135,56 @@ test("_verifyWithKey returns sig-missing when the signature file does not exist"
133
135
  }
134
136
  });
135
137
 
138
+ // The version-aware remediation lives in describeSigMissing (attached by
139
+ // verifyOneBinary{,Sync}), not in the low-level _verifyWithKey result. Refs #944.
140
+ test("isPreSigningVersion is true below 2.77.0 and false at/above it", () => {
141
+ assert.equal(isPreSigningVersion("2.76.0"), true);
142
+ assert.equal(isPreSigningVersion("2.73.0"), true);
143
+ assert.equal(isPreSigningVersion("1.9.0"), true);
144
+ assert.equal(isPreSigningVersion("2.77.0"), false);
145
+ assert.equal(isPreSigningVersion("2.83.0"), false);
146
+ assert.equal(isPreSigningVersion("2.88.2"), false);
147
+ // Unknown / unparsable versions default to false so the caller uses the
148
+ // cautious possible-tampering message rather than telling the user to bump.
149
+ assert.equal(isPreSigningVersion("unknown"), false);
150
+ assert.equal(isPreSigningVersion(undefined), false);
151
+ });
152
+
153
+ test("describeSigMissing gives upgrade guidance for a pre-signing version", () => {
154
+ const base = { ok: false, code: "sig-missing", message: "signature not found at /x/fallow.sig" };
155
+ const result = describeSigMissing(base, "2.76.0");
156
+ // The security-critical invariant: enriching the message must never flip the
157
+ // verdict. A sig-missing result stays a hard failure.
158
+ assert.equal(result.ok, false);
159
+ assert.equal(result.code, "sig-missing");
160
+ assert.match(result.message, /predates signed binaries/);
161
+ assert.match(result.message, /2\.77\.0/);
162
+ // Names WHERE the pin lives so the user can act.
163
+ assert.match(result.message, /package\.json/);
164
+ // The bypass escape hatch is owned by the caller's trailer + SECURITY.md, not
165
+ // surfaced inline, so it is not normalized in CI logs.
166
+ assert.doesNotMatch(result.message, new RegExp(SKIP_ENV));
167
+ // The original low-level detail is preserved.
168
+ assert.match(result.message, /signature not found/);
169
+ });
170
+
171
+ test("describeSigMissing flags possible tampering for a signed-era version", () => {
172
+ const base = { ok: false, code: "sig-missing", message: "signature not found at /x/fallow.sig" };
173
+ const result = describeSigMissing(base, "2.83.0");
174
+ assert.equal(result.ok, false);
175
+ assert.equal(result.code, "sig-missing");
176
+ assert.match(result.message, /tampered with or incomplete/);
177
+ assert.match(result.message, /Reinstall/);
178
+ // Must NOT nudge a possible-tampering victim toward bypassing verification.
179
+ assert.doesNotMatch(result.message, new RegExp(SKIP_ENV));
180
+ });
181
+
182
+ test("describeSigMissing passes non-sig-missing results through untouched", () => {
183
+ const base = { ok: false, code: "digest-mismatch", message: "digest mismatch" };
184
+ const result = describeSigMissing(base, "2.76.0");
185
+ assert.equal(result.message, "digest mismatch");
186
+ });
187
+
136
188
  test("_verifyWithKey returns binary-missing when the binary does not exist", () => {
137
189
  const { rawPub } = makeKeypair();
138
190
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-vbtest-"));
@@ -343,6 +395,32 @@ test("verifyInstalled with dirOverride reports sig-missing when a .sig is absent
343
395
  assert.match(result.binary, /fallow-mcp/);
344
396
  });
345
397
 
398
+ test("verifyInstalled threads the resolved version into the sig-missing message", async (t) => {
399
+ const { privateKey, rawPub } = makeKeypair();
400
+ const preDir = makePlatformDir(privateKey, { skipSigFor: "fallow-mcp" });
401
+ const eraDir = makePlatformDir(privateKey, { skipSigFor: "fallow-mcp" });
402
+ t.after(() => {
403
+ cleanup(preDir);
404
+ cleanup(eraDir);
405
+ });
406
+ const pre = await verifyInstalled({
407
+ dirOverride: preDir,
408
+ version: "2.76.0",
409
+ verifyFn: (p) => _verifyWithKey(p, rawPub),
410
+ digestProvider: makeDigestProvider(preDir),
411
+ });
412
+ assert.equal(pre.code, "sig-missing");
413
+ assert.match(pre.message, /predates signed binaries/);
414
+ const era = await verifyInstalled({
415
+ dirOverride: eraDir,
416
+ version: "2.83.0",
417
+ verifyFn: (p) => _verifyWithKey(p, rawPub),
418
+ digestProvider: makeDigestProvider(eraDir),
419
+ });
420
+ assert.equal(era.code, "sig-missing");
421
+ assert.match(era.message, /tampered with or incomplete/);
422
+ });
423
+
346
424
  test("verifyInstalled reports digest-mismatch when SHA-256 disagrees with the provider", async (t) => {
347
425
  const { privateKey, rawPub } = makeKeypair();
348
426
  const dir = makePlatformDir(privateKey);
@@ -122,7 +122,7 @@ When using fallow via MCP (`fallow-mcp`), the following tools are available:
122
122
  | `find_dupes` | Code duplication detection. Set `changed_since` to scope to changed files since a git ref. Set `min_occurrences` (≥ 2, default 2) to hide pair-only clones and focus on widespread copy-paste; JSON gains `stats.clone_groups_below_min_occurrences` when the filter hides anything. Each `clone_groups[]` entry carries a stable `fingerprint`, usually `dup:<8hex>` and widened only on rare report collisions; pass it to `trace_clone` to deep-dive that group |
123
123
  | `fix_preview` | Dry-run auto-fix preview |
124
124
  | `fix_apply` | Apply auto-fixes (destructive) |
125
- | `check_health` | Complexity metrics, health scores, hotspots, and refactoring targets. Optional `runtime_coverage` merges a V8 or Istanbul dump; tune it with `min_invocations_hot` (default 100), `min_observation_volume` (default 5000), and `low_traffic_threshold` (default 0.001). When runtime evidence combines with static usage, test coverage, CRAP/complexity, ownership, or change scope, read `coverage_intelligence` for stable `fallow:coverage-intel:<hash>` recommendations. Set `group_by` to `owner`, `directory`, `package`, or `section` for per-group `vital_signs` / `health_score`; SARIF results gain `properties.group`, CodeClimate issues gain a top-level `group` field |
125
+ | `check_health` | Complexity metrics, health scores, hotspots, and refactoring targets. Set `complexity_breakdown: true` to add a per-decision-point `contributions[]` array to each complexity finding (each `else-if`, nested `if`, boolean operator, loop, `case`, etc. with its source line and cyclomatic/cognitive weight) so you can explain WHY a function scored high and pinpoint refactor targets. Optional `runtime_coverage` merges a V8 or Istanbul dump; tune it with `min_invocations_hot` (default 100), `min_observation_volume` (default 5000), and `low_traffic_threshold` (default 0.001). When runtime evidence combines with static usage, test coverage, CRAP/complexity, ownership, or change scope, read `coverage_intelligence` for stable `fallow:coverage-intel:<hash>` recommendations. Set `group_by` to `owner`, `directory`, `package`, or `section` for per-group `vital_signs` / `health_score`; SARIF results gain `properties.group`, CodeClimate issues gain a top-level `group` field |
126
126
  | `check_runtime_coverage` | Merge V8 or Istanbul runtime-coverage data into the health report. One local capture is free; continuous/cloud or multi-capture runtime monitoring is paid. Required `coverage` param (V8 dir, V8 JSON, or Istanbul `coverage-final.json`). Tuning knobs: `min_invocations_hot` (default 100), `min_observation_volume` (default 5000), `low_traffic_threshold` (default 0.001), `max_crap` (default 30.0), `top`, `group_by`. Cloud runtime rows can expose `resolutionStatus` / `mappingQuality` on function-list JSON and `resolution_status` / `mapping_quality` in runtime-context JSON. Use `coverage_intelligence` and the confidence table below before acting on file-level runtime signals. Long dumps may exceed the 120s MCP timeout; raise `FALLOW_TIMEOUT_SECS`. Pick this over `check_health` when you have a coverage dump. |
127
127
  | `get_hot_paths` | Runtime-context slice over the same runtime coverage pipeline. Same params as `check_runtime_coverage`; read `runtime_coverage.hot_paths` for production hot paths. |
128
128
  | `get_blast_radius` | Runtime-context slice for blast-radius review. Same params as `check_runtime_coverage`; read `runtime_coverage.blast_radius` for stable `fallow:blast:<hash>` IDs, caller counts, traffic-weighted caller reach, optional cloud deploy touch counts, and low/medium/high risk bands. |
@@ -51,7 +51,14 @@ Analyzes the project for unused files, exports, dependencies, types, members, an
51
51
  | `--workspace` | string | — | Scope to one or more workspaces. Comma-separated values, globs (`apps/*`, `@scope/*`), and `!`-prefixed negation (`!apps/legacy`) supported. Matched against package name AND workspace path relative to repo root. |
52
52
  | `--changed-workspaces` | string (git ref) | — | Git-derived monorepo CI scoping: scope to workspaces containing any file changed since `REF` (e.g. `origin/main`). Auto-derives the workspace set from `git diff`. Mutually exclusive with `--workspace`. Missing ref is a hard error (exit 2), not silent full-scope fallback. |
53
53
  | `--include-dupes` | bool | `false` | Cross-reference with duplication findings |
54
+ | `--dupes-mode` | enum | `config` | Override duplicate detection mode in combined mode. Falls back to the config value when unset. Mirrors the standalone `dupes --mode`. |
55
+ | `--dupes-threshold` | number | `config` | Override the duplication percentage failure threshold in combined mode. Falls back to the config value when unset. Mirrors the standalone `dupes --threshold`. |
56
+ | `--dupes-min-tokens` | number | `config` | Override the minimum token count for clone detection in combined mode. Falls back to the config value when unset. Mirrors the standalone `dupes --min-tokens`. |
57
+ | `--dupes-min-lines` | number | `config` | Override the minimum line count for clone detection in combined mode. Falls back to the config value when unset. Mirrors the standalone `dupes --min-lines`. |
54
58
  | `--dupes-min-occurrences` | number | `config` | Override the minimum clone occurrences in combined mode (must be >= 2). Falls back to the config value when unset. Mirrors the standalone `dupes --min-occurrences`. |
59
+ | `--dupes-skip-local` | bool | `config` | Only report cross-directory duplicates in combined mode. Falls back to the config value when unset. Mirrors the standalone `dupes --skip-local`. |
60
+ | `--dupes-cross-language` | bool | `config` | Enable TypeScript to JavaScript duplicate matching in combined mode. Falls back to the config value when unset. Mirrors the standalone `dupes --cross-language`. |
61
+ | `--dupes-ignore-imports` | bool | `config` | Exclude import declarations from duplicate detection in combined mode. Falls back to the config value when unset. Mirrors the standalone `dupes --ignore-imports`. |
55
62
  | `--file` | path (multiple) | — | Scope output to specific files. Only issues in the specified files are reported. Project-wide dependency issues are suppressed. Warns on non-existent paths. Useful for lint-staged |
56
63
  | `--include-entry-exports` | bool | `false` | Report unused exports in entry files (package.json `main`/`exports`, framework pages). Catches typos like `meatdata` vs `metadata`. Global flag, also accepted on combined mode (`fallow --include-entry-exports`) and `fallow audit`. Also configurable as `includeEntryExports: true` in fallow config |
57
64
  | `--trace` | `FILE:EXPORT` | — | Trace export usage chain |
@@ -368,6 +375,7 @@ Angular templates contribute synthetic `<template>` complexity findings whenever
368
375
  | `--top` | number | — | Only show the top N most complex functions (and file scores/hotspots/targets) |
369
376
  | `--sort` | `cyclomatic\|cognitive\|lines\|severity` | `cyclomatic` | Sort order for complexity findings |
370
377
  | `--complexity` | bool | `false` | Show only function complexity findings. When no section flags are set, all sections are shown by default. |
378
+ | `--complexity-breakdown` | bool | `false` | Add a per-decision-point `contributions[]` array to each complexity finding in `--format json`. Each entry names the construct (`if`, `else-if`, `ternary`, boolean operator, loop, `case`, `catch`, `optional-chain`, ...) and carries its source line, the metric it adds to (`cyclomatic` or `cognitive`), its weight, and the nesting depth, so a consumer can explain WHY a function scored high. Off by default (no change to existing JSON/SARIF/markdown). Used by the VS Code inline editor breakdown and the MCP `check_health` `complexity_breakdown` param. |
371
379
  | `--file-scores` | bool | `false` | Show only per-file health scores (maintainability index, LOC, fan-in, fan-out, dead code ratio, complexity density, CRAP risk). Runs the full analysis pipeline. Sorted by risk-aware triage concern: lower maintainability index and higher CRAP risk first. When no section flags are set, all sections are shown by default. |
372
380
  | `--hotspots` | bool | `false` | Show only hotspots: files that are both complex and frequently changing. Combines git churn history with complexity data. Requires a git repository. When no section flags are set, all sections are shown by default. |
373
381
  | `--targets` | bool | `false` | Show only refactoring targets: ranked recommendations based on complexity, coupling, churn, and dead code signals. Categories: churn+complexity, circular dep, high impact, dead code, complexity, coupling. When no section flags are set, all sections are shown by default. |
@@ -376,8 +384,9 @@ Angular templates contribute synthetic `<template>` complexity findings whenever
376
384
  | `--min-score` | number | — | Fail (exit 1) only when the health score is below this threshold. Implies `--score`. Authoritative CI quality gate: when set, complexity findings are demoted to informational and the exit code is driven solely by the score, so `--min-score 0` always exits 0. Composes with `--min-severity`. |
377
385
  | `--min-severity` | `moderate\|high\|critical` | — | Only exit with an error for findings at or above this severity. Composes with `--min-score` (the run fails if either gate trips). |
378
386
  | `--report-only` | bool | `false` | Print the score and findings but never fail CI (always exit 0). Advisory mode. Mutually exclusive with `--min-score` and `--min-severity`. |
379
- | `--since` | string | `6m` | Git history window for hotspot analysis. Accepts durations (`6m`, `90d`, `1y`, `2w`) or ISO dates (`2025-06-01`). |
387
+ | `--since` | string | `6m` | Git history window for hotspot analysis. Accepts durations (`6m`, `90d`, `1y`, `2w`) or ISO dates (`2025-06-01`). Ignored when `--churn-file` is set. |
380
388
  | `--min-commits` | number | `3` | Minimum number of commits for a file to be included in hotspot ranking. |
389
+ | `--churn-file` | string | (none) | Import change history from a `fallow-churn/v1` JSON file (`{schema, events:[{path, timestamp, author, added, deleted}]}`, one entry per changed file per commit) instead of `git log`, so `--hotspots`/`--ownership`/`--targets` work on non-git VCS (Yandex Arc, Mercurial, Perforce). Resolved relative to `--root`; wins over git. Authoritative for the window, so `--since` then only labels output. Malformed file exits 2; `audit`/`impact`/`--changed-since` still require git. |
381
390
  | `--ownership` | bool | `false` | Attach ownership signals to hotspot entries: bus factor (Avelino truck factor), contributor count, top contributor with stale-days, recent contributors (top-3), `suggested_reviewers`, declared CODEOWNERS owner, `ownership_state`, ownership drift, unowned-hotspot detection. Human output gains a project-level summary line. JSON adds `low-bus-factor`, `unowned-hotspot`, `ownership-drift` action types. Test files get a `[test]` tag. Implies `--hotspots`. Requires git. |
382
391
  | `--ownership-emails` | `raw\|handle\|anonymized\|hash` | `handle` | Privacy mode for author emails. `handle` shows the local-part only (default, with GitHub noreply unwrap and deterministic same-handle disambiguation). `anonymized` emits stable `xxh3:` pseudonyms; `hash` remains accepted as the legacy spelling. `raw` shows full addresses. Use `anonymized` in regulated environments. Implies `--ownership`. Configure default via `health.ownership.emailMode`. |
383
392
  | `--changed-since` | string | — | Only analyze files changed since a git ref |
@@ -486,7 +495,7 @@ fallow health --format json --quiet --trend
486
495
  {
487
496
  "kind": "health",
488
497
  "schema_version": 7,
489
- "version": "2.88.2",
498
+ "version": "2.89.0",
490
499
  "elapsed_ms": 32,
491
500
  "summary": {
492
501
  "files_analyzed": 482,
@@ -876,7 +885,7 @@ fallow audit \
876
885
  {
877
886
  "kind": "audit",
878
887
  "schema_version": 7,
879
- "version": "2.88.2",
888
+ "version": "2.89.0",
880
889
  "command": "audit",
881
890
  "verdict": "fail",
882
891
  "changed_files_count": 12,
@@ -949,7 +958,7 @@ fallow flags --format json --quiet --workspace my-package
949
958
  ```json
950
959
  {
951
960
  "schema_version": 7,
952
- "version": "2.88.2",
961
+ "version": "2.89.0",
953
962
  "elapsed_ms": 116,
954
963
  "feature_flags": [],
955
964
  "total_flags": 0
@@ -1389,6 +1398,7 @@ Available on all commands:
1389
1398
  | `FALLOW_EXTENDS_TIMEOUT_SECS` | Timeout for fetching remote config inheritance in seconds (default: `5`). Do not raise this for untrusted sources. |
1390
1399
  | `FALLOW_CACHE_MAX_SIZE` | Maximum on-disk extraction cache (`.fallow/cache.bin`) size in megabytes (default: `256`). Triggers LRU eviction when crossed. Wins over `cache.maxSizeMb` config field. Intended for CI runners with disk quotas. `--no-cache` short-circuits this knob. |
1391
1400
  | `FALLOW_AUDIT_CACHE_MAX_AGE_DAYS` | Max age (in days since last reuse or fresh create) of a persistent reusable `fallow audit` base-snapshot worktree cache. Older entries are reclaimed at the top of the next `fallow audit` invocation (default: `30`). Wins over `audit.cacheMaxAgeDays` config field. `0` disables the GC; invalid values silently fall back to config / default. |
1401
+ | `FALLOW_UPDATE_CHECK` | Set to `off`, `0`, `false`, `disabled`, or `no` to disable the human-TTY upgrade nudge and its background latest-version check. `DO_NOT_TRACK`, `FALLOW_TELEMETRY_DISABLED`, and CI also suppress it. |
1392
1402
  | `FALLOW_COMMAND` | GitLab CI: command to run (default: `dead-code`). |
1393
1403
  | `FALLOW_FAIL_ON_ISSUES` | GitLab CI: set to `true` to exit 1 if issues found. |
1394
1404
  | `FALLOW_CHANGED_SINCE` | GitLab CI: git ref for incremental analysis. Auto-detected in MR pipelines. |
@@ -1488,7 +1498,7 @@ The HTTP layer mirrors the bash `gh_api_retry` / `curl_retry` helpers: `FALLOW_A
1488
1498
  {
1489
1499
  "kind": "dead-code",
1490
1500
  "schema_version": 7,
1491
- "version": "2.88.2",
1501
+ "version": "2.89.0",
1492
1502
  "elapsed_ms": 45,
1493
1503
  "total_issues": 12,
1494
1504
  "entry_points": {
@@ -1648,7 +1658,7 @@ When `--baseline` is used in combined output, the JSON includes a `baseline_delt
1648
1658
  {
1649
1659
  "kind": "dupes",
1650
1660
  "schema_version": 7,
1651
- "version": "2.88.2",
1661
+ "version": "2.89.0",
1652
1662
  "elapsed_ms": 82,
1653
1663
  "total_clones": 15,
1654
1664
  "total_lines_duplicated": 230,
@@ -1692,11 +1702,11 @@ When running `fallow` with no subcommand (all analyses), the JSON output combine
1692
1702
  {
1693
1703
  "kind": "combined",
1694
1704
  "schema_version": 7,
1695
- "version": "2.88.2",
1705
+ "version": "2.89.0",
1696
1706
  "elapsed_ms": 159,
1697
1707
  "check": {
1698
1708
  "schema_version": 7,
1699
- "version": "2.88.2",
1709
+ "version": "2.89.0",
1700
1710
  "elapsed_ms": 45,
1701
1711
  "total_issues": 12,
1702
1712
  "unused_files": [],
@@ -353,6 +353,18 @@ export type CoverageTier = ("none" | "partial" | "high")
353
353
  * Provenance of a CRAP finding's coverage signal.
354
354
  */
355
355
  export type CoverageSource = ("istanbul" | "estimated" | "estimated_component_inherited")
356
+ /**
357
+ * Which complexity metric a [`ComplexityContribution`] adds to.
358
+ */
359
+ export type ComplexityMetric = ("cyclomatic" | "cognitive")
360
+ /**
361
+ * The syntactic construct that produced a single complexity increment.
362
+ *
363
+ * Mirrors `SonarSource` cognitive-complexity vocabulary where it overlaps.
364
+ * `Case` means a `case` label carrying a test; a bare `default` adds nothing
365
+ * to cyclomatic complexity and so produces no contribution.
366
+ */
367
+ export type ComplexityContributionKind = ("if" | "else" | "else-if" | "ternary" | "logical-and" | "logical-or" | "nullish-coalescing" | "logical-assignment" | "optional-chain" | "for" | "for-in" | "for-of" | "while" | "do-while" | "switch" | "case" | "catch" | "labeled-break" | "labeled-continue")
356
368
  /**
357
369
  * Discriminant for [`HealthFindingAction::kind`]. Mirrors the action types
358
370
  * emitted by `build_health_finding_actions`. A single finding's `actions`
@@ -1654,6 +1666,15 @@ line: number
1654
1666
  * 0-based byte column offset of the import that starts the cycle.
1655
1667
  */
1656
1668
  col: number
1669
+ /**
1670
+ * Per-file import anchors, one entry per hop in cycle order: `edges[i]`
1671
+ * is the import in `files[i]` pointing to `files[(i + 1) % len]`. Always
1672
+ * the same length as `files`. Drives the per-file LSP diagnostic
1673
+ * squiggly. `#[serde(default)]` so pre-`edges` baselines deserialize;
1674
+ * always emitted on output but intentionally not in the schema's
1675
+ * `required` set (see the struct doc).
1676
+ */
1677
+ edges?: CircularDependencyEdge[]
1657
1678
  /**
1658
1679
  * Whether this cycle crosses workspace package boundaries.
1659
1680
  */
@@ -1669,6 +1690,34 @@ actions: IssueAction[]
1669
1690
  */
1670
1691
  introduced?: (AuditIntroduced | null)
1671
1692
  }
1693
+ /**
1694
+ * One import hop in a circular dependency: the file containing the import
1695
+ * and where that import statement sits.
1696
+ *
1697
+ * `edges[i]` is the import IN `path` (the hop SOURCE, equal to the cycle's
1698
+ * `files[i]`) that points to the NEXT file in the cycle
1699
+ * (`files[(i + 1) % files.len()]`); the target is not repeated here to keep
1700
+ * the wire compact. Enables a per-file diagnostic squiggly anchored under
1701
+ * the offending import rather than a single squiggly on the first file.
1702
+ *
1703
+ * `col` is a 0-based BYTE column, matching the cycle's top-level `col`;
1704
+ * converting it to a UTF-16 code-unit column for LSP clients is a tracked
1705
+ * follow-up shared with the existing field.
1706
+ */
1707
+ export interface CircularDependencyEdge {
1708
+ /**
1709
+ * The file containing the import (the hop SOURCE; equal to `files[i]`).
1710
+ */
1711
+ path: string
1712
+ /**
1713
+ * 1-based line number of the import statement pointing to the next file.
1714
+ */
1715
+ line: number
1716
+ /**
1717
+ * 0-based byte column offset of the import statement.
1718
+ */
1719
+ col: number
1720
+ }
1672
1721
  /**
1673
1722
  * Wire-shape envelope for a [`ReExportCycle`] finding. Mirrors
1674
1723
  * [`CircularDependencyFinding`]: flattens the bare finding and carries a
@@ -2200,6 +2249,15 @@ line_count: number
2200
2249
  * each group in the human listing.
2201
2250
  */
2202
2251
  fingerprint: string
2252
+ /**
2253
+ * Best-effort human-readable name for the clone: the dominant repeated
2254
+ * identifier across the duplicated fragment (e.g. a shared `parseCsv`
2255
+ * function). `None` when the clone has no clear dominant name (generic or
2256
+ * tied identifiers); consumers then fall back to a file-based label. Lets
2257
+ * editors and agents label a clone by what it is rather than an opaque
2258
+ * ordinal.
2259
+ */
2260
+ suggested_name?: (string | null)
2203
2261
  /**
2204
2262
  * Suggested next steps: an `extract-shared` primary and a
2205
2263
  * `suppress-line` secondary. Always emitted (possibly empty for
@@ -2534,6 +2592,13 @@ coverage_tier?: (CoverageTier | null)
2534
2592
  coverage_source?: (CoverageSource | null)
2535
2593
  inherited_from?: (string | null)
2536
2594
  component_rollup?: (ComponentRollup | null)
2595
+ /**
2596
+ * Per-decision-point complexity breakdown explaining WHICH constructs drove
2597
+ * the cyclomatic and cognitive scores. Populated only when the caller opts
2598
+ * in via `health --complexity-breakdown`; empty (and omitted from JSON)
2599
+ * otherwise so default and CI output stay lean.
2600
+ */
2601
+ contributions?: ComplexityContribution[]
2537
2602
  /**
2538
2603
  * Machine-actionable fix and suppress hints.
2539
2604
  */
@@ -2553,6 +2618,36 @@ template_path: string
2553
2618
  template_cyclomatic: number
2554
2619
  template_cognitive: number
2555
2620
  }
2621
+ /**
2622
+ * A single complexity increment, located at its source line/column.
2623
+ *
2624
+ * `weight` is the amount this construct added to `metric`; for nested
2625
+ * cognitive increments `weight == 1 + nesting`. Consumers that render inline
2626
+ * (the VS Code editor breakdown) group contributions by `line` and sum the
2627
+ * weights, deferring the per-kind list to a hover.
2628
+ */
2629
+ export interface ComplexityContribution {
2630
+ /**
2631
+ * 1-based line number where the construct begins.
2632
+ */
2633
+ line: number
2634
+ /**
2635
+ * 0-based byte column where the construct begins.
2636
+ */
2637
+ col: number
2638
+ metric: ComplexityMetric
2639
+ kind: ComplexityContributionKind
2640
+ /**
2641
+ * The amount added to `metric` at this site (`1 + nesting` for nested
2642
+ * cognitive increments, otherwise `1`).
2643
+ */
2644
+ weight: number
2645
+ /**
2646
+ * The nesting depth at the increment site (`0` when not nested). Lets a
2647
+ * consumer explain a cognitive `+3` as "+1 base, +2 nesting".
2648
+ */
2649
+ nesting: number
2650
+ }
2556
2651
  /**
2557
2652
  * Suggested action attached to a [`ComplexityViolation`].
2558
2653
  *