@reneturcios/hookcheck 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,9 +14,9 @@ Reports in October 2026 described AI agents orchestrating attacks and a self-pro
14
14
 
15
15
  | surface | what it looks for |
16
16
  | --- | --- |
17
- | `package.json` | preinstall, install, postinstall, prepare scripts. The root package.json and nested workspace package.json files (for example `packages/*/package.json`, `apps/*/package.json`, any directory up to 4 levels below the root, node_modules excluded) are inventoried the same way |
17
+ | `package.json` | preinstall, install, postinstall, prepare scripts. The root package.json and nested workspace package.json files (for example `packages/*/package.json`, `apps/*/package.json`, any directory up to 8 levels below the root, node_modules excluded) are inventoried the same way |
18
18
  | `.claude/settings.json` and `settings.local.json` | SessionStart, PreToolUse, and other hook commands. Also discovered: the GNU stow layout (`claude/settings.json`, no dot) and settings nested one level under a config dir (for example `config/.claude/settings.json`). A settings path that exists as a directory is reported as a warning, not silently skipped |
19
- | Claude Code plugin hooks | `hooks/hooks.json` in a plugin root: at the repo root, or nested under plugin directories (for example `plugins/<name>/hooks/hooks.json`), with the plugin root at most 4 levels below the scan root. The file carries the same events map as the hooks key of `.claude/settings.json` (SessionStart, PreToolUse, PostToolUse, Stop, UserPromptSubmit, and the rest of the Claude Code event set) and its commands are classified exactly the same way. The sibling `hooks/hooks-cursor.json` variant carries the same command object shape under Cursor event names and is inventoried the same way |
19
+ | Claude Code plugin hooks | `hooks/hooks.json` in a plugin root: at the repo root, or nested under plugin directories (for example `plugins/<name>/hooks/hooks.json`), with the plugin root at most 8 levels below the scan root. The file carries the same events map as the hooks key of `.claude/settings.json` (SessionStart, PreToolUse, PostToolUse, Stop, UserPromptSubmit, and the rest of the Claude Code event set) and its commands are classified exactly the same way. The sibling `hooks/hooks-cursor.json` variant carries the same command object shape under Cursor event names and is inventoried the same way |
20
20
  | `.vscode/tasks.json` | tasks with runOn folderOpen, plus task inventory |
21
21
  | `.husky/` | every hook file and its full command content |
22
22
  | `lefthook.yml` | run commands |
@@ -26,10 +26,12 @@ Reports in October 2026 described AI agents orchestrating attacks and a self-pro
26
26
  | `.github/workflows/` | run steps, as inventory, including block scalar bodies (`run: |` and `run: >`, with chomping variants). Every workflow finding carries the line number of its `run:` key |
27
27
  | `.githooks/` | hook files |
28
28
 
29
+ Every surface above is discovered by one bounded recursive walk, not only at the scan root: a surface that appears inside a subdirectory (for example `packages/foo/.claude/settings.json`, `sub/.vscode/tasks.json`, or `sub/.github/workflows/ci.yml`) is inventoried from a parent scan with its real relative path in the finding, within the discovery bound documented under Limits. A surface file is inventoried exactly once however many discovery paths reach it.
30
+
29
31
  Classification per hook:
30
32
 
31
33
  - **SAFE**: matches known safe dev patterns such as test, lint, build, typecheck, husky, or lint-staged, and matches no risky rule.
