fallow 2.88.1 → 2.88.3

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.1",
3
+ "version": "2.88.3",
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.1",
87
- "@fallow-cli/darwin-x64": "2.88.1",
88
- "@fallow-cli/linux-x64-gnu": "2.88.1",
89
- "@fallow-cli/linux-arm64-gnu": "2.88.1",
90
- "@fallow-cli/linux-x64-musl": "2.88.1",
91
- "@fallow-cli/linux-arm64-musl": "2.88.1",
92
- "@fallow-cli/win32-arm64-msvc": "2.88.1",
93
- "@fallow-cli/win32-x64-msvc": "2.88.1"
86
+ "@fallow-cli/darwin-arm64": "2.88.3",
87
+ "@fallow-cli/darwin-x64": "2.88.3",
88
+ "@fallow-cli/linux-x64-gnu": "2.88.3",
89
+ "@fallow-cli/linux-arm64-gnu": "2.88.3",
90
+ "@fallow-cli/linux-x64-musl": "2.88.3",
91
+ "@fallow-cli/linux-arm64-musl": "2.88.3",
92
+ "@fallow-cli/win32-arm64-msvc": "2.88.3",
93
+ "@fallow-cli/win32-x64-msvc": "2.88.3"
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);
@@ -486,7 +486,7 @@ fallow health --format json --quiet --trend
486
486
  {
487
487
  "kind": "health",
488
488
  "schema_version": 7,
489
- "version": "2.88.1",
489
+ "version": "2.88.3",
490
490
  "elapsed_ms": 32,
491
491
  "summary": {
492
492
  "files_analyzed": 482,
@@ -876,7 +876,7 @@ fallow audit \
876
876
  {
877
877
  "kind": "audit",
878
878
  "schema_version": 7,
879
- "version": "2.88.1",
879
+ "version": "2.88.3",
880
880
  "command": "audit",
881
881
  "verdict": "fail",
882
882
  "changed_files_count": 12,
@@ -949,7 +949,7 @@ fallow flags --format json --quiet --workspace my-package
949
949
  ```json
950
950
  {
951
951
  "schema_version": 7,
952
- "version": "2.88.1",
952
+ "version": "2.88.3",
953
953
  "elapsed_ms": 116,
954
954
  "feature_flags": [],
955
955
  "total_flags": 0
@@ -1488,7 +1488,7 @@ The HTTP layer mirrors the bash `gh_api_retry` / `curl_retry` helpers: `FALLOW_A
1488
1488
  {
1489
1489
  "kind": "dead-code",
1490
1490
  "schema_version": 7,
1491
- "version": "2.88.1",
1491
+ "version": "2.88.3",
1492
1492
  "elapsed_ms": 45,
1493
1493
  "total_issues": 12,
1494
1494
  "entry_points": {
@@ -1648,7 +1648,7 @@ When `--baseline` is used in combined output, the JSON includes a `baseline_delt
1648
1648
  {
1649
1649
  "kind": "dupes",
1650
1650
  "schema_version": 7,
1651
- "version": "2.88.1",
1651
+ "version": "2.88.3",
1652
1652
  "elapsed_ms": 82,
1653
1653
  "total_clones": 15,
1654
1654
  "total_lines_duplicated": 230,
@@ -1692,11 +1692,11 @@ When running `fallow` with no subcommand (all analyses), the JSON output combine
1692
1692
  {
1693
1693
  "kind": "combined",
1694
1694
  "schema_version": 7,
1695
- "version": "2.88.1",
1695
+ "version": "2.88.3",
1696
1696
  "elapsed_ms": 159,
1697
1697
  "check": {
1698
1698
  "schema_version": 7,
1699
- "version": "2.88.1",
1699
+ "version": "2.88.3",
1700
1700
  "elapsed_ms": 45,
1701
1701
  "total_issues": 12,
1702
1702
  "unused_files": [],