@codyswann/lisa 2.328.3 → 2.328.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +20 -12
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/dist/sync/lifecycle-defaults.d.ts +12 -3
  5. package/dist/sync/lifecycle-defaults.d.ts.map +1 -1
  6. package/dist/sync/lifecycle-defaults.js +13 -4
  7. package/dist/sync/lifecycle-defaults.js.map +1 -1
  8. package/package.json +1 -1
  9. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/SKILL.md +32 -2
  12. package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  13. package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
  14. package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +8 -8
  15. package/plugins/lisa/.codex-plugin/skills/lisa-linear-claim/SKILL.md +5 -4
  16. package/plugins/lisa/.codex-plugin/skills/lisa-linear-sync/SKILL.md +19 -19
  17. package/plugins/lisa/.codex-plugin/skills/lisa-linear-validate-issue/SKILL.md +5 -5
  18. package/plugins/lisa/.codex-plugin/skills/lisa-linear-write-issue/SKILL.md +2 -2
  19. package/plugins/lisa/.codex-plugin/skills/lisa-setup-linear/SKILL.md +2 -2
  20. package/plugins/lisa/agents/linear-agent.md +2 -2
  21. package/plugins/lisa/agents/linear-build-intake.md +13 -13
  22. package/plugins/lisa/rules/reference/config-resolution.md +5 -3
  23. package/plugins/lisa/scripts/queue-contract-resolution.mjs +20 -8
  24. package/plugins/lisa/skills/lisa-detect-tooling/SKILL.md +33 -3
  25. package/plugins/lisa/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  26. package/plugins/lisa/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
  27. package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +8 -8
  28. package/plugins/lisa/skills/lisa-linear-claim/SKILL.md +6 -5
  29. package/plugins/lisa/skills/lisa-linear-sync/SKILL.md +19 -19
  30. package/plugins/lisa/skills/lisa-linear-validate-issue/SKILL.md +5 -5
  31. package/plugins/lisa/skills/lisa-linear-write-issue/SKILL.md +2 -2
  32. package/plugins/lisa/skills/lisa-setup-linear/SKILL.md +2 -2
  33. package/plugins/lisa-agy/agents/linear-agent.md +2 -2
  34. package/plugins/lisa-agy/agents/linear-build-intake.md +13 -13
  35. package/plugins/lisa-agy/plugin.json +1 -1
  36. package/plugins/lisa-agy/scripts/queue-contract-resolution.mjs +20 -8
  37. package/plugins/lisa-agy/skills/lisa-detect-tooling/SKILL.md +33 -3
  38. package/plugins/lisa-agy/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  39. package/plugins/lisa-agy/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
  40. package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +8 -8
  41. package/plugins/lisa-agy/skills/lisa-linear-claim/SKILL.md +6 -5
  42. package/plugins/lisa-agy/skills/lisa-linear-sync/SKILL.md +19 -19
  43. package/plugins/lisa-agy/skills/lisa-linear-validate-issue/SKILL.md +5 -5
  44. package/plugins/lisa-agy/skills/lisa-linear-write-issue/SKILL.md +2 -2
  45. package/plugins/lisa-agy/skills/lisa-setup-linear/SKILL.md +2 -2
  46. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  49. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-copilot/agents/linear-agent.agent.md +2 -2
  53. package/plugins/lisa-copilot/agents/linear-build-intake.agent.md +13 -13
  54. package/plugins/lisa-copilot/rules/reference/config-resolution.md +5 -3
  55. package/plugins/lisa-copilot/scripts/queue-contract-resolution.mjs +20 -8
  56. package/plugins/lisa-copilot/skills/lisa-detect-tooling/SKILL.md +33 -3
  57. package/plugins/lisa-copilot/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  58. package/plugins/lisa-copilot/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
  59. package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +8 -8
  60. package/plugins/lisa-copilot/skills/lisa-linear-claim/SKILL.md +6 -5
  61. package/plugins/lisa-copilot/skills/lisa-linear-sync/SKILL.md +19 -19
  62. package/plugins/lisa-copilot/skills/lisa-linear-validate-issue/SKILL.md +5 -5
  63. package/plugins/lisa-copilot/skills/lisa-linear-write-issue/SKILL.md +2 -2
  64. package/plugins/lisa-copilot/skills/lisa-setup-linear/SKILL.md +2 -2
  65. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-cursor/agents/linear-agent.md +2 -2
  67. package/plugins/lisa-cursor/agents/linear-build-intake.md +13 -13
  68. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +5 -3
  69. package/plugins/lisa-cursor/scripts/queue-contract-resolution.mjs +20 -8
  70. package/plugins/lisa-cursor/skills/lisa-detect-tooling/SKILL.md +33 -3
  71. package/plugins/lisa-cursor/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  72. package/plugins/lisa-cursor/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
  73. package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +8 -8
  74. package/plugins/lisa-cursor/skills/lisa-linear-claim/SKILL.md +6 -5
  75. package/plugins/lisa-cursor/skills/lisa-linear-sync/SKILL.md +19 -19
  76. package/plugins/lisa-cursor/skills/lisa-linear-validate-issue/SKILL.md +5 -5
  77. package/plugins/lisa-cursor/skills/lisa-linear-write-issue/SKILL.md +2 -2
  78. package/plugins/lisa-cursor/skills/lisa-setup-linear/SKILL.md +2 -2
  79. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  81. package/plugins/lisa-expo-agy/plugin.json +1 -1
  82. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  86. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  87. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  91. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  92. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  96. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  97. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  101. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  102. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  105. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  106. package/plugins/lisa-rails-agy/plugin.json +1 -1
  107. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  110. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  111. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  112. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  113. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  114. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  115. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  116. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  117. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  118. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  119. package/plugins/src/base/agents/linear-agent.md +2 -2
  120. package/plugins/src/base/agents/linear-build-intake.md +13 -13
  121. package/plugins/src/base/rules/reference/config-resolution.md +5 -3
  122. package/plugins/src/base/scripts/queue-contract-resolution.mjs +20 -8
  123. package/plugins/src/base/skills/lisa-detect-tooling/SKILL.md +33 -3
  124. package/plugins/src/base/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  125. package/plugins/src/base/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +170 -4
  126. package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +8 -8
  127. package/plugins/src/base/skills/lisa-linear-claim/SKILL.md +6 -5
  128. package/plugins/src/base/skills/lisa-linear-sync/SKILL.md +19 -19
  129. package/plugins/src/base/skills/lisa-linear-validate-issue/SKILL.md +5 -5
  130. package/plugins/src/base/skills/lisa-linear-write-issue/SKILL.md +2 -2
  131. package/plugins/src/base/skills/lisa-setup-linear/SKILL.md +2 -2