32
- - **RISKY**: matches at least one risky rule: network fetch, curl piped to an interpreter, base64 decode piped to an interpreter, credential store paths or credential files (matched on content and paths, never on a script filename that merely contains the word credentials), credential shaped environment names (token, secret, password, api key) or bulk environment access, base64 decoding or eval style obfuscation, inline code execution, or a remote URL. Ordinary single named environment reads (NODE_ENV, HOME, RUNNER_DEBUG) are not flagged. Neither are operations that are not reads: `env -u NAME` unsets a variable, `os.environ.setdefault(...)` writes one key with a default, and a `[ -n "$NAME" ]` test checks existence. A hook whose target is a shell script is classified by that script's contents, not by the fact that it is a shell script. Comments are stripped before classification, so a docs URL inside a comment is not a fetch, and a fetch whose every URL is loopback (a localhost health check) is not treated as network behavior. In script files (JavaScript, Python), the contents of string and regex literals are data, not behavior: a defensive guard whose pattern list quotes `eval(` or `curl | sh` performs neither, and hookcheck does not classify it as an attacker. Literals handed to exec style calls (`execSync("...")`, `os.system("...")`) are code by construction and are still matched. Two rules are about value flow, not co-occurrence: ENV-EXFIL fires only when a credential value demonstrably flows into an outbound send (a request payload, a query string, a pipe into a network tool, or printed output next to a send), and CRED-EXFIL when credential store contents are piped or posted straight into a network send. A token used as authentication (an Authorization header, the userinfo of a push URL to the repo's own host, a `--token` flag) is authentication, not exfiltration, and never fires either rule. Authentication positions are recognized by shape, not only by the Authorization spelling: any curl `-H` header argument whatever its name, any dict or object entry whose key is an authentication header name (Authorization, `x-api-key`, `*-api-key`, `*-auth-token`, and friends) in a one hop script file, and any header shaped entry inside a headers construction (a dict assigned to a headers variable, a `headers=` argument, a headers property of a request options object) all count as authentication positions, while the same credential in a body, a query string, or printed output still counts as exfiltration. Fetch detection requires command position: curl or wget counts as a network fetch only when it is the invoked command (the head of a statement or pipeline segment, after sudo or env wrappers, or the head of a command string handed to an interpreter), never when the word merely names a package in an install line (`pacman -S curl`, `apt-get install curl`). In script files the remote URL rule follows the same data view as the other behavior rules: a URL that appears only inside a string literal or a comment (an error message, a documentation link, an API base constant) is data, while a URL handed to a network call (fetch, requests, http.get, urlopen, axios, sendBeacon) is a fetch target and still fires. Inline code execution (DOWNLOAD-EXEC) fires only when the evaluated content itself carries a download or remote reference; constant inline code such as a `node -e` version comparison downloads nothing and is not flagged.
34
+ - **RISKY**: matches at least one risky rule: network fetch, curl piped to an interpreter, base64 decode piped to an interpreter, credential store paths or credential files (matched on content and paths, never on a script filename that merely contains the word credentials), credential shaped environment names (token, secret, password, api key) or bulk environment access, base64 decoding or eval style obfuscation, inline code execution, or a remote URL. Ordinary single named environment reads (NODE_ENV, HOME, RUNNER_DEBUG) are not flagged. Neither are operations that are not reads: `env -u NAME` unsets a variable, `os.environ.setdefault(...)` writes one key with a default, and a `[ -n "$NAME" ]` test checks existence. A hook whose target is a shell script is classified by that script's contents, not by the fact that it is a shell script. Comments are stripped before classification, so a docs URL inside a comment is not a fetch, and a fetch whose every URL is loopback (a localhost health check) is not treated as network behavior. In script files (JavaScript, Python), the contents of string and regex literals are data, not behavior: a defensive guard whose pattern list quotes `eval(` or `curl | sh` performs neither, and hookcheck does not classify it as an attacker. Literals handed to exec style calls (`execSync("...")`, `os.system("...")`) are code by construction and are still matched. Two rules are about value flow, not co-occurrence: ENV-EXFIL fires only when a credential value demonstrably flows into an outbound send (a request payload, a query string, a pipe into a network tool, or printed output next to a send), and CRED-EXFIL when credential store contents are piped or posted straight into a network send. Environment inheritance is not a send: when the only environment movement is inheritance or bulk access passed as a subprocess environment (env=os.environ, a {**os.environ, ...} spread, dict(os.environ), os.environ.copy(), and their JavaScript forms), ENV-EXFIL additionally requires a non-loopback destination in the flow, so a test harness that starts its own server on 127.0.0.1 and hands it the inherited environment does not verdict as exfiltration; the same forms serialized into a payload, or paired with a remote destination, still fire. The ENV-BULK evidence names the form it saw: inheritance and copy forms are described as bulk access in inheritance or copy form, not as a serialization or dump. A token used as authentication (an Authorization header, the userinfo of a push URL to the repo's own host, a `--token` flag) is authentication, not exfiltration, and never fires either rule. Authentication positions are recognized by shape, not only by the Authorization spelling: any curl `-H` header argument whatever its name, any dict or object entry whose key is an authentication header name (Authorization, `x-api-key`, `*-api-key`, `*-auth-token`, and friends) in a one hop script file, and any header shaped entry inside a headers construction (a dict assigned to a headers variable, a `headers=` argument, a headers property of a request options object) all count as authentication positions, while the same credential in a body, a query string, or printed output still counts as exfiltration. Fetch detection requires command position: curl or wget counts as a network fetch only when it is the invoked command (the head of a statement or pipeline segment, after sudo or env wrappers, or the head of a command string handed to an interpreter), never when the word merely names a package in an install line (`pacman -S curl`, `apt-get install curl`). In script files the remote URL rule follows the same data view as the other behavior rules: a URL that appears only inside a string literal or a comment (an error message, a documentation link, an API base constant) is data, while a URL handed to a network call (fetch, requests, http.get, urlopen, axios, sendBeacon) is a fetch target and still fires. Inline code execution (DOWNLOAD-EXEC) fires only when the evaluated content itself carries a download or remote reference; constant inline code such as a `node -e` version comparison downloads nothing and is not flagged.
33
35
  - **UNKNOWN**: matches neither safe nor risky patterns. A local script name alone is UNKNOWN until a human reads it. When a hook runs a local file such as `node setup.mjs`, hookcheck reads that file and escalates the finding if the file itself matches risky rules. The same one hop following applies to direct script execution (`./setup.sh`), `node --require ./setup.mjs`, and package.json script chains (`postinstall: npm run other-script`). If the referenced file itself spawns a further local script, the chain runs past the one hop limit: the finding is floored at UNKNOWN, never SAFE, and the evidence names the second hop file, so the verdict lands at REVIEW for a hand read.
