@brainervirus/workit-core 2.5.0 → 2.7.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/scripts/analyze-release-scope.ts +99 -8
- package/scripts/bundled-deps.json +133 -0
- package/scripts/publish-changed-packages.ts +28 -16
- package/scripts/rewrite-workspace-deps.ts +11 -5
- package/scripts/sync-release-manifests.ts +5 -1
- package/skills/workit-behavioral-tdd/SKILL.md +15 -9
- package/skills/workit-debug/SKILL.md +2 -1
- package/src/check-config.ts +294 -0
- package/src/checks.ts +480 -0
- package/src/core/detect-hosts.ts +9 -3
- package/src/core/doctor.ts +61 -0
- package/src/core/host-install.ts +144 -1
- package/src/core/methods.ts +5 -2
- package/src/core/setup.ts +27 -15
- package/src/core/support-matrix.ts +4 -0
- package/src/core/task-context.ts +6 -2
- package/src/core/task-contract.ts +70 -12
- package/src/core/task-engine.ts +120 -10
- package/src/core/task-evaluation.ts +176 -1
- package/src/core/uninstall.ts +107 -4
- package/src/git/rev.ts +50 -0
- package/src/hooks/context.ts +44 -5
- package/src/hooks/handle.ts +15 -4
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Named checks (design §2.1 S9 `workit check`; D5, D14). A check is *named*
|
|
2
|
+
// (configured) when its name is in the repo's check config and the argv run is
|
|
3
|
+
// exactly the configured argv, run from the repository top. Only named checks
|
|
4
|
+
// satisfy a gate that binds to them; `workit check -- true` is ad-hoc and
|
|
5
|
+
// never does (design §0 #3).
|
|
6
|
+
//
|
|
7
|
+
// Config, first source that exists at the repository top:
|
|
8
|
+
// 1. `workit.checks.json`: `{"checks":{"test":"bun test","lint":["bun","run","lint"]},
|
|
9
|
+
// "gates":{"testing":"test"}}`, committed and visible in review. (Not
|
|
10
|
+
// `.workit/checks.json`: the store directory is git-ignored, so a file there
|
|
11
|
+
// is never reviewed.)
|
|
12
|
+
// 2. Built-in ecosystem defaults detected from the repo: package.json scripts
|
|
13
|
+
// `test`, `lint`, `typecheck`, `check` (`<pm> run <script>`, pm from the
|
|
14
|
+
// lockfile); go.mod → `go test ./...`; Cargo.toml → `cargo test`;
|
|
15
|
+
// pytest.ini or a pyproject.toml that mentions pytest → `pytest`; a Makefile
|
|
16
|
+
// `test:` target → `make test`. The first source to define a name wins.
|
|
17
|
+
//
|
|
18
|
+
// Gates: `testing` binds to `gates.testing` (default `test`), `verification`
|
|
19
|
+
// to `gates.verification` (default: every configured check). Ad-hoc runs never
|
|
20
|
+
// satisfy a gate; with nothing configured the gate says to add the file.
|
|
21
|
+
//
|
|
22
|
+
// Kept separate from checks.ts (the runner) because task evaluation, and so
|
|
23
|
+
// every hook bundle, imports it. Plain TS over node built-ins.
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
/** The package.json scripts that count as named checks when no workit.checks.json exists. */
|
|
29
|
+
export const SCRIPT_CHECKS = ["test", "lint", "typecheck", "check"] as const;
|
|
30
|
+
export const CHECKS_FILE = "workit.checks.json";
|
|
31
|
+
|
|
32
|
+
export type NamedCheck = {
|
|
33
|
+
name: string;
|
|
34
|
+
/** The argv `workit check <name>` runs. */
|
|
35
|
+
argv: string[];
|
|
36
|
+
/** Every argv that counts as this check (the run argv plus package-manager aliases). */
|
|
37
|
+
accepts: string[][];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type CheckConfig = {
|
|
41
|
+
/** Where the config came from; `defaults` = detected ecosystem defaults, `none` = nothing. */
|
|
42
|
+
source: "workit.checks.json" | "defaults" | "none";
|
|
43
|
+
/** The directory configured commands run in (the repository top). */
|
|
44
|
+
root: string;
|
|
45
|
+
checks: NamedCheck[];
|
|
46
|
+
/** Explicit gate bindings from workit.checks.json (`gates.testing`, `gates.verification`). */
|
|
47
|
+
gates: Partial<Record<"testing" | "verification", string[]>>;
|
|
48
|
+
/** Set when the config exists but cannot be used; gates then fail closed. */
|
|
49
|
+
error: string | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
53
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
|
+
|
|
55
|
+
const SHELL_META = /[|&;<>()$`\\*?[\]{}~!#]/u;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Split a configured command string into argv: whitespace separated, with
|
|
59
|
+
* single or double quotes grouping. Shell syntax (pipes, globs, variables,
|
|
60
|
+
* redirects) is refused: configured checks never run through a shell.
|
|
61
|
+
*/
|
|
62
|
+
export function splitCommand(command: string): string[] | Error {
|
|
63
|
+
const argv: string[] = [];
|
|
64
|
+
let current = "";
|
|
65
|
+
let quote: '"' | "'" | null = null;
|
|
66
|
+
let started = false;
|
|
67
|
+
for (const char of command) {
|
|
68
|
+
if (quote) {
|
|
69
|
+
if (char === quote) quote = null;
|
|
70
|
+
else current += char;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (char === '"' || char === "'") {
|
|
74
|
+
quote = char;
|
|
75
|
+
started = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (/\s/u.test(char)) {
|
|
79
|
+
if (started) argv.push(current);
|
|
80
|
+
current = "";
|
|
81
|
+
started = false;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (SHELL_META.test(char))
|
|
85
|
+
return new Error(`"${command}" uses shell syntax (${char}); configure an argv array instead`);
|
|
86
|
+
current += char;
|
|
87
|
+
started = true;
|
|
88
|
+
}
|
|
89
|
+
if (quote) return new Error(`"${command}" has an unterminated quote`);
|
|
90
|
+
if (started) argv.push(current);
|
|
91
|
+
return argv.length ? argv : new Error("empty command");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const git = (cwd: string, args: string[]): string | null => {
|
|
95
|
+
const run = spawnSync("git", args, {
|
|
96
|
+
cwd,
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
timeout: 20_000,
|
|
99
|
+
killSignal: "SIGKILL",
|
|
100
|
+
windowsHide: true,
|
|
101
|
+
});
|
|
102
|
+
const value = run.status === 0 ? (run.stdout ?? "").trim() : "";
|
|
103
|
+
return value || null;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The repository top for `cwd`, or `cwd` itself outside git. */
|
|
107
|
+
export function checkRoot(cwd: string): string {
|
|
108
|
+
return path.resolve(git(cwd, ["rev-parse", "--show-toplevel"]) ?? cwd);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const packageManager = (root: string): "bun" | "pnpm" | "yarn" | "npm" => {
|
|
112
|
+
const has = (file: string) => fs.existsSync(path.join(root, file));
|
|
113
|
+
if (has("bun.lock") || has("bun.lockb")) return "bun";
|
|
114
|
+
if (has("pnpm-lock.yaml")) return "pnpm";
|
|
115
|
+
if (has("yarn.lock")) return "yarn";
|
|
116
|
+
return "npm";
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const scriptCheck = (pm: ReturnType<typeof packageManager>, name: string): NamedCheck => {
|
|
120
|
+
const argv = [pm, "run", name];
|
|
121
|
+
const accepts = [argv];
|
|
122
|
+
// `bun test` is bun's own runner, not the script; npm only aliases `test`.
|
|
123
|
+
if (pm === "pnpm" || pm === "yarn" || (pm === "npm" && name === "test")) accepts.push([pm, name]);
|
|
124
|
+
return { name, argv, accepts };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const CHECK_NAME = /^[a-z0-9][a-z0-9:._-]{0,63}$/u;
|
|
128
|
+
|
|
129
|
+
/** Built-in ecosystem defaults detected at `root` (see the header). */
|
|
130
|
+
function detectedChecks(root: string): NamedCheck[] {
|
|
131
|
+
const read = (file: string): string | null => {
|
|
132
|
+
try {
|
|
133
|
+
return fs.readFileSync(path.join(root, file), "utf8");
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
const checks: NamedCheck[] = [];
|
|
139
|
+
const add = (check: NamedCheck) => {
|
|
140
|
+
if (!checks.some((item) => item.name === check.name)) checks.push(check);
|
|
141
|
+
};
|
|
142
|
+
const manifestText = read("package.json");
|
|
143
|
+
if (manifestText !== null) {
|
|
144
|
+
let manifest: unknown = null;
|
|
145
|
+
try {
|
|
146
|
+
manifest = JSON.parse(manifestText);
|
|
147
|
+
} catch {
|
|
148
|
+
// An unparsable package.json defines no scripts.
|
|
149
|
+
}
|
|
150
|
+
const scripts = isRecord(manifest) && isRecord(manifest.scripts) ? manifest.scripts : {};
|
|
151
|
+
const pm = packageManager(root);
|
|
152
|
+
for (const name of SCRIPT_CHECKS) {
|
|
153
|
+
const script = scripts[name];
|
|
154
|
+
if (typeof script === "string" && script.trim() !== "") add(scriptCheck(pm, name));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const plain = (name: string, argv: string[]): NamedCheck => ({ name, argv, accepts: [argv] });
|
|
158
|
+
if (read("go.mod") !== null) add(plain("test", ["go", "test", "./..."]));
|
|
159
|
+
if (read("Cargo.toml") !== null) add(plain("test", ["cargo", "test"]));
|
|
160
|
+
if (read("pytest.ini") !== null || /\bpytest\b/u.test(read("pyproject.toml") ?? ""))
|
|
161
|
+
add(plain("test", ["pytest"]));
|
|
162
|
+
const makefile = read("Makefile") ?? read("makefile") ?? read("GNUmakefile");
|
|
163
|
+
if (makefile !== null && /^test\s*:(?!=)/mu.test(makefile)) add(plain("test", ["make", "test"]));
|
|
164
|
+
return checks;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const GATES = ["testing", "verification"] as const;
|
|
168
|
+
|
|
169
|
+
/** The named checks for the repository containing `cwd` (see the header). */
|
|
170
|
+
export function loadCheckConfig(cwd: string): CheckConfig {
|
|
171
|
+
const root = checkRoot(cwd);
|
|
172
|
+
const file = path.join(root, CHECKS_FILE);
|
|
173
|
+
const none = (error: string | null = null): CheckConfig => ({
|
|
174
|
+
source: error ? CHECKS_FILE : "none",
|
|
175
|
+
root,
|
|
176
|
+
checks: [],
|
|
177
|
+
gates: {},
|
|
178
|
+
error,
|
|
179
|
+
});
|
|
180
|
+
let text: string | null = null;
|
|
181
|
+
try {
|
|
182
|
+
text = fs.readFileSync(file, "utf8");
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
185
|
+
return none(`cannot read ${CHECKS_FILE}: ${(error as Error).message}`);
|
|
186
|
+
}
|
|
187
|
+
if (text === null) {
|
|
188
|
+
const checks = detectedChecks(root);
|
|
189
|
+
return checks.length ? { source: "defaults", root, checks, gates: {}, error: null } : none();
|
|
190
|
+
}
|
|
191
|
+
let parsed: unknown;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(text);
|
|
194
|
+
} catch {
|
|
195
|
+
return none(`${CHECKS_FILE} is not valid JSON`);
|
|
196
|
+
}
|
|
197
|
+
if (!isRecord(parsed) || !isRecord(parsed.checks))
|
|
198
|
+
return none(`${CHECKS_FILE} must be {"checks":{"<name>":"<command>"|["argv",…]}}`);
|
|
199
|
+
const checks: NamedCheck[] = [];
|
|
200
|
+
for (const [name, value] of Object.entries(parsed.checks)) {
|
|
201
|
+
if (!CHECK_NAME.test(name))
|
|
202
|
+
return none(`${CHECKS_FILE}: check name "${name}" must match ${CHECK_NAME.source}`);
|
|
203
|
+
const argv =
|
|
204
|
+
typeof value === "string"
|
|
205
|
+
? splitCommand(value)
|
|
206
|
+
: Array.isArray(value) &&
|
|
207
|
+
value.length > 0 &&
|
|
208
|
+
value.every((item) => typeof item === "string" && item.length > 0)
|
|
209
|
+
? (value as string[])
|
|
210
|
+
: new Error("expected a command string or a non-empty argv array");
|
|
211
|
+
if (argv instanceof Error) return none(`${CHECKS_FILE}: check "${name}": ${argv.message}`);
|
|
212
|
+
checks.push({ name, argv, accepts: [argv] });
|
|
213
|
+
}
|
|
214
|
+
const gates: CheckConfig["gates"] = {};
|
|
215
|
+
if (parsed.gates !== undefined) {
|
|
216
|
+
if (!isRecord(parsed.gates))
|
|
217
|
+
return none(`${CHECKS_FILE}: gates must be {"testing":"<name>"|["<name>",…]}`);
|
|
218
|
+
for (const [gate, value] of Object.entries(parsed.gates)) {
|
|
219
|
+
if (!(GATES as readonly string[]).includes(gate))
|
|
220
|
+
return none(`${CHECKS_FILE}: unknown gate "${gate}" (use ${GATES.join(" or ")})`);
|
|
221
|
+
const names = typeof value === "string" ? [value] : value;
|
|
222
|
+
if (
|
|
223
|
+
!Array.isArray(names) ||
|
|
224
|
+
names.length === 0 ||
|
|
225
|
+
!names.every((name) => typeof name === "string" && checks.some((c) => c.name === name))
|
|
226
|
+
)
|
|
227
|
+
return none(`${CHECKS_FILE}: gates.${gate} must name configured checks`);
|
|
228
|
+
gates[gate as (typeof GATES)[number]] = names as string[];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { source: CHECKS_FILE, root, checks, gates, error: null };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const sameArgv = (left: readonly string[], right: readonly string[]): boolean =>
|
|
235
|
+
left.length === right.length && left.every((item, index) => item === right[index]);
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Is a run of `argv` from `cwd` (relative to the repo top, posix) the named
|
|
239
|
+
* check `name`? Only an exact argv match from the repository top counts.
|
|
240
|
+
*/
|
|
241
|
+
export function matchesNamedCheck(
|
|
242
|
+
config: CheckConfig,
|
|
243
|
+
name: string | null,
|
|
244
|
+
argv: readonly string[],
|
|
245
|
+
cwd: string,
|
|
246
|
+
): boolean {
|
|
247
|
+
if (config.error || name === null || cwd !== ".") return false;
|
|
248
|
+
const check = config.checks.find((item) => item.name === name);
|
|
249
|
+
return Boolean(check?.accepts.some((accepted) => sameArgv(accepted, argv)));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The named checks a gate of `dimension` binds to: `gates.<dimension>` when
|
|
254
|
+
* set, else `testing` → `test` (only when configured) and `verification` →
|
|
255
|
+
* every configured check. Empty means nothing can satisfy the gate: ad-hoc
|
|
256
|
+
* runs never do (D14).
|
|
257
|
+
*/
|
|
258
|
+
export function gateCheckNames(config: CheckConfig, dimension: string): string[] {
|
|
259
|
+
const explicit = config.gates[dimension as (typeof GATES)[number]];
|
|
260
|
+
if (explicit) return explicit;
|
|
261
|
+
const names = config.checks.map((check) => check.name);
|
|
262
|
+
if (dimension === "testing") return names.includes("test") ? ["test"] : [];
|
|
263
|
+
return names;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let workitOnPath: boolean | undefined;
|
|
267
|
+
|
|
268
|
+
/** Is a `workit` executable on PATH (PATHEXT-aware on Windows)? Probed once per process. */
|
|
269
|
+
export function workitAvailable(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
270
|
+
if (workitOnPath !== undefined) return workitOnPath;
|
|
271
|
+
const windows = process.platform === "win32";
|
|
272
|
+
const exts = windows ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
|
|
273
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
|
|
274
|
+
workitOnPath = dirs.some((dir) =>
|
|
275
|
+
exts.some((ext) => {
|
|
276
|
+
try {
|
|
277
|
+
return fs.statSync(path.join(dir, `workit${ext}`)).isFile();
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
return workitOnPath;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The command that runs `workit check <args>`: plain `workit` when it is on
|
|
288
|
+
* PATH, else a pinned `npx -y @brainervirus/workit-cli@<version>` fallback.
|
|
289
|
+
*/
|
|
290
|
+
export function checkCommand(args: string, version: string, onPath = workitAvailable()): string {
|
|
291
|
+
return onPath
|
|
292
|
+
? `workit check ${args}`
|
|
293
|
+
: `npx -y @brainervirus/workit-cli@${version} check ${args}`;
|
|
294
|
+
}
|