@@ -23,6 +23,15 @@ const DEFAULT_JIRA_BUILD_DONE = {
23
23
  production: "Done",
24
24
  };
25
25
 
26
+ // Linear shares JIRA's env rung NAMES but is a separate constant on purpose:
27
+ // the two vocabularies are independently configurable and a shared object would
28
+ // silently couple them.
29
+ const DEFAULT_LINEAR_BUILD_DONE = {
30
+ dev: "On Dev",
31
+ staging: "On Stg",
32
+ production: "Done",
33
+ };
34
+
26
35
  const DEFAULT_GITHUB_LINEAR_PRD_ROLES = {
27
36
  draft: "prd-draft",
28
37
  ready: "prd-ready",
@@ -290,18 +299,21 @@ export function resolveBuildLifecycleRoles(
290
299
  },
291
300
  };
292
301
  case "linear":
302
+ // Linear resolves BUILD roles to native workflow states, like JIRA — not
303
+ // to labels like GitHub, which has no workflow-state field to use. The
304
+ // `kind` matters to callers: it tells them whether a transition is a
305
+ // state move or a label swap.
293
306
  return {
294
307
  vendor: "linear",
295
- kind: "labels",
308
+ kind: "workflow",
296
309
  roles: {
297
- ready: config.linear?.labels?.build?.ready || "status:ready",
298
- claimed:
299
- config.linear?.labels?.build?.claimed || "status:in-progress",
300
- review: config.linear?.labels?.build?.review || "status:code-review",
301
- blocked: config.linear?.labels?.build?.blocked || "status:blocked",
310
+ ready: config.linear?.workflow?.ready || "Ready",
311
+ claimed: config.linear?.workflow?.claimed || "In Progress",
312
+ review: config.linear?.workflow?.review || "In Review",
313
+ blocked: config.linear?.workflow?.blocked || "Blocked",
302
314
  done:
303
- config.linear?.labels?.build?.done ||
304
- structuredClone(DEFAULT_GITHUB_BUILD_DONE),
315
+ config.linear?.workflow?.done ||
316
+ structuredClone(DEFAULT_LINEAR_BUILD_DONE),
305
317
  },
306
318
  };