34
36
 
35
37
  Verdict rules:
@@ -38,7 +40,7 @@ Verdict rules:
38
40
  - **REVIEW**: at least one UNKNOWN finding, or a RISKY finding whose evidence is weak on its own (a remote URL alone, a download by itself, credential access with no network behavior). Exit code 1.
39
41
  - **COMPROMISED-PATTERN**: at least one RISKY finding with strong evidence: fetched or decoded content executed directly as code (curl piped to an interpreter, base64 decode piped to an interpreter, eval of fetched content), a verified exfil flow (a credential value flowing into an outbound send, or credential store contents piped into a network tool), or credential store access combined with network behavior in the same finding. This is the Shai-Hulud persistence shape. Treat the repo as untrusted until proven otherwise. Exit code 1. Canonical legitimate installers that download their own binary (esbuild, puppeteer) verdict REVIEW under this calibration, not COMPROMISED-PATTERN; the download is reported as a RISKY finding with its evidence, and the top verdict no longer over-claims it. The same applies to a repo's own release automation: a step that reads its token and pushes to its own repo, posts its own workflow dispatch, or updates its own homebrew tap uses the token as authentication and verdicts REVIEW, because the credential value never flows into a payload to a third party. One more calibration in the same family: a CI workflow step whose only severe evidence is a curl pipe to a named vendor's official installer over HTTPS (an installer shaped path such as /install or /uv/install.sh, or a known installer host such as astral.sh or sh.rustup.rs), with no credential, persistence, or second severe signal in the finding, verdicts REVIEW with the finding kept RISKY. The same pipe on an auto-exec hook surface (an install script, a Claude settings or plugin hook, a folderOpen task), a pipe to a non-installer source, or a pipe accompanied by any other signal still reaches COMPROMISED-PATTERN.
40
42
 
