fallow 3.16.0 → 3.18.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/README.md CHANGED
@@ -28,7 +28,7 @@ npx fallow fix --dry-run # Preview automatic cleanup
28
28
 
29
29
  ## Output and exit codes
30
30
 
31
- Add `--format json --quiet` to any command for one typed JSON document on stdout. Exit code 1 means findings, not failure; 0 is clean (or an audit pass or warn verdict); 2 is a validation or runtime error, reported as a JSON error envelope rather than a stack trace.
31
+ Add `--format json --quiet` to any command for one typed JSON document on stdout. Exit code 1 means findings, not failure; 0 is clean (or an audit pass or warn verdict); 2 is a validation or runtime error, reported as a JSON error envelope rather than a stack trace. License, coverage setup, network, and security-gate workflows use additional documented codes; read `fallow schema.exit_codes` instead of suppressing the process status.
32
32
 
33
33
  Parsing the output in TypeScript? Import the typed shapes, version-pinned to the CLI you install:
34
34
 
package/capabilities.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fallow",
3
- "version": "3.16.0",
3
+ "version": "3.18.0",
4
4
  "manifest_version": "1",
5
5
  "description": "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
6
6
  "global_flags": [
@@ -302,7 +302,7 @@
302
302
  "name": "--report-path-prefix",
303
303
  "type": "string",
304
304
  "required": false,
305
- "description": "Prefix prepended to every path in the CI-facing formats (`github-annotations`, `github-summary`, `codeclimate`, `review-github`, `review-gitlab`). CI platforms address files by repository-root-relative path, so when the analyzed project lives in a subdirectory (e.g. `packages/app/`), paths need that offset. fallow detects the offset via the git toplevel automatically; this flag overrides the detection. Pass an empty string to disable rebasing and emit paths relative to `--root`"
305
+ "description": "Prefix prepended to every path in the CI-facing formats (`github-annotations`, `github-summary`, `codeclimate`, `pr-comment-github`, `pr-comment-gitlab`, `review-github`, `review-gitlab`). CI platforms address files by repository-root-relative path, so when the analyzed project lives in a subdirectory (e.g. `packages/app/`), paths need that offset. fallow detects the offset via the git toplevel automatically; this flag overrides the detection. Pass an empty string to disable rebasing and emit paths relative to `--root`"
306
306
  },