307
319
  case "jira":
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lisa-detect-tooling
3
- description: "Find the command-line tools a project needs but never declares, and propose pinned manifest entries for them. Reads npm scripts, MCP servers, credential usage notes and quality configuration, subtracts whatever remoteEnv.tools already covers, and prints proposals with evidence. Writes nothing and installs nothing — a tool reaches a machine only when a human has reviewed a pinned, checksummed entry. Invoked by lisa-setup-remote-env before provisioning, and runnable on its own."
3
+ description: "Find the command-line tools a project needs but never declares, and propose pinned manifest entries for them. Reads git hooks and npm scripts for what the project actually RUNS — discovering tools no list mentions — plus MCP servers, credential usage notes and quality configuration. Subtracts whatever remoteEnv.tools, node_modules and the workstation layer already cover, and prints proposals with evidence. Writes nothing and installs nothing — a tool reaches a machine only when a human has reviewed a pinned, checksummed entry. Invoked by lisa-setup-remote-env before provisioning, and runnable on its own."
4
4
  allowed-tools: ["Bash", "Read", "AskUserQuestion"]
5
5
  ---
6
6
 
@@ -20,15 +20,45 @@ Nothing populates that last row. So a project can ship scripts that invoke `maes
20
20
 
21
21
  That is the same failure this repository has now paid for twice: `gh` was declared nowhere and a cloud session could not commit; `tar` was needed by an install method and asserted by nothing.
22
22
 
23
+ ## Discovery, not recognition
24
+
25
+ The first version of this could only find tools it had been told about: it matched script bodies against a fixed `KNOWN_TOOLS` list. That makes the detector useful for exactly the set that needs it least — the tools someone already thought of.
26
+
27
+ An Expo project invoking `eas` from **eight** npm scripts and six CI steps produced no proposal, because `eas` was not on the list. `gitleaks`, which runs on every commit in every one of these repositories, produced no proposal either.
28
+
29
+ So the question asked of executable sources is now the open one — *what does this project actually run* — and everything already provided is subtracted afterwards:
30
+
31
+ - what `remoteEnv.tools` declares
32
+ - what `node_modules/.bin` provides (a dependency's binary never needs to be on PATH)
33
+ - what `lisa-setup-workstation` owns (coding agents belong to the machine, not the checkout)
34
+
35
+ The curated list stays, because a vetted `why` is worth more than an observed one — proposals say which they are, `[curated]` or `[discovered]`.
36
+
23
37
  ## What it does
24
38
 
25
- Reads four signals, subtracts what `remoteEnv.tools` already declares, and prints what is left with the evidence for each:
39
+ Reads six signals, subtracts what is already covered, and prints what is left with the evidence for each:
40
+
41
+ - **git hooks** (`.husky/*`, `.git/hooks/*`) that invoke a binary. The **strongest** signal available, stronger than an npm script: a hook runs on every commit and push whether anyone asked or not, so a machine without the tool cannot commit at all.
26
42
 
27
- - **npm scripts** that invoke a binary. The strongest signal there is a script running `maestro test` is the project stating a dependency in executable form. Matched on the script *body*, not its name, because the name is a label.
43
+ A `command -v` guard counts as evidence rather than as "optional", because the guarded shape is the worse one. Lisa's pre-commit hook wraps its secret scan in `command -v gitleaks`, so on a machine without gitleaks the scan is **skipped silently** — the hook passes, the commit succeeds, and nothing was scanned. A guard makes the absence invisible precisely where it matters.
44
+
45
+ - **npm scripts** that invoke a binary — a script running `maestro test` is the project stating a dependency in executable form. Read from the script *body*, not its name, because the name is a label.
28
46
  - **MCP servers with a CLI equivalent.** An MCP server is not a substitute for the binary: several authenticate by browser OAuth, which a container cannot do, so a project relying on one remotely has *no* integration rather than a degraded one. Do not infer a CLI for access layers that already define their own headless substrate; Linear is handled by `lisa-linear-access` through `LINEAR_API_KEY` + GraphQL when MCP OAuth is unavailable.
29
47
  - **Credential usage notes.** A note explaining what a token is for usually names the program that consumes it. `lisa-secrets-access` already exposes these without touching a value, which makes them a first-class input rather than a trick.
30
48
  - **Quality configuration**, where a threshold implies the tool that produces it.
31
49
 
50
+ ## Reading shell is the hard part
51
+
52
+ Discovery only works if "what does this run" can be answered from shell text without inventing commands. Three things had to be right, each found by running it against real repositories:
53
+
54
+ **Only the first word of a command position counts.** Word-scanning this repo's own hooks proposed `ltrimstr`, `map` and `select` (jq filter internals), `console.log` and `process.exit` (embedded JavaScript), and `load_audit_cves` (a function the hook defines three lines up). Reading only where a command can actually start removes all of them without a denylist entry for any.
55
+
56
+ **Quoting and commenting nest, so neither can be stripped independently.** The comment on line 9 of `pre-push.local` ends `...the script's exit code)`. That lone apostrophe pairs with the next real quote 28 lines later, blanks everything between, and leaks a `node -e` payload's JavaScript into the results. Stripping comments first does not fix it, because a `#` inside a quoted string is not a comment. One character scanner tracking both is the only correct shape.
57
+
58
+ **`$( )` restarts the quoting context.** In `"$(printf '%s' "$JSON" | node -e '…')"`, quotes inside the substitution pair among themselves. A flat scanner falls out of phase on the first one and starts reporting the payload's own string literals.
59
+
60
+ Against Lisa and the three TunnlAI repositories, what survives is `gitleaks`, `jq`, `gtimeout` and `eas` — every one a real, undeclared invocation, with no false positives.
61
+
32
62
  ## What it will not do
33
63
 
34
64
  **It writes nothing and installs nothing.** Output is a proposal with `<pin>`, `<release url>` and `<sha256>` left for a human.
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Pull the commands out of a shell body.
3
+ *
4
+ * `lisa-detect-tooling` used to match script bodies against a fixed list of tool
5
+ * names, which meant it could only ever re-find tools someone had already
6
+ * thought of — the set that needs a detector least. An Expo project invoking
7
+ * `eas` from eight npm scripts produced nothing, because `eas` was not in the
8
+ * list.
9
+ *
10
+ * So the question this module answers is the open one: what does this shell text
11
+ * actually RUN? Everything downstream then subtracts what is already provided.
12
+ *
13
+ * ## Only the first word of a command position
14
+ *
15
+ * Naive word-scanning of this repository's own hooks proposed `ltrimstr`, `map`
16
+ * and `select` (jq filter internals), `console.log` and `process.exit` (embedded
17
+ * JavaScript), and `load_audit_cves` (a function the hook defines three lines
18
+ * up). None of those are tools; all of them appear where a word-scanner looks.
19
+ *
20
+ * A command runs at a command POSITION — the start of the text, or just after a
21
+ * separator. Reading only that position removes every one of those false
22
+ * positives without a denylist entry for any of them.
23
+ * @module commands
24
+ */
25
+
26
+ /**
27
+ * Shell keywords and coreutils: present on any machine, never provisioned.
28
+ *
29
+ * This is a floor, not a policy. Anything genuinely absent from a minimal
30
+ * container belongs in the manifest, and a few of these (`unzip`, `curl`) are
31
+ * already declared there by projects that need them — being listed here only
32
+ * means discovery will not RAISE them, not that they are assumed present.
33
+ */
34
+ export const SHELL_VOCABULARY = new Set([
35
+ // Keywords and builtins.
36
+ "if",
37
+ "then",
38
+ "else",
39
+ "elif",
40
+ "fi",
41
+ "for",
42
+ "while",
43
+ "until",
44
+ "do",
45
+ "done",
46
+ "case",
47
+ "esac",
48
+ "in",
49
+ "function",
50
+ "return",
51
+ "break",
52
+ "continue",
53
+ "exit",
54
+ "local",
55
+ "readonly",
56
+ "declare",
57
+ "export",
58
+ "unset",
59
+ "shift",
60
+ "eval",
61
+ "exec",
62
+ "source",
63
+ "set",
64
+ "trap",
65
+ "wait",
66
+ "read",
67
+ "echo",
68
+ "printf",
69
+ "cd",
70
+ "pwd",
71
+ "true",
72
+ "false",
73
+ "test",
74
+ "let",
75
+ "alias",
76
+ "shopt",
77
+ "getopts",
78
+ "umask",
79
+ // Coreutils and the usual POSIX furniture.
80
+ "cat",
81
+ "grep",
82
+ "egrep",
83
+ "fgrep",
84
+ "sed",
85
+ "awk",
86
+ "cut",
87
+ "tr",
88
+ "sort",
89
+ "uniq",
90
+ "head",
91
+ "tail",
92
+ "wc",
93
+ "tee",
94
+ "xargs",
95
+ "find",
96
+ "ls",
97
+ "mkdir",
98
+ "rmdir",
99
+ "rm",
100
+ "mv",
101
+ "cp",
102
+ "ln",
103
+ "touch",
104
+ "chmod",
105
+ "chown",
106
+ "stat",
107
+ "du",
108
+ "df",
109
+ "dirname",
110
+ "basename",
111
+ "realpath",
112
+ "readlink",
113
+ "mktemp",
114
+ "date",
115
+ "sleep",
116
+ "seq",
117
+ "env",
118
+ "kill",
119
+ "pgrep",
120
+ "pkill",
121
+ "ps",
122
+ "diff",
123
+ "comm",
124
+ "join",
125
+ "paste",
126
+ "split",
127
+ "tar",
128
+ "gzip",
129
+ "gunzip",
130
+ "zip",
131
+ "unzip",
132
+ "curl",
133
+ "wget",
134
+ "sh",
135
+ "bash",
136
+ "zsh",
137
+ "dash",
138
+ "nohup",
139
+ "timeout",
140
+ "yes",
141
+ "tty",
142
+ "id",
143
+ "whoami",
144
+ "uname",
145
+ "hostname",
146
+ ]);
147
+
148
+ /**
149
+ * Runtimes and package managers whose presence is assumed by everything here.
150
+ *
151
+ * Separate from the vocabulary above because these are genuinely provisioned —
152
+ * just not by this detector. `node` is what runs it; proposing it would be
153
+ * circular.
154
+ */
155
+ export const RUNTIMES = new Set([
156
+ "node",
157
+ "npm",
158
+ "npx",
159
+ "bun",
160
+ "bunx",
161
+ "yarn",
162
+ "pnpm",
163
+ "pnpx",
164
+ "corepack",
165
+ "git",
166
+ "python",
167
+ "python3",
168
+ "pip",
169
+ "pip3",
170
+ "ruby",
171
+ "bundle",
172
+ "go",
173
+ "cargo",
174
+ "docker",
175
+ "make",
176
+ ]);
177
+
178
+ /**
179
+ * Commands that RESOLVE the next word from `node_modules`.
180
+ *
181
+ * `npx playwright test` needs no manifest entry: npm put the binary in
182
+ * `node_modules/.bin` and npx finds it there. Reading the mediated word as a
183
+ * tool would propose pinning something the package manager already provides —
184
+ * the noise that teaches a reader to skim the output.
185
+ */
186
+ const MEDIATORS = new Set(["npx", "bunx", "pnpx", "dlx"]);
187
+
188
+ /** Sub-commands after a package manager that mean "run something local". */
189
+ const LOCAL_RUNNERS = new Set(["run", "exec", "x", "dlx", "run-script"]);
190
+
191
+ /**
192
+ * Commands that TEST for another command rather than running it.
193
+ *
194
+ * `command -v gitleaks` is the single most valuable signal a hook can carry, and
195
+ * a word-scanner reading only the first position would throw it away. It is also
196
+ * the shape of the worst failure mode in this codebase: a hook that guards its
197
+ * own tool with `command -v` SKIPS SILENTLY when the tool is absent. The commit
198
+ * succeeds and nothing was scanned.
199
+ *
200
+ * A guarded tool is therefore stronger evidence than an unguarded one, not
201
+ * weaker — its absence is invisible at the moment it matters.
202
+ */
203
+ const PROBES = new Set(["command", "which", "type", "hash"]);
204
+
205
+ /**
206
+ * Words that stand in FRONT of a command without being one.
207
+ *
208
+ * Control keywords have to be skipped past rather than treated as the command,
209
+ * or the very shape this exists to catch is missed: in
210
+ * `if ! command -v gitleaks; then`, stopping at `if` never reaches the probe.
211
+ */
212
+ const PREFIXES = new Set([
213
+ "!",
214
+ "sudo",
215
+ "time",
216
+ "nice",
217
+ "builtin",
218
+ "exec",
219
+ "if",
220
+ "then",
221
+ "elif",
222
+ "else",
223
+ "while",
224
+ "until",
225
+ "do",
226
+ ]);
227
+
228
+ /** A name that could plausibly be an executable on PATH. */
229
+ const TOOL_NAME = /^[a-z][a-z0-9_-]*$/u;
230
+
231
+ /**
232
+ * Names the text defines as shell functions, which are never PATH tools.
233
+ *
234
+ * This repository's `pre-push` hook defines `load_audit_cves` and
235
+ * `missing_opencode_metadata` and then calls them, which reads exactly like a
236
+ * command invocation because it is one — just not of anything installable.
237
+ * @param {string} text Shell body.
238
+ * @returns {Set<string>} Locally defined function names.
239
+ */
240
+ export function localFunctions(text) {
241
+ const names = new Set();
242
+ const pattern =
243
+ /(?:^|\n)\s*(?:function\s+)?([a-z_][a-z0-9_]*)\s*\(\)\s*\{/giu;
244
+ let match = pattern.exec(text);
245
+ while (match) {
246
+ names.add(match[1].toLowerCase());
247
+ match = pattern.exec(text);
248
+ }
249
+ return names;
250
+ }
251
+
252
+ /**
253
+ * Pull out `sh -c '...'` payloads so their contents are parsed as shell too.
254
+ *
255
+ * Done BEFORE quoted spans are stripped, because the payload is precisely a
256
+ * quoted span — and it is the one kind that really is a command. Frontend's
257
+ * `eas:publish:e2e` script wraps its `eas update` in exactly this shape.
258
+ * @param {string} text Shell body.
259
+ * @returns {{stripped: string, payloads: string[]}} Text and nested bodies.
260
+ */
261
+ export function extractShellPayloads(text) {
262
+ const payloads = [];
263
+ const stripped = text.replace(
264
+ /\b(?:sh|bash|zsh)\s+-[a-z]*c\s+('([^']*)'|"([^"]*)")/gu,
265
+ (_all, _quoted, single, double) => {
266
+ payloads.push(single ?? double ?? "");
267
+ return " ";
268
+ }
269
+ );
270
+ return { stripped, payloads };
271
+ }
272
+
273
+ /**
274
+ * Blank out quoted spans and comments, keeping only text at command level.
275
+ *
276
+ * Quoted text is where embedded programs live — the jq filters and `node -e`
277
+ * snippets that produced `ltrimstr` and `console.log`. A command inside quotes
278
+ * is an argument to something else, with one exception, which
279
+ * `extractShellPayloads` has already lifted out by the time this runs.
280
+ *
281
+ * This is a character scanner rather than a chain of regexes because quoting and
282
+ * commenting are MUTUALLY nested and no ordering of independent passes gets it
283
+ * right. This repository's own `pre-push.local` proves it: the comment on line 9
284
+ * ends `...the script's exit code)`, and that lone apostrophe pairs with the
285
+ * next real quote 28 lines later, blanking everything between — including the
286
+ * `node -e` payload, whose JavaScript then leaked out as `try`, `catch`,
287
+ * `const` and `unreadable` proposals.
288
+ *
289
+ * Stripping comments first does not fix it either, since a `#` inside a quoted
290
+ * string is not a comment. One pass that tracks both is the only correct shape.
291
+ *
292
+ * Newlines survive so command positions are preserved; everything else inside a
293
+ * masked span becomes a space.
294
+ * @param {string} text Shell body.
295
+ * @returns {string} Text with non-command spans blanked.
296
+ */
297
+ export function stripNonCommandSpans(text) {
298
+ const out = [];
299
+ let state = "normal";
300
+ // `$( )` restarts the quoting context: inside it, quotes pair among
301
+ // themselves rather than continuing the enclosing span. A flat scanner falls
302
+ // out of phase on the very first one — this repository's
303
+ // `"$(printf '%s' "$JSON" | node -e '...')"` left the payload's own string
304
+ // literals exposed at command level, proposing `data`, `end` and `stale`.
305
+ const enclosing = [];
306
+
307
+ for (let index = 0; index < text.length; index += 1) {
308
+ const char = text[index];
309
+ const previous = index > 0 ? text[index - 1] : "";
310
+
311
+ // A substitution opens a fresh context from inside ANY state but a
312
+ // single-quoted one, where shell performs no substitution at all.
313
+ if (
314
+ state !== "single" &&
315
+ state !== "comment" &&
316
+ char === "$" &&
317
+ text[index + 1] === "("
318
+ ) {
319
+ enclosing.push(state);
320
+ state = "normal";
321
+ out.push("\n");
322
+ index += 1;
323
+ continue;
324
+ }
325
+ if (state === "normal" && char === ")" && enclosing.length > 0) {
326
+ state = enclosing.pop();
327
+ out.push("\n");
328
+ continue;
329
+ }
330
+
331
+ if (state === "normal") {
332
+ if (char === "\\") {
333
+ // An escaped character is never a delimiter. Skip both.
334
+ out.push(" ");
335
+ index += 1;
336
+ if (text[index] === "\n") out.push("\n");
337
+ else out.push(" ");
338
+ continue;
339
+ }
340
+ if (char === "'") {
341
+ state = "single";
342
+ out.push(" ");
343
+ continue;
344
+ }
345
+ if (char === '"') {
346
+ state = "double";
347
+ out.push(" ");
348
+ continue;
349
+ }
350
+ // A `#` only opens a comment at the start of a word.
351
+ if (char === "#" && (previous === "" || /\s/u.test(previous))) {
352
+ state = "comment";
353
+ out.push(" ");
354
+ continue;
355
+ }
356
+ out.push(char);
357
+ continue;
358
+ }
359
+
360
+ if (state === "comment") {
361
+ if (char === "\n") {
362
+ state = "normal";
363
+ out.push("\n");
364
+ continue;
365
+ }
366
+ out.push(" ");
367
+ continue;
368
+ }
369
+
370
+ // Inside quotes. A single-quoted span has no escapes at all in shell.
371
+ if (state === "double" && char === "\\") {
372
+ out.push(" ");
373
+ index += 1;
374
+ out.push(text[index] === "\n" ? "\n" : " ");
375
+ continue;
376
+ }
377
+ if (
378
+ (state === "single" && char === "'") ||
379
+ (state === "double" && char === '"')
380
+ ) {
381
+ state = "normal";
382
+ out.push(" ");
383
+ continue;
384
+ }
385
+ out.push(char === "\n" ? "\n" : " ");
386
+ }
387
+
388
+ // Redirections last, on text that is already command-level.
389
+ return out.join("").replace(/\d?[<>]+&?\S*/gu, " ");
390
+ }
391
+
392
+ /**
393
+ * Whether a word can be a tool this detector should raise.
394
+ *
395
+ * One gate, applied to every path into the results. Probed names used to skip
396
+ * it, which is how `command -v bun` — a hook checking its own runtime — became
397
+ * a proposal to pin bun on every repository scanned.
398
+ * @param {string} word Candidate command name.
399
+ * @param {Set<string>} defined Function names the text defines itself.
400
+ * @returns {boolean} Whether to raise it.
401
+ */
402
+ function admissible(word, defined) {
403
+ // A path is not a PATH lookup: `./scripts/check.sh` is project code.
404
+ if (word.includes("/") || word.includes("$")) return false;
405
+ if (!TOOL_NAME.test(word)) return false;
406
+ if (SHELL_VOCABULARY.has(word)) return false;
407
+ // `bun run build` resolves locally, and the runtime itself is assumed.
408
+ if (RUNTIMES.has(word)) return false;
409
+ return !defined.has(word);
410
+ }
411
+
412
+ /**
413
+ * Read the tool a probe is testing for.
414
+ * @param {string[]} words Words after the probe.
415
+ * @returns {string|null} The probed name, or null.
416
+ */
417
+ function probedTool(words) {
418
+ for (const word of words) {
419
+ if (word.startsWith("-")) continue;
420
+ return TOOL_NAME.test(word) ? word : null;
421
+ }
422
+ return null;
423
+ }
424
+
425
+ /**
426
+ * Every command this shell text runs, at a command position.
427
+ * @param {string} text Shell body.
428
+ * @returns {string[]} Command names, deduplicated, in first-seen order.
429
+ */
430
+ export function commandsIn(text) {
431
+ if (typeof text !== "string" || text.trim() === "") return [];
432
+
433
+ const { stripped, payloads } = extractShellPayloads(text);
434
+ const defined = localFunctions(text);
435
+ const found = new Set();
436
+
437
+ // Nested `sh -c` bodies are shell in their own right, so they get the same
438
+ // treatment rather than a weaker one.
439
+ for (const payload of payloads) {
440
+ for (const command of commandsIn(payload)) found.add(command);
441
+ }
442
+
443
+ const segments = stripNonCommandSpans(stripped).split(
444
+ /\|\||&&|\$\(|[;|&\n(){}`]/u
445
+ );
446
+
447
+ for (const segment of segments) {
448
+ const words = segment.trim().split(/\s+/u).filter(Boolean);
449
+ let index = 0;
450
+ // Environment assignments and decorations sit in front of the command.
451
+ while (
452
+ index < words.length &&
453
+ (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[index]) ||
454
+ PREFIXES.has(words[index]))
455
+ ) {
456
+ index += 1;
457
+ }
458
+
459
+ const word = words[index]?.toLowerCase();
460
+ if (!word) continue;
461
+
462
+ if (PROBES.has(word)) {
463
+ // `command -v gitleaks` — the probed name is the evidence. It goes
464
+ // through the SAME filters as a directly invoked command: reading it
465
+ // straight into the results proposed `bun` and `yarn` on every repo,
466
+ // because `command -v bun` is how a hook checks its own runtime.
467
+ const probed = probedTool(words.slice(index + 1));
468
+ if (probed && admissible(probed, defined)) found.add(probed);
469
+ continue;
470
+ }
471
+ if (MEDIATORS.has(word)) continue;
472
+ if (LOCAL_RUNNERS.has(word)) continue;
473
+ if (!admissible(word, defined)) continue;
474
+
475
+ found.add(word);
476
+ }
477
+
478
+ return [...found];
479
+ }