41
- Every finding reports file, hook id, command text, classification, rule ids, and evidence. Malformed JSON is reported as a warning, never silently ignored. Scanning a path that does not exist, or that is a file instead of a directory, is a clean error with exit code 2.
43
+ Every finding reports file, hook id, command text, classification, rule ids, and evidence. Malformed JSON is reported as a warning, never silently ignored. Scanning a path that does not exist, or that is a file instead of a directory, is a clean error with exit code 2. Reports are written through a flush-aware emit: if the reader of a piped report closes the pipe early, the process exits 141 (the shell-visible equivalent of death by SIGPIPE) rather than reporting the verdict code for a report that was never delivered.
42
44
 
43
45
  ## Limits
44
46
 
@@ -53,8 +55,7 @@ Honest boundaries, pinned by tests in test/hardening.test.ts:
53
55
  - Non-string package.json script values are skipped: npm only executes string script values, so an array or number in scripts cannot auto-run.
54
56
  - Classification reads up to 200,000 characters of a hook file or a referenced local file. Beyond that depth a payload can hide; the cap exists so a hostile file cannot make the scan unbounded.
55
57
  - GitHub workflow run steps in block scalars (`run: |`, `run: >`, and chomping variants) are parsed and classified like one line commands. Plain multi-line scalars without a block header are not joined, and reusable workflow internals are not resolved. YAML anchors, aliases, and merge keys on run steps are resolved when the anchor is defined in the same file; anchors applied to other step fields, and anchors defined in other files, are not resolved. Workflow files with CRLF line endings are parsed exactly like their LF equivalents.
56
- - Nested package.json inventory is bounded: the root package.json plus nested package.json files whose directory sits at most 4 levels below the root, visited in sorted order, capped at 200 nested files (the cap is reported as a warning when hit). node_modules, .git, dot directories, and symlinked directories are never entered, so package.json files deeper than the depth bound, or anywhere under node_modules, are not scanned.
57
- - Plugin hooks inventory is bounded the same way: `hooks/hooks.json` and `hooks/hooks-cursor.json` files whose plugin root sits at most 4 levels below the scan root, visited in sorted order, capped at 200 files (the cap is reported as a warning when hit), with node_modules, .git, dot directories, and symlinked directories never entered. Plugin hook files deeper than the bound are not scanned. Only the object command form of the plugin schema is inventoried; a hook entry written as a bare string instead of a command object is outside the documented schema and is not inventoried.
58
+ - Nested discovery is one bounded walk covering every inventoried surface: package.json files, `.claude/settings.json` and `settings.local.json` (including the stow layout `claude/settings.json`), plugin `hooks/hooks.json` and `hooks/hooks-cursor.json` files, `.vscode/tasks.json`, `.github/workflows/*.yml` and `*.yaml`, `.husky/` and `.githooks/` hook files, lefthook configs (`lefthook.yml`, `lefthook.yaml`, `.lefthook.yml`), `.pre-commit-config.yaml`, `.cursor/` and `.codex/` config files, and MCP configs (`.mcp.json`, `mcp.json`, `.cursor/mcp.json`, `.windsurf/mcp.json`, `.codex/mcp.json`, and `.vscode/mcp.json`). A surface is in scope when its project directory sits at most 8 levels below the scan root (the plugin hooks directory itself sits one level below its plugin root), visited in sorted order. Each surface family inventories at most 200 nested files; the scan root files always count. node_modules, .git, dot directories, and symlinked directories are never entered, so surfaces deeper than the depth bound, or anywhere under node_modules, are not scanned. Non-regular files (FIFOs, sockets, device files) are never opened: every file the scanner reads is checked by stat type first and a non-regular file is skipped with a warning naming it, because opening a FIFO blocks until a writer appears and would hang the scan. The bound is never silent: when the walk hits the depth bound with more tree remaining, the report carries a warning naming the depth bound and counts the directories just below the bound that contain hook surface files or config directories; when a family hits the file cap, a warning names the cap and the count found. A scan cut short by either bound, by the cap, or by unreadable files is partial, says so in its warnings, and never verdicts a bare CLEAN when surfaces are known to have been missed. Only the object command form of the plugin schema is inventoried; a hook entry written as a bare string instead of a command object is outside the documented schema and is not inventoried.
58
59
  - A file or directory the scanner sets out to inventory but cannot read (for example a workflow file at mode 000) is never skipped silently: the report carries a warning naming it, and the verdict is floored at REVIEW while any part of the estate is unread, so a partial scan never presents as a complete all-clear.