307
307
  {
308
308
  "name": "--fail-on-regression",
@@ -815,7 +815,7 @@
815
815
  "name": "--unused-dependency-overrides",
816
816
  "type": "bool",
817
817
  "required": false,
818
- "description": "Only report unused pnpm dependency overrides",
818
+ "description": "Only report unused package-manager dependency overrides",
819
819
  "possible_values": [
820
820
  "true",
821
821
  "false"
@@ -825,7 +825,7 @@
825
825
  "name": "--misconfigured-dependency-overrides",
826
826
  "type": "bool",
827
827
  "required": false,
828
- "description": "Only report misconfigured pnpm dependency overrides",
828
+ "description": "Only report misconfigured package-manager dependency overrides",
829
829
  "possible_values": [
830
830
  "true",
831
831
  "false"
@@ -1659,13 +1659,13 @@
1659
1659
  "name": "--coverage",
1660
1660
  "type": "string",
1661
1661
  "required": false,
1662
- "description": "Path to Istanbul-format coverage data (coverage-final.json) for accurate per-function CRAP scores in the health sub-analysis. Also configurable via FALLOW_COVERAGE"
1662
+ "description": "Path to Istanbul-format coverage data (coverage-final.json) for accurate per-function CRAP scores in the health sub-analysis. Also configurable via FALLOW_COVERAGE or health.coverage"
1663
1663
  },
1664
1664
  {
1665
1665
  "name": "--coverage-root",
1666
1666
  "type": "string",
1667
1667
  "required": false,
1668
- "description": "Absolute prefix to strip from coverage data paths before CRAP matching. Use when coverage was generated under a different checkout root in CI or Docker"
1668
+ "description": "Absolute prefix to strip from coverage data paths before CRAP matching. Use when coverage was generated under a different checkout root in CI or Docker. Also configurable via FALLOW_COVERAGE_ROOT or health.coverageRoot"
1669
1669
  },
1670
1670
  {
1671
1671
  "name": "--no-css",
@@ -1897,7 +1897,7 @@
1897
1897
  },
1898
1898
  {
1899
1899
  "name": "report",
1900
- "description": "Render a saved `--format json` results file in another format without re-running analysis (analyze once, render annotations and the job summary from the same file). Supports `github-annotations`, `github-summary`, `codeclimate`, and `sarif`",
1900
+ "description": "Render a saved `--format json` results file in another format without re-running analysis (analyze once, then render every CI surface from the same file). Supports GitHub annotations/summary, CodeClimate, SARIF, and GitHub/GitLab PR-comment and review formats",
1901
1901
  "flags": [
1902
1902
  {
1903
1903
  "name": "--from",
@@ -3094,7 +3094,7 @@
3094
3094
  "rule_id": "fallow/unused-dependency-override",
3095
3095
  "command": "dead-code",
3096
3096
  "category": "Dependencies",
3097
- "description": "pnpm.overrides entry targets a package not declared or resolved",
3097
+ "description": "Package-manager override target is not declared or resolved",
3098
3098
  "label": "Unused Dependency Overrides",
3099
3099
  "config_key": "unused-dependency-overrides",
3100
3100
  "registry_index": 42,
@@ -3135,7 +3135,7 @@
3135
3135
  "rule_id": "fallow/misconfigured-dependency-override",
3136
3136
  "command": "dead-code",
3137
3137
  "category": "Dependencies",
3138
- "description": "pnpm.overrides entry has an unparsable key or value",
3138
+ "description": "Package-manager override has an unparsable key or value",
3139
3139
  "label": "Misconfigured Dependency Overrides",
3140
3140
  "config_key": "misconfigured-dependency-overrides",
3141
3141
  "registry_index": 43,
@@ -5923,18 +5923,32 @@
5923
5923
  "sarif",
5924
5924
  "compact",
5925
5925
  "markdown",
5926
+ "md",
5926
5927
  "codeclimate",
5927
5928
  "gitlab-codequality",
5929
+ "gitlab-code-quality",
5928
5930
  "pr-comment-github",
5929
5931
  "pr-comment-gitlab",
5930
5932
  "review-github",
5931
5933
  "review-gitlab",
5932
- "badge"
5934
+ "badge",
5935
+ "github-annotations",
5936
+ "github-summary"
5933
5937
  ],
5934
5938
  "exit_codes": {
5935
5939
  "0": "Success (no error-severity issues found)",
5936
- "1": "Error-severity issues found (per rules config, or --fail-on-issues promotes warnerror)",
5937
- "2": "Error (invalid config, invalid input, etc.). When --format json is active, errors are emitted as structured JSON on stdout: {\"error\": true, \"message\": \"...\", \"exit_code\": 2}"
5940
+ "1": "Error-severity issues found (per rules config, or --fail-on-issues promotes warn to error)",
5941
+ "2": "Error (invalid config, invalid input, etc.). When --format json is active, errors are emitted as structured JSON on stdout: {\"error\": true, \"message\": \"...\", \"exit_code\": 2}",
5942
+ "3": "Requested resource unavailable: config --path found no config, or a license is missing, invalid, or beyond its offline hard-fail window",
5943
+ "4": "Runtime coverage sidecar is unavailable, unverifiable, protocol-incompatible, or terminated unexpectedly",
5944
+ "5": "Runtime coverage input could not be prepared or parsed",
5945
+ "6": "Runtime coverage sidecar reported an internal error",
5946
+ "7": "Network or cloud request failed during a license, coverage, or CI operation",
5947
+ "8": "Security gate matched a candidate selected by --gate",
5948
+ "10": "Coverage inventory or static-findings upload input or project validation failed",
5949
+ "11": "Coverage inventory or static-findings upload exceeded the server payload limit",
5950
+ "12": "Coverage inventory or static-findings upload authentication or authorization was rejected",
5951
+ "13": "Coverage inventory or static-findings upload failed after retries or returned another server error"
5938
5952
  },
5939
5953
  "environment_variables": {
5940
5954
  "FALLOW_FORMAT": "Default output format (json/human/sarif/compact/markdown/codeclimate/gitlab-codequality/pr-comment-github/pr-comment-gitlab/review-github/review-gitlab/badge/github-annotations/github-summary). CLI --format flag overrides this.",
@@ -5949,14 +5963,14 @@
5949
5963
  "FALLOW_PR_COMMENT_LAYOUT": "Sticky PR comment layout: default, compact, gate-only, or details.",
5950
5964
  "FALLOW_CONSOLIDATED_STATUS": "When split PR gate check runs are enabled, truthy values add one aggregate Fallow check alongside the per-gate checks.",
5951
5965
  "FALLOW_DIFF_CONTEXT": "Line radius around changed diff lines when scoping findings to a diff in the review/PR-comment formats (default 3).",
5952
- "FALLOW_BOT_LOGIN": "Bot or token username treated as fallow's own when reconciling existing PR/MR comments in review-github/review-gitlab. Required when posting with a personal access token (the author then carries a human identity).",
5966
+ "FALLOW_BOT_LOGIN": "Allowlisted posting username used to recognize fallow-owned finding roots and resolution replies. When set, it narrows ownership to that exact username (an empty value matches nobody); when unset, provider-native bot metadata is trusted. This is an authorship control, not a lifecycle or deduplication token.",
5953
5967
  "FALLOW_API_RETRIES": "Maximum HTTP attempts for review-comment reconciliation API calls (default 3).",
5954
5968
  "FALLOW_API_RETRY_DELAY": "Floor delay in seconds between HTTP retry attempts (default 2); a server-supplied Retry-After overrides it on 429 responses.",
5955
5969
  "FALLOW_CACHE_DIR": "Directory for fallow's persistent analysis cache. Relative paths resolve from the project root and override cache.dir.",
5956
5970
  "FALLOW_CACHE_MAX_SIZE": "Extraction cache size cap in megabytes (default 256). Wins over the cache.maxSizeMb config field.",
5957
5971
  "FALLOW_EXTENDS_TIMEOUT_SECS": "Timeout in seconds for fetching https:// configs referenced via the extends field (default 5).",
5958
- "FALLOW_COVERAGE": "Path to Istanbul coverage data (coverage-final.json) for accurate per-function CRAP scores. CLI --coverage flag overrides this.",
5959
- "FALLOW_COVERAGE_ROOT": "Absolute coverage-data path prefix for rebasing Istanbul paths in CI or containers. CLI --coverage-root flag overrides this.",
5972
+ "FALLOW_COVERAGE": "Path to Istanbul coverage data (coverage-final.json) for accurate per-function CRAP scores. CLI --coverage flag overrides this; it wins over the health.coverage config field. Honored by the health, bare fallow, and audit CLI commands and by the MCP audit and check_health tools on both their typed route and their CLI fallback, where the explicit coverage parameter overrides it.",
5973
+ "FALLOW_COVERAGE_ROOT": "Absolute coverage-data path prefix for rebasing Istanbul paths in CI or containers. CLI --coverage-root flag overrides this; it wins over the health.coverageRoot config field. Honored by the health, bare fallow, and audit CLI commands and by the MCP audit and check_health tools on both their typed route and their CLI fallback, where the explicit coverage_root parameter overrides it.",
5960
5974
  "FALLOW_MAX_FILE_SIZE": "Per-file size ceiling in megabytes for source discovery (default 5; 0 = no limit). CLI --max-file-size flag overrides this.",
5961
5975
  "FALLOW_TYPE_AWARE": "Enable or disable TypeScript semantic (type-aware) analysis for the run (true/false/1/0/yes/no/on/off). Precedence: --type-aware/--no-type-aware CLI flags, then FALLOW_TYPE_AWARE, then the audit.typeAware config field, then typeAware.enabled.",
5962
5976
  "FALLOW_TYPE_AWARE_BIN": "Trusted executable override for the TypeScript semantic refinement sidecar used by dead-code --type-aware. Relative paths resolve from the caller's working directory before --root is applied; project node_modules and PATH are intentionally not searched. Default: sibling of the active Fallow executable.",
@@ -6837,7 +6851,7 @@
6837
6851
  {
6838
6852
  "name": "trace_symbol",
6839
6853
  "kind": "trace",
6840
- "description": "Trace an exact TypeScript symbol with checker-backed references, namespace identity, aliases, and re-export hops. Root trace fields preserve syntactic context; treat semantic.references, semantic.status, and semantic.identity as the authoritative exact evidence. This is project-wide evidence for Fallow decisions, not a compiler-diagnostic or lint-rule surface.",
6854
+ "description": "Trace an exact TypeScript symbol with checker-backed references, namespace identity, aliases, and re-export hops. Root trace fields preserve syntactic context; treat semantic.references, semantic.status, and semantic.identity as the authoritative exact evidence. The proof covers only the lane named by semantic.target.namespace, so a root trace that lists a reference the proof does not is wider evidence rather than stale. This is project-wide evidence for Fallow decisions, not a compiler-diagnostic or lint-rule surface.",
6841
6855
  "cli_command": "fallow dead-code --type-aware --trace <file:export> --format json --quiet",
6842
6856
  "key_params": [
6843
6857
  "file",
@@ -1558,7 +1558,7 @@
1558
1558
  "rule_id": "fallow/unused-dependency-override",
1559
1559
  "command": "dead-code",
1560
1560
  "category": "Dependencies",
1561
- "description": "pnpm.overrides entry targets a package not declared or resolved",
1561
+ "description": "Package-manager override target is not declared or resolved",
1562
1562
  "label": "Unused Dependency Overrides",
1563
1563
  "config_key": "unused-dependency-overrides",
1564
1564
  "registry_index": 42,
@@ -1599,7 +1599,7 @@
1599
1599
  "rule_id": "fallow/misconfigured-dependency-override",
1600
1600
  "command": "dead-code",
1601
1601
  "category": "Dependencies",
1602
- "description": "pnpm.overrides entry has an unparsable key or value",
1602
+ "description": "Package-manager override has an unparsable key or value",
1603
1603
  "label": "Misconfigured Dependency Overrides",
1604
1604
  "config_key": "misconfigured-dependency-overrides",
1605
1605
  "registry_index": 43,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fallow",
3
- "version": "3.16.0",
3
+ "version": "3.18.0",
4
4
  "description": "Codebase intelligence for TypeScript and JavaScript. Free static analysis of code and styles, optional paid runtime intelligence (Fallow Runtime). Quality, risk, architecture, dependencies, duplication, and design-system drift for humans, CI, and the agents writing your code. Zero-config framework support.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -87,14 +87,14 @@
87
87
  "@tanstack/intent": "0.3.6"
88
88
  },
89
89
  "optionalDependencies": {
90
- "@fallow-cli/darwin-arm64": "3.16.0",
91
- "@fallow-cli/darwin-x64": "3.16.0",
92
- "@fallow-cli/linux-x64-gnu": "3.16.0",
93
- "@fallow-cli/linux-arm64-gnu": "3.16.0",
94
- "@fallow-cli/linux-x64-musl": "3.16.0",
95
- "@fallow-cli/linux-arm64-musl": "3.16.0",
96
- "@fallow-cli/win32-arm64-msvc": "3.16.0",
97
- "@fallow-cli/win32-x64-msvc": "3.16.0",
98
- "fallow-type-aware": "3.16.0"
90
+ "@fallow-cli/darwin-arm64": "3.18.0",
91
+ "@fallow-cli/darwin-x64": "3.18.0",
92
+ "@fallow-cli/linux-x64-gnu": "3.18.0",
93
+ "@fallow-cli/linux-arm64-gnu": "3.18.0",
94
+ "@fallow-cli/linux-x64-musl": "3.18.0",
95
+ "@fallow-cli/linux-arm64-musl": "3.18.0",
96
+ "@fallow-cli/win32-arm64-msvc": "3.18.0",
97
+ "@fallow-cli/win32-x64-msvc": "3.18.0",
98
+ "fallow-type-aware": "3.18.0"
99
99
  }
100
100
  }
package/schema.json CHANGED
@@ -1028,7 +1028,7 @@
1028
1028
  "default": 60
1029
1029
  },
1030
1030
  "coverage": {
1031
- "description": "Path to Istanbul-format coverage data for accurate per-function CRAP\nscores. Relative paths resolve against the project root. The CLI\n`--coverage` flag and `FALLOW_COVERAGE` environment variable override\nthis value.",
1031
+ "description": "Path to Istanbul-format coverage data for accurate per-function CRAP\nscores. Relative paths resolve against the project root. The CLI\n`--coverage` flag and `FALLOW_COVERAGE` environment variable override\nthis value. Consulted by `fallow health`, bare `fallow`, `fallow audit`,\nand the MCP `audit` / `check_health` tools.",
1032
1032
  "type": [
1033
1033
  "string",
1034
1034
  "null"
@@ -1036,7 +1036,7 @@
1036
1036
  "default": null
1037
1037
  },
1038
1038
  "coverageRoot": {
1039
- "description": "Absolute prefix to strip from Istanbul file paths before CRAP matching.\nUse when coverage was generated under a different checkout root in CI\nor Docker. The CLI `--coverage-root` flag and `FALLOW_COVERAGE_ROOT`\nenvironment variable override this value.",
1039
+ "description": "Absolute prefix to strip from Istanbul file paths before CRAP matching.\nUse when coverage was generated under a different checkout root in CI\nor Docker. The CLI `--coverage-root` flag and `FALLOW_COVERAGE_ROOT`\nenvironment variable override this value. Consulted by `fallow health`,\nbare `fallow`, `fallow audit`, and the MCP `audit` / `check_health`\ntools.",
1040
1040
  "type": [
1041
1041
  "string",
1042
1042
  "null"
@@ -1458,12 +1458,12 @@
1458
1458
  "default": "error"
1459
1459
  },
1460
1460
  "unused-dependency-overrides": {
1461
- "description": "A pnpm `overrides:` / `pnpm.overrides` entry whose target package no\nworkspace `package.json` declares. Defaults to `warn`.",
1461
+ "description": "A pnpm, npm, or Bun override entry whose target package no workspace\n`package.json` declares and the active readable lockfile does not\nresolve. Defaults to `warn`.",
1462
1462
  "$ref": "#/$defs/Severity",
1463
1463
  "default": "warn"
1464
1464
  },
1465
1465
  "misconfigured-dependency-overrides": {
1466
- "description": "A pnpm `overrides:` / `pnpm.overrides` entry whose key or value cannot\nbe parsed into a valid pnpm shape. Defaults to `error`.",
1466
+ "description": "A pnpm, npm, or Bun override or Bun `resolutions` entry whose key or\nvalue cannot be parsed in its declaration source's grammar. Defaults to\n`error`.",
1467
1467
  "$ref": "#/$defs/Severity",
1468
1468
  "default": "error"
1469
1469
  },
@@ -235,6 +235,9 @@ function isSkipRequested(env) {
235
235
  // production install path always has fallowDigests)
236
236
  // env - process.env (defaults to process.env)
237
237
  // platform - process.platform (defaults to process.platform)
238
+ // homedir - os.homedir() override (tests only)
239
+ // isWritable - sentinel directory probe override (tests only)
240
+ // ensureDir - sentinel directory creation override (tests only)
238
241
  // logger - function (line: string) -> void (defaults to stderr)
239
242
  //
240
243
  // Returns one of:
@@ -253,6 +256,16 @@ function buildVerifyOptions(input, manifest) {
253
256
  return opts;
254
257
  }
255
258
 
259
+ function buildSentinelOptions(input, platformPkgDir, packageName, env, platform) {
260
+ const opts = { platformPkgDir, packageName, env, platform };
261
+ for (const key of ["homedir", "isWritable", "ensureDir"]) {
262
+ if (Object.prototype.hasOwnProperty.call(input, key)) {
263
+ opts[key] = input[key];
264
+ }
265
+ }
266
+ return opts;
267
+ }
268
+
256
269
  // Persist the sentinel on a successful verify. Logs (warn-once) when the
257
270
  // resolved cache location is read-only or every cascade step failed.
258
271
  function persistSentinel(sentinel, platformPkgDir, manifest, platform) {
@@ -315,7 +328,9 @@ function ensureVerified(input) {
315
328
  };
316
329
  }
317
330
 
318
- const sentinel = resolveSentinelPath({ platformPkgDir, packageName, env, platform });
331
+ const sentinel = resolveSentinelPath(
332
+ buildSentinelOptions(input || {}, platformPkgDir, packageName, env, platform),
333
+ );
319
334
 
320
335
  // Cache hit: sentinel exists, schema matches, mtimes match, version matches.
321
336
  if (sentinel.path && isSentinelValid(sentinel.path, platformPkgDir, manifest, platform)) {
@@ -16,6 +16,9 @@ const { _verifyWithKey, SKIP_ENV } = require("./verify-binary");
16
16
 
17
17
  // ---- shared fixtures ------------------------------------------------------
18
18
 
19
+ const DEFAULT_PLATFORM = "linux";
20
+ const DEFAULT_PACKAGE_NAME = "@fallow-cli/test-platform";
21
+
19
22
  function makeKeypair() {
20
23
  const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
21
24
  const spki = publicKey.export({ format: "der", type: "spki" });
@@ -23,18 +26,18 @@ function makeKeypair() {
23
26
  return { privateKey, rawPub };
24
27
  }
25
28
 
26
- function ext() {
27
- return process.platform === "win32" ? ".exe" : "";
29
+ function packageNameForPlatform(platform) {
30
+ return platform === "win32" ? "@fallow-cli/win32-x64-msvc" : DEFAULT_PACKAGE_NAME;
28
31
  }
29
32
 
30
- function binaryNames() {
33
+ function binaryNames(platform) {
31
34
  // Platform packages ship a single multicall `fallow` binary.
32
- return [`fallow${ext()}`];
35
+ return [platform === "win32" ? "fallow.exe" : "fallow"];
33
36
  }
34
37
 
35
- function computeDigestsForDir(dir) {
38
+ function computeDigestsForDir(dir, platform) {
36
39
  const out = {};
37
- for (const base of binaryNames()) {
40
+ for (const base of binaryNames(platform)) {
38
41
  const full = path.join(dir, base);
39
42
  out[base] = "sha256:" + crypto.createHash("sha256").update(fs.readFileSync(full)).digest("hex");
40
43
  }
@@ -43,8 +46,9 @@ function computeDigestsForDir(dir) {
43
46
 
44
47
  function mkPlatformDir(privateKey, options) {
45
48
  const opts = options || {};
49
+ const platform = opts.platform || DEFAULT_PLATFORM;
46
50
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-lazy-test-"));
47
- for (const base of binaryNames()) {
51
+ for (const base of binaryNames(platform)) {
48
52
  const binaryPath = path.join(dir, base);
49
53
  const content = Buffer.from(`mock ${base}`);
50
54
  fs.writeFileSync(binaryPath, content);
@@ -56,9 +60,9 @@ function mkPlatformDir(privateKey, options) {
56
60
  fs.writeFileSync(
57
61
  path.join(dir, "package.json"),
58
62
  JSON.stringify({
59
- name: opts.packageName || "@fallow-cli/test-platform",
63
+ name: opts.packageName || packageNameForPlatform(platform),
60
64
  version: opts.version || "2.81.0",
61
- fallowDigests: opts.skipDigests ? undefined : computeDigestsForDir(dir),
65
+ fallowDigests: opts.skipDigests ? undefined : computeDigestsForDir(dir, platform),
62
66
  }),
63
67
  );
64
68
  return dir;
@@ -88,13 +92,15 @@ function setupCacheRoot(t) {
88
92
  }
89
93
 
90
94
  function baseInput(dir, verifyFn, extras) {
95
+ const manifestPath = path.join(dir, "package.json");
96
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
91
97
  return {
92
98
  platformPkgDir: dir,
93
- packageName: "@fallow-cli/test-platform",
94
- manifestPath: path.join(dir, "package.json"),
99
+ packageName: manifest.name,
100
+ manifestPath,
95
101
  verifyFn,
96
102
  env: {},
97
- platform: process.platform,
103
+ platform: DEFAULT_PLATFORM,
98
104
  ...extras,
99
105
  };
100
106
  }
@@ -120,6 +126,29 @@ test("ensureVerified verifies on cache miss and writes the sentinel", (t) => {
120
126
  assert.equal(Object.keys(sentinel.binaries).length, 1);
121
127
  });
122
128
 
129
+ test("ensureVerified verifies and caches a win32 executable on any host", (t) => {
130
+ _resetWarningState();
131
+ const { privateKey, rawPub } = makeKeypair();
132
+ const dir = mkPlatformDir(privateKey, { platform: "win32" });
133
+ t.after(() => cleanup(dir));
134
+ const input = baseInput(dir, (binaryPath) => _verifyWithKey(binaryPath, rawPub), {
135
+ platform: "win32",
136
+ });
137
+
138
+ const verified = ensureVerified(input);
139
+ assert.equal(verified.ok, true);
140
+ assert.equal(verified.cached, false);
141
+ const sentinel = JSON.parse(fs.readFileSync(verified.sentinelPath, "utf8"));
142
+ assert.deepEqual(Object.keys(sentinel.binaries), ["fallow.exe"]);
143
+
144
+ const cached = ensureVerified({
145
+ ...input,
146
+ verifyFn: () => assert.fail("signature verification must not run on a cache hit"),
147
+ });
148
+ assert.equal(cached.ok, true);
149
+ assert.equal(cached.cached, true);
150
+ });
151
+
123
152
  test("ensureVerified returns cached:true on a valid sentinel", (t) => {
124
153
  _resetWarningState();
125
154
  const { privateKey, rawPub } = makeKeypair();
@@ -154,7 +183,7 @@ test("ensureVerified invalidates sentinel on mtime drift", (t) => {
154
183
 
155
184
  // Bump the mtime of one binary; sentinel should now be stale.
156
185
  const newTime = new Date(Date.now() + 10_000);
157
- fs.utimesSync(path.join(dir, `fallow${ext()}`), newTime, newTime);
186
+ fs.utimesSync(path.join(dir, binaryNames(DEFAULT_PLATFORM)[0]), newTime, newTime);
158
187
 
159
188
  let verifyCallCount = 0;
160
189
  const result = ensureVerified(
@@ -215,7 +244,9 @@ test("ensureVerified invalidates sentinel on packageName drift", (t) => {
215
244
  packageVersion: "2.81.0",
216
245
  packageName: "@fallow-cli/wrong-name",
217
246
  binaries: {
218
- [`fallow${ext()}`]: { mtimeMs: fs.statSync(path.join(dir, `fallow${ext()}`)).mtimeMs },
247
+ [binaryNames(DEFAULT_PLATFORM)[0]]: {
248
+ mtimeMs: fs.statSync(path.join(dir, binaryNames(DEFAULT_PLATFORM)[0])).mtimeMs,
249
+ },
219
250
  },
220
251
  }),
221
252
  );
@@ -261,7 +292,7 @@ test("ensureVerified invalidates sentinel on schemaVersion drift", (t) => {
261
292
  test("ensureVerified returns sig-invalid on a tampered signature", (t) => {
262
293
  _resetWarningState();
263
294
  const { privateKey, rawPub } = makeKeypair();
264
- const dir = mkPlatformDir(privateKey, { corruptSigFor: `fallow${ext()}` });
295
+ const dir = mkPlatformDir(privateKey, { corruptSigFor: binaryNames(DEFAULT_PLATFORM)[0] });
265
296
  t.after(() => cleanup(dir));
266
297
 
267
298
  const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
@@ -289,89 +320,43 @@ test("ensureVerified returns digest-unavailable on a pre-#597 manifest", (t) =>
289
320
 
290
321
  test("ensureVerified honors FALLOW_VERIFY_CACHE_DIR when platform pkg dir is non-writable", (t) => {
291
322
  _resetWarningState();
292
- if (process.platform === "win32") {
293
- t.skip("Windows ACL chmod is not portable; covered by sentinel-path tests");
294
- return;
295
- }
296
323
  const { privateKey, rawPub } = makeKeypair();
297
324
  const dir = mkPlatformDir(privateKey);
298
325
  const cacheRoot = setupCacheRoot(t);
326
+ t.after(() => cleanup(dir));
299
327
 
300
- fs.chmodSync(dir, 0o555);
301
- try {
302
- const result = ensureVerified({
303
- ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
304
- env: { FALLOW_VERIFY_CACHE_DIR: cacheRoot },
305
- });
306
- assert.equal(result.ok, true);
307
- assert.equal(result.cached, false);
308
- assert.match(result.sentinelPath, new RegExp(cacheRoot.replace(/\\/g, "\\\\")));
309
- } finally {
310
- fs.chmodSync(dir, 0o755);
311
- cleanup(dir);
312
- }
328
+ const result = ensureVerified({
329
+ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
330
+ env: { FALLOW_VERIFY_CACHE_DIR: cacheRoot },
331
+ isWritable: (candidate) => candidate !== dir,
332
+ });
333
+ assert.equal(result.ok, true);
334
+ assert.equal(result.cached, false);
335
+ assert.match(result.sentinelPath, new RegExp(cacheRoot.replace(/\\/g, "\\\\")));
313
336
  });
314
337
 
315
- test("ensureVerified emits a single warning when sentinel write fails", (t) => {
338
+ test("ensureVerified emits a single warning when no sentinel location is writable", (t) => {
316
339
  _resetWarningState();
317
- if (process.platform === "win32") {
318
- t.skip("Windows ACL chmod is not portable");
319
- return;
320
- }
321
340
  const { privateKey, rawPub } = makeKeypair();
322
341
  const dir = mkPlatformDir(privateKey);
323
342
  const stderr = captureStderr(t);
343
+ t.after(() => cleanup(dir));
344
+ const input = {
345
+ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
346
+ homedir: undefined,
347
+ isWritable: () => false,
348
+ };
324
349
 
325
- // Make platform pkg dir non-writable AND point FALLOW_VERIFY_CACHE_DIR at
326
- // a non-existent path. resolveSentinelPath will still find the XDG / home
327
- // fallback, but we can simulate "every cache location read-only" by giving
328
- // it a writable dir that we then chmod down right before ensureVerified
329
- // runs writeSentinel. Simpler: instead of fighting the cascade in env,
330
- // simulate write failure via a non-writable FALLOW_VERIFY_CACHE_DIR that
331
- // PASSES isWritable() but then has its perms revoked between resolve and
332
- // write. Since that race is hard to script, we test the warn-once helper
333
- // via a synthetic path that fails the rename atomically: pass a sentinel-
334
- // chmod-locked dir.
335
- //
336
- // The portable shape: chmod the platform pkg dir read-only AND pass a
337
- // FALLOW_VERIFY_CACHE_DIR that is a regular file (not a dir). isWritable
338
- // returns false for both, so resolveSentinelPath falls through to XDG. If
339
- // the user has no HOME (synthetic env), the cascade returns null. We can
340
- // achieve this only by injecting both env.HOME='' AND env.XDG_CACHE_HOME=''
341
- // AND ensuring os.homedir() is not consulted; ensureVerified does not
342
- // accept a homedir override, so the warn-once path is unreachable via env
343
- // alone on a machine with a real HOME. Skip this test on machines with a
344
- // real homedir; the warn-once helper is otherwise covered by the
345
- // "no-writable-location" branch in sentinel-path tests.
346
- if (os.homedir() && os.homedir().length > 0) {
347
- t.skip(
348
- "warn-once-on-no-writable-cache requires homedir-override knob (covered by sentinel-path unit test)",
349
- );
350
- cleanup(dir);
351
- return;
352
- }
350
+ const result = ensureVerified(input);
351
+ assert.equal(result.sentinelPath, null);
352
+ const warnings = stderr.lines.filter((line) => line.includes("no writable cache location"));
353
+ assert.equal(warnings.length, 1);
353
354
 
354
- fs.chmodSync(dir, 0o555);
355
- try {
356
- const env = { HOME: "", XDG_CACHE_HOME: "" };
357
- const result = ensureVerified({
358
- ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
359
- env,
360
- });
361
- if (result.sentinelPath !== null) {
362
- t.diagnostic(`sentinel landed at ${result.sentinelPath} despite empty HOME; skipping`);
363
- return;
364
- }
365
- const warnings = stderr.lines.filter((l) => l.includes("no writable cache location"));
366
- assert.equal(warnings.length, 1);
367
- // Second call: no new warning.
368
- ensureVerified({ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)), env });
369
- const warnings2 = stderr.lines.filter((l) => l.includes("no writable cache location"));
370
- assert.equal(warnings2.length, 1);
371
- } finally {
372
- fs.chmodSync(dir, 0o755);
373
- cleanup(dir);
374
- }
355
+ ensureVerified(input);
356
+ const repeatedWarnings = stderr.lines.filter((line) =>
357
+ line.includes("no writable cache location"),
358
+ );
359
+ assert.equal(repeatedWarnings.length, 1);
375
360
  });
376
361
 
377
362
  // ---- FALLOW_SKIP_BINARY_VERIFY -------------------------------------------
@@ -457,10 +442,6 @@ test("ensureVerified is idempotent under concurrent first-runs", async (t) => {
457
442
 
458
443
  test("ensureVerified rejects a sentinel written for a different install dir", (t) => {
459
444
  _resetWarningState();
460
- if (process.platform === "win32") {
461
- t.skip("chmod-based read-only platform pkg dir is not portable on Windows");
462
- return;
463
- }
464
445
  // Two installs of the same package + version. install A is clean and writes
465
446
  // a sentinel to a shared cache; install B has a tampered binary at the same
466
447
  // package name + version. B must NOT trust A's sentinel via cache hit even
@@ -471,17 +452,14 @@ test("ensureVerified rejects a sentinel written for a different install dir", (t
471
452
  // Tamper install B's fallow binary AFTER mkPlatformDir wrote a valid sig
472
453
  // for the original bytes (mkPlatformDir does not expose a corrupt-binary
473
454
  // option, so simulate the attack by overwriting bytes here).
474
- fs.writeFileSync(path.join(installB, `fallow${ext()}`), Buffer.from("tampered bytes"));
455
+ fs.writeFileSync(
456
+ path.join(installB, binaryNames(DEFAULT_PLATFORM)[0]),
457
+ Buffer.from("tampered bytes"),
458
+ );
475
459
  const sharedCache = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-shared-cache-"));
460
+ const isWritable = (candidate) => candidate !== installA && candidate !== installB;
476
461
 
477
- // Force the shared-cache cascade by making both platform pkg dirs
478
- // non-writable (simulates yarn PnP / Docker layered images / pnpm
479
- // verify-store invariants).
480
- fs.chmodSync(installA, 0o555);
481
- fs.chmodSync(installB, 0o555);
482
462
  t.after(() => {
483
- fs.chmodSync(installA, 0o755);
484
- fs.chmodSync(installB, 0o755);
485
463
  cleanup(installA);
486
464
  cleanup(installB);
487
465
  cleanup(sharedCache);
@@ -492,6 +470,7 @@ test("ensureVerified rejects a sentinel written for a different install dir", (t
492
470
  const resultA = ensureVerified({
493
471
  ...baseInput(installA, (p) => _verifyWithKey(p, rawPub)),
494
472
  env: { FALLOW_VERIFY_CACHE_DIR: sharedCache },
473
+ isWritable,
495
474
  });
496
475
  assert.equal(resultA.ok, true);
497
476
  assert.match(resultA.sentinelPath, new RegExp(sharedCache.replace(/\\/g, "\\\\")));
@@ -502,7 +481,6 @@ test("ensureVerified rejects a sentinel written for a different install dir", (t
502
481
  // + SHA-256 binding must prevent that.
503
482
  for (const name of binaryNames()) {
504
483
  const aStat = fs.statSync(path.join(installA, name));
505
- fs.chmodSync(path.join(installB, name), 0o644);
506
484
  fs.utimesSync(path.join(installB, name), aStat.atime, aStat.mtime);
507
485
  }
508
486
 
@@ -513,6 +491,7 @@ test("ensureVerified rejects a sentinel written for a different install dir", (t
513
491
  return _verifyWithKey(p, rawPub);
514
492
  }),
515
493
  env: { FALLOW_VERIFY_CACHE_DIR: sharedCache },
494
+ isWritable,
516
495
  });
517
496
  assert.equal(resultB.ok, false);
518
497
  assert.equal(resultB.code, "sig-invalid");
@@ -530,7 +509,7 @@ test("ensureVerified rejects a sentinel where bytes drift but mtime stays", (t)
530
509
 
531
510
  // Tamper the binary in place AND restore the prior mtime, so the mtime
532
511
  // pre-filter matches but the bytes do not.
533
- const binPath = path.join(dir, `fallow${ext()}`);
512
+ const binPath = path.join(dir, binaryNames(DEFAULT_PLATFORM)[0]);
534
513
  const before = fs.statSync(binPath);
535
514
  fs.writeFileSync(binPath, Buffer.from("tampered"));
536
515
  fs.utimesSync(binPath, before.atime, before.mtime);
@@ -145,18 +145,38 @@ function resolveTypeAwareCompanion(
145
145
  }
146
146
  }
147
147
 
148
- function childEnvironment(resolvedVersion, resolveCompanion = resolveTypeAwareCompanion) {
148
+ function typeAwareCommand(
149
+ companion,
150
+ { platform = process.platform, execPath = process.execPath } = {},
151
+ ) {
152
+ return platform === "win32"
153
+ ? { binary: execPath, script: companion }
154
+ : { binary: companion, script: undefined };
155
+ }
156
+
157
+ function childEnvironment(
158
+ resolvedVersion,
159
+ resolveCompanion = resolveTypeAwareCompanion,
160
+ commandOptions,
161
+ ) {
149
162
  if (process.env.FALLOW_TYPE_AWARE_BIN) return process.env;
150
163
  const companion = resolveCompanion(resolvedVersion);
151
- return companion === undefined
152
- ? process.env
153
- : {
154
- ...process.env,
155
- FALLOW_TYPE_AWARE_BIN: companion,
156
- // Marks the wiring as launcher-provided node_modules resolution so
157
- // `type-aware status` does not report it as a user-set override.
158
- FALLOW_TYPE_AWARE_BIN_SOURCE: "npm-wrapper",
159
- };
164
+ if (companion === undefined) return process.env;
165
+
166
+ const command = typeAwareCommand(companion, commandOptions);
167
+ const environment = {
168
+ ...process.env,
169
+ FALLOW_TYPE_AWARE_BIN: command.binary,
170
+ // Marks the wiring as launcher-provided node_modules resolution so
171
+ // `type-aware status` does not report it as a user-set override.
172
+ FALLOW_TYPE_AWARE_BIN_SOURCE: "npm-wrapper",
173
+ };
174
+ if (command.script) {
175
+ environment.FALLOW_TYPE_AWARE_SCRIPT = command.script;
176
+ } else {
177
+ delete environment.FALLOW_TYPE_AWARE_SCRIPT;
178
+ }
179
+ return environment;
160
180
  }
161
181
 
162
182
  // Swallow EPIPE on stdout. When fallow's output is piped into a reader that