@brainervirus/workit-core 8.15.1 → 8.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/workit-fanout/SKILL.md +7 -7
- package/skills/workit-fanout/references/brief.md +3 -1
- package/skills/workit-implement/SKILL.md +8 -1
- package/skills/workit-review/SKILL.md +2 -1
- package/skills/workit-verify-app/SKILL.md +2 -0
- package/src/check-config.ts +21 -1
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +92 -0
- package/src/core/heavy.ts +542 -0
- package/src/core/resources.ts +32 -76
- package/src/core/task-contract.ts +21 -0
- package/src/core/task-engine.ts +14 -2
- package/src/core/task-evaluation.ts +71 -0
- package/src/core/workspaces.ts +22 -0
- package/src/fanout-brief.ts +2 -0
- package/src/fanout.ts +26 -2
- package/src/hooks/descriptor.ts +2 -0
- package/src/hooks/handle.ts +32 -3
- package/src/hooks/heavy-gate.ts +91 -0
- package/src/hooks/hosts/claude-code.ts +34 -3
- package/src/hooks/hosts/codex.ts +6 -1
- package/src/hooks/hosts/cursor.ts +1 -1
- package/src/hooks/hosts/opencode.ts +1 -1
- package/src/hooks/hosts/pi.ts +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/protocol.ts +8 -0
- package/src/hooks/raw-git.ts +104 -2
- package/src/hooks/write-gate.ts +42 -2
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The hook launchers' pre-check for the heavy-command gate (#325): could
|
|
2
|
+
// this shell command be heavy? A launcher answers light commands before the
|
|
3
|
+
// Workit runtime loads, so this only looks for a catalog word (core/heavy.ts
|
|
4
|
+
// TOOLS plus the runners it looks through) or the first word of a machine or
|
|
5
|
+
// repo pattern, with node built-ins. A false yes only costs a runtime load.
|
|
6
|
+
// packages/workit-claude-code/bin/workit-hook.mjs keeps a copy (it cannot
|
|
7
|
+
// import TypeScript); test/workit-core/heavy-words.test.ts keeps them equal.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { repoHeavyCommands } from "../check-config";
|
|
12
|
+
|
|
13
|
+
export const HEAVY_WORDS: ReadonlySet<string> = new Set(
|
|
14
|
+
(
|
|
15
|
+
"npm npx pnpm pnpx yarn bun bunx deno corepack make gmake docker podman nerdctl " +
|
|
16
|
+
"python python3 py uv poetry pdm pipenv hatch rye bundle rake nx " +
|
|
17
|
+
"jest vitest mocha ava tap karma jasmine nyc c8 tsc vue-tsc svelte-check eslint stylelint " +
|
|
18
|
+
"tslint webpack webpack-cli rollup parcel tsup vite next nuxt nuxi astro ng react-scripts " +
|
|
19
|
+
"playwright cypress wdio testcafe turbo lerna sonar-scanner sonarless sonarless-mcp go " +
|
|
20
|
+
"golangci-lint gotestsum staticcheck cargo mvn mvnw gradle gradlew ant sbt bazel bazelisk " +
|
|
21
|
+
"buck2 dotnet msbuild pytest py.test tox nox mypy pyright pylint coverage maturin pip pip3 " +
|
|
22
|
+
"rspec rails phpunit pest phpstan psalm composer mix swift xcodebuild flutter dart pod cmake " +
|
|
23
|
+
"ninja meson ctest scons docker-compose buildah earthly"
|
|
24
|
+
).split(" "),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const wordsOf = (command: string): string[] =>
|
|
28
|
+
command
|
|
29
|
+
.split(/[\s;&|()<>`'"]+/u)
|
|
30
|
+
.map((word) =>
|
|
31
|
+
(word.split(/[\\/]/u).at(-1) ?? "").toLowerCase().replace(/\.(?:exe|cmd|bat)$/u, ""),
|
|
32
|
+
)
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
|
|
35
|
+
const stateDir = (env: NodeJS.ProcessEnv): string => {
|
|
36
|
+
if (env.WORKFLOW_TOOLKIT_STATE) return env.WORKFLOW_TOOLKIT_STATE;
|
|
37
|
+
if (env.XDG_STATE_HOME) return path.join(env.XDG_STATE_HOME, "workit");
|
|
38
|
+
const home = env.HOME || os.homedir();
|
|
39
|
+
if (process.platform === "darwin")
|
|
40
|
+
return path.join(home, "Library", "Application Support", "workit");
|
|
41
|
+
if (process.platform === "win32")
|
|
42
|
+
return path.join(env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "workit");
|
|
43
|
+
return path.join(home, ".local", "state", "workit");
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const readJson = (file: string): unknown => {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** First words of the machine and repo patterns; null when one could match anything. */
|
|
55
|
+
const patternWords = (cwd: string, env: NodeJS.ProcessEnv): Set<string> | null => {
|
|
56
|
+
const patterns: unknown[] = [];
|
|
57
|
+
const machine = readJson(path.join(stateDir(env), "heavy-commands.json")) as
|
|
58
|
+
| { patterns?: unknown }
|
|
59
|
+
| undefined;
|
|
60
|
+
if (Array.isArray(machine?.patterns)) patterns.push(...machine.patterns);
|
|
61
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
62
|
+
if (fs.existsSync(path.join(dir, ".git"))) {
|
|
63
|
+
patterns.push(...repoHeavyCommands(dir));
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
if (path.dirname(dir) === dir) break;
|
|
67
|
+
}
|
|
68
|
+
const words = new Set<string>();
|
|
69
|
+
for (const pattern of patterns) {
|
|
70
|
+
if (typeof pattern !== "string") continue;
|
|
71
|
+
const first = wordsOf(pattern)[0];
|
|
72
|
+
if (!first || first.includes("*")) return null;
|
|
73
|
+
words.add(first);
|
|
74
|
+
}
|
|
75
|
+
return words;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Could `command` be heavy? Never throws; a doubt answers yes. */
|
|
79
|
+
export function maybeHeavy(
|
|
80
|
+
command: string,
|
|
81
|
+
cwd: string,
|
|
82
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
83
|
+
): boolean {
|
|
84
|
+
try {
|
|
85
|
+
const words = wordsOf(command);
|
|
86
|
+
if (words.some((word) => HEAVY_WORDS.has(word) || /^python[\d.]*$/u.test(word))) return true;
|
|
87
|
+
const extra = patternWords(cwd, env);
|
|
88
|
+
return extra === null || words.some((word) => extra.has(word));
|
|
89
|
+
} catch {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
// Heavy commands (issue #325): which shell commands must run under the
|
|
2
|
+
// machine-wide heavy-check lease (core/resources.ts). The shell hook asks
|
|
3
|
+
// `heavyCommand` about every command an agent runs; on a heavy one it
|
|
4
|
+
// rewrites the command through `workit check --heavy` (hosts that can
|
|
5
|
+
// rewrite) or denies it with that exact rewrite (hosts that cannot).
|
|
6
|
+
//
|
|
7
|
+
// The built-in catalog covers test runners, builds and bundlers, lint and
|
|
8
|
+
// typecheck, installs, image builds, Sonar and browser test runners across
|
|
9
|
+
// JS/Go/Rust/Java/.NET/Python/Ruby/PHP/Elixir/Swift/C. Wrappers are seen
|
|
10
|
+
// through: package runners (`npm run`, `npx`, `pnpm exec`, `uv run`,
|
|
11
|
+
// `bundle exec`, `python -m`), `make` targets, and everything the hooks'
|
|
12
|
+
// shell reader already unwraps (`bash -c`, `flock`, `nice`, `env`, `timeout`,
|
|
13
|
+
// `xargs`, chains and substitutions).
|
|
14
|
+
//
|
|
15
|
+
// Extensions, both adding only: patterns in `resources.heavyCommands` of the
|
|
16
|
+
// repo's checks config (check-config.ts), and machine-wide patterns `workit lock heavy add`
|
|
17
|
+
// writes to `<state>/heavy-commands.json`. A pattern is words matched as a
|
|
18
|
+
// prefix of a command line (`mvn verify`), `*` inside a word matching
|
|
19
|
+
// anything (`test:*`).
|
|
20
|
+
//
|
|
21
|
+
// Long-running servers (`npm run dev`, `vite`, `next dev`, `docker compose
|
|
22
|
+
// up`) are not heavy here: a lease held for a server's lifetime would starve
|
|
23
|
+
// every other heavy check. They belong to the dev-server registry (#328).
|
|
24
|
+
//
|
|
25
|
+
// Hook-safe: node built-ins and the shell reader only, no spawn.
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { commandLines, simpleCommand } from "../hooks/raw-git";
|
|
30
|
+
import type { ShellDialect } from "../hooks/shell-words";
|
|
31
|
+
import { repoHeavyCommands } from "../check-config";
|
|
32
|
+
import { gitTop, wrapAllowed, type RuleRead } from "./heavy-rules";
|
|
33
|
+
|
|
34
|
+
/** Workit's state dir for `env` (the logger's resolution, over an explicit env). */
|
|
35
|
+
export function stateDir(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform) {
|
|
36
|
+
if (env.WORKFLOW_TOOLKIT_STATE) return env.WORKFLOW_TOOLKIT_STATE;
|
|
37
|
+
if (env.XDG_STATE_HOME) return path.join(env.XDG_STATE_HOME, "workit");
|
|
38
|
+
const home = env.HOME || os.homedir();
|
|
39
|
+
if (platform === "darwin") return path.join(home, "Library", "Application Support", "workit");
|
|
40
|
+
if (platform === "win32")
|
|
41
|
+
return path.join(env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "workit");
|
|
42
|
+
return path.join(home, ".local", "state", "workit");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const HEAVY_COMMANDS_FILE = "heavy-commands.json";
|
|
46
|
+
export const heavyCommandsFile = (env: NodeJS.ProcessEnv): string =>
|
|
47
|
+
path.join(stateDir(env), HEAVY_COMMANDS_FILE);
|
|
48
|
+
|
|
49
|
+
/** Script and target names that test, lint, typecheck, build or serve. */
|
|
50
|
+
const HEAVY_SCRIPT =
|
|
51
|
+
/^(?:test|tests|t|tst|unit|integration|e2e|coverage|cov|lint|eslint|typecheck|type-check|types|tsc|check|verify|validate|build|compile|bundle|ci|sonar|cypress|playwright|jest|vitest|mocha|bench|benchmark|all)(?:$|[:_.-])/iu;
|
|
52
|
+
|
|
53
|
+
const INFO_FLAGS = new Set(["--version", "-v", "-V", "--help", "-h", "help", "version"]);
|
|
54
|
+
|
|
55
|
+
/** Watch modes and scripts never end: they would hold the lease for good (#328). */
|
|
56
|
+
const WATCH_FLAG = /^(?:--watch(?:All)?(?:=.*)?|-w|--serve)$/u;
|
|
57
|
+
const WATCH_SCRIPT = /(?:^|[:_.-])(?:watch|dev|serve|start|storybook)(?:$|[:_.-])/iu;
|
|
58
|
+
|
|
59
|
+
type Rule = "any" | ReadonlySet<string>;
|
|
60
|
+
const sub = (...names: string[]): Rule => new Set(names);
|
|
61
|
+
|
|
62
|
+
/** Tools whose run is heavy: always (`any`), or when the subcommand is listed. */
|
|
63
|
+
const TOOLS: Record<string, Rule> = {
|
|
64
|
+
// JS test runners, typecheck, lint, bundlers, frameworks, browsers
|
|
65
|
+
jest: "any",
|
|
66
|
+
vitest: "any",
|
|
67
|
+
mocha: "any",
|
|
68
|
+
ava: "any",
|
|
69
|
+
tap: "any",
|
|
70
|
+
karma: "any",
|
|
71
|
+
jasmine: "any",
|
|
72
|
+
nyc: "any",
|
|
73
|
+
c8: "any",
|
|
74
|
+
tsc: "any",
|
|
75
|
+
"vue-tsc": "any",
|
|
76
|
+
"svelte-check": "any",
|
|
77
|
+
eslint: "any",
|
|
78
|
+
stylelint: "any",
|
|
79
|
+
tslint: "any",
|
|
80
|
+
webpack: "any",
|
|
81
|
+
"webpack-cli": "any",
|
|
82
|
+
rollup: "any",
|
|
83
|
+
parcel: "any",
|
|
84
|
+
tsup: "any",
|
|
85
|
+
vite: sub("build", "optimize"),
|
|
86
|
+
next: sub("build", "lint"),
|
|
87
|
+
nuxt: sub("build", "generate"),
|
|
88
|
+
nuxi: sub("build", "generate"),
|
|
89
|
+
astro: sub("build", "check"),
|
|
90
|
+
ng: sub("build", "b", "test", "t", "e2e", "lint"),
|
|
91
|
+
"react-scripts": sub("build", "test"),
|
|
92
|
+
playwright: sub("test", "install"),
|
|
93
|
+
cypress: sub("run"),
|
|
94
|
+
wdio: "any",
|
|
95
|
+
testcafe: "any",
|
|
96
|
+
turbo: "any",
|
|
97
|
+
lerna: sub("run", "exec", "bootstrap"),
|
|
98
|
+
"sonar-scanner": "any",
|
|
99
|
+
sonarless: sub("scan"),
|
|
100
|
+
"sonarless-mcp": sub("scan"),
|
|
101
|
+
// Go
|
|
102
|
+
go: sub("test", "build", "vet", "install", "generate"),
|
|
103
|
+
"golangci-lint": sub("run"),
|
|
104
|
+
gotestsum: "any",
|
|
105
|
+
staticcheck: "any",
|
|
106
|
+
// Rust
|
|
107
|
+
cargo: sub(
|
|
108
|
+
"build",
|
|
109
|
+
"b",
|
|
110
|
+
"test",
|
|
111
|
+
"t",
|
|
112
|
+
"check",
|
|
113
|
+
"c",
|
|
114
|
+
"clippy",
|
|
115
|
+
"bench",
|
|
116
|
+
"nextest",
|
|
117
|
+
"doc",
|
|
118
|
+
"install",
|
|
119
|
+
"tarpaulin",
|
|
120
|
+
"llvm-cov",
|
|
121
|
+
),
|
|
122
|
+
// JVM
|
|
123
|
+
mvn: "any",
|
|
124
|
+
mvnw: "any",
|
|
125
|
+
gradle: "any",
|
|
126
|
+
gradlew: "any",
|
|
127
|
+
ant: "any",
|
|
128
|
+
sbt: "any",
|
|
129
|
+
bazel: sub("build", "test", "run", "coverage"),
|
|
130
|
+
bazelisk: sub("build", "test", "run", "coverage"),
|
|
131
|
+
buck2: sub("build", "test", "run"),
|
|
132
|
+
// .NET
|
|
133
|
+
dotnet: sub("build", "test", "publish", "restore", "pack", "msbuild"),
|
|
134
|
+
msbuild: "any",
|
|
135
|
+
// Python
|
|
136
|
+
pytest: "any",
|
|
137
|
+
"py.test": "any",
|
|
138
|
+
tox: "any",
|
|
139
|
+
nox: "any",
|
|
140
|
+
mypy: "any",
|
|
141
|
+
pyright: "any",
|
|
142
|
+
pylint: "any",
|
|
143
|
+
coverage: sub("run"),
|
|
144
|
+
maturin: sub("build", "develop"),
|
|
145
|
+
pip: sub("install", "wheel"),
|
|
146
|
+
pip3: sub("install", "wheel"),
|
|
147
|
+
// Ruby, PHP, Elixir, Swift, Dart
|
|
148
|
+
rspec: "any",
|
|
149
|
+
|
|
150
|
+
rails: sub("test", "assets:precompile"),
|
|
151
|
+
phpunit: "any",
|
|
152
|
+
pest: "any",
|
|
153
|
+
phpstan: "any",
|
|
154
|
+
psalm: "any",
|
|
155
|
+
composer: sub("install", "update", "require", "test"),
|
|
156
|
+
mix: sub("test", "compile", "deps.get", "dialyzer", "credo"),
|
|
157
|
+
swift: sub("build", "test"),
|
|
158
|
+
xcodebuild: "any",
|
|
159
|
+
flutter: sub("build", "test"),
|
|
160
|
+
dart: sub("test", "compile"),
|
|
161
|
+
pod: sub("install"),
|
|
162
|
+
// C and C++
|
|
163
|
+
cmake: sub("--build"),
|
|
164
|
+
ninja: "any",
|
|
165
|
+
meson: sub("compile", "test"),
|
|
166
|
+
ctest: "any",
|
|
167
|
+
scons: "any",
|
|
168
|
+
// Containers
|
|
169
|
+
"docker-compose": sub("build"),
|
|
170
|
+
buildah: sub("bud", "build"),
|
|
171
|
+
earthly: "any",
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** Options of package managers that take a value before the subcommand. */
|
|
175
|
+
const PM_VALUES = new Set([
|
|
176
|
+
"--prefix",
|
|
177
|
+
"-w",
|
|
178
|
+
"--workspace",
|
|
179
|
+
"-C",
|
|
180
|
+
"--dir",
|
|
181
|
+
"--filter",
|
|
182
|
+
"-F",
|
|
183
|
+
"--cwd",
|
|
184
|
+
"-p",
|
|
185
|
+
"--package",
|
|
186
|
+
"--cache",
|
|
187
|
+
"--registry",
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
/** The index of the first positional word after `from`, skipping options and their values. */
|
|
191
|
+
function positional(words: string[], from: number, values: ReadonlySet<string> = PM_VALUES) {
|
|
192
|
+
let index = from;
|
|
193
|
+
while (index < words.length && words[index].startsWith("-")) {
|
|
194
|
+
if (words[index] === "--") return index + 1;
|
|
195
|
+
index += values.has(words[index]) ? 2 : 1;
|
|
196
|
+
}
|
|
197
|
+
return index;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const baseName = (word: string | undefined): string =>
|
|
201
|
+
(word?.split(/[\\/]/u).at(-1) ?? "").toLowerCase().replace(/\.(?:exe|cmd|bat|ps1)$/u, "");
|
|
202
|
+
|
|
203
|
+
const WORKIT = /^(?:workit|@brainervirus\/workit-cli(?:@.*)?)$/u;
|
|
204
|
+
const PYTHONS = /^(?:python[\d.]*|py)$/u;
|
|
205
|
+
|
|
206
|
+
const NPM_INSTALL = new Set([
|
|
207
|
+
"ci",
|
|
208
|
+
"clean-install",
|
|
209
|
+
"cit",
|
|
210
|
+
"install-test",
|
|
211
|
+
"it",
|
|
212
|
+
"install",
|
|
213
|
+
"i",
|
|
214
|
+
"in",
|
|
215
|
+
"add",
|
|
216
|
+
"rebuild",
|
|
217
|
+
"update",
|
|
218
|
+
"up",
|
|
219
|
+
]);
|
|
220
|
+
const NPM_RUN = new Set(["run", "run-script", "rum", "urn"]);
|
|
221
|
+
const NPM_TEST = new Set(["test", "t", "tst"]);
|
|
222
|
+
const EXEC = new Set(["exec", "x", "dlx"]);
|
|
223
|
+
|
|
224
|
+
/** Why `words` (one command line, prefixes stripped) is heavy, or null. */
|
|
225
|
+
function builtIn(words: string[], depth = 0): string | null {
|
|
226
|
+
if (depth > 6 || !words.length) return null;
|
|
227
|
+
const name = baseName(words[0]);
|
|
228
|
+
const label = () => words.slice(0, 3).join(" ");
|
|
229
|
+
const inner = (from: number) => builtIn(words.slice(from), depth + 1);
|
|
230
|
+
if (WORKIT.test(name)) return null;
|
|
231
|
+
if (INFO_FLAGS.has(words[1] ?? "") && words.length === 2) return null;
|
|
232
|
+
if (
|
|
233
|
+
words.some((word) => word === "--watch" || word.startsWith("--watch=") || word === "--watchAll")
|
|
234
|
+
)
|
|
235
|
+
return null;
|
|
236
|
+
if (name === "npx" || name === "bunx" || name === "pnpx") return inner(positional(words, 1));
|
|
237
|
+
if (name === "corepack") return inner(1);
|
|
238
|
+
if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun" || name === "deno") {
|
|
239
|
+
const at = positional(words, 1);
|
|
240
|
+
const command = words[at];
|
|
241
|
+
if (command === undefined) return name === "yarn" ? "yarn (install)" : null;
|
|
242
|
+
if (name === "deno") {
|
|
243
|
+
if (["test", "bench", "check", "compile", "lint"].includes(command)) return label();
|
|
244
|
+
if (command === "task" && HEAVY_SCRIPT.test(words[at + 1] ?? "")) return label();
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
if (NPM_INSTALL.has(command) || NPM_TEST.has(command)) return label();
|
|
248
|
+
if (name === "bun" && command === "build") return label();
|
|
249
|
+
if (EXEC.has(command)) return inner(positional(words, at + 1));
|
|
250
|
+
if (name === "yarn" && command === "workspace") return inner(at + 2);
|
|
251
|
+
if (name === "yarn" && command === "workspaces")
|
|
252
|
+
return words.slice(at + 1).some((word) => HEAVY_SCRIPT.test(word)) ? label() : null;
|
|
253
|
+
if (NPM_RUN.has(command)) {
|
|
254
|
+
const script = words[positional(words, at + 1)];
|
|
255
|
+
if (script === undefined || WATCH_SCRIPT.test(script)) return null;
|
|
256
|
+
if (HEAVY_SCRIPT.test(script)) return label();
|
|
257
|
+
return name === "bun" ? inner(positional(words, at + 1)) : null;
|
|
258
|
+
}
|
|
259
|
+
if (name === "npm") return null;
|
|
260
|
+
// `pnpm <script>`, `yarn <script>`, `bun <script>`, or a package binary (`yarn jest`).
|
|
261
|
+
if (WATCH_SCRIPT.test(command)) return null;
|
|
262
|
+
if (HEAVY_SCRIPT.test(command) && !/\.[cm]?[jt]sx?$/u.test(command)) return label();
|
|
263
|
+
return inner(at);
|
|
264
|
+
}
|
|
265
|
+
if (PYTHONS.test(name)) {
|
|
266
|
+
const at = words.indexOf("-m");
|
|
267
|
+
return at > 0 && words[at + 1] ? inner(at + 1) : null;
|
|
268
|
+
}
|
|
269
|
+
if (["uv", "poetry", "pdm", "pipenv", "hatch", "rye"].includes(name)) {
|
|
270
|
+
const at = positional(words, 1);
|
|
271
|
+
const command = words[at];
|
|
272
|
+
if (command === "run") return inner(positional(words, at + 1));
|
|
273
|
+
if (command === "pip") return inner(at);
|
|
274
|
+
return ["sync", "install", "build", "test", "add"].includes(command ?? "") ? label() : null;
|
|
275
|
+
}
|
|
276
|
+
if (name === "bundle") {
|
|
277
|
+
const command = words[positional(words, 1)];
|
|
278
|
+
if (command === "exec") return inner(positional(words, 2));
|
|
279
|
+
return command === "install" || command === undefined ? label() : null;
|
|
280
|
+
}
|
|
281
|
+
if (name === "make" || name === "gmake") {
|
|
282
|
+
const targets = words.slice(1).filter((word) => !word.startsWith("-") && !word.includes("="));
|
|
283
|
+
if (targets.some((target) => WATCH_SCRIPT.test(target))) return null;
|
|
284
|
+
return !targets.length || targets.some((target) => HEAVY_SCRIPT.test(target)) ? label() : null;
|
|
285
|
+
}
|
|
286
|
+
if (name === "rake") {
|
|
287
|
+
const targets = words.slice(1).filter((word) => !word.startsWith("-") && !word.includes("="));
|
|
288
|
+
return targets.some((target) => HEAVY_SCRIPT.test(target) || /^spec(?:$|:)/u.test(target))
|
|
289
|
+
? label()
|
|
290
|
+
: null;
|
|
291
|
+
}
|
|
292
|
+
if (name === "nx") {
|
|
293
|
+
const at = positional(words, 1);
|
|
294
|
+
const command = words[at] ?? "";
|
|
295
|
+
if (["run-many", "affected"].includes(command))
|
|
296
|
+
return words.some((word) => /^(?:-t|--targets?)(?:=|$)/u.test(word)) &&
|
|
297
|
+
!words.some((word) => WATCH_SCRIPT.test(word.replace(/^--?targets?=/u, "")))
|
|
298
|
+
? label()
|
|
299
|
+
: null;
|
|
300
|
+
if (command === "run") {
|
|
301
|
+
const target = (words[at + 1] ?? "").split(":")[1] ?? "";
|
|
302
|
+
return HEAVY_SCRIPT.test(target) && !WATCH_SCRIPT.test(target) ? label() : null;
|
|
303
|
+
}
|
|
304
|
+
return HEAVY_SCRIPT.test(command) && !WATCH_SCRIPT.test(command) ? label() : null;
|
|
305
|
+
}
|
|
306
|
+
if (name === "docker" || name === "podman" || name === "nerdctl") {
|
|
307
|
+
const at = positional(words, 1, new Set(["-H", "--host", "--context", "-c", "--config"]));
|
|
308
|
+
const command = words[at];
|
|
309
|
+
if (command === "build") return label();
|
|
310
|
+
if (command === "buildx" || command === "image" || command === "builder")
|
|
311
|
+
return words[positional(words, at + 1)] === "build" ? label() : null;
|
|
312
|
+
if (command === "compose") {
|
|
313
|
+
const at2 = positional(words, at + 1, new Set(["-f", "--file", "-p"]));
|
|
314
|
+
const action = words[at2];
|
|
315
|
+
if (action === "build") return label();
|
|
316
|
+
return action === "up" && words.slice(at2 + 1).includes("--build") ? label() : null;
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
const rule = TOOLS[name];
|
|
321
|
+
if (!rule) return null;
|
|
322
|
+
// A tool's own watch mode (`tsc -w`, `jest --watch`, `vitest watch`).
|
|
323
|
+
if (words.slice(1).some((word) => WATCH_FLAG.test(word) || word === "watch" || word === "dev"))
|
|
324
|
+
return null;
|
|
325
|
+
if (rule === "any") return label();
|
|
326
|
+
// `cargo +nightly build`: a toolchain selector is not the subcommand.
|
|
327
|
+
const command = words
|
|
328
|
+
.slice(1)
|
|
329
|
+
.find((word) => (!word.startsWith("-") && !word.startsWith("+")) || rule.has(word));
|
|
330
|
+
return command !== undefined && rule.has(command) ? label() : null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const globWord = (word: string): RegExp =>
|
|
334
|
+
new RegExp(`^${word.replace(/[.+?^${}()|[\]\\]/gu, "\\$&").replaceAll("*", ".*")}$`, "u");
|
|
335
|
+
|
|
336
|
+
/** A pattern as matchers for the words of a command line (the first by its base name). */
|
|
337
|
+
export function parsePattern(pattern: string): RegExp[] | null {
|
|
338
|
+
const words = pattern.trim().split(/\s+/u).filter(Boolean);
|
|
339
|
+
return words.length ? words.map(globWord) : null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const matches = (matchers: RegExp[], words: string[]): boolean =>
|
|
343
|
+
matchers.length <= words.length &&
|
|
344
|
+
matchers.every((matcher, index) =>
|
|
345
|
+
index === 0
|
|
346
|
+
? matcher.test(words[0]) || matcher.test(baseName(words[0]))
|
|
347
|
+
: matcher.test(words[index]),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const readJson = (file: string): unknown => {
|
|
351
|
+
try {
|
|
352
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
353
|
+
} catch {
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
359
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
360
|
+
|
|
361
|
+
const stringList = (value: unknown): string[] =>
|
|
362
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
363
|
+
|
|
364
|
+
/** The machine-wide patterns (`workit lock heavy add`). Lenient: unreadable means none. */
|
|
365
|
+
export function machinePatterns(env: NodeJS.ProcessEnv): string[] {
|
|
366
|
+
const value = readJson(heavyCommandsFile(env));
|
|
367
|
+
return isRecord(value) ? stringList(value.patterns) : [];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** `resources.heavyCommands` of the repo's checks config (the checks-config loader's file). */
|
|
371
|
+
export function repoPatterns(cwd: string): string[] {
|
|
372
|
+
return repoHeavyCommands(gitTop(cwd) ?? path.resolve(cwd));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export type HeavyMatch = {
|
|
376
|
+
/** The command line that made it heavy. */
|
|
377
|
+
line: string[];
|
|
378
|
+
/** `catalog` (built in), or the pattern that matched. */
|
|
379
|
+
source: "catalog" | { pattern: string; from: "machine" | "repo" };
|
|
380
|
+
reason: string;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
export type HeavyOptions = {
|
|
384
|
+
cwd: string;
|
|
385
|
+
env?: NodeJS.ProcessEnv;
|
|
386
|
+
dialect?: ShellDialect;
|
|
387
|
+
/** Extra patterns (tests, or a caller that already read them). */
|
|
388
|
+
patterns?: Array<{ pattern: string; from: "machine" | "repo" }>;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/** Every heavy command line of `lines`: the catalog first, then the patterns. */
|
|
392
|
+
function heavyLines(lines: string[][], options: HeavyOptions): HeavyMatch[] {
|
|
393
|
+
const found: HeavyMatch[] = [];
|
|
394
|
+
let patterns: Array<{ pattern: string; from: "machine" | "repo"; matchers: RegExp[] }> | null =
|
|
395
|
+
null;
|
|
396
|
+
for (const line of lines) {
|
|
397
|
+
const reason = builtIn(line);
|
|
398
|
+
if (reason) {
|
|
399
|
+
found.push({ line, source: "catalog", reason });
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
patterns ??= (
|
|
403
|
+
options.patterns ?? [
|
|
404
|
+
...machinePatterns(options.env ?? process.env).map((pattern) => ({
|
|
405
|
+
pattern,
|
|
406
|
+
from: "machine" as const,
|
|
407
|
+
})),
|
|
408
|
+
...repoPatterns(options.cwd).map((pattern) => ({ pattern, from: "repo" as const })),
|
|
409
|
+
]
|
|
410
|
+
).flatMap((entry) => {
|
|
411
|
+
const matchers = parsePattern(entry.pattern);
|
|
412
|
+
return matchers ? [{ ...entry, matchers }] : [];
|
|
413
|
+
});
|
|
414
|
+
const hit = patterns.find((entry) => matches(entry.matchers, line));
|
|
415
|
+
if (hit)
|
|
416
|
+
found.push({
|
|
417
|
+
line,
|
|
418
|
+
source: { pattern: hit.pattern, from: hit.from },
|
|
419
|
+
reason: `${hit.from} pattern "${hit.pattern}"`,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return found;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const linesOf = (command: string, dialect: ShellDialect): string[][] | null => {
|
|
426
|
+
try {
|
|
427
|
+
return commandLines(command, dialect);
|
|
428
|
+
} catch {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Is `command` heavy? The first command line it runs that the catalog or a
|
|
435
|
+
* pattern names, or null. Fails open: a command the shell reader cannot
|
|
436
|
+
* analyze is not heavy.
|
|
437
|
+
*/
|
|
438
|
+
export function heavyCommand(command: string, options: HeavyOptions): HeavyMatch | null {
|
|
439
|
+
const lines = linesOf(command, options.dialect ?? "posix");
|
|
440
|
+
return lines ? (heavyLines(lines, options)[0] ?? null) : null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export type HeavyPlan =
|
|
444
|
+
| { kind: "none" }
|
|
445
|
+
/** One heavy command: run `command` (the lease wrapper) instead. */
|
|
446
|
+
| { kind: "rewrite"; command: string; reason: string }
|
|
447
|
+
/** A chain with heavy parts: run each part alone (`unblock` is the first heavy one). */
|
|
448
|
+
| { kind: "split"; reason: string; unblock: string | null }
|
|
449
|
+
/** A host rule names it, or wrapping would raise it: it runs as typed. */
|
|
450
|
+
| { kind: "passthrough"; note: string };
|
|
451
|
+
|
|
452
|
+
export type PlanOptions = HeavyOptions & {
|
|
453
|
+
/** The host's permission rules; null where the host has none to read (Pi). */
|
|
454
|
+
rules: RuleRead | null;
|
|
455
|
+
/** The host skips its permission prompts (Claude `bypassPermissions`). */
|
|
456
|
+
bypass?: boolean;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const quoteWord = (word: string): string =>
|
|
460
|
+
/^[\w@%+=:,./-]+$/u.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`;
|
|
461
|
+
|
|
462
|
+
const leaseCommand = (words: readonly string[]) =>
|
|
463
|
+
`workit check --heavy -- ${words.map(quoteWord).join(" ")}`;
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* What the shell hook does with `command` (ruling mvid5onk), after every
|
|
467
|
+
* Workit gate passed on it: rewrite one heavy command through the lease,
|
|
468
|
+
* tell a chain with heavy parts to run them alone (never `bash -c`), or step
|
|
469
|
+
* aside when a host rule names it or wrapping would raise its permission.
|
|
470
|
+
* Outside a git checkout, and for PowerShell, nothing changes.
|
|
471
|
+
*/
|
|
472
|
+
export function heavyPlan(command: string, options: PlanOptions): HeavyPlan {
|
|
473
|
+
const dialect = options.dialect ?? "posix";
|
|
474
|
+
if (dialect !== "posix" || !gitTop(options.cwd)) return { kind: "none" };
|
|
475
|
+
const lines = linesOf(command, dialect);
|
|
476
|
+
if (!lines) return { kind: "none" };
|
|
477
|
+
const heavy = heavyLines(lines, options);
|
|
478
|
+
if (!heavy.length) return { kind: "none" };
|
|
479
|
+
const simple = simpleCommand(command, dialect);
|
|
480
|
+
const single = simple && !WORKIT.test(baseName(simple[0])) && heavyLines([simple], options)[0];
|
|
481
|
+
const judged = single
|
|
482
|
+
? [{ words: lines.flat(), text: command }]
|
|
483
|
+
: heavy.map((match) => ({
|
|
484
|
+
words: match.line,
|
|
485
|
+
text: match.line.join(" "),
|
|
486
|
+
}));
|
|
487
|
+
if (options.rules !== null)
|
|
488
|
+
for (const { words, text } of judged) {
|
|
489
|
+
const verdict = wrapAllowed(options.rules, words, text, { bypass: options.bypass });
|
|
490
|
+
if (!verdict.ok)
|
|
491
|
+
return {
|
|
492
|
+
kind: "passthrough",
|
|
493
|
+
note: `workit: \`${command.trim()}\` is heavy but runs outside the heavy-check lease because a host permission rule names it (or \`workit\` is allowlisted); the host decides.`,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
if (single) {
|
|
497
|
+
const rewrite = leaseRewrite(command);
|
|
498
|
+
return rewrite
|
|
499
|
+
? { kind: "rewrite", command: rewrite, reason: single.reason }
|
|
500
|
+
: { kind: "none" };
|
|
501
|
+
}
|
|
502
|
+
const parts = heavy.map((match) => leaseCommand(match.line));
|
|
503
|
+
return {
|
|
504
|
+
kind: "split",
|
|
505
|
+
reason: `heavy_command: \`${command.trim()}\` chains heavy work (${heavy.map((match) => match.reason).join("; ")}) with other commands, and a chain cannot run under the machine-wide heavy-check lease. Run each heavy part alone as \`workit check --heavy -- <segment>\` (here: ${parts.map((part) => `\`${part}\``).join("; ")}); run the other parts as their own commands.`,
|
|
506
|
+
unblock: parts[0] ?? null,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Is a `workit` CLI on PATH? The rewrite runs it, so without one the gate stays out of the way. */
|
|
511
|
+
export function workitOnPath(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
512
|
+
const names = process.platform === "win32" ? ["workit.exe", "workit.cmd", "workit"] : ["workit"];
|
|
513
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter))
|
|
514
|
+
for (const name of names) {
|
|
515
|
+
if (!dir) continue;
|
|
516
|
+
try {
|
|
517
|
+
fs.accessSync(path.join(dir, name), fs.constants.X_OK);
|
|
518
|
+
return true;
|
|
519
|
+
} catch {
|
|
520
|
+
// Not here.
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Leading `VAR=value` words, kept in front of `workit` so the command still sees them. */
|
|
527
|
+
const ASSIGNMENTS = /^\s*((?:[A-Za-z_]\w*=(?:'[^']*'|"(?:[^"\\]|\\.)*"|[^\s'"])*\s+)*)/u;
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The lease wrapper for one simple command: `[VAR=x …] workit check --heavy
|
|
531
|
+
* -- <cmd>`, the command's own text kept verbatim after `--`. It may only use
|
|
532
|
+
* flags the oldest supported CLI accepts: the plugin and the CLI update
|
|
533
|
+
* separately (docs/guides/verification.md), so a newer flag here would make
|
|
534
|
+
* an older CLI refuse every heavy command. The CLI caps the wait itself.
|
|
535
|
+
*/
|
|
536
|
+
export function leaseRewrite(command: string): string | null {
|
|
537
|
+
const text = command.trim();
|
|
538
|
+
const prefix = ASSIGNMENTS.exec(text)?.[1] ?? "";
|
|
539
|
+
const rest = text.slice(prefix.length).trim();
|
|
540
|
+
if (!rest) return null;
|
|
541
|
+
return `${prefix}workit check --heavy -- ${rest}`;
|
|
542
|
+
}
|