59
60
  - Custom git hook directories set through core.hooksPath are not scanned: hookcheck inventories the standard `.githooks/` directory and `.husky/`, but it does not parse git config or a committed `.gitconfig` to discover a custom core.hooksPath directory. Hooks kept only in a custom hooksPath directory are outside the inventory; audit them by scanning a tree where they sit in a standard location.
60
61
  - Agent config coverage boundaries: `.gemini/settings.json` is not scanned yet. Template configs (for example `templates/settings.json.template`) are not scanned: they are inert until copied into place, at which point the copied file is scanned under its real name.
package/dist/cli.js CHANGED
@@ -4,10 +4,51 @@
4
4
  * hookcheck scan [path] [--json]
5
5
  * hookcheck mcp
6
6
  * hookcheck --version
7
- * Exit codes: 0 CLEAN, 1 REVIEW or COMPROMISED-PATTERN, 2 usage error.
7
+ * Exit codes: 0 CLEAN, 1 REVIEW or COMPROMISED-PATTERN, 2 usage error,
8
+ * 141 when the reader closed the output pipe before the report was
9
+ * delivered (the shell-visible equivalent of death by SIGPIPE).
10
+ *
11
+ * Every report goes through emit(), which waits for stdout to flush
12
+ * before the exit code takes effect. This is the factory emit
13
+ * contract (~/workspace/security-wave/FACTORY-NOTES.md). The P-017 fix
14
+ * in v0.1.3 replaced process.exit() with process.exitCode, which made
15
+ * delivery reliable for cooperative readers, but the CLI still printed
16
+ * with console.log and had no EPIPE handling at all: on a very large
17
+ * report (35 MB on the chaos big-workflow target), a reader that
18
+ * closed the pipe early still received the verdict exit code,
19
+ * reporting silent truncation as success (P-071). Sibling tools
20
+ * actionpin (P-001) and injectcheck (P-021) exit 141 in that case;
21
+ * hookcheck now does the same.
8
22
  */
9
23
  import { existsSync, statSync } from "node:fs";
10
24
  import { explainHook, renderReport, scanHooks, VERSION } from "./core.js";
25
+ function exitBrokenPipe() {
26
+ // The reader closed the pipe before the report was delivered. A Unix
27
+ // writer in this position dies from SIGPIPE; exit 141 (128 + 13) is
28
+ // the equivalent a shell reports, and it guarantees an undelivered
29
+ // report is never mistaken for a successful run.
30
+ process.exit(141);
31
+ }
32
+ process.stdout.on("error", (err) => {
33
+ if (err.code === "EPIPE")
34
+ exitBrokenPipe();
35
+ throw err;
36
+ });
37
+ /** Write one full report and resolve only after stdout has flushed it. */
38
+ async function emit(text, code) {
39
+ process.exitCode = code;
40
+ await new Promise((resolve, reject) => {
41
+ process.stdout.write(`${text}\n`, (err) => {
42
+ if (!err) {
43
+ resolve();
44
+ return;
45
+ }
46
+ if (err.code === "EPIPE")
47
+ exitBrokenPipe();
48
+ reject(err);
49
+ });
50
+ });
51
+ }
11
52
  /** A scan target must be an existing directory; anything else is a usage error (exit 2). */
