@tech-leads-club/harness-toolkit 0.2.1
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/CHANGELOG.md +95 -0
- package/LICENSE +84 -0
- package/NOTICE +9 -0
- package/README.md +480 -0
- package/bin/tlc +10 -0
- package/bin/tlc-build +80 -0
- package/bin/tlc-cli.ts +1509 -0
- package/bin/tlc-exec +10 -0
- package/bin/tlc-exec.cmd +4 -0
- package/bin/tlc-exec.d.mts +60 -0
- package/bin/tlc-exec.mjs +248 -0
- package/bin/tlc.cmd +4 -0
- package/bin/tlc.mjs +7 -0
- package/bin/write-user-hooks.d.mts +40 -0
- package/bin/write-user-hooks.mjs +123 -0
- package/capabilities/catalog.json +418 -0
- package/config.example.json +51 -0
- package/dist/compact-before.mjs +7839 -0
- package/dist/doctor.mjs +8263 -0
- package/dist/help-topic.mjs +82 -0
- package/dist/init-project.mjs +993 -0
- package/dist/install-runtime.mjs +969 -0
- package/dist/lessons-cli.mjs +7258 -0
- package/dist/obs-cli.mjs +7025 -0
- package/dist/price-lookup.mjs +221 -0
- package/dist/prompt-submit.mjs +7860 -0
- package/dist/refresh-model-prices.mjs +158 -0
- package/dist/response-after.mjs +7879 -0
- package/dist/run.mjs +7830 -0
- package/dist/session-end.mjs +7937 -0
- package/dist/session-start.mjs +8056 -0
- package/dist/shim.mjs +52 -0
- package/dist/stop.mjs +8494 -0
- package/dist/subagent-start.mjs +7861 -0
- package/dist/subagent-stop.mjs +7851 -0
- package/dist/support.mjs +7045 -0
- package/dist/tlc-cli.mjs +8063 -0
- package/dist/tool-after.mjs +8113 -0
- package/dist/tool-before.mjs +7985 -0
- package/dist/tool-failure.mjs +7850 -0
- package/dist/uninstall-runtime.mjs +1307 -0
- package/docs/architecture.md +270 -0
- package/docs/concepts.md +562 -0
- package/docs/coverage.md +55 -0
- package/docs/decisions/ad-001.md +32 -0
- package/docs/decisions/ad-002.md +51 -0
- package/docs/decisions/ad-003.md +30 -0
- package/docs/decisions/ad-004.md +37 -0
- package/docs/decisions/ad-005.md +36 -0
- package/docs/decisions/ad-006.md +49 -0
- package/docs/decisions/ad-007.md +36 -0
- package/docs/decisions/ad-008.md +54 -0
- package/docs/decisions/ad-009.md +61 -0
- package/docs/decisions/ad-010.md +45 -0
- package/docs/decisions/ad-011.md +59 -0
- package/docs/decisions/ad-012.md +71 -0
- package/docs/decisions/ad-013.md +87 -0
- package/docs/decisions/ad-014.md +56 -0
- package/docs/decisions/ad-015.md +33 -0
- package/docs/decisions/ad-016.md +98 -0
- package/docs/decisions/ad-017.md +65 -0
- package/docs/decisions/ad-018.md +77 -0
- package/docs/decisions/ad-019.md +75 -0
- package/docs/decisions/ad-020.md +88 -0
- package/docs/decisions/ad-021.md +57 -0
- package/docs/decisions/ad-022.md +120 -0
- package/docs/decisions/ad-023.md +87 -0
- package/docs/decisions/ad-024.md +85 -0
- package/docs/decisions/ad-025.md +92 -0
- package/docs/decisions/ad-026.md +131 -0
- package/docs/decisions/ad-027.md +90 -0
- package/docs/decisions/ad-028.md +90 -0
- package/docs/decisions/ad-029.md +73 -0
- package/docs/decisions/ad-030.md +97 -0
- package/docs/decisions/ad-031.md +94 -0
- package/docs/decisions/ad-032.md +83 -0
- package/docs/decisions/ad-033.md +89 -0
- package/docs/decisions/ad-034.md +86 -0
- package/docs/decisions/ad-035.md +86 -0
- package/docs/decisions/ad-036.md +68 -0
- package/docs/decisions/ad-037.md +47 -0
- package/docs/decisions/ad-038.md +52 -0
- package/docs/decisions/ad-039.md +69 -0
- package/docs/decisions/ad-040.md +89 -0
- package/docs/decisions/ad-041.md +98 -0
- package/docs/decisions/ad-042.md +82 -0
- package/docs/decisions/ad-043.md +79 -0
- package/docs/decisions/ad-044.md +61 -0
- package/docs/decisions/ad-045.md +94 -0
- package/docs/decisions/ad-046.md +111 -0
- package/docs/decisions/ad-047.md +96 -0
- package/docs/decisions/ad-048.md +85 -0
- package/docs/decisions/ad-049.md +66 -0
- package/docs/decisions/ad-050.md +94 -0
- package/docs/decisions/ad-051.md +69 -0
- package/docs/decisions/ad-052.md +69 -0
- package/docs/decisions/ad-053.md +78 -0
- package/docs/decisions/ad-054.md +98 -0
- package/docs/decisions/ad-055.md +74 -0
- package/docs/decisions/ad-056.md +85 -0
- package/docs/decisions/ad-057.md +68 -0
- package/docs/decisions/ad-058.md +97 -0
- package/docs/decisions/ad-059.md +82 -0
- package/docs/decisions/ad-060.md +75 -0
- package/docs/decisions/ad-061.md +68 -0
- package/docs/decisions/ad-062.md +72 -0
- package/docs/decisions/ad-063.md +84 -0
- package/docs/decisions/ad-064.md +79 -0
- package/docs/decisions/ad-065.md +81 -0
- package/docs/decisions/ad-066.md +111 -0
- package/docs/decisions/ad-067.md +64 -0
- package/docs/decisions/ad-068.md +79 -0
- package/docs/decisions/ad-069.md +74 -0
- package/docs/decisions/ad-070.md +86 -0
- package/docs/decisions/ad-071.md +93 -0
- package/docs/decisions/ad-072.md +82 -0
- package/docs/decisions/ad-073.md +102 -0
- package/docs/decisions/ad-074.md +91 -0
- package/docs/decisions/ad-075.md +79 -0
- package/docs/decisions/ad-076.md +102 -0
- package/docs/decisions/ad-077.md +94 -0
- package/docs/decisions/ad-078.md +84 -0
- package/docs/decisions/ad-079.md +73 -0
- package/docs/decisions/ad-080.md +86 -0
- package/docs/decisions/ad-081.md +70 -0
- package/docs/decisions/ad-082.md +79 -0
- package/docs/decisions/ad-083.md +88 -0
- package/docs/decisions/index.md +111 -0
- package/docs/diagnose.md +248 -0
- package/docs/index.md +52 -0
- package/docs/init.md +101 -0
- package/docs/lessons.md +294 -0
- package/docs/log.md +139 -0
- package/docs/measure.md +178 -0
- package/docs/providers/claude-code.md +118 -0
- package/docs/providers/cursor.md +110 -0
- package/docs/providers/index.md +71 -0
- package/docs/troubleshooting.md +138 -0
- package/model-aliases.json +12 -0
- package/model-prices.cursor.json +410 -0
- package/model-prices.json +1 -0
- package/package.json +57 -0
- package/skills/harness-init/SKILL.md +241 -0
- package/skills/harness-init/evals/trigger_evals.json +86 -0
- package/skills/harness-init/references/capabilities.md +113 -0
- package/src/contracts/capabilities.ts +30 -0
- package/src/contracts/decision.ts +21 -0
- package/src/contracts/effort.ts +15 -0
- package/src/contracts/harness-event.ts +87 -0
- package/src/contracts/index.ts +6 -0
- package/src/contracts/policy-defaults.ts +14 -0
- package/src/contracts/wiring.ts +20 -0
- package/src/core/attest/attest.service.ts +0 -0
- package/src/core/capability/capability.service.ts +62 -0
- package/src/core/capability/capability.store.ts +51 -0
- package/src/core/capability/capability.types.ts +56 -0
- package/src/core/comment-policy/comment-policy.service.ts +307 -0
- package/src/core/comment-policy/comment-policy.types.ts +6 -0
- package/src/core/comment-policy/comment-resolvability.ts +134 -0
- package/src/core/comment-policy/comment-syntax.catalog.ts +310 -0
- package/src/core/comment-policy/comment-syntax.store.ts +65 -0
- package/src/core/comment-policy/comment-syntax.types.ts +24 -0
- package/src/core/core.facade.ts +461 -0
- package/src/core/duplication/duplication.service.ts +219 -0
- package/src/core/floor/floor.catalog.ts +54 -0
- package/src/core/floor/floor.paths.ts +90 -0
- package/src/core/floor/floor.policy-surface.ts +311 -0
- package/src/core/floor/floor.service.ts +306 -0
- package/src/core/floor/floor.tokenize.ts +179 -0
- package/src/core/floor/floor.verb.ts +28 -0
- package/src/core/floor/index.ts +1 -0
- package/src/core/gate/gate.artifact.ts +157 -0
- package/src/core/gate/gate.command.ts +130 -0
- package/src/core/gate/gate.findings.ts +201 -0
- package/src/core/gate/gate.inputs.ts +0 -0
- package/src/core/gate/gate.lock.ts +283 -0
- package/src/core/gate/gate.service.ts +28 -0
- package/src/core/gate/gate.types.ts +67 -0
- package/src/core/handoff/handoff.service.ts +43 -0
- package/src/core/handoff/handoff.store.ts +59 -0
- package/src/core/handoff/handoff.types.ts +89 -0
- package/src/core/index.ts +52 -0
- package/src/core/integrity/state-seal.ts +81 -0
- package/src/core/lesson/lesson.authored.ts +96 -0
- package/src/core/lesson/lesson.credit.ts +51 -0
- package/src/core/lesson/lesson.garden.ts +283 -0
- package/src/core/lesson/lesson.link.ts +93 -0
- package/src/core/lesson/lesson.score.ts +61 -0
- package/src/core/lesson/lesson.select.ts +209 -0
- package/src/core/lesson/lesson.service.ts +110 -0
- package/src/core/lesson/lesson.store.ts +317 -0
- package/src/core/lesson/lesson.sync.ts +70 -0
- package/src/core/lesson/lesson.types.ts +92 -0
- package/src/core/lesson/lesson.validity.ts +44 -0
- package/src/core/observability/observability.report.ts +326 -0
- package/src/core/observability/observability.service.ts +328 -0
- package/src/core/observability/observability.store.ts +269 -0
- package/src/core/observability/observability.types.ts +193 -0
- package/src/core/observability/observability.why.ts +206 -0
- package/src/core/observe/observe.service.ts +76 -0
- package/src/core/plan/plan.detect.ts +47 -0
- package/src/core/plan/plan.service.ts +81 -0
- package/src/core/plan/plan.types.ts +25 -0
- package/src/core/policy/policy.defaults.ts +103 -0
- package/src/core/policy/policy.guard.ts +34 -0
- package/src/core/policy/policy.integrity.ts +238 -0
- package/src/core/policy/policy.loader.ts +118 -0
- package/src/core/policy/policy.operator.ts +82 -0
- package/src/core/policy/policy.posture.ts +79 -0
- package/src/core/policy/policy.rails.ts +45 -0
- package/src/core/policy/policy.types.ts +157 -0
- package/src/core/presence/presence.service.ts +111 -0
- package/src/core/presence/presence.store.ts +59 -0
- package/src/core/presence/presence.types.ts +9 -0
- package/src/core/release/release.decisions.ts +147 -0
- package/src/core/release/release.seen.ts +38 -0
- package/src/core/shell-policy/shell-policy.service.ts +215 -0
- package/src/core/shell-policy/shell-policy.stall.ts +57 -0
- package/src/core/shell-policy/shell-policy.types.ts +26 -0
- package/src/core/ship/ship.ledger.ts +76 -0
- package/src/core/ship/ship.service.ts +137 -0
- package/src/core/ship/ship.types.ts +19 -0
- package/src/core/stagnation/stagnation.resolution.ts +87 -0
- package/src/core/stagnation/stagnation.service.ts +20 -0
- package/src/core/stagnation/stagnation.store.ts +49 -0
- package/src/core/stagnation/stagnation.types.ts +12 -0
- package/src/core/subagent-policy/subagent-policy.parent-model.ts +202 -0
- package/src/core/subagent-policy/subagent-policy.service.ts +139 -0
- package/src/core/subagent-policy/subagent-policy.types.ts +22 -0
- package/src/core/supply-chain/supply-chain.catalog.ts +51 -0
- package/src/core/supply-chain/supply-chain.service.ts +217 -0
- package/src/core/turn/turn.activity.ts +94 -0
- package/src/core/turn/turn.autopilot.ts +131 -0
- package/src/core/turn/turn.failure-signals.ts +193 -0
- package/src/core/turn/turn.loop-counter.ts +71 -0
- package/src/core/turn/turn.types.ts +14 -0
- package/src/core/untrusted/untrusted.detect.ts +53 -0
- package/src/core/untrusted/untrusted.recall.ts +87 -0
- package/src/core/untrusted/untrusted.service.ts +129 -0
- package/src/core/untrusted/untrusted.store.ts +75 -0
- package/src/core/untrusted/untrusted.types.ts +42 -0
- package/src/entrypoints/compact-before.ts +14 -0
- package/src/entrypoints/prompt-submit.ts +25 -0
- package/src/entrypoints/response-after.ts +60 -0
- package/src/entrypoints/run.ts +179 -0
- package/src/entrypoints/session-end.ts +58 -0
- package/src/entrypoints/session-start.ts +172 -0
- package/src/entrypoints/shim.ts +46 -0
- package/src/entrypoints/stop.ts +876 -0
- package/src/entrypoints/subagent-start.ts +16 -0
- package/src/entrypoints/subagent-stop.ts +32 -0
- package/src/entrypoints/support.ts +182 -0
- package/src/entrypoints/tool-after.ts +111 -0
- package/src/entrypoints/tool-before.ts +186 -0
- package/src/entrypoints/tool-failure.ts +26 -0
- package/src/platform/backoff.ts +49 -0
- package/src/platform/cli-output.ts +30 -0
- package/src/platform/env-scope.ts +22 -0
- package/src/platform/fs-atomic.ts +142 -0
- package/src/platform/fs-jsonl.ts +24 -0
- package/src/platform/git.ts +187 -0
- package/src/platform/paths.ts +80 -0
- package/src/platform/pricing.ts +229 -0
- package/src/platform/process.ts +64 -0
- package/src/platform/sanitize.ts +23 -0
- package/src/platform/screen.ts +46 -0
- package/src/platform/style.ts +103 -0
- package/src/providers/claude/claude.capabilities.ts +25 -0
- package/src/providers/claude/claude.detect.ts +14 -0
- package/src/providers/claude/claude.inbound.ts +252 -0
- package/src/providers/claude/claude.lessons-view.ts +33 -0
- package/src/providers/claude/claude.outbound.ts +65 -0
- package/src/providers/claude/claude.policy-defaults.ts +9 -0
- package/src/providers/claude/claude.transcript.ts +62 -0
- package/src/providers/claude/claude.wiring.ts +245 -0
- package/src/providers/claude/index.ts +17 -0
- package/src/providers/cursor/cursor.capabilities.ts +28 -0
- package/src/providers/cursor/cursor.detect.ts +14 -0
- package/src/providers/cursor/cursor.inbound.ts +203 -0
- package/src/providers/cursor/cursor.lessons-view.ts +30 -0
- package/src/providers/cursor/cursor.outbound.ts +43 -0
- package/src/providers/cursor/cursor.policy-defaults.ts +9 -0
- package/src/providers/cursor/cursor.wiring.ts +214 -0
- package/src/providers/cursor/index.ts +17 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/provider.degrade.ts +152 -0
- package/src/providers/provider.port.ts +20 -0
- package/src/providers/provider.registry.ts +28 -0
- package/tools/doctor.ts +631 -0
- package/tools/help-topic.ts +74 -0
- package/tools/init-project.ts +277 -0
- package/tools/install-runtime.ts +164 -0
- package/tools/lessons-cli.ts +474 -0
- package/tools/obs-cli.ts +175 -0
- package/tools/price-lookup.ts +57 -0
- package/tools/refresh-model-prices.ts +210 -0
- package/tools/test-env.mjs +28 -0
- package/tools/test-env.names.d.mts +1 -0
- package/tools/test-env.names.mjs +14 -0
- package/tools/uninstall-runtime.ts +463 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { AppendFilesMode } from "./gate.types.ts";
|
|
3
|
+
|
|
4
|
+
const RECIPE_RUNNERS = new Set(["just", "make", "task", "mise", "rake"]);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* hazard: `npm` was absent from the no-append set while `missing script:` sat in the resolution-failure patterns —
|
|
8
|
+
* so the codebase already knew npm invokes a script and still appended file paths to it. Measured on a real install:
|
|
9
|
+
* `npm test` with `appendFiles: "auto"` had changed files appended, and whether they reach the runner depends on the
|
|
10
|
+
* package manager and its version. The harness cannot know, so it must not guess
|
|
11
|
+
* ([/decisions/ad-033.md](/decisions/ad-033.md)).
|
|
12
|
+
*
|
|
13
|
+
* why: the reason is the recipe-runner reason. The argument is forwarded to somebody else's script, and what that
|
|
14
|
+
* script does with a path is not something this process can reason about. `never` and `always` stay available for an
|
|
15
|
+
* operator who knows their own command.
|
|
16
|
+
*/
|
|
17
|
+
const SCRIPT_RUNNERS = new Set(["npm", "yarn", "pnpm"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* why: `npx`, `bunx` and `pnpm dlx` are transparent — they run the tool named next, and that tool is what decides
|
|
21
|
+
* whether a file path narrows the run. Treating them as script runners would refuse to narrow `npx jest <file>`,
|
|
22
|
+
* which is the shape narrowing exists for.
|
|
23
|
+
*/
|
|
24
|
+
const TRANSPARENT_PREFIXES = new Set(["npx", "bunx", "dlx", "exec"]);
|
|
25
|
+
|
|
26
|
+
const GLOB_CHARS = /[*?[\]]/;
|
|
27
|
+
|
|
28
|
+
export type AppendVerdict = { appends: boolean; reason?: string };
|
|
29
|
+
|
|
30
|
+
const RESOLUTION_FAILURE_PATTERNS = [
|
|
31
|
+
/does not contain recipe/i,
|
|
32
|
+
/no rule to make target/i,
|
|
33
|
+
/unknown recipe/i,
|
|
34
|
+
/missing script:/i,
|
|
35
|
+
/task ".*" does not exist/i,
|
|
36
|
+
/don't know how to build task/i,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** why: resolves through a transparent prefix to the tool that actually receives the arguments. */
|
|
40
|
+
function effectiveCommand(command: string[]): string[] {
|
|
41
|
+
let rest = command;
|
|
42
|
+
while (rest.length > 1 && TRANSPARENT_PREFIXES.has(bareName(rest[0] as string))) {
|
|
43
|
+
rest = rest.slice(1);
|
|
44
|
+
}
|
|
45
|
+
// why: `bun run <script>` and `pnpm dlx <tool>` put the meaningful token one further along.
|
|
46
|
+
if (rest.length > 2 && bareName(rest[0] as string) === "bun" && rest[1] === "run") {
|
|
47
|
+
return ["npm", ...rest.slice(1)];
|
|
48
|
+
}
|
|
49
|
+
return rest;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function bareName(argv0: string): string {
|
|
53
|
+
return basename(argv0)
|
|
54
|
+
.replace(/\.(exe|cmd|bat)$/i, "")
|
|
55
|
+
.toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** why: the name of the tool that actually receives the arguments, after resolving any transparent prefix. */
|
|
59
|
+
function executableName(command: string[]): string {
|
|
60
|
+
return bareName(effectiveCommand(command)[0] ?? "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A recipe runner takes a target name, not a file list. Appending changed files makes it read the first path
|
|
65
|
+
* as a second target and abort, so the gate reports a failure that the code cannot cause and the agent cannot
|
|
66
|
+
* fix. Anything else receives the file list, which keeps per-file runs fast where the runner supports them.
|
|
67
|
+
*/
|
|
68
|
+
export function isRecipeRunner(command: string[]): boolean {
|
|
69
|
+
return RECIPE_RUNNERS.has(executableName(command));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isScriptRunner(command: string[]): boolean {
|
|
73
|
+
return SCRIPT_RUNNERS.has(executableName(command));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* hazard: a command that already carries its own glob cannot be narrowed by appending paths — it walks the glob
|
|
78
|
+
* regardless. Measured on a real install: an eslint command globbing `src/**` and `test/**` linted the whole tree on
|
|
79
|
+
* every stop, three times per turn, while `auto` reported itself as narrowing to changed files.
|
|
80
|
+
*
|
|
81
|
+
* why: the verdict carries a reason so `doctor` can say why narrowing is not happening. A capability that silently
|
|
82
|
+
* does nothing is the defect this project keeps finding; one that says why is a configuration choice.
|
|
83
|
+
*/
|
|
84
|
+
export function appendFilesVerdict(command: string[], mode: AppendFilesMode): AppendVerdict {
|
|
85
|
+
if (command.length === 0) {
|
|
86
|
+
return { appends: false, reason: "the command is empty" };
|
|
87
|
+
}
|
|
88
|
+
if (mode === "always") {
|
|
89
|
+
return { appends: true };
|
|
90
|
+
}
|
|
91
|
+
if (mode === "never") {
|
|
92
|
+
return { appends: false, reason: "appendFiles is set to never" };
|
|
93
|
+
}
|
|
94
|
+
if (isRecipeRunner(command)) {
|
|
95
|
+
return {
|
|
96
|
+
appends: false,
|
|
97
|
+
reason: `\`${executableName(command)}\` takes a target name, so a file path would read as a second target`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (isScriptRunner(command)) {
|
|
101
|
+
return {
|
|
102
|
+
appends: false,
|
|
103
|
+
reason: `\`${executableName(command)}\` invokes a script, and whether a path reaches the runner is not something the harness can know`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const glob = command.find((arg) => GLOB_CHARS.test(arg));
|
|
107
|
+
if (glob !== undefined) {
|
|
108
|
+
return {
|
|
109
|
+
appends: false,
|
|
110
|
+
reason: `the command already scopes itself with \`${glob}\`, so appending files would not narrow the run`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { appends: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function shouldAppendFiles(command: string[], mode: AppendFilesMode): boolean {
|
|
117
|
+
return appendFilesVerdict(command, mode).appends;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The command never ran: a missing binary (127) or a runner that could not resolve the target. This is a
|
|
122
|
+
* configuration fault, not a failing assertion, and classifying it as verification sends the agent to edit
|
|
123
|
+
* healthy tests.
|
|
124
|
+
*/
|
|
125
|
+
export function isCommandResolutionFailure(args: { exitCode: number; output: string }): boolean {
|
|
126
|
+
if (args.exitCode === 127) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return RESOLUTION_FAILURE_PATTERNS.some((pattern) => pattern.test(args.output));
|
|
130
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { GateFinding } from "./gate.types.ts";
|
|
2
|
+
|
|
3
|
+
export type LineRole = "count" | "test" | "assertion" | "other";
|
|
4
|
+
|
|
5
|
+
const DETAIL_MAX = 500;
|
|
6
|
+
const SUMMARY_MAX = 200;
|
|
7
|
+
|
|
8
|
+
// why: a tally is composed only of tally clauses. Matching "N fail" alone would swallow
|
|
9
|
+
// `1 fail — runCascade …`, which names the failure and must survive as a finding.
|
|
10
|
+
const TALLY =
|
|
11
|
+
/\d+\s+(?:tests?|specs?|examples?)?\s*(?:fail(?:ed|ures?)?|pass(?:ed|ing)?|pending|skipped|todo|errors?)\b|(?:failures?|errors?)\s*=\s*\d+/gi;
|
|
12
|
+
const COUNT_LABEL = /^(?:tests?|specs?|failed|failures?|summary|results?)\b[:\s]*/i;
|
|
13
|
+
const COUNT_RESIDUE = /^[\s\d:;,|—–\-()=.✗×✕✖*]*$/;
|
|
14
|
+
|
|
15
|
+
// why: a structural marker identifies one failing test across frameworks — bun, TAP and `node --test`, go,
|
|
16
|
+
// jest, vitest, mocha. Checked before the weak `✗ name` form so `✗ 4 tests failed` stays a tally.
|
|
17
|
+
const STRONG_TEST = [
|
|
18
|
+
/^\(fail\)\s*\S/i,
|
|
19
|
+
/^not ok\s+\d+/i,
|
|
20
|
+
/^---\s*FAIL:\s*\S/i,
|
|
21
|
+
/^(?:FAIL|FAILED)\s+(?!\()\S/i,
|
|
22
|
+
];
|
|
23
|
+
const WEAK_TEST = /^[✗×✕✖]\s+\S/;
|
|
24
|
+
|
|
25
|
+
// hazard: a bare `error:` is NOT an assertion. `error: cannot find module` is a distinct failure, so it must
|
|
26
|
+
// not be folded into a neighbouring test as detail. Assertion vocabulary is the narrower, safer signal.
|
|
27
|
+
const ASSERTION_HINT =
|
|
28
|
+
/(?:expect\(|toEqual|toBe\b|toMatch|toThrow|AssertionError|assert(?:ion)?\b|deep(?:Strict)?Equal|strictEqual|Expected\b|received\b|actual\b)/i;
|
|
29
|
+
|
|
30
|
+
function isCountOnly(line: string): boolean {
|
|
31
|
+
if (!/\d/.test(line) || !/(?:fail|error)/i.test(line)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const stripped = line.replace(COUNT_LABEL, " ").replace(TALLY, " ");
|
|
35
|
+
return COUNT_RESIDUE.test(stripped);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function classifyLine(line: string): LineRole {
|
|
39
|
+
if (STRONG_TEST.some((pattern) => pattern.test(line))) {
|
|
40
|
+
return "test";
|
|
41
|
+
}
|
|
42
|
+
if (isCountOnly(line)) {
|
|
43
|
+
return "count";
|
|
44
|
+
}
|
|
45
|
+
if (WEAK_TEST.test(line)) {
|
|
46
|
+
return "test";
|
|
47
|
+
}
|
|
48
|
+
return ASSERTION_HINT.test(line) ? "assertion" : "other";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Failure = { summary: string; details: string[] };
|
|
52
|
+
|
|
53
|
+
// why: bun prints the assertion before the test line, TAP and `node --test` after. One walk covers both by
|
|
54
|
+
// buffering assertions seen before any test line and attaching them to the next one opened.
|
|
55
|
+
function groupFailures(lines: string[]): { failures: Failure[]; firstCount: string | null } {
|
|
56
|
+
const failures: Failure[] = [];
|
|
57
|
+
const pending: string[] = [];
|
|
58
|
+
let firstCount: string | null = null;
|
|
59
|
+
let current: Failure | null = null;
|
|
60
|
+
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
switch (classifyLine(line)) {
|
|
63
|
+
case "count":
|
|
64
|
+
firstCount ??= line;
|
|
65
|
+
break;
|
|
66
|
+
case "test": {
|
|
67
|
+
const failure: Failure = { summary: line, details: pending.splice(0) };
|
|
68
|
+
failures.push(failure);
|
|
69
|
+
current = failure;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case "assertion":
|
|
73
|
+
if (current) {
|
|
74
|
+
current.details.push(line);
|
|
75
|
+
} else {
|
|
76
|
+
pending.push(line);
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
default: {
|
|
80
|
+
// why: an unrecognised hit is a failure in its own right, so unknown formats degrade to today's
|
|
81
|
+
// behaviour minus the noise rather than to silence. It opens a group so a following assertion
|
|
82
|
+
// attaches to it instead of starting a second item.
|
|
83
|
+
const failure: Failure = { summary: line, details: pending.splice(0) };
|
|
84
|
+
failures.push(failure);
|
|
85
|
+
current = failure;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// hazard: an assertion with no test line ever seen is still a real failure. Dropping the buffer here is
|
|
91
|
+
// how a genuine failure would disappear.
|
|
92
|
+
if (pending.length > 0) {
|
|
93
|
+
failures.push({ summary: pending[0] as string, details: pending.slice(1) });
|
|
94
|
+
}
|
|
95
|
+
return { failures, firstCount };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalize(text: string): string {
|
|
99
|
+
return text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function dedupe(failures: Failure[]): Failure[] {
|
|
103
|
+
const byKey = new Map<string, Failure>();
|
|
104
|
+
for (const failure of failures) {
|
|
105
|
+
const key = normalize(failure.summary);
|
|
106
|
+
const seen = byKey.get(key);
|
|
107
|
+
if (seen) {
|
|
108
|
+
seen.details.push(...failure.details);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
byKey.set(key, { summary: failure.summary, details: [...failure.details] });
|
|
112
|
+
}
|
|
113
|
+
return [...byKey.values()];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toFinding(failure: Failure): GateFinding {
|
|
117
|
+
const detail = [...new Set(failure.details)].join("\n").slice(0, DETAIL_MAX);
|
|
118
|
+
return detail
|
|
119
|
+
? { summary: failure.summary.slice(0, SUMMARY_MAX), detail }
|
|
120
|
+
: { summary: failure.summary.slice(0, SUMMARY_MAX) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One finding per failure. A tally is never a finding, two views of the same failure are one item, and the
|
|
125
|
+
* assertion that explains a test travels with it as detail rather than as a second problem to fix.
|
|
126
|
+
*
|
|
127
|
+
* invariant: never returns an empty list while the gate failed, and never merges two distinct summaries —
|
|
128
|
+
* the consumer instructs an agent to fix every item, so a lost failure and an invented one are both harmful.
|
|
129
|
+
*/
|
|
130
|
+
export function findingsFromLines(lines: string[], exitCode: number, max: number): GateFinding[] {
|
|
131
|
+
const { failures, firstCount } = groupFailures(lines);
|
|
132
|
+
const unique = dedupe(failures);
|
|
133
|
+
|
|
134
|
+
if (unique.length === 0) {
|
|
135
|
+
// why: the gate did fail, so something must be reported. The tally becomes detail instead of being
|
|
136
|
+
// promoted to a finding of its own.
|
|
137
|
+
const fallback: GateFinding = { summary: `gate exited with code ${exitCode}` };
|
|
138
|
+
return firstCount ? [{ ...fallback, detail: firstCount.slice(0, DETAIL_MAX) }] : [fallback];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (unique.length <= max) {
|
|
142
|
+
return unique.map(toFinding);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// hazard: a silent cap reads as "that was everything". The omitted count is part of the finding list.
|
|
146
|
+
// why: the disclosure costs one slot, so it only appears once there are more failures than slots — which
|
|
147
|
+
// makes the omitted count always two or more, never one.
|
|
148
|
+
const kept = unique.slice(0, Math.max(1, max - 1)).map(toFinding);
|
|
149
|
+
const omitted = unique.length - kept.length;
|
|
150
|
+
return [...kept, { summary: `…and ${omitted} more failures in the gate output` }];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const SOURCE_EXT = "ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rb|rs|java|kt|swift|php|sh|sql";
|
|
154
|
+
// why: the runner prints the file it failed in — `test at src/x.test.ts:12:1`, or a file:// URL inside a stack
|
|
155
|
+
// frame. Those paths are evidence. The changed files from the diff are only context, and were being presented
|
|
156
|
+
// as the thing to fix.
|
|
157
|
+
const PATH_IN_OUTPUT = new RegExp(
|
|
158
|
+
`(?:file://)?((?:[A-Za-z]:)?[\\w./~@+-]*[\\w-]\\.(?:${SOURCE_EXT}))(?=[:)\\s,'"\`]|$)`,
|
|
159
|
+
"g",
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* hazard: a test runner colours its output, and an escape ends `[39m` — the escape character itself does not match
|
|
164
|
+
* the path pattern but `39m` does, so a coloured `src/x.test.ts` came out as `39msrc/x.test.ts`. The autopilot then
|
|
165
|
+
* told the agent, by name, to go and fix a file that does not exist. Seen in this repository's own gate output.
|
|
166
|
+
*/
|
|
167
|
+
export function stripAnsi(text: string): string {
|
|
168
|
+
// why: the escape is built from its char code rather than written literally, so the pattern can name it without
|
|
169
|
+
// putting a control character in the source — which is what the linter is right to refuse.
|
|
170
|
+
const esc = String.fromCharCode(27);
|
|
171
|
+
return text.replaceAll(new RegExp(`${esc}\\[[0-9;]*[A-Za-z]`, "g"), "");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Source files the gate output itself names, in first-appearance order, deduplicated.
|
|
176
|
+
*
|
|
177
|
+
* why: `projectDir` is taken so an absolute path inside the repository collapses onto the relative spelling of
|
|
178
|
+
* the same file. Node prints both forms for one failure — `test at src/x.test.ts:12` and a `file:///…` stack
|
|
179
|
+
* frame — and without this they would read as two separate places to look.
|
|
180
|
+
*/
|
|
181
|
+
export function filesFromOutput(outputTail: string, projectDir: string): string[] {
|
|
182
|
+
const seen = new Set<string>();
|
|
183
|
+
const files: string[] = [];
|
|
184
|
+
const prefix = `${projectDir.replace(/\/+$/, "")}/`;
|
|
185
|
+
|
|
186
|
+
for (const match of stripAnsi(outputTail).matchAll(PATH_IN_OUTPUT)) {
|
|
187
|
+
const raw = match[1];
|
|
188
|
+
if (!raw) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
// hazard: a path outside the project stays as printed — the runner named it, so hiding it would lose the
|
|
192
|
+
// only pointer the reader has.
|
|
193
|
+
const path = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
|
|
194
|
+
if (seen.has(path)) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
seen.add(path);
|
|
198
|
+
files.push(path);
|
|
199
|
+
}
|
|
200
|
+
return files;
|
|
201
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
statSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { hostname } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { nextDelay } from "../../platform/backoff.ts";
|
|
14
|
+
import { projectStateDir } from "../../platform/paths.ts";
|
|
15
|
+
import type { LockBody } from "./gate.types.ts";
|
|
16
|
+
|
|
17
|
+
export const GATE_LOCK_WAIT_MS = 120_000;
|
|
18
|
+
export const GATE_LOCK_STALE_MS = 30 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
// hazard: tryAcquire creates the file before writing the body, so a legitimate lock is briefly unreadable.
|
|
21
|
+
// The grace window has to outlast that gap by orders of magnitude without approaching the stale threshold —
|
|
22
|
+
// a lock nobody can read has no owner to wait for and no pid to release it.
|
|
23
|
+
export const GATE_LOCK_UNREADABLE_GRACE_MS = 5_000;
|
|
24
|
+
|
|
25
|
+
export class GateLockTimeoutError extends Error {
|
|
26
|
+
constructor(message = "gate lock timeout") {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "GateLockTimeoutError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function gateLockPath(root: string): string {
|
|
33
|
+
return join(projectStateDir(root), "grind.lock");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
37
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readLockBody(path: string): LockBody | null {
|
|
41
|
+
if (!existsSync(path)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(path, "utf8")) as LockBody;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function lockAgeMs(path: string, now: number): number | null {
|
|
52
|
+
if (!existsSync(path)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return now - statSync(path).mtimeMs;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isLockStale(path: string, args: { now: number; staleMs: number }): boolean {
|
|
63
|
+
const age = lockAgeMs(path, args.now);
|
|
64
|
+
return age !== null && age >= args.staleMs;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// why: a body that parses but does not carry a holder is as useless as one that does not parse — there is
|
|
68
|
+
// still no provider to name and no pid to release by. Both are "unreadable" for the purpose of reclaiming.
|
|
69
|
+
export function isUsableLockBody(value: unknown): value is LockBody {
|
|
70
|
+
if (typeof value !== "object" || value === null) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const body = value as Partial<LockBody>;
|
|
74
|
+
return (
|
|
75
|
+
typeof body.provider === "string" && typeof body.session === "string" && typeof body.pid === "number"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isLockUnreadable(path: string, args: { now: number; graceMs: number }): boolean {
|
|
80
|
+
const age = lockAgeMs(path, args.now);
|
|
81
|
+
if (age === null || age < args.graceMs) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return !isUsableLockBody(readLockBody(path));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether the recorded process is provably gone.
|
|
89
|
+
*
|
|
90
|
+
* hazard: a pid means nothing on another machine, so this answers `false` unless the lock names this host.
|
|
91
|
+
* Without that check a shared checkout would let one machine reclaim a lock whose owner is alive on another,
|
|
92
|
+
* and two gates would run at once — the thing the lock exists to prevent.
|
|
93
|
+
*
|
|
94
|
+
* why: `process.kill(pid, 0)` sends no signal, it only asks whether the process exists. `EPERM` means it
|
|
95
|
+
* exists and belongs to another user, which is alive for our purposes. Anything unexpected is treated as
|
|
96
|
+
* alive, because the age rule is the safe fallback and reclaiming a live holder is the expensive mistake.
|
|
97
|
+
*/
|
|
98
|
+
/** Asks the OS whether a pid exists. Injected so the ESRCH and EPERM branches are testable on every platform. */
|
|
99
|
+
export type ProcessProbe = (pid: number) => void;
|
|
100
|
+
|
|
101
|
+
const probeProcess: ProcessProbe = (pid) => {
|
|
102
|
+
process.kill(pid, 0);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export function isLockOwnerGone(
|
|
106
|
+
body: unknown,
|
|
107
|
+
thisHost: string = hostname(),
|
|
108
|
+
probe: ProcessProbe = probeProcess,
|
|
109
|
+
): boolean {
|
|
110
|
+
if (!isUsableLockBody(body)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const { host, pid } = body;
|
|
114
|
+
if (typeof host !== "string" || host !== thisHost) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
probe(pid);
|
|
122
|
+
return false;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return (error as NodeJS.ErrnoException).code === "ESRCH";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function isLockReclaimable(
|
|
129
|
+
path: string,
|
|
130
|
+
args: { now: number; staleMs: number; graceMs: number },
|
|
131
|
+
): boolean {
|
|
132
|
+
return (
|
|
133
|
+
isLockStale(path, { now: args.now, staleMs: args.staleMs }) ||
|
|
134
|
+
isLockUnreadable(path, { now: args.now, graceMs: args.graceMs }) ||
|
|
135
|
+
// why: an owner that no longer exists has nothing to wait for. Without this the gate honoured a dead
|
|
136
|
+
// session's lock for the full stale window — measured at 30 minutes, blocking on a pid `ps` reported gone.
|
|
137
|
+
isLockOwnerGone(readLockBody(path))
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type DescribeHolderOptions = {
|
|
142
|
+
now?: number;
|
|
143
|
+
staleMs?: number;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// hazard: an abandoned lock must not read as held. Callers short-circuit on this answer before
|
|
147
|
+
// withGateLock can reach stealIfStale, so reporting a stale holder blocks the grind for as long as the
|
|
148
|
+
// file survives — not until the threshold, which is what the threshold exists to bound.
|
|
149
|
+
export function describeHolder(root: string, options: DescribeHolderOptions = {}): string | null {
|
|
150
|
+
const path = gateLockPath(root);
|
|
151
|
+
const now = options.now ?? Date.now();
|
|
152
|
+
const staleMs = options.staleMs ?? GATE_LOCK_STALE_MS;
|
|
153
|
+
if (isLockStale(path, { now, staleMs })) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const body = readLockBody(path);
|
|
157
|
+
if (!isUsableLockBody(body)) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
// hazard: the comment above predicted this exact failure and the code did not implement it. A lock whose
|
|
161
|
+
// owner is dead but whose file is younger than the threshold was reported as held, and the caller
|
|
162
|
+
// short-circuited before withGateLock could reclaim it — measured blocking a gate on a pid `ps` said was
|
|
163
|
+
// gone. Reclaimability and holder reporting have to agree, or the reclaim path is unreachable.
|
|
164
|
+
if (isLockOwnerGone(body)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return `${body.provider} session ${body.session} (pid ${body.pid})`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function tryAcquire(path: string, body: LockBody): boolean {
|
|
171
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
172
|
+
try {
|
|
173
|
+
const fd = openSync(path, "wx");
|
|
174
|
+
try {
|
|
175
|
+
writeFileSync(fd, JSON.stringify(body));
|
|
176
|
+
} finally {
|
|
177
|
+
closeSync(fd);
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function stealIfReclaimable(
|
|
186
|
+
path: string,
|
|
187
|
+
args: { staleMs: number; graceMs: number; now: number },
|
|
188
|
+
body: LockBody,
|
|
189
|
+
): { stolen: boolean; previousHolder: LockBody | null } {
|
|
190
|
+
if (!isLockReclaimable(path, args)) {
|
|
191
|
+
return { stolen: false, previousHolder: null };
|
|
192
|
+
}
|
|
193
|
+
const previousHolder = readLockBody(path);
|
|
194
|
+
try {
|
|
195
|
+
unlinkSync(path);
|
|
196
|
+
} catch {
|
|
197
|
+
return { stolen: false, previousHolder: null };
|
|
198
|
+
}
|
|
199
|
+
return { stolen: tryAcquire(path, body), previousHolder };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function releaseLock(path: string, pid: number): void {
|
|
203
|
+
const body = readLockBody(path);
|
|
204
|
+
if (body && body.pid === pid) {
|
|
205
|
+
try {
|
|
206
|
+
unlinkSync(path);
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type WithGateLockOptions = {
|
|
212
|
+
waitMs?: number;
|
|
213
|
+
staleMs?: number;
|
|
214
|
+
unreadableGraceMs?: number;
|
|
215
|
+
now?: () => number;
|
|
216
|
+
sleep?: (ms: number) => Promise<void>;
|
|
217
|
+
random?: () => number;
|
|
218
|
+
baseMs?: number;
|
|
219
|
+
capMs?: number;
|
|
220
|
+
onSteal?: (previousHolder: LockBody) => void;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export async function withGateLock<T>(
|
|
224
|
+
root: string,
|
|
225
|
+
provider: string,
|
|
226
|
+
session: string,
|
|
227
|
+
fn: () => Promise<T>,
|
|
228
|
+
options: WithGateLockOptions = {},
|
|
229
|
+
): Promise<T> {
|
|
230
|
+
const waitMs = options.waitMs ?? GATE_LOCK_WAIT_MS;
|
|
231
|
+
const staleMs = options.staleMs ?? GATE_LOCK_STALE_MS;
|
|
232
|
+
const graceMs = options.unreadableGraceMs ?? GATE_LOCK_UNREADABLE_GRACE_MS;
|
|
233
|
+
const nowFn = options.now ?? Date.now;
|
|
234
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
235
|
+
const random = options.random ?? Math.random;
|
|
236
|
+
const baseMs = options.baseMs ?? 20;
|
|
237
|
+
const capMs = options.capMs ?? 500;
|
|
238
|
+
const path = gateLockPath(root);
|
|
239
|
+
const deadline = nowFn() + waitMs;
|
|
240
|
+
const pid = process.pid;
|
|
241
|
+
|
|
242
|
+
let attempt = 0;
|
|
243
|
+
while (true) {
|
|
244
|
+
const now = nowFn();
|
|
245
|
+
const body: LockBody = {
|
|
246
|
+
provider,
|
|
247
|
+
session,
|
|
248
|
+
pid,
|
|
249
|
+
acquired_at: new Date(now).toISOString(),
|
|
250
|
+
host: hostname(),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (tryAcquire(path, body)) {
|
|
254
|
+
return runUnderLock(path, pid, fn);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const steal = stealIfReclaimable(path, { staleMs, graceMs, now }, body);
|
|
258
|
+
if (steal.stolen) {
|
|
259
|
+
if (steal.previousHolder) {
|
|
260
|
+
options.onSteal?.(steal.previousHolder);
|
|
261
|
+
}
|
|
262
|
+
return runUnderLock(path, pid, fn);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (nowFn() >= deadline) {
|
|
266
|
+
const holder = describeHolder(root);
|
|
267
|
+
throw new GateLockTimeoutError(
|
|
268
|
+
`gate lock busy at ${path} after ${waitMs}ms${holder ? ` — held by ${holder}` : ""}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
await sleep(nextDelay({ attempt, baseMs, capMs, random }));
|
|
273
|
+
attempt += 1;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function runUnderLock<T>(path: string, pid: number, fn: () => Promise<T>): Promise<T> {
|
|
278
|
+
try {
|
|
279
|
+
return await fn();
|
|
280
|
+
} finally {
|
|
281
|
+
releaseLock(path, pid);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { FINDINGS_MAX } from "./gate.artifact.ts";
|
|
2
|
+
import type { FailureCategory, GateGap, LastGateArtifact } from "./gate.types.ts";
|
|
3
|
+
|
|
4
|
+
export function gapsFromArtifact(args: {
|
|
5
|
+
artifact: LastGateArtifact;
|
|
6
|
+
category: FailureCategory;
|
|
7
|
+
max?: number;
|
|
8
|
+
}): GateGap[] {
|
|
9
|
+
const max = args.max ?? FINDINGS_MAX;
|
|
10
|
+
const findings = args.artifact.findings.slice(0, max);
|
|
11
|
+
if (findings.length === 0) {
|
|
12
|
+
return [
|
|
13
|
+
{
|
|
14
|
+
id: `${args.artifact.gate}-0`,
|
|
15
|
+
gate: args.artifact.gate,
|
|
16
|
+
category: args.category,
|
|
17
|
+
summary: `${args.artifact.gate} failed (exit ${args.artifact.exitCode})`,
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
return findings.map((finding, index) => ({
|
|
22
|
+
id: finding.id ?? `${args.artifact.gate}-${index}`,
|
|
23
|
+
gate: args.artifact.gate,
|
|
24
|
+
category: args.category,
|
|
25
|
+
summary: finding.summary,
|
|
26
|
+
detail: finding.detail,
|
|
27
|
+
}));
|
|
28
|
+
}
|