audit-tools 0.32.20 → 0.32.22
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/dist/audit/clarification/blastRadius.d.ts +23 -0
- package/dist/audit/clarification/blastRadius.d.ts.map +1 -0
- package/dist/audit/clarification/blastRadius.js +65 -0
- package/dist/audit/clarification/blastRadius.js.map +1 -0
- package/dist/audit/clarification/dials.d.ts +28 -0
- package/dist/audit/clarification/dials.d.ts.map +1 -0
- package/dist/audit/clarification/dials.js +37 -0
- package/dist/audit/clarification/dials.js.map +1 -0
- package/dist/audit/clarification/partition.d.ts +25 -0
- package/dist/audit/clarification/partition.d.ts.map +1 -0
- package/dist/audit/clarification/partition.js +77 -0
- package/dist/audit/clarification/partition.js.map +1 -0
- package/dist/audit/clarification/riskGate.d.ts +24 -0
- package/dist/audit/clarification/riskGate.d.ts.map +1 -0
- package/dist/audit/clarification/riskGate.js +38 -0
- package/dist/audit/clarification/riskGate.js.map +1 -0
- package/dist/audit/clarification/voiQueue.d.ts +17 -0
- package/dist/audit/clarification/voiQueue.d.ts.map +1 -0
- package/dist/audit/clarification/voiQueue.js +37 -0
- package/dist/audit/clarification/voiQueue.js.map +1 -0
- package/dist/audit/cli/auditStep.d.ts +2 -0
- package/dist/audit/cli/auditStep.d.ts.map +1 -1
- package/dist/audit/cli/auditStep.js +9 -1
- package/dist/audit/cli/auditStep.js.map +1 -1
- package/dist/audit/cli/charterClarificationPrompt.d.ts +19 -0
- package/dist/audit/cli/charterClarificationPrompt.d.ts.map +1 -0
- package/dist/audit/cli/charterClarificationPrompt.js +60 -0
- package/dist/audit/cli/charterClarificationPrompt.js.map +1 -0
- package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
- package/dist/audit/cli/nextStepCommand.js +74 -0
- package/dist/audit/cli/nextStepCommand.js.map +1 -1
- package/dist/audit/cli/nextStepHelpers.d.ts +62 -0
- package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
- package/dist/audit/cli/nextStepHelpers.js +129 -0
- package/dist/audit/cli/nextStepHelpers.js.map +1 -1
- package/dist/audit/cli/steps.d.ts +4 -4
- package/dist/audit/cli/steps.d.ts.map +1 -1
- package/dist/audit/cli/steps.js +2 -0
- package/dist/audit/cli/steps.js.map +1 -1
- package/dist/audit/io/artifacts.d.ts +6 -0
- package/dist/audit/io/artifacts.d.ts.map +1 -1
- package/dist/audit/io/artifacts.js +2 -0
- package/dist/audit/io/artifacts.js.map +1 -1
- package/dist/audit/orchestrator/advance.d.ts +23 -0
- package/dist/audit/orchestrator/advance.d.ts.map +1 -1
- package/dist/audit/orchestrator/advance.js +122 -4
- package/dist/audit/orchestrator/advance.js.map +1 -1
- package/dist/audit/orchestrator/advanceTypes.d.ts +22 -1
- package/dist/audit/orchestrator/advanceTypes.d.ts.map +1 -1
- package/dist/audit/orchestrator/charterClarificationExecutor.d.ts +28 -0
- package/dist/audit/orchestrator/charterClarificationExecutor.d.ts.map +1 -0
- package/dist/audit/orchestrator/charterClarificationExecutor.js +130 -0
- package/dist/audit/orchestrator/charterClarificationExecutor.js.map +1 -0
- package/dist/audit/orchestrator/dependencyMap.d.ts +2 -0
- package/dist/audit/orchestrator/dependencyMap.d.ts.map +1 -1
- package/dist/audit/orchestrator/dependencyMap.js +24 -0
- package/dist/audit/orchestrator/dependencyMap.js.map +1 -1
- package/dist/audit/orchestrator/designReviewPrompt.d.ts +17 -0
- package/dist/audit/orchestrator/designReviewPrompt.d.ts.map +1 -1
- package/dist/audit/orchestrator/designReviewPrompt.js +50 -0
- package/dist/audit/orchestrator/designReviewPrompt.js.map +1 -1
- package/dist/audit/orchestrator/executorRunners.d.ts.map +1 -1
- package/dist/audit/orchestrator/executorRunners.js +4 -0
- package/dist/audit/orchestrator/executorRunners.js.map +1 -1
- package/dist/audit/orchestrator/executors.d.ts.map +1 -1
- package/dist/audit/orchestrator/executors.js +20 -0
- package/dist/audit/orchestrator/executors.js.map +1 -1
- package/dist/audit/orchestrator/nextStep.d.ts +11 -1
- package/dist/audit/orchestrator/nextStep.d.ts.map +1 -1
- package/dist/audit/orchestrator/nextStep.js +6 -2
- package/dist/audit/orchestrator/nextStep.js.map +1 -1
- package/dist/audit/orchestrator/staleness.d.ts +27 -1
- package/dist/audit/orchestrator/staleness.d.ts.map +1 -1
- package/dist/audit/orchestrator/staleness.js +33 -16
- package/dist/audit/orchestrator/staleness.js.map +1 -1
- package/dist/audit/orchestrator/state.d.ts +12 -1
- package/dist/audit/orchestrator/state.d.ts.map +1 -1
- package/dist/audit/orchestrator/state.js +41 -2
- package/dist/audit/orchestrator/state.js.map +1 -1
- package/dist/audit/orchestrator/synthesisExecutors.d.ts.map +1 -1
- package/dist/audit/orchestrator/synthesisExecutors.js +1 -0
- package/dist/audit/orchestrator/synthesisExecutors.js.map +1 -1
- package/dist/audit/orchestrator/systemicChallengeExecutor.d.ts +25 -0
- package/dist/audit/orchestrator/systemicChallengeExecutor.d.ts.map +1 -0
- package/dist/audit/orchestrator/systemicChallengeExecutor.js +107 -0
- package/dist/audit/orchestrator/systemicChallengeExecutor.js.map +1 -0
- package/dist/audit/reporting/mergeFindings.d.ts +2 -1
- package/dist/audit/reporting/mergeFindings.d.ts.map +1 -1
- package/dist/audit/reporting/mergeFindings.js +6 -1
- package/dist/audit/reporting/mergeFindings.js.map +1 -1
- package/dist/audit/reporting/synthesis.d.ts +2 -0
- package/dist/audit/reporting/synthesis.d.ts.map +1 -1
- package/dist/audit/reporting/synthesis.js +1 -1
- package/dist/audit/reporting/synthesis.js.map +1 -1
- package/dist/audit/systemic/aggregateMetricsDigest.d.ts +11 -0
- package/dist/audit/systemic/aggregateMetricsDigest.d.ts.map +1 -0
- package/dist/audit/systemic/aggregateMetricsDigest.js +84 -0
- package/dist/audit/systemic/aggregateMetricsDigest.js.map +1 -0
- package/dist/audit/systemic/metricsDigestTypes.d.ts +35 -0
- package/dist/audit/systemic/metricsDigestTypes.d.ts.map +1 -0
- package/dist/audit/systemic/metricsDigestTypes.js +10 -0
- package/dist/audit/systemic/metricsDigestTypes.js.map +1 -0
- package/dist/audit/systemic/secondOrderAdversaryPrompt.d.ts +16 -0
- package/dist/audit/systemic/secondOrderAdversaryPrompt.d.ts.map +1 -0
- package/dist/audit/systemic/secondOrderAdversaryPrompt.js +85 -0
- package/dist/audit/systemic/secondOrderAdversaryPrompt.js.map +1 -0
- package/dist/audit/systemic/systemicChallengeLoop.d.ts +48 -0
- package/dist/audit/systemic/systemicChallengeLoop.d.ts.map +1 -0
- package/dist/audit/systemic/systemicChallengeLoop.js +107 -0
- package/dist/audit/systemic/systemicChallengeLoop.js.map +1 -0
- package/dist/audit/types/charterClarification.d.ts +52 -0
- package/dist/audit/types/charterClarification.d.ts.map +1 -0
- package/dist/audit/types/charterClarification.js +2 -0
- package/dist/audit/types/charterClarification.js.map +1 -0
- package/dist/audit/types/systemicChallenge.d.ts +69 -0
- package/dist/audit/types/systemicChallenge.d.ts.map +1 -0
- package/dist/audit/types/systemicChallenge.js +2 -0
- package/dist/audit/types/systemicChallenge.js.map +1 -0
- package/dist/remediate/steps/dispatch/acceptNode.d.ts +178 -0
- package/dist/remediate/steps/dispatch/acceptNode.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/acceptNode.js +351 -0
- package/dist/remediate/steps/dispatch/acceptNode.js.map +1 -0
- package/dist/remediate/steps/dispatch/common.d.ts +123 -0
- package/dist/remediate/steps/dispatch/common.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/common.js +236 -0
- package/dist/remediate/steps/dispatch/common.js.map +1 -0
- package/dist/remediate/steps/dispatch/dagNodeFields.d.ts +37 -0
- package/dist/remediate/steps/dispatch/dagNodeFields.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/dagNodeFields.js +19 -0
- package/dist/remediate/steps/dispatch/dagNodeFields.js.map +1 -0
- package/dist/remediate/steps/dispatch/implementPrompt.d.ts +70 -0
- package/dist/remediate/steps/dispatch/implementPrompt.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/implementPrompt.js +577 -0
- package/dist/remediate/steps/dispatch/implementPrompt.js.map +1 -0
- package/dist/remediate/steps/dispatch/marshal.d.ts +32 -0
- package/dist/remediate/steps/dispatch/marshal.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/marshal.js +935 -0
- package/dist/remediate/steps/dispatch/marshal.js.map +1 -0
- package/dist/remediate/steps/dispatch/verifyCommands.d.ts +97 -0
- package/dist/remediate/steps/dispatch/verifyCommands.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/verifyCommands.js +204 -0
- package/dist/remediate/steps/dispatch/verifyCommands.js.map +1 -0
- package/dist/remediate/steps/dispatch/waveScheduling.d.ts +90 -0
- package/dist/remediate/steps/dispatch/waveScheduling.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/waveScheduling.js +352 -0
- package/dist/remediate/steps/dispatch/waveScheduling.js.map +1 -0
- package/dist/remediate/steps/dispatch/worktreeLifecycle.d.ts +189 -0
- package/dist/remediate/steps/dispatch/worktreeLifecycle.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/worktreeLifecycle.js +643 -0
- package/dist/remediate/steps/dispatch/worktreeLifecycle.js.map +1 -0
- package/dist/remediate/steps/dispatch/writeScope.d.ts +151 -0
- package/dist/remediate/steps/dispatch/writeScope.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/writeScope.js +376 -0
- package/dist/remediate/steps/dispatch/writeScope.js.map +1 -0
- package/dist/remediate/steps/dispatch.d.ts +16 -871
- package/dist/remediate/steps/dispatch.d.ts.map +1 -1
- package/dist/remediate/steps/dispatch.js +18 -3623
- package/dist/remediate/steps/dispatch.js.map +1 -1
- package/dist/shared/decompose/charterClarification.d.ts +80 -0
- package/dist/shared/decompose/charterClarification.d.ts.map +1 -0
- package/dist/shared/decompose/charterClarification.js +105 -0
- package/dist/shared/decompose/charterClarification.js.map +1 -0
- package/dist/shared/decompose/systemicChallenge.d.ts +326 -0
- package/dist/shared/decompose/systemicChallenge.d.ts.map +1 -0
- package/dist/shared/decompose/systemicChallenge.js +30 -0
- package/dist/shared/decompose/systemicChallenge.js.map +1 -0
- package/dist/shared/index.d.ts +10 -5
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/index.js +6 -4
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/opencodePermissions.d.ts +73 -0
- package/dist/shared/opencodePermissions.d.ts.map +1 -1
- package/dist/shared/opencodePermissions.js +172 -0
- package/dist/shared/opencodePermissions.js.map +1 -1
- package/dist/shared/tooling/exec.d.ts +10 -1
- package/dist/shared/tooling/exec.d.ts.map +1 -1
- package/dist/shared/tooling/exec.js +13 -1
- package/dist/shared/tooling/exec.js.map +1 -1
- package/dist/shared/types/charter.d.ts +89 -0
- package/dist/shared/types/charter.d.ts.map +1 -1
- package/dist/shared/types/charter.js +68 -0
- package/dist/shared/types/charter.js.map +1 -1
- package/dist/shared/types/intentCheckpoint.d.ts +15 -0
- package/dist/shared/types/intentCheckpoint.d.ts.map +1 -1
- package/dist/shared/types/intentCheckpoint.js +11 -0
- package/dist/shared/types/intentCheckpoint.js.map +1 -1
- package/dist/shared/validation/charterGate.d.ts +20 -1
- package/dist/shared/validation/charterGate.d.ts.map +1 -1
- package/dist/shared/validation/charterGate.js +23 -0
- package/dist/shared/validation/charterGate.js.map +1 -1
- package/opencode.json +36 -20
- package/package.json +1 -1
- package/scripts/postinstall.mjs +2 -1
- package/wrapper/audit-code-wrapper-opencode.mjs +38 -12
- package/wrapper/remediate-code-wrapper-opencode.mjs +38 -12
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, relative, dirname, isAbsolute } from "node:path";
|
|
3
|
+
import { spawnSyncHidden } from "audit-tools/shared";
|
|
4
|
+
import { runShellCommand } from "../../utils/commands.js";
|
|
5
|
+
import { toRepoRelative, refSafeSegment, gitTopLevel, canonicalPathKey, gitBranchExists, gitEditedFilesForBranch, } from "./common.js";
|
|
6
|
+
/**
|
|
7
|
+
* Create an isolated git worktree on a fresh branch at HEAD. Throws on non-zero exit.
|
|
8
|
+
*
|
|
9
|
+
* Refuses when `root` is not ITSELF a git top-level: a bare `git worktree add`
|
|
10
|
+
* with `cwd: root` walks UP to the nearest enclosing repo and silently creates
|
|
11
|
+
* the worktree/branch in that ancestor (observed polluting the monorepo with
|
|
12
|
+
* leaked `remediate-*` branches during the rolling_engine flip). The resolved
|
|
13
|
+
* top-level must equal the target root, or we refuse rather than escape.
|
|
14
|
+
*/
|
|
15
|
+
export function createWorktree(root, worktreePath, branchName) {
|
|
16
|
+
const top = gitTopLevel(root);
|
|
17
|
+
if (top === null) {
|
|
18
|
+
throw new Error(`Refusing to create a worktree: ${root} is not inside a git repository ` +
|
|
19
|
+
`(git rev-parse --show-toplevel failed). Rolling dispatch requires the target root to be a git repo.`);
|
|
20
|
+
}
|
|
21
|
+
if (canonicalPathKey(top) !== canonicalPathKey(root)) {
|
|
22
|
+
throw new Error(`Refusing to create a worktree: the git top-level for ${root} is ${top}, not the target root ` +
|
|
23
|
+
`itself, so 'git worktree add' would escape to an ancestor repo. Initialize a git repo at the ` +
|
|
24
|
+
`target root before rolling dispatch.`);
|
|
25
|
+
}
|
|
26
|
+
const result = spawnSyncHidden("git", ["worktree", "add", "-b", branchName, worktreePath, "HEAD"], { cwd: root, encoding: "utf8", shell: false });
|
|
27
|
+
if (result.status !== 0) {
|
|
28
|
+
const stderr = (result.stderr ?? "").trim();
|
|
29
|
+
const stdout = (result.stdout ?? "").trim();
|
|
30
|
+
throw new Error(`git worktree add failed (exit ${result.status ?? "unknown"}):\n${stderr || stdout}`);
|
|
31
|
+
}
|
|
32
|
+
// Link the main checkout's (gitignored → absent-in-worktree) node_modules so
|
|
33
|
+
// per-node verify can resolve deps. Folded into creation, not left to each
|
|
34
|
+
// caller to remember — a fresh worktree without it silently fails verify with
|
|
35
|
+
// missing-module errors. Best-effort + idempotent (see ensureWorktreeNodeModules).
|
|
36
|
+
ensureWorktreeNodeModules(root, worktreePath);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Materialize into a fresh worktree any of the node's declared target paths that
|
|
40
|
+
* exist in the main tree but are absent from the worktree — i.e. git-untracked or
|
|
41
|
+
* gitignored files that `git worktree add HEAD` does not bring over. Without this
|
|
42
|
+
* a node whose scope names an untracked config file (the dogfood hit
|
|
43
|
+
* `opencode.json` and an uncommitted `.gemini/commands/*.toml`) cannot see its own
|
|
44
|
+
* target, so the edit silently no-ops. The "absent in worktree" test is the
|
|
45
|
+
* discriminator: a tracked path is already materialized from HEAD, so only the
|
|
46
|
+
* genuinely-missing untracked/ignored declarations are copied — a tracked-but-dirty
|
|
47
|
+
* file keeps its clean-from-HEAD worktree content and is never clobbered. Paths are
|
|
48
|
+
* repo-relative (the declared scope contract); absolute/escaping paths are skipped.
|
|
49
|
+
* Best-effort: a copy failure must not abort the dispatch (logged, not thrown).
|
|
50
|
+
*/
|
|
51
|
+
export function seedUntrackedDeclaredPaths(root, worktreeRoot, declaredPaths) {
|
|
52
|
+
for (const rel of new Set(declaredPaths)) {
|
|
53
|
+
if (!rel || isAbsolute(rel))
|
|
54
|
+
continue;
|
|
55
|
+
// Reject paths that escape the root (defence-in-depth; declared scope is
|
|
56
|
+
// repo-relative and never `..`-prefixed in practice).
|
|
57
|
+
const dst = join(worktreeRoot, rel);
|
|
58
|
+
const src = join(root, rel);
|
|
59
|
+
if (relative(worktreeRoot, dst).startsWith(".."))
|
|
60
|
+
continue;
|
|
61
|
+
if (!existsSync(src) || existsSync(dst))
|
|
62
|
+
continue;
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
65
|
+
cpSync(src, dst, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
process.stderr.write(`[remediate-code] worktree seed: could not copy untracked declared path ${rel}: ${err.message}\n`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Remove a git worktree. Best-effort: logs but does not throw on failure. */
|
|
73
|
+
export function removeWorktree(root, worktreePath) {
|
|
74
|
+
// Already-absent worktree = silent no-op: no spawn, no stderr, no throw. A
|
|
75
|
+
// path that still exists but fails git-remove for another reason is surfaced
|
|
76
|
+
// exactly as before. We do NOT match on the 'not a working tree' stderr text.
|
|
77
|
+
if (!existsSync(worktreePath))
|
|
78
|
+
return;
|
|
79
|
+
const result = spawnSyncHidden("git", ["worktree", "remove", "--force", worktreePath], { cwd: root, encoding: "utf8", shell: false });
|
|
80
|
+
if (result.status !== 0) {
|
|
81
|
+
const stderr = (result.stderr ?? "").trim();
|
|
82
|
+
process.stderr.write(`[remediate-code] worktree remove failed (exit ${result.status ?? "unknown"}): ${stderr}\n`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Fully reset a node's isolated worktree + branch so a fresh `createWorktree -b`
|
|
87
|
+
* can run, even when a prior attempt left either behind. This is the idempotent
|
|
88
|
+
* cleanup the in-process driver needs across a `rate_limited` re-queue: the
|
|
89
|
+
* engine re-enters the dispatcher for the SAME block while its branch (and maybe
|
|
90
|
+
* a stale worktree admin entry) still exist, and `git worktree add -b <branch>`
|
|
91
|
+
* would otherwise fail with "branch already exists". Removing the worktree,
|
|
92
|
+
* pruning stale admin records, then force-deleting the branch makes every
|
|
93
|
+
* (re-)dispatch start clean from HEAD. All steps are best-effort (a missing
|
|
94
|
+
* worktree/branch is the expected first-attempt case, not an error). Any partial
|
|
95
|
+
* edits from a throttled prior attempt are intentionally discarded — the
|
|
96
|
+
* re-dispatch redoes the node from HEAD.
|
|
97
|
+
*/
|
|
98
|
+
export function resetNodeWorktreeAndBranch(root, worktreePath, branchName) {
|
|
99
|
+
removeWorktree(root, worktreePath);
|
|
100
|
+
// Prune stale worktree admin entries (e.g. a dir deleted out from under git),
|
|
101
|
+
// otherwise `git worktree add` can refuse a path it still thinks is registered.
|
|
102
|
+
spawnSyncHidden("git", ["worktree", "prune"], { cwd: root, shell: false });
|
|
103
|
+
// Force-delete the leftover branch from a prior attempt so `-b` recreates it.
|
|
104
|
+
spawnSyncHidden("git", ["branch", "-D", branchName], { cwd: root, shell: false });
|
|
105
|
+
// Force-remove a leftover worktree DIRECTORY: when a prior attempt's worktree
|
|
106
|
+
// became an orphaned dir (registered admin entry gone but files remain),
|
|
107
|
+
// `git worktree remove` no-ops ("is not a working tree") and `git worktree add`
|
|
108
|
+
// then refuses because the path already exists. Deleting the dir makes the
|
|
109
|
+
// re-create succeed. Best-effort.
|
|
110
|
+
if (existsSync(worktreePath)) {
|
|
111
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Run each targeted command in the worktree directory. Returns pass/fail and
|
|
116
|
+
* combined output.
|
|
117
|
+
*
|
|
118
|
+
* `targeted_commands` are opaque host-authored command *strings* (e.g.
|
|
119
|
+
* `npm run build`, `grep -c '/packages/' .gitignore`, anything with pipes,
|
|
120
|
+
* quotes, or redirections), NOT pre-tokenized argv. They are run through the
|
|
121
|
+
* platform shell (`runShellCommand` → `spawnSync(..., { shell: true })`, the
|
|
122
|
+
* same path `close.ts` uses for `test_command`/`e2e_command`) so the shell — not
|
|
123
|
+
* a word-split + `spawnSync(shell:false)` — resolves the verb. That is what
|
|
124
|
+
* makes this OS-agnostic: on win32 `cmd.exe` natively execs `.cmd` shims (npm,
|
|
125
|
+
* npx, …) and resolves PATH commands; on darwin/linux `/bin/sh` does. The prior
|
|
126
|
+
* argv path ENOENT'd the *spawn itself* for any verb that wasn't a bare
|
|
127
|
+
* executable (e.g. `grep` on Windows), turning a correct fix into a phantom
|
|
128
|
+
* contract failure that burned the retry budget.
|
|
129
|
+
*/
|
|
130
|
+
export function verifyNodeInWorktree(worktreePath, targetedCommands) {
|
|
131
|
+
const outputs = [];
|
|
132
|
+
for (const cmd of targetedCommands) {
|
|
133
|
+
const r = runShellCommand(cmd, {
|
|
134
|
+
cwd: worktreePath,
|
|
135
|
+
encoding: "utf8",
|
|
136
|
+
});
|
|
137
|
+
if (r.error) {
|
|
138
|
+
// Shell itself failed to spawn — surface it as a verify failure with the
|
|
139
|
+
// error text rather than a silent status.
|
|
140
|
+
outputs.push(`$ ${cmd}\n${r.error.message}`);
|
|
141
|
+
return { passed: false, output: outputs.join("\n---\n") };
|
|
142
|
+
}
|
|
143
|
+
const combined = [r.stdout ?? "", r.stderr ?? ""].filter(Boolean).join("\n");
|
|
144
|
+
outputs.push(`$ ${cmd}\n${combined}`);
|
|
145
|
+
if (r.status !== 0) {
|
|
146
|
+
return { passed: false, output: outputs.join("\n---\n") };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { passed: true, output: outputs.join("\n---\n") };
|
|
150
|
+
}
|
|
151
|
+
/** Merge the worktree branch into the current HEAD via cherry-pick. On failure, removes the worktree and returns the error. */
|
|
152
|
+
export function mergeWorktree(root, worktreePath, branchName) {
|
|
153
|
+
// Get the tip commit of the worktree branch
|
|
154
|
+
const revResult = spawnSyncHidden("git", ["rev-parse", branchName], {
|
|
155
|
+
cwd: root,
|
|
156
|
+
encoding: "utf8",
|
|
157
|
+
shell: false,
|
|
158
|
+
});
|
|
159
|
+
if (revResult.status !== 0) {
|
|
160
|
+
const errMsg = (revResult.stderr ?? "").trim();
|
|
161
|
+
removeWorktree(root, worktreePath);
|
|
162
|
+
return { success: false, error: `Failed to resolve worktree branch ${branchName}: ${errMsg}` };
|
|
163
|
+
}
|
|
164
|
+
const worktreeTip = revResult.stdout.trim();
|
|
165
|
+
const mergeResult = spawnSyncHidden("git", ["cherry-pick", worktreeTip], {
|
|
166
|
+
cwd: root,
|
|
167
|
+
encoding: "utf8",
|
|
168
|
+
shell: false,
|
|
169
|
+
});
|
|
170
|
+
if (mergeResult.status !== 0) {
|
|
171
|
+
const stderr = (mergeResult.stderr ?? "").trim();
|
|
172
|
+
// Abort the cherry-pick so the main tree stays clean
|
|
173
|
+
spawnSyncHidden("git", ["cherry-pick", "--abort"], { cwd: root, shell: false });
|
|
174
|
+
removeWorktree(root, worktreePath);
|
|
175
|
+
return { success: false, error: `cherry-pick failed: ${stderr}` };
|
|
176
|
+
}
|
|
177
|
+
removeWorktree(root, worktreePath);
|
|
178
|
+
return { success: true };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Repo-relative tracked paths in the MAIN checkout that have uncommitted changes
|
|
182
|
+
* AND collide with a path the node's branch edits. A pre-existing dirty tracked
|
|
183
|
+
* file the cherry-pick would touch makes `git cherry-pick` abort with the opaque
|
|
184
|
+
* "Your local changes to the following files would be overwritten by merge" — a
|
|
185
|
+
* condition the node itself cannot fix and that identically re-fails every
|
|
186
|
+
* auto-retry (observed: a docs-only node routed to human triage over unrelated
|
|
187
|
+
* uncommitted WIP on the same file). Detected here so `acceptNodeWorktree` can
|
|
188
|
+
* surface the actionable cause (which file, commit-or-stash) instead of the raw
|
|
189
|
+
* git error. Best-effort: an unavailable diff / status probe returns `[]` (fall
|
|
190
|
+
* through to the normal cherry-pick, which reproduces the original behaviour).
|
|
191
|
+
*/
|
|
192
|
+
export function dirtyMainTreeCollisions(root, branch) {
|
|
193
|
+
const edited = gitEditedFilesForBranch(root, branch);
|
|
194
|
+
if (!edited.available || edited.files.size === 0)
|
|
195
|
+
return [];
|
|
196
|
+
const status = spawnSyncHidden("git", ["status", "--porcelain", "--", ...edited.files], { cwd: root, encoding: "utf8", shell: false });
|
|
197
|
+
if (status.error || status.status !== 0)
|
|
198
|
+
return [];
|
|
199
|
+
const dirty = [];
|
|
200
|
+
for (const line of (status.stdout ?? "").split(/\r?\n/)) {
|
|
201
|
+
// Porcelain v1: two status chars + a space, then the path. Rename entries
|
|
202
|
+
// ("R old -> new") keep the destination after the arrow.
|
|
203
|
+
const raw = line.slice(3).trim();
|
|
204
|
+
if (raw.length === 0)
|
|
205
|
+
continue;
|
|
206
|
+
const arrow = raw.lastIndexOf(" -> ");
|
|
207
|
+
const p = (arrow >= 0 ? raw.slice(arrow + 4) : raw).replace(/\\/g, "/");
|
|
208
|
+
if (p.length > 0)
|
|
209
|
+
dirty.push(p);
|
|
210
|
+
}
|
|
211
|
+
return dirty;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Rebase a node's worktree branch onto the main checkout's current HEAD (the
|
|
215
|
+
* remediation branch tip) so a sibling that merged AFTER this worktree was created
|
|
216
|
+
* is folded in before this node verifies and merges. Additive edits to a shared
|
|
217
|
+
* file merge automatically (git's per-commit 3-way); a true hunk conflict is a
|
|
218
|
+
* genuine seam that aborts cleanly so the node routes to triage instead of landing
|
|
219
|
+
* a broken merge. The branch is checked out in the worktree, so the rebase runs
|
|
220
|
+
* there. A no-op (branch already on HEAD — the common, no-sibling-merged case)
|
|
221
|
+
* succeeds. Leaves the branch on its pre-rebase commit on abort (so the failed
|
|
222
|
+
* node's work can still be quarantined).
|
|
223
|
+
*/
|
|
224
|
+
export function rebaseBranchOntoHead(root, worktreePath, branch) {
|
|
225
|
+
const head = spawnSyncHidden("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", shell: false });
|
|
226
|
+
if (head.error || head.status !== 0) {
|
|
227
|
+
const detail = (head.stderr ?? head.error?.message ?? "git rev-parse failed").toString().trim();
|
|
228
|
+
return { ok: false, error: `could not resolve remediation HEAD for rebase: ${detail}` };
|
|
229
|
+
}
|
|
230
|
+
const target = head.stdout.trim();
|
|
231
|
+
const rebase = spawnSyncHidden("git", ["rebase", target], {
|
|
232
|
+
cwd: worktreePath,
|
|
233
|
+
encoding: "utf8",
|
|
234
|
+
shell: false,
|
|
235
|
+
});
|
|
236
|
+
if (rebase.error || rebase.status !== 0) {
|
|
237
|
+
const detail = [rebase.stdout ?? "", rebase.stderr ?? "", rebase.error?.message ?? ""]
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
.join("\n")
|
|
240
|
+
.trim();
|
|
241
|
+
// Leave a clean tree: abort the in-progress rebase before the worktree is dropped.
|
|
242
|
+
spawnSyncHidden("git", ["rebase", "--abort"], { cwd: worktreePath, shell: false });
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
error: `rebase onto the current remediation HEAD conflicted (a real seam — two ` +
|
|
246
|
+
`nodes edited the same lines): ${detail}`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return { ok: true };
|
|
250
|
+
}
|
|
251
|
+
/** Worktree path for a remediation block. */
|
|
252
|
+
export function worktreePath(root, blockId, runId) {
|
|
253
|
+
return join(root, ".audit-tools", "worktrees", `remediate-${blockId}-${runId}`);
|
|
254
|
+
}
|
|
255
|
+
export function remediationBranchName(runId) {
|
|
256
|
+
return `remediation/${refSafeSegment(runId, "run")}`;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Lock path for the base-mutating accept critical section (INV-2/CE-001), keyed on
|
|
260
|
+
* the base repo root + run's remediation branch. DISTINCT from the per-run
|
|
261
|
+
* `rolling-session.lock` (`withFileLock` is non-reentrant — an exclusive `wx`
|
|
262
|
+
* create — so the base lock MUST be a different path than the session lock
|
|
263
|
+
* `advanceHostRolling` already holds, or the nested acquire would self-deadlock).
|
|
264
|
+
* Both rolling drivers (host-subagent + in-process) serialize the rebase →
|
|
265
|
+
* cherry-pick → cross-package check → reset sequence through this single lock.
|
|
266
|
+
*/
|
|
267
|
+
export function baseBranchLockPath(root, runId) {
|
|
268
|
+
return join(root, ".audit-tools", "remediation", "runs", refSafeSegment(runId, "run"), "base-branch.lock");
|
|
269
|
+
}
|
|
270
|
+
/** Durable ref under which a failed-but-committed node's commit is preserved. */
|
|
271
|
+
export function quarantineRef(runId, blockId) {
|
|
272
|
+
return `refs/remediation-quarantine/${refSafeSegment(runId, "run")}/${refSafeSegment(blockId, "node")}`;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Preserve a failed-but-committed node's work so it can never be lost. A node that
|
|
276
|
+
* committed real edits to its worktree branch but then failed verify / the
|
|
277
|
+
* write-scope gate / the cherry-pick is about to have its worktree removed and (on
|
|
278
|
+
* the next re-dispatch) its branch force-deleted — orphaning the commit. The dogfood
|
|
279
|
+
* lost a verified fix exactly this way (the worktree was pruned before recovery).
|
|
280
|
+
* Point a durable ref at the branch tip: a ref under refs/remediation-quarantine/
|
|
281
|
+
* survives `git branch -D` and `git worktree prune`, so the work stays reachable for
|
|
282
|
+
* a manual `git cherry-pick`. Best-effort; returns the ref + commit, or null.
|
|
283
|
+
*/
|
|
284
|
+
export function quarantineFailedNodeCommit(root, branch, runId, blockId) {
|
|
285
|
+
const rev = spawnSyncHidden("git", ["rev-parse", "--verify", "--quiet", `${branch}^{commit}`], {
|
|
286
|
+
cwd: root,
|
|
287
|
+
encoding: "utf8",
|
|
288
|
+
shell: false,
|
|
289
|
+
});
|
|
290
|
+
if (rev.status !== 0)
|
|
291
|
+
return null;
|
|
292
|
+
const commit = (rev.stdout ?? "").trim();
|
|
293
|
+
const ref = quarantineRef(runId, blockId);
|
|
294
|
+
const upd = spawnSyncHidden("git", ["update-ref", ref, commit], {
|
|
295
|
+
cwd: root,
|
|
296
|
+
encoding: "utf8",
|
|
297
|
+
shell: false,
|
|
298
|
+
});
|
|
299
|
+
if (upd.status !== 0) {
|
|
300
|
+
process.stderr.write(`[remediate-code] could not quarantine ${branch}: ${(upd.stderr ?? "").trim()}\n`);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
process.stderr.write(`[remediate-code] preserved failed node ${blockId} commit ${commit.slice(0, 8)} at ${ref} for recovery\n`);
|
|
304
|
+
return { ref, commit };
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Preserve a failed-but-UNCOMMITTED node's worktree edits before the worktree is
|
|
308
|
+
* removed. The verify / scope / merge-fail paths quarantine an already-committed
|
|
309
|
+
* branch tip; this is the missing twin for the commit-REFUSAL path: when
|
|
310
|
+
* `commitWorktree` fails loudly (e.g. a generated artifact landed under the write
|
|
311
|
+
* scope — the genuine CE-003 fail-loud), the worker's real source edits are still
|
|
312
|
+
* sitting uncommitted in the worktree and would be destroyed by `removeWorktree`.
|
|
313
|
+
* Stage everything and land a preservation commit on the node's ISOLATED branch
|
|
314
|
+
* (never cherry-picked into main — it exists only so a durable quarantine ref can
|
|
315
|
+
* point at the otherwise-lost work), then quarantine that commit. A guard that
|
|
316
|
+
* destroys good work is worse than the bug it guards. Best-effort; returns the
|
|
317
|
+
* quarantine ref + commit, or null when there was nothing to preserve / it failed.
|
|
318
|
+
*/
|
|
319
|
+
export function quarantineUncommittedWorktreeEdits(root, worktreeRoot, branch, runId, blockId) {
|
|
320
|
+
// Stage tracked modifications + new (non-ignored) source files. `git add -A`
|
|
321
|
+
// honours .gitignore, so the incidental ignored churn (node_modules/dist) and
|
|
322
|
+
// the offending generated artifact stay out — what we preserve is the worker's
|
|
323
|
+
// real source work.
|
|
324
|
+
const add = spawnSyncHidden("git", ["add", "-A"], {
|
|
325
|
+
cwd: worktreeRoot,
|
|
326
|
+
encoding: "utf8",
|
|
327
|
+
shell: false,
|
|
328
|
+
});
|
|
329
|
+
if (add.status !== 0)
|
|
330
|
+
return null;
|
|
331
|
+
// Nothing staged → no uncommitted edits to preserve.
|
|
332
|
+
const staged = spawnSyncHidden("git", ["diff", "--cached", "--quiet"], {
|
|
333
|
+
cwd: worktreeRoot,
|
|
334
|
+
shell: false,
|
|
335
|
+
});
|
|
336
|
+
if (staged.status === 0)
|
|
337
|
+
return null;
|
|
338
|
+
const commit = spawnSyncHidden("git", ["commit", "-m", `remediate-quarantine ${blockId} (${runId}) — fail-loud preserve`], { cwd: worktreeRoot, encoding: "utf8", shell: false });
|
|
339
|
+
if (commit.status !== 0) {
|
|
340
|
+
process.stderr.write(`[remediate-code] could not preserve uncommitted edits for ${blockId}: ${(commit.stderr ?? "").trim()}\n`);
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
return quarantineFailedNodeCommit(root, branch, runId, blockId);
|
|
344
|
+
}
|
|
345
|
+
/** Clear a node's quarantine ref (e.g. once a later re-dispatch landed successfully). Best-effort. */
|
|
346
|
+
export function clearQuarantinedCommit(root, runId, blockId) {
|
|
347
|
+
spawnSyncHidden("git", ["update-ref", "-d", quarantineRef(runId, blockId)], {
|
|
348
|
+
cwd: root,
|
|
349
|
+
encoding: "utf8",
|
|
350
|
+
shell: false,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
/** Quarantined failed-node commits still preserved for a run, for recovery surfacing in the report. */
|
|
354
|
+
export function listQuarantinedCommits(root, runId) {
|
|
355
|
+
const prefix = `refs/remediation-quarantine/${refSafeSegment(runId, "run")}/`;
|
|
356
|
+
const res = spawnSyncHidden("git", ["for-each-ref", "--format=%(refname) %(objectname)", prefix], {
|
|
357
|
+
cwd: root,
|
|
358
|
+
encoding: "utf8",
|
|
359
|
+
shell: false,
|
|
360
|
+
});
|
|
361
|
+
if (res.status !== 0 || !res.stdout)
|
|
362
|
+
return [];
|
|
363
|
+
const out = [];
|
|
364
|
+
for (const line of res.stdout.split("\n")) {
|
|
365
|
+
const [ref, commit] = line.trim().split(/\s+/);
|
|
366
|
+
if (!ref || !commit)
|
|
367
|
+
continue;
|
|
368
|
+
out.push({ block: ref.slice(prefix.length), ref, commit });
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Ensure the main checkout is on the dedicated remediation branch BEFORE any node
|
|
374
|
+
* commit is cherry-picked, so accepted work lands there and the user's base branch
|
|
375
|
+
* is NEVER modified — the run leaves a feature branch for review (it does not merge
|
|
376
|
+
* back). Idempotent across waves: creates the branch from the current HEAD (the base)
|
|
377
|
+
* the first time, checks it out on later waves. Best-effort on a non-git root (the
|
|
378
|
+
* worktree dispatch flow can't run there anyway): returns null without throwing so
|
|
379
|
+
* non-git callers/tests are unaffected. Returns the branch name on success.
|
|
380
|
+
*/
|
|
381
|
+
/** Sidecar recording the base branch the run was launched from (B5). */
|
|
382
|
+
export function remediationBaseBranchPath(artifactsDir) {
|
|
383
|
+
return join(artifactsDir, "remediation-base-branch.json");
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* The base branch this run was launched from, recorded when the remediation
|
|
387
|
+
* branch was first created. `null` when unrecorded (e.g. a detached HEAD at
|
|
388
|
+
* launch, or a branch created by a prior run) — the opt-in merge-to-base
|
|
389
|
+
* closing action degrades to "merge manually" rather than guessing a target.
|
|
390
|
+
*/
|
|
391
|
+
export function readRemediationBaseBranch(artifactsDir) {
|
|
392
|
+
try {
|
|
393
|
+
const parsed = JSON.parse(readFileSync(remediationBaseBranchPath(artifactsDir), "utf8"));
|
|
394
|
+
return typeof parsed?.base_branch === "string" && parsed.base_branch.length > 0
|
|
395
|
+
? parsed.base_branch
|
|
396
|
+
: null;
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
export function ensureRemediationBranchCheckedOut(root, runId, artifactsDir) {
|
|
403
|
+
const top = gitTopLevel(root);
|
|
404
|
+
if (top === null || canonicalPathKey(top) !== canonicalPathKey(root))
|
|
405
|
+
return null;
|
|
406
|
+
const branch = remediationBranchName(runId);
|
|
407
|
+
const current = spawnSyncHidden("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
408
|
+
cwd: root,
|
|
409
|
+
encoding: "utf8",
|
|
410
|
+
shell: false,
|
|
411
|
+
});
|
|
412
|
+
if (current.status === 0 && (current.stdout ?? "").trim() === branch)
|
|
413
|
+
return branch;
|
|
414
|
+
const branchExisted = gitBranchExists(root, branch);
|
|
415
|
+
const args = branchExisted ? ["checkout", branch] : ["checkout", "-b", branch];
|
|
416
|
+
const co = spawnSyncHidden("git", args, { cwd: root, encoding: "utf8", shell: false });
|
|
417
|
+
if (co.status !== 0) {
|
|
418
|
+
process.stderr.write(`[remediate-code] could not switch to remediation branch ${branch}: ${(co.stderr ?? "").trim()}\n`);
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
// First creation: record the branch we came from as the merge-back target (B5).
|
|
422
|
+
// Only a real branch name — a detached HEAD reports "HEAD" and is skipped.
|
|
423
|
+
if (!branchExisted && artifactsDir) {
|
|
424
|
+
const base = current.status === 0 ? (current.stdout ?? "").trim() : "";
|
|
425
|
+
if (base && base !== "HEAD" && base !== branch) {
|
|
426
|
+
try {
|
|
427
|
+
writeFileSync(remediationBaseBranchPath(artifactsDir), JSON.stringify({ base_branch: base }, null, 2));
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// best-effort: an unrecorded base degrades to manual merge, never throws
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
process.stderr.write(`[remediate-code] remediation changes land on branch ${branch} (base branch left untouched)\n`);
|
|
435
|
+
return branch;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Stage and commit all of a worktree's edits onto its branch. The TOOL owns this
|
|
439
|
+
* commit (never the worker/host) so that the branch has a real commit for two
|
|
440
|
+
* downstream invariants: `gitEditedFilesForBranch` (the write-scope ground truth,
|
|
441
|
+
* `HEAD...<branch>`) and `mergeWorktree`'s cherry-pick both operate on the worker's
|
|
442
|
+
* changes rather than an empty diff against HEAD. Gitignored paths (node_modules,
|
|
443
|
+
* .audit-tools artifacts, the result file written to the main artifacts dir) are
|
|
444
|
+
* excluded by `git add -A` honoring .gitignore, so the commit captures exactly the
|
|
445
|
+
* source edits. Returns `committed:false` (not an error) when the worker made no
|
|
446
|
+
* tracked edits — there is then nothing to verify or merge.
|
|
447
|
+
*/
|
|
448
|
+
/**
|
|
449
|
+
* Source-file extensions a worker is allowed to CREATE as a brand-new untracked
|
|
450
|
+
* file that `.gitignore` happens to shadow. A new source file (a .ts the build
|
|
451
|
+
* compiles, a .js shim, a source .json fixture/config) is real work that MUST land
|
|
452
|
+
* even though `git add -A` honours `.gitignore` and would silently drop it
|
|
453
|
+
* (CE-003/CE-004 — the dogfood lost a gitignored friction-dir file exactly this
|
|
454
|
+
* way). A new
|
|
455
|
+
* file with any OTHER suffix under write scope is a GENERATED artifact (`.tsbuildinfo`,
|
|
456
|
+
* an emitted `.d.ts`, a coverage dump, …) — committing it would land a stale build
|
|
457
|
+
* output, so those FAIL LOUDLY rather than commit a half-change.
|
|
458
|
+
*/
|
|
459
|
+
const SOURCE_NEW_FILE_EXTENSIONS = new Set([
|
|
460
|
+
".ts",
|
|
461
|
+
".tsx",
|
|
462
|
+
".mts",
|
|
463
|
+
".cts",
|
|
464
|
+
".js",
|
|
465
|
+
".jsx",
|
|
466
|
+
".mjs",
|
|
467
|
+
".cjs",
|
|
468
|
+
".json",
|
|
469
|
+
]);
|
|
470
|
+
/**
|
|
471
|
+
* A new untracked-ignored file is a SOURCE file (force-add it) iff its extension
|
|
472
|
+
* is in {@link SOURCE_NEW_FILE_EXTENSIONS} AND it is not a generated declaration
|
|
473
|
+
* (`.d.ts` / `.d.mts` / `.d.cts`) — a generated `.d.ts` has a `.ts` suffix but is
|
|
474
|
+
* build output, so it must be excluded from the source set.
|
|
475
|
+
*/
|
|
476
|
+
function isSourceNewFile(rel) {
|
|
477
|
+
const lower = rel.toLowerCase();
|
|
478
|
+
if (/\.d\.[cm]?ts$/.test(lower))
|
|
479
|
+
return false;
|
|
480
|
+
const dot = lower.lastIndexOf(".");
|
|
481
|
+
if (dot < 0)
|
|
482
|
+
return false;
|
|
483
|
+
return SOURCE_NEW_FILE_EXTENSIONS.has(lower.slice(dot));
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Whether a repo-relative path falls UNDER one of the block's declared write
|
|
487
|
+
* paths. A declared path matches a new file when it is the file itself, a
|
|
488
|
+
* directory prefix of it (declared `src/x` matches `src/x/new.ts`), or a glob
|
|
489
|
+
* whose pre-wildcard directory prefix contains it (a leading-wildcard glob like
|
|
490
|
+
* a friction-dir pattern matches `src/a/friction/new.ts`). Declared paths and the
|
|
491
|
+
* candidate are normalized to
|
|
492
|
+
* repo-relative forward-slash form so the comparison is OS-agnostic.
|
|
493
|
+
*/
|
|
494
|
+
function isUnderWritePaths(rel, declaredWritePaths, root) {
|
|
495
|
+
const target = toRepoRelative(rel, root);
|
|
496
|
+
for (const raw of declaredWritePaths) {
|
|
497
|
+
const declared = toRepoRelative(raw, root);
|
|
498
|
+
if (declared === target)
|
|
499
|
+
return true;
|
|
500
|
+
// Glob: reduce a wildcard pattern to the leading literal dir segment(s)
|
|
501
|
+
// before the first wildcard and treat that as a containing prefix.
|
|
502
|
+
const wildcard = declared.search(/[*?[]/);
|
|
503
|
+
const literalPrefix = (wildcard >= 0 ? declared.slice(0, wildcard) : declared)
|
|
504
|
+
.replace(/\/+$/, "");
|
|
505
|
+
if (literalPrefix.length === 0) {
|
|
506
|
+
// A leading-wildcard glob (a friction-dir style pattern) — match on the
|
|
507
|
+
// trailing literal segment appearing anywhere in the candidate's path.
|
|
508
|
+
const trailing = declared.replace(/^[*?/]+/, "").replace(/\/+$/, "");
|
|
509
|
+
if (trailing.length > 0 && (target === trailing || target.includes(`/${trailing}/`) || target.includes(`${trailing}/`))) {
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (target === literalPrefix || target.startsWith(`${literalPrefix}/`))
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Force-add worker-created NEW files that `.gitignore` shadows so genuine new
|
|
521
|
+
* source work lands in the commit. `git add -A` (and any diff) honour `.gitignore`
|
|
522
|
+
* and can ONLY be enumerated via `git ls-files --others --ignored
|
|
523
|
+
* --exclude-standard`. Only files UNDER the block's declared write scope are
|
|
524
|
+
* considered: a source-extension one is `git add -f`'d so it lands; a non-source
|
|
525
|
+
* one (generated artifact under a source dir) FAILS LOUDLY rather than committing
|
|
526
|
+
* build output. An untracked-ignored file OUTSIDE the write scope is incidental
|
|
527
|
+
* churn (node_modules created by running npm in the worktree, dist/, .audit-tools/,
|
|
528
|
+
* another module's output) — `git add -A` already skips it, so it is skipped here
|
|
529
|
+
* too (it must NOT trip a fail-loud; doing so falsely rejected every node whose
|
|
530
|
+
* worker ran npm in its worktree). `declaredWritePaths` undefined → the lifecycle
|
|
531
|
+
* unit-test path with no scope: skip force-add entirely (legacy behaviour).
|
|
532
|
+
*/
|
|
533
|
+
function forceAddNewSourceFiles(worktreeRoot, declaredWritePaths) {
|
|
534
|
+
if (declaredWritePaths === undefined)
|
|
535
|
+
return { ok: true };
|
|
536
|
+
const ls = spawnSyncHidden("git", ["ls-files", "--others", "--ignored", "--exclude-standard"], { cwd: worktreeRoot, encoding: "utf8", shell: false });
|
|
537
|
+
if (ls.error || ls.status !== 0) {
|
|
538
|
+
return {
|
|
539
|
+
ok: false,
|
|
540
|
+
error: `git ls-files (untracked-ignored enumeration) failed: ${(ls.stderr ?? ls.error?.message ?? "").toString().trim()}`,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const newFiles = (ls.stdout ?? "")
|
|
544
|
+
.split(/\r?\n/)
|
|
545
|
+
.map((l) => l.trim().replace(/\\/g, "/"))
|
|
546
|
+
.filter((l) => l.length > 0);
|
|
547
|
+
const toForceAdd = [];
|
|
548
|
+
for (const rel of newFiles) {
|
|
549
|
+
// Only files UNDER the node's declared write scope are candidates. An
|
|
550
|
+
// untracked-ignored file OUTSIDE the write scope is incidental churn the
|
|
551
|
+
// worker did not author as part of this change — node_modules (e.g.
|
|
552
|
+
// `node_modules/.bin/esbuild` created by running npm/vitest in the worktree),
|
|
553
|
+
// `dist/`, `.audit-tools/`, or another module's output. `git add -A` already
|
|
554
|
+
// skips all of it; it must NOT trip a fail-loud (doing so falsely rejected
|
|
555
|
+
// every node whose worker ran npm in its worktree). Skip it.
|
|
556
|
+
if (!isUnderWritePaths(rel, declaredWritePaths, worktreeRoot))
|
|
557
|
+
continue;
|
|
558
|
+
if (isSourceNewFile(rel)) {
|
|
559
|
+
toForceAdd.push(rel);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
// Under the declared write scope but NOT a source extension: a generated
|
|
563
|
+
// artifact (e.g. a `.tsbuildinfo` / generated `.d.ts`) a worker dropped under
|
|
564
|
+
// a source dir. This is the genuine CE-003 case — fail loudly rather than
|
|
565
|
+
// committing build output.
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
error: `Worker created a new non-source (generated) file under its write scope: ${rel}. ` +
|
|
569
|
+
`Only source-extension new files are committed; a generated artifact must not land. ` +
|
|
570
|
+
`Refusing to commit a half-change.`,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
for (const rel of toForceAdd) {
|
|
574
|
+
const add = spawnSyncHidden("git", ["add", "-f", "--", rel], {
|
|
575
|
+
cwd: worktreeRoot,
|
|
576
|
+
encoding: "utf8",
|
|
577
|
+
shell: false,
|
|
578
|
+
});
|
|
579
|
+
if (add.status !== 0) {
|
|
580
|
+
return { ok: false, error: `git add -f ${rel} failed: ${(add.stderr ?? "").trim()}` };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return { ok: true };
|
|
584
|
+
}
|
|
585
|
+
export function commitWorktree(worktreeRoot, message, declaredWritePaths) {
|
|
586
|
+
// Force-add worker-created new SOURCE files that `.gitignore` shadows (and fail
|
|
587
|
+
// loudly on a generated-artifact / out-of-scope new file) BEFORE `git add -A`,
|
|
588
|
+
// which on its own silently drops every untracked-ignored path (CE-003/CE-004).
|
|
589
|
+
const forced = forceAddNewSourceFiles(worktreeRoot, declaredWritePaths);
|
|
590
|
+
if (!forced.ok) {
|
|
591
|
+
return { committed: false, error: forced.error };
|
|
592
|
+
}
|
|
593
|
+
const add = spawnSyncHidden("git", ["add", "-A"], {
|
|
594
|
+
cwd: worktreeRoot,
|
|
595
|
+
encoding: "utf8",
|
|
596
|
+
shell: false,
|
|
597
|
+
});
|
|
598
|
+
if (add.status !== 0) {
|
|
599
|
+
return { committed: false, error: `git add failed: ${(add.stderr ?? "").trim()}` };
|
|
600
|
+
}
|
|
601
|
+
// `git diff --cached --quiet` exits 0 when nothing is staged → no worker edits.
|
|
602
|
+
const staged = spawnSyncHidden("git", ["diff", "--cached", "--quiet"], {
|
|
603
|
+
cwd: worktreeRoot,
|
|
604
|
+
shell: false,
|
|
605
|
+
});
|
|
606
|
+
if (staged.status === 0) {
|
|
607
|
+
return { committed: false };
|
|
608
|
+
}
|
|
609
|
+
const commit = spawnSyncHidden("git", ["commit", "-m", message], {
|
|
610
|
+
cwd: worktreeRoot,
|
|
611
|
+
encoding: "utf8",
|
|
612
|
+
shell: false,
|
|
613
|
+
});
|
|
614
|
+
if (commit.status !== 0) {
|
|
615
|
+
return { committed: false, error: `git commit failed: ${(commit.stderr ?? "").trim()}` };
|
|
616
|
+
}
|
|
617
|
+
return { committed: true };
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Make the main checkout's installed `node_modules` available to a worktree. A
|
|
621
|
+
* fresh `git worktree add` checks out only tracked files, and `node_modules` is
|
|
622
|
+
* gitignored, so per-node verify commands (`npm run check`, focused tests) would
|
|
623
|
+
* otherwise fail with missing dependencies. Best-effort junction/symlink to the
|
|
624
|
+
* main root's `node_modules`; on failure it logs and the verify step surfaces the
|
|
625
|
+
* missing-deps error rather than crashing the dispatch. NOTE: workspace package
|
|
626
|
+
* symlinks inside `node_modules/@audit-tools/*` point back into the MAIN checkout,
|
|
627
|
+
* so cross-package runtime resolution sees the main tree — the authoritative
|
|
628
|
+
* cross-package re-check is the central post-merge build/gate, not this fast
|
|
629
|
+
* per-node verify (which gates obvious breakage early).
|
|
630
|
+
*/
|
|
631
|
+
export function ensureWorktreeNodeModules(mainRoot, worktreeRoot) {
|
|
632
|
+
const target = join(mainRoot, "node_modules");
|
|
633
|
+
const link = join(worktreeRoot, "node_modules");
|
|
634
|
+
if (!existsSync(target) || existsSync(link))
|
|
635
|
+
return;
|
|
636
|
+
try {
|
|
637
|
+
symlinkSync(target, link, "junction");
|
|
638
|
+
}
|
|
639
|
+
catch (err) {
|
|
640
|
+
process.stderr.write(`[remediate-code] worktree node_modules link failed (${worktreeRoot}): ${String(err)}\n`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
//# sourceMappingURL=worktreeLifecycle.js.map
|