12
53
  function requireDirectory(target) {
13
54
  if (!existsSync(target)) {
@@ -52,16 +93,7 @@ async function main() {
52
93
  const target = rest.find((a) => !a.startsWith("--")) ?? ".";
53
94
  requireDirectory(target);
54
95
  const report = scanHooks(target);
55
- if (asJson)
56
- console.log(JSON.stringify(report, null, 2));
57
- else
58
- console.log(renderReport(report));
59
- // exitCode, not process.exit: stdout to a pipe is async in Node, and
60
- // process.exit right after console.log truncates large reports at
61
- // exactly 65,536 characters (measured during v0.1.2 re-validation
62
- // on a sparse cli/cli checkout). Letting main return lets the
63
- // runtime flush stdout in full before exiting with this code.
64
- process.exitCode = report.verdict === "CLEAN" ? 0 : 1;
96
+ await emit(asJson ? JSON.stringify(report, null, 2) : renderReport(report), report.verdict === "CLEAN" ? 0 : 1);
65
97
  }
66
98
  else if (cmd === "explain") {
67
99
  const args = rest.filter((a) => !a.startsWith("--"));
@@ -78,11 +110,7 @@ async function main() {
78
110
  usage();
79
111
  requireDirectory(target);
80
112
  const result = explainHook(target, id);
81
- if (asJson)
82
- console.log(JSON.stringify(result, null, 2));
83
- else
84
- console.log(result.explanation);
85
- process.exitCode = result.finding ? 0 : 1;
113
+ await emit(asJson ? JSON.stringify(result, null, 2) : result.explanation, result.finding ? 0 : 1);
86
114
  }
87
115
  else {
88
116
  usage();
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
package/dist/core.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { relative } from "node:path";
2
2
  import type { Classification, HookFinding, ScanReport, Verdict } from "./types.js";
3
- export declare const VERSION = "0.1.7";
3
+ export declare const VERSION = "0.1.9";
4
4
  interface RuleDef {
5
5
  id: string;
6
6
  pattern: RegExp;
@@ -60,46 +60,64 @@ export declare function classifyCommand(command: string, lang?: Lang): {
60
60
  };
61
61
  export declare function computeVerdict(findings: HookFinding[]): Verdict;
62
62
  /**
63
- * Nested package.json discovery (P-032). Install scripts in workspace
64
- * subpackages (packages/foo, apps/web) run on a root install, so a
65
- * root-only scan misses exactly the surface a worm would persist in.
66
- * The walk is bounded so a hostile or giant tree cannot explode the
67
- * report: directories are visited in sorted order to depth
68
- * PACKAGE_MAX_DEPTH (the package.json directory depth below the root),
69
- * node_modules, .git, and dot directories are never entered, symlinked
70
- * directories are never followed (a symlink loop cannot hang the scan),
71
- * and at most PACKAGE_MAX_NESTED nested files are inventoried, with the
72
- * cap reported as a warning when it is hit.
73
- */
74
- export declare const PACKAGE_MAX_DEPTH = 4;
75
- export declare const PACKAGE_MAX_NESTED = 200;
76
- /**
77
- * Claude Code plugin hook files (P-060, BLOCKER). Plugins distribute
78
- * hooks in a hooks/ directory at the plugin root: hooks/hooks.json at
79
- * the repo root for a single plugin repo (obra/superpowers), or
80
- * plugins/<name>/hooks/hooks.json inside a marketplace or monorepo
81
- * (anthropics/claude-code carries 9 such files, wshobson/agents 2 among
82
- * 92 plugin directories). Before v0.1.7 none of these files was scanned
83
- * at any depth: a SessionStart curl pipe in hooks/hooks.json scanned
84
- * CLEAN with total 0 while the identical payload in .claude/settings.json
85
- * verdicts COMPROMISED-PATTERN. The file carries the same schema as the
86
- * hooks key of .claude/settings.json (an events map of command hooks:
87
- * SessionStart, PreToolUse, PostToolUse, Stop, UserPromptSubmit, and
88
- * the rest of the Claude Code event set), so commands are classified
89
- * exactly as settings hooks are. The sibling hooks/hooks-cursor.json
90
- * variant shipped by the same plugins (superpowers) carries the same
91
- * command object shape under Cursor event names, so it is inventoried
92
- * the same way.
63
+ * Unified nested discovery (P-032, P-060, P-065). Every surface the
64
+ * tool inventories at the scan root is also inventoried when it
65
+ * appears inside a subdirectory: before v0.1.8 only nested package.json
66
+ * files (P-032) and plugin hooks files (P-060) were recursed, so a
67
+ * parent scan of a monorepo or workspace silently missed nested
68
+ * .claude/settings.json, .vscode/tasks.json, workflows, husky and
69
+ * githooks directories, lefthook and pre-commit configs, and MCP
70
+ * configs, and a tree holding only nested surfaces scanned CLEAN with
71
+ * the empty estate note attached (P-065).
93
72
  *
94
- * The walk mirrors the nested package.json discipline (P-032): sorted
95
- * order, node_modules, .git, and dot directories never entered,
96
- * symlinked directories never followed, and a cap with a warning. The
97
- * depth bound applies to the plugin root (at most PLUGIN_HOOKS_MAX_DEPTH
98
- * directories below the scan root); the hooks directory itself sits one
99
- * level below its plugin root.
73
+ * One bounded walk enumerates every project root (each directory at
74
+ * most NESTED_MAX_DEPTH levels below the scan root, the scan root
75
+ * itself included) plus the nested package.json and plugin hooks file
76
+ * lists. Per surface scanners then run once per project root with the
77
+ * root prefix carried into every finding file path, so findings carry
78
+ * their real relative paths. The walk is bounded so a hostile or
79
+ * giant tree cannot explode the report: directories are visited in
80
+ * sorted order, node_modules, .git, and dot directories are never
81
+ * entered, symlinked directories are never followed (a symlink loop
82
+ * cannot hang the scan), and each surface family inventories at most
83
+ * NESTED_MAX_FILES_PER_SURFACE nested files, with the cap reported as
84
+ * a warning when it is hit. When directories remain below the depth
85
+ * bound the walk says so in a warning naming the bound, and it probes
86
+ * the directories just below the bound (names only, no file reads) so
87
+ * surfaces sitting there are counted in the warning instead of being
88
+ * silently dropped.
89
+ *
90
+ * The bound itself was raised from 4 to 8 in v0.1.9 (P-072): at depth
91
+ * 4, realistic monorepo nesting (apps/<team>/<app>, packages grouped
92
+ * by area, fixture trees) sat below the bound, so a package.json
93
+ * dropper at depth 5 floored at REVIEW via the probe and a depth 8
94
+ * chain verdicted CLEAN with only the warning. Depth 8 covers those
95
+ * layouts; the walk cost stays linear in the number of directories
96
+ * actually present, and the 2,000 file tree and 5 MB workflow perf
97
+ * spot checks for v0.1.9 showed no material regression against
98
+ * v0.1.8. Whatever bound remains keeps the warning and the
99
+ * never-bare-CLEAN rule when the probe detects surface below it.
100
100
  */
101
- export declare const PLUGIN_HOOKS_MAX_DEPTH = 4;
101
+ export declare const NESTED_MAX_DEPTH = 8;
102
+ export declare const NESTED_MAX_FILES_PER_SURFACE = 200;
103
+ export declare const PACKAGE_MAX_DEPTH = 8;
104
+ export declare const PACKAGE_MAX_NESTED = 200;
105
+ export declare const PLUGIN_HOOKS_MAX_DEPTH = 8;
102
106
  export declare const PLUGIN_HOOKS_MAX_FILES = 200;
107
+ export interface Discovery {
108
+ /** Project root prefixes, scan root ("") first, then sorted. */
109
+ projectRoots: string[];
110
+ /** Nested package.json relative paths (scan root excluded), sorted, uncapped. */
111
+ packageFiles: string[];
112
+ /** Plugin hooks file relative paths, sorted, uncapped. */
113
+ pluginHookFiles: string[];
114
+ /** Directories just below the depth bound that were not entered. */
115
+ frontierDirs: number;
116
+ /** Frontier directories whose listing shows a hook surface file or config directory. */
117
+ surfacesBelowBound: number;
118
+ /** True when the depth bound left directories unentered. */
119
+ depthBoundHit: boolean;
120
+ }
103
121
  /**
104
122
  * Empty estate note (P-014, hookcheck half). A scan that inventories
105
123
  * nothing must say so next to the verdict, in JSON as well as in the