claude-dev-env 1.71.0 → 1.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +8 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
- package/agents/clean-coder.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +3 -1
- package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +676 -0
- package/hooks/blocking/code_rules_enforcer.py +26 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_rules_test_assertions.py +152 -1
- package/hooks/blocking/code_rules_type_escape.py +447 -2
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
- package/hooks/blocking/md_to_html_blocker.py +7 -8
- package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
- package/hooks/blocking/plain_language_blocker.py +51 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
- package/hooks/blocking/state_description_blocker.py +75 -36
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
- package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
- package/hooks/hooks.json +9 -79
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/blocking_check_limits.py +75 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/validation/mypy_validator.py +215 -17
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_mypy_validator.py +184 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +3 -2
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Show-Asset.ps1 +106 -0
- package/skills/autoconverge/SKILL.md +123 -3
- package/skills/autoconverge/reference/convergence.md +41 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
- package/skills/autoconverge/workflow/converge.mjs +203 -8
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
|
@@ -14,6 +14,7 @@ export const meta = {
|
|
|
14
14
|
description: 'Drive one draft PR to convergence in a single autonomous run: parallel Bugbot + code-review + bug-audit lenses on the same HEAD each round, dedup findings, fix once, re-verify, then a Copilot wait-gate and a final convergence check that marks the PR ready.',
|
|
15
15
|
whenToUse: 'Launched by the /autoconverge skill after it resolves PR scope, enters a worktree, and grants project .claude permissions.',
|
|
16
16
|
phases: [
|
|
17
|
+
{ title: 'Reuse', detail: 'Before convergence, one reuse lens scans the full diff for reuse improvements that are certain, behaviorally identical, and autonomously implementable, and applies the qualifying ones in one commit' },
|
|
17
18
|
{ title: 'Converge', detail: 'Bugbot + code-review + bug-audit in parallel each round; one clean-coder applies all fixes; loop until all three are clean on a stable HEAD' },
|
|
18
19
|
{ title: 'Copilot gate', detail: 'Request Copilot review and poll up to three times; route findings back into Converge; when Copilot is down or out of quota, log a notice and mark the PR ready with the gate bypassed' },
|
|
19
20
|
{ title: 'Finalize', detail: 'Run check_convergence.py; mark draft=false on a full pass' },
|
|
@@ -35,16 +36,45 @@ const HEADLESS_SAFETY_PREAMBLE =
|
|
|
35
36
|
'- Keep scratch files and cleanup inside the OS temp dir or $CLAUDE_JOB_DIR/tmp (auto-allowed as ephemeral); never target a repository or worktree path with rm -rf.\n' +
|
|
36
37
|
'- If a step appears to require a real destructive command, use a non-destructive equivalent or report it as a blocker instead of running it.\n\n'
|
|
37
38
|
|
|
39
|
+
let activeRepoPath = null
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the per-agent worktree directive for a path-scoped run.
|
|
43
|
+
*
|
|
44
|
+
* A multi-PR parent run drives several converge children from one shared
|
|
45
|
+
* working directory, so each child pins its own agents to the worktree its PR
|
|
46
|
+
* is checked out in; without that pin every child's git, gh, diff, edit,
|
|
47
|
+
* commit, and test commands would run in the shared launch directory rather
|
|
48
|
+
* than the PR's own checkout. The parent hands the worktree path in as
|
|
49
|
+
* input.repoPath, which sets activeRepoPath. A single-PR run carries no
|
|
50
|
+
* repoPath, so this returns an empty string and every agent keeps its own
|
|
51
|
+
* working directory — behavior identical to a run with no path scoping.
|
|
52
|
+
* @param {string|null} repoPath the PR worktree absolute path, or null for the single-PR default
|
|
53
|
+
* @returns {string} the worktree directive to prepend, or an empty string when repoPath is null
|
|
54
|
+
*/
|
|
55
|
+
const worktreeDirective = (repoPath) =>
|
|
56
|
+
repoPath
|
|
57
|
+
? `WORKTREE — this PR is checked out at ${repoPath}. Unless a step explicitly names a different repository directory (for example an environment-hardening repo checkout, which you cd into exactly as that step directs), run every git, gh, diff, edit, commit, push, and test command for this PR in that worktree: cd "${repoPath}" before any such command, and resolve repository roots from there.\n\n`
|
|
58
|
+
: ''
|
|
59
|
+
|
|
38
60
|
/**
|
|
39
61
|
* Spawn a workflow agent with the headless-safety preamble prepended to its
|
|
40
62
|
* prompt. Every agent in this convergence loop runs unattended, so each one is
|
|
41
|
-
* routed through here to inherit the same no-confirmation-prompt guidance.
|
|
63
|
+
* routed through here to inherit the same no-confirmation-prompt guidance. On a
|
|
64
|
+
* path-scoped run the worktree directive is prepended too, so every agent runs
|
|
65
|
+
* in the PR's own worktree (activeRepoPath); on a single-PR run that directive
|
|
66
|
+
* is empty and the agent keeps its own working directory.
|
|
42
67
|
* @param {string} prompt the agent's role-specific instruction body
|
|
43
68
|
* @param {object} options the agent() options (label, phase, schema, agentType, model)
|
|
44
69
|
* @returns {Promise<*>} the agent() result
|
|
45
70
|
*/
|
|
46
71
|
const convergeAgent = (prompt, options) =>
|
|
47
|
-
agent(`${HEADLESS_SAFETY_PREAMBLE}${prompt}`, options)
|
|
72
|
+
agent(`${HEADLESS_SAFETY_PREAMBLE}${worktreeDirective(activeRepoPath)}${prompt}`, options)
|
|
73
|
+
|
|
74
|
+
const PRE_COMMIT_GATE_STEP =
|
|
75
|
+
`\n\nFINAL STEP — pre-commit gate check (do NOT commit): before your turn ends, prove your working-tree changes CAN be committed by dry-running the CODE_RULES commit gate that gates git commit (precommit_code_rules_gate). From inside the checkout that holds your changes, resolve its root with git rev-parse --show-toplevel, stage your changes with git add -A, then run exactly:\n` +
|
|
76
|
+
` python "${CONFIG.prLoopScripts}/code_rules_gate.py" --repo-root "<that root>" --staged\n` +
|
|
77
|
+
`Exit 0 means the commit gate would accept the commit. On any non-zero exit, read every violation it prints, fix each one test-first per CODE_RULES, and re-run the gate until it exits 0. Then unstage with git restore --staged . so the verify step reads the working-tree diff. Make NO git commit and NO git push here — this is a dry committability check; the separate verify and commit steps run after you, and the verified-commit gate is their job, not yours. Your turn does not end while the commit gate would reject the commit.`
|
|
48
78
|
|
|
49
79
|
const LENS_SCHEMA = {
|
|
50
80
|
type: 'object',
|
|
@@ -130,6 +160,28 @@ const REPAIR_EDIT_SCHEMA = {
|
|
|
130
160
|
required: ['edited', 'rebased', 'resolvedWithoutCommit', 'summary'],
|
|
131
161
|
}
|
|
132
162
|
|
|
163
|
+
const MERGE_CONFLICT_SCHEMA = {
|
|
164
|
+
type: 'object',
|
|
165
|
+
additionalProperties: false,
|
|
166
|
+
properties: {
|
|
167
|
+
conflicting: {
|
|
168
|
+
type: 'boolean',
|
|
169
|
+
description: 'true only when GitHub reports the PR branch conflicts with its base (mergeable:false or mergeable_state:dirty); false when it merges cleanly or mergeability could not be computed',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
required: ['conflicting'],
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const CONFLICT_EDIT_SCHEMA = {
|
|
176
|
+
type: 'object',
|
|
177
|
+
additionalProperties: false,
|
|
178
|
+
properties: {
|
|
179
|
+
rebased: { type: 'boolean', description: 'true when the branch was rebased onto origin/main and every conflict resolved in the working tree' },
|
|
180
|
+
summary: { type: 'string' },
|
|
181
|
+
},
|
|
182
|
+
required: ['rebased', 'summary'],
|
|
183
|
+
}
|
|
184
|
+
|
|
133
185
|
const STANDARDS_EDIT_SCHEMA = {
|
|
134
186
|
type: 'object',
|
|
135
187
|
additionalProperties: false,
|
|
@@ -529,6 +581,19 @@ function isResolvedHeadUsable(resolvedHead) {
|
|
|
529
581
|
return typeof resolvedHead === 'string' && resolvedHead.length > 0
|
|
530
582
|
}
|
|
531
583
|
|
|
584
|
+
/**
|
|
585
|
+
* Decide whether the pre-flight merge-conflict check found the PR branch in
|
|
586
|
+
* conflict with its base. A dead check agent (null/undefined result) reports
|
|
587
|
+
* not-conflicting so the run proceeds straight to the bug checks rather than
|
|
588
|
+
* force-pushing a rebase on a verdict that does not exist — a transient check
|
|
589
|
+
* failure must never trigger a destructive rebase.
|
|
590
|
+
* @param {object|null|undefined} mergeState the checkMergeConflicts result
|
|
591
|
+
* @returns {boolean} true only when the check reported conflicting:true
|
|
592
|
+
*/
|
|
593
|
+
function isMergeConflicting(mergeState) {
|
|
594
|
+
return mergeState != null && mergeState.conflicting === true
|
|
595
|
+
}
|
|
596
|
+
|
|
532
597
|
/**
|
|
533
598
|
* Decide whether the mark-ready step actually cleared the draft state. The run
|
|
534
599
|
* reports converged only when the mark-ready agent confirms ready:true; a dead
|
|
@@ -655,6 +720,7 @@ if (runInput.blocker) {
|
|
|
655
720
|
return { converged: false, rounds: 0, finalSha: null, blocker: runInput.blocker }
|
|
656
721
|
}
|
|
657
722
|
const input = runInput.input
|
|
723
|
+
activeRepoPath = typeof input.repoPath === 'string' && input.repoPath ? input.repoPath : null
|
|
658
724
|
const prCoordinates = `owner=${input.owner} repo=${input.repo} PR #${input.prNumber} (https://github.com/${input.owner}/${input.repo}/pull/${input.prNumber})`
|
|
659
725
|
|
|
660
726
|
/**
|
|
@@ -749,6 +815,29 @@ function runAuditLens(head) {
|
|
|
749
815
|
)
|
|
750
816
|
}
|
|
751
817
|
|
|
818
|
+
/**
|
|
819
|
+
* Reuse lens: a one-time pre-convergence pass that scans the full diff for
|
|
820
|
+
* places the PR re-implements behavior the codebase already provides, and
|
|
821
|
+
* returns only the reuse improvements that are certain, behaviorally identical,
|
|
822
|
+
* and autonomously implementable. It reports findings without editing; the
|
|
823
|
+
* reuse pass routes the qualifying findings through applyFixes so they are
|
|
824
|
+
* implemented in one commit before the convergence rounds begin.
|
|
825
|
+
* @param {string} head PR HEAD SHA to evaluate
|
|
826
|
+
* @returns {Promise<object>} LENS_SCHEMA result carrying the qualifying reuse findings
|
|
827
|
+
*/
|
|
828
|
+
function runReuseAuditPass(head) {
|
|
829
|
+
return convergeAgent(
|
|
830
|
+
`You are the REUSE lens for ${prCoordinates}, HEAD ${head}. This pass runs once before convergence to find where the PR re-implements behavior the codebase already provides.\n\n` +
|
|
831
|
+
`Review the FULL origin/main...HEAD diff — every file the PR touches. Do NOT delta-scope to recent commits or a single file. The workflow already fetched origin/main, so do NOT run git fetch; run git diff --name-only origin/main...HEAD to enumerate the changed files, then read the complete diff of each. For every new function, helper, constant, type, or block of logic the PR introduces, search the repository (Serena symbol search, grep, and the project's config/ and shared/ modules) for an existing equivalent that already provides the same behavior.\n\n` +
|
|
832
|
+
`Report a reuse finding ONLY when ALL THREE criteria hold — when any one is in doubt, omit the finding:\n` +
|
|
833
|
+
` A. CERTAIN: an existing symbol or module unquestionably covers the new code's behavior, and you can cite it at file:line.\n` +
|
|
834
|
+
` B. BEHAVIORALLY IDENTICAL: replacing the new code with the existing one changes no observable behavior — same inputs, outputs, side effects, and error handling.\n` +
|
|
835
|
+
` C. AUTONOMOUSLY IMPLEMENTABLE: the replacement is a mechanical edit (import and call the existing symbol, delete the duplicate) that needs no product decision, no API the existing code lacks, and no human judgment.\n\n` +
|
|
836
|
+
`Do NOT edit, commit, or push — report only; a separate fix step applies what you return. Return strictly the schema: clean=true with empty findings when no reuse case clears all three criteria, otherwise one entry per qualifying reuse improvement. For each: file and line of the duplicate in the PR; severity P2; category 'code-standard'; title naming the existing symbol to reuse; detail giving the existing symbol's file:line and the exact mechanical replacement; replyToCommentId=null. Set sha=${'`'}${head}${'`'}, down=false.`,
|
|
837
|
+
{ label: 'lens:reuse', phase: 'Reuse', schema: LENS_SCHEMA, agentType: 'code-quality-agent' },
|
|
838
|
+
)
|
|
839
|
+
}
|
|
840
|
+
|
|
752
841
|
/**
|
|
753
842
|
* Render the numbered findings block shared by the fix steps.
|
|
754
843
|
* @param {Array<object>} findings deduped findings to render
|
|
@@ -792,7 +881,8 @@ function applyFixesEdit(head, findings, sourceLabel) {
|
|
|
792
881
|
`Return values:\n` +
|
|
793
882
|
`- When you edited code to fix at least one finding: edited=true, resolvedWithoutCommit=false.\n` +
|
|
794
883
|
`- When every finding was already addressed so no code change was needed — yet you still resolved each GitHub review thread above: edited=false, resolvedWithoutCommit=true. Only set this when every thread that carries a comment id is resolved; otherwise the round is treated as stalled.\n` +
|
|
795
|
-
`Always include a one-line summary
|
|
884
|
+
`Always include a one-line summary.` +
|
|
885
|
+
PRE_COMMIT_GATE_STEP,
|
|
796
886
|
{ label: `fix-edit:${sourceLabel}`, phase: 'Converge', schema: EDIT_SCHEMA, agentType: 'clean-coder' },
|
|
797
887
|
)
|
|
798
888
|
}
|
|
@@ -865,7 +955,8 @@ function recoverCommitBlockEdit(head, blockerDetail, sourceLabel, attempt) {
|
|
|
865
955
|
`- Confirm the working tree is on the PR branch at HEAD ${head} with the prior fixes still present.\n` +
|
|
866
956
|
`- Fix ONLY the violation named above, test-first (failing test, then minimum code to pass) per CODE_RULES. Do not re-open the original findings, and do not touch GitHub review threads — the edit step already handled those.\n` +
|
|
867
957
|
`- Leave the corrected fixes in the working tree. Do NOT commit and do NOT push — the verify step re-binds a verdict and the commit step pushes after you.\n\n` +
|
|
868
|
-
`Return values: edited=true with a one-line summary when you changed code to clear the block; edited=false, resolvedWithoutCommit=false when the block cannot be cleared with a code change
|
|
958
|
+
`Return values: edited=true with a one-line summary when you changed code to clear the block; edited=false, resolvedWithoutCommit=false when the block cannot be cleared with a code change.` +
|
|
959
|
+
PRE_COMMIT_GATE_STEP,
|
|
869
960
|
{ label: `fix-recover:${sourceLabel}`, phase: 'Converge', schema: EDIT_SCHEMA, agentType: 'clean-coder' },
|
|
870
961
|
)
|
|
871
962
|
}
|
|
@@ -891,7 +982,8 @@ function recoverVerifyFailEdit(head, objection, sourceLabel, attempt) {
|
|
|
891
982
|
`- Confirm the working tree is on the PR branch at HEAD ${head} with the prior fixes still present.\n` +
|
|
892
983
|
`- Address every objection above test-first (failing test, then minimum code to pass) per CODE_RULES, so each named concern is genuinely resolved the way the verdict requires. Do not touch GitHub review threads — the edit step already handled those.\n` +
|
|
893
984
|
`- Leave the corrected fixes in the working tree. Do NOT commit and do NOT push — the verify step re-binds a verdict and the commit step pushes after you.\n\n` +
|
|
894
|
-
`Return values: edited=true with a one-line summary when you changed code to address the objections; edited=false, resolvedWithoutCommit=false when the objections cannot be cleared with a code change
|
|
985
|
+
`Return values: edited=true with a one-line summary when you changed code to address the objections; edited=false, resolvedWithoutCommit=false when the objections cannot be cleared with a code change.` +
|
|
986
|
+
PRE_COMMIT_GATE_STEP,
|
|
895
987
|
{ label: `fix-verify-recover:${sourceLabel}`, phase: 'Converge', schema: EDIT_SCHEMA, agentType: 'clean-coder' },
|
|
896
988
|
)
|
|
897
989
|
}
|
|
@@ -1130,7 +1222,8 @@ function repairConvergenceEdit(head, failures) {
|
|
|
1130
1222
|
`- edited=true when you changed code in the working tree to fix a bot-thread concern.\n` +
|
|
1131
1223
|
`- rebased=true when you rebased the branch onto origin/main.\n` +
|
|
1132
1224
|
`- resolvedWithoutCommit=true only when you addressed the gates with neither a code change nor a rebase (bot threads resolved only), so there is nothing for the commit step to push.\n` +
|
|
1133
|
-
`Always include a one-line summary
|
|
1225
|
+
`Always include a one-line summary.` +
|
|
1226
|
+
PRE_COMMIT_GATE_STEP,
|
|
1134
1227
|
{ label: 'repair-edit', phase: 'Finalize', schema: REPAIR_EDIT_SCHEMA, agentType: 'clean-coder' },
|
|
1135
1228
|
)
|
|
1136
1229
|
}
|
|
@@ -1235,6 +1328,79 @@ async function repairConvergence(head, failures) {
|
|
|
1235
1328
|
})
|
|
1236
1329
|
}
|
|
1237
1330
|
|
|
1331
|
+
/**
|
|
1332
|
+
* Pre-flight merge-conflict check: ask GitHub whether the PR branch still merges
|
|
1333
|
+
* cleanly into its base. GitHub computes mergeability asynchronously, so the
|
|
1334
|
+
* agent polls until .mergeable resolves to a boolean before judging. Read-only —
|
|
1335
|
+
* it makes no edit, commit, push, or rebase.
|
|
1336
|
+
* @param {string} head PR HEAD SHA the check runs against
|
|
1337
|
+
* @returns {Promise<object>} MERGE_CONFLICT_SCHEMA result
|
|
1338
|
+
*/
|
|
1339
|
+
function checkMergeConflicts(head) {
|
|
1340
|
+
return convergeAgent(
|
|
1341
|
+
`Report whether ${prCoordinates} (HEAD ${head}) has merge conflicts with its base branch. Do not edit, commit, push, or rebase — read only.\n\n` +
|
|
1342
|
+
`GitHub computes mergeability asynchronously, so .mergeable is null right after a push until it finishes. Poll until it resolves: run\n` +
|
|
1343
|
+
` gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq '{mergeable: .mergeable, state: .mergeable_state}'\n` +
|
|
1344
|
+
`up to 5 times, 5 seconds apart (delay each retry with "sleep 5", or the PowerShell alternative "Start-Sleep -Seconds 5"), stopping as soon as mergeable is true or false.\n\n` +
|
|
1345
|
+
`Return conflicting:true when mergeable is false or state is "dirty" (the branch conflicts with the base). Return conflicting:false when mergeable is true, or when mergeable stays null after the full poll budget — mergeability is unknown, so let the bug checks proceed rather than rebase on a guess.`,
|
|
1346
|
+
{ label: 'check-merge-conflicts', phase: 'Converge', schema: MERGE_CONFLICT_SCHEMA, agentType: 'Explore' },
|
|
1347
|
+
)
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Conflict-resolution edit step: one clean-coder rebases the PR branch onto
|
|
1352
|
+
* origin/main and resolves every conflict in the working tree, making NO push —
|
|
1353
|
+
* the verify step binds a verdict to the rebased tree before the commit step
|
|
1354
|
+
* force-pushes it. A rebase necessarily creates local commits, which is expected;
|
|
1355
|
+
* only the force-push is withheld so the verifier binds the surface first.
|
|
1356
|
+
* @param {string} head PR HEAD SHA before the rebase
|
|
1357
|
+
* @returns {Promise<object>} CONFLICT_EDIT_SCHEMA result
|
|
1358
|
+
*/
|
|
1359
|
+
function resolveConflictsEdit(head) {
|
|
1360
|
+
return convergeAgent(
|
|
1361
|
+
`You are the EDIT step resolving merge conflicts for ${prCoordinates}, HEAD ${head}, before the bug checks run. The PR branch conflicts with origin/main. A separate verify step then a separate commit step run after you.\n\n` +
|
|
1362
|
+
`Rules:\n` +
|
|
1363
|
+
`- Confirm the working tree is on the PR branch at HEAD ${head} with no unrelated edits before you start.\n` +
|
|
1364
|
+
`- Rebase the branch onto origin/main and resolve every conflict so the tree is clean and conflict-free: git fetch origin main; git rebase origin/main; resolve each conflict, preserving the intent of both the PR's change and the incoming base change. A rebase creates local commits, which is fine.\n` +
|
|
1365
|
+
`- Do NOT push and do NOT force-push — the commit step force-pushes after the verify step binds a verdict. Pushing here would change the surface the verifier binds to.\n\n` +
|
|
1366
|
+
`Return rebased=true with a one-line summary when you rebased onto origin/main and resolved the conflicts; rebased=false with a summary when the branch did not actually need a rebase or you could not complete it.`,
|
|
1367
|
+
{ label: 'resolve-conflicts-edit', phase: 'Converge', schema: CONFLICT_EDIT_SCHEMA, agentType: 'clean-coder' },
|
|
1368
|
+
)
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Pre-flight conflict resolution: when the PR branch conflicts with its base,
|
|
1373
|
+
* rebase it clean before the bug checks run — check (Explore probes mergeability)
|
|
1374
|
+
* -> edit (clean-coder rebases and resolves, no push) -> verify (code-verifier
|
|
1375
|
+
* binds a verdict to the rebased tree) -> commit (clean-coder force-with-lease
|
|
1376
|
+
* pushes). Returns the post-rebase HEAD so the first converge round runs its
|
|
1377
|
+
* lenses on the conflict-free diff. A non-conflicting PR, a rebase the edit step
|
|
1378
|
+
* declined, or a failed verdict returns the unchanged HEAD so the run proceeds to
|
|
1379
|
+
* the bug checks unchanged. A mid-run conflict (origin/main advancing later) is
|
|
1380
|
+
* still caught by the FINALIZE convergence repair, which also rebases.
|
|
1381
|
+
* @param {string} head PR HEAD SHA before any rebase
|
|
1382
|
+
* @returns {Promise<string>} the HEAD SHA after a successful rebase push, or the unchanged head
|
|
1383
|
+
*/
|
|
1384
|
+
async function resolveMergeConflicts(head) {
|
|
1385
|
+
const mergeState = await checkMergeConflicts(head)
|
|
1386
|
+
if (!isMergeConflicting(mergeState)) return head
|
|
1387
|
+
log(`Pre-flight: ${prCoordinates} conflicts with origin/main — rebasing clean before the bug checks`)
|
|
1388
|
+
const editResult = await resolveConflictsEdit(head)
|
|
1389
|
+
if (editResult?.rebased !== true) return head
|
|
1390
|
+
const failures = ['PR branch had merge conflicts with origin/main; the rebase must leave a clean, conflict-free tree']
|
|
1391
|
+
const verifyTranscript = await verifyWithRecovery({
|
|
1392
|
+
runVerify: () => verifyRepairChanges(head, failures),
|
|
1393
|
+
runRecoverEdit: (objection, attempt) => recoverVerifyFailEdit(head, objection, 'conflict-rebase', attempt),
|
|
1394
|
+
})
|
|
1395
|
+
if (!verdictPassed(verifyTranscript)) return head
|
|
1396
|
+
const commitResult = await commitWithRecovery({
|
|
1397
|
+
runCommit: () => commitRepairFixes(head, true),
|
|
1398
|
+
runVerify: () => verifyRepairChanges(head, failures),
|
|
1399
|
+
runRecoverEdit: (detail, attempt) => recoverCommitBlockEdit(head, detail, 'conflict-rebase', attempt),
|
|
1400
|
+
})
|
|
1401
|
+
return commitResult?.newSha || head
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1238
1404
|
/**
|
|
1239
1405
|
* Decide whether a review round surfaced ONLY code-standard violations — pure
|
|
1240
1406
|
* CODE_RULES/style findings with no behavioral impact. Such a round passes for
|
|
@@ -1269,7 +1435,8 @@ function standardsFollowUpEdit(head, findings, sourceLabel) {
|
|
|
1269
1435
|
`1. Follow-up fix issue: file a GitHub issue on ${input.owner}/${input.repo} (gh issue create --body-file with a temp file) titled "Deferred code-standard fixes from PR #${input.prNumber}". The body references the PR and lists each finding with its file:line, severity, and detail. The issue carries the fix work; do not open a fix PR. Capture the issue URL.\n` +
|
|
1270
1436
|
`2. Stage the environment-hardening change: in the Claude environment config repo (the repo owning ~/.claude hooks and rules — JonEcho/llm-settings for hooks, jl-cmd/claude-code-config for rules/skills; pick whichever owns the surface that would block these violation classes), find or clone a local checkout, fetch origin, and create a branch off origin/main. Edit the hooks/rules in that checkout's WORKING TREE so each violation class found here is blocked at Write/Edit time, before code is written. Do NOT commit and do NOT push — the commit step does that after the verify step binds a verdict to the working tree. Return the checkout's absolute path in hardeningRepoPath, the branch name in hardeningBranch, and set hardeningEdited=true. When no hardening is feasible for these classes, leave hardeningRepoPath and hardeningBranch empty and hardeningEdited=false; the follow-up issue still stands.\n` +
|
|
1271
1437
|
`3. For each finding that carries a GitHub review comment id (${threadIds.length ? threadIds.join(', ') : 'none this batch'}): post an inline reply via python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --in-reply-to <id> --body "Code-standard-only finding — deferred to follow-up issue <url>." Then resolve the thread by its PRRT_ node id (GraphQL lookup on comment databaseId, then resolveReviewThread or the github MCP pull_request_review_write method=resolve_thread — not the numeric comment id).\n\n` +
|
|
1272
|
-
`Return the issue URL in issueUrl (empty string when it could not be filed), the hardening checkout path and branch, hardeningEdited, and a one-line summary
|
|
1438
|
+
`Return the issue URL in issueUrl (empty string when it could not be filed), the hardening checkout path and branch, hardeningEdited, and a one-line summary.` +
|
|
1439
|
+
PRE_COMMIT_GATE_STEP,
|
|
1273
1440
|
{ label: `standards-edit:${sourceLabel}`, phase: 'Converge', schema: STANDARDS_EDIT_SCHEMA, agentType: 'clean-coder' },
|
|
1274
1441
|
)
|
|
1275
1442
|
}
|
|
@@ -1428,9 +1595,36 @@ let bugbotDown = input.bugbotDisabled || false
|
|
|
1428
1595
|
let copilotDown = false
|
|
1429
1596
|
let copilotNote = null
|
|
1430
1597
|
let standardsNote = null
|
|
1598
|
+
let reuseNote = null
|
|
1431
1599
|
const allRoundFindings = []
|
|
1432
1600
|
const fixSummaries = []
|
|
1433
1601
|
|
|
1602
|
+
const preflightHead = await resolveHead()
|
|
1603
|
+
if (isResolvedHeadUsable(preflightHead)) {
|
|
1604
|
+
await resolveMergeConflicts(preflightHead)
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
log('Reuse pass: scanning the full diff for certain, behaviorally identical, autonomously implementable reuse improvements before convergence')
|
|
1608
|
+
const reuseHead = await resolveHead()
|
|
1609
|
+
if (isResolvedHeadUsable(reuseHead)) {
|
|
1610
|
+
await prefetchMainForRound()
|
|
1611
|
+
const reuse = await runReuseAuditPass(reuseHead)
|
|
1612
|
+
const reuseFindings = reuse?.findings || []
|
|
1613
|
+
if (reuseFindings.length > 0) {
|
|
1614
|
+
log(`Reuse pass: ${reuseFindings.length} qualifying reuse improvement(s) — applying before convergence`)
|
|
1615
|
+
allRoundFindings.push(...reuseFindings)
|
|
1616
|
+
const reuseFix = await applyFixes(reuseHead, reuseFindings, 'reuse-pass')
|
|
1617
|
+
if (reuseFix?.summary) fixSummaries.push(reuseFix.summary)
|
|
1618
|
+
reuseNote = reuseFix?.pushed === true
|
|
1619
|
+
? `${reuseFindings.length} reuse improvement(s) applied before convergence (${reuseFix.newSha?.slice(0, SHA_COMPARISON_PREFIX_LENGTH)})`
|
|
1620
|
+
: `${reuseFindings.length} reuse improvement(s) identified before convergence but not landed — the code-review lens re-surfaces any that remain`
|
|
1621
|
+
} else {
|
|
1622
|
+
log('Reuse pass: no reuse case cleared all three criteria — proceeding to convergence')
|
|
1623
|
+
}
|
|
1624
|
+
} else {
|
|
1625
|
+
log('Reuse pass: could not resolve HEAD — proceeding to convergence')
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1434
1628
|
while (iterations < CONFIG.maxIterations) {
|
|
1435
1629
|
iterations += 1
|
|
1436
1630
|
if (phase === 'CONVERGE') {
|
|
@@ -1556,7 +1750,7 @@ while (iterations < CONFIG.maxIterations) {
|
|
|
1556
1750
|
const readyOutcome = classifyReadyOutcome(readyResult)
|
|
1557
1751
|
if (readyOutcome.converged) {
|
|
1558
1752
|
await spawnConvergenceSummary(dedupeFindings(allRoundFindings), fixSummaries, rounds, standardsNote, copilotNote)
|
|
1559
|
-
return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote }
|
|
1753
|
+
return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote, reuseNote }
|
|
1560
1754
|
}
|
|
1561
1755
|
blocker = readyOutcome.blocker
|
|
1562
1756
|
break
|
|
@@ -1575,4 +1769,5 @@ return {
|
|
|
1575
1769
|
blocker: blocker || `iteration cap reached (${CONFIG.maxIterations})`,
|
|
1576
1770
|
standardsNote,
|
|
1577
1771
|
copilotNote,
|
|
1772
|
+
reuseNote,
|
|
1578
1773
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const workflowDirectory = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const convergeSource = readFileSync(join(workflowDirectory, 'converge.mjs'), 'utf8');
|
|
9
|
+
|
|
10
|
+
function sliceBetween(startNeedle, endNeedle) {
|
|
11
|
+
const sliceStart = convergeSource.indexOf(startNeedle);
|
|
12
|
+
assert.notEqual(sliceStart, -1, `expected ${startNeedle} to exist`);
|
|
13
|
+
const sliceEnd = convergeSource.indexOf(endNeedle, sliceStart + startNeedle.length);
|
|
14
|
+
assert.notEqual(sliceEnd, -1, `expected ${endNeedle} to exist after ${startNeedle}`);
|
|
15
|
+
return convergeSource.slice(sliceStart, sliceEnd);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const productionModule = new Function(
|
|
19
|
+
`${sliceBetween('const worktreeDirective =', '\nconst convergeAgent =')}\n` +
|
|
20
|
+
'return { worktreeDirective };',
|
|
21
|
+
)();
|
|
22
|
+
const { worktreeDirective } = productionModule;
|
|
23
|
+
|
|
24
|
+
test('a single-PR run (no repoPath) produces an empty worktree directive', () => {
|
|
25
|
+
assert.equal(worktreeDirective(null), '');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a path-scoped run pins every agent to the PR worktree by absolute path', () => {
|
|
29
|
+
const directive = worktreeDirective('/worktrees/pr-398');
|
|
30
|
+
assert.match(directive, /\/worktrees\/pr-398/);
|
|
31
|
+
assert.match(directive, /cd /);
|
|
32
|
+
assert.match(directive, /git, gh, diff, edit, commit, push, and test/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a path-scoped run defers to a step that names a different repository directory', () => {
|
|
36
|
+
assert.match(worktreeDirective('/worktrees/pr-398'), /different repository directory/i);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('convergeAgent prepends the worktree directive for the active repo path', () => {
|
|
40
|
+
const agentDefinition = sliceBetween('const convergeAgent =', '\nconst PRE_COMMIT_GATE_STEP');
|
|
41
|
+
assert.match(agentDefinition, /worktreeDirective\(activeRepoPath\)/);
|
|
42
|
+
assert.match(agentDefinition, /HEADLESS_SAFETY_PREAMBLE/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the run binds activeRepoPath from input.repoPath after the input is parsed', () => {
|
|
46
|
+
assert.match(convergeSource, /activeRepoPath = typeof input\.repoPath === 'string'/);
|
|
47
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autoconverge multi-PR fan-out workflow driver.
|
|
3
|
+
*
|
|
4
|
+
* SINGLE-FILE CONTRACT — keep this file self-contained. The Workflow runtime
|
|
5
|
+
* wraps this body in a function (so top-level await and return work) and rejects
|
|
6
|
+
* static import statements, and `export const meta` must be the first statement.
|
|
7
|
+
* This driver fans out one converge.mjs child run per PR with parallel(); the
|
|
8
|
+
* converge.mjs child uses only agent()/parallel() (never workflow()), so the
|
|
9
|
+
* one-level workflow() nesting limit holds.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const meta = {
|
|
13
|
+
name: 'autoconverge-multi',
|
|
14
|
+
description: 'Drive several draft PRs to convergence in one run: fan out one autoconverge converge.mjs child per PR in parallel, each pinned to its own checked-out worktree via repoPath, then report every PR\'s outcome together.',
|
|
15
|
+
whenToUse: 'Launched by the /autoconverge skill when the user names more than one PR to converge at once; the single-PR path launches workflow/converge.mjs directly.',
|
|
16
|
+
phases: [
|
|
17
|
+
{ title: 'Converge all', detail: 'One converge.mjs child run per PR, all in parallel; each child is pinned to its own PR worktree through repoPath' },
|
|
18
|
+
],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normalize the workflow args global into a parsed object.
|
|
23
|
+
*
|
|
24
|
+
* The Workflow runtime may deliver args as a JSON-encoded string or as an
|
|
25
|
+
* object; a string is parsed and an object passes through unchanged. A non-JSON
|
|
26
|
+
* or empty string yields null so a malformed payload becomes a structured
|
|
27
|
+
* blocker rather than aborting the run.
|
|
28
|
+
* @param {string|object} rawArgs the workflow args global (JSON string or object)
|
|
29
|
+
* @returns {object|null} the parsed args, or null when a string payload fails to parse
|
|
30
|
+
*/
|
|
31
|
+
function normalizeMultiInput(rawArgs) {
|
|
32
|
+
if (typeof rawArgs !== 'string') return rawArgs
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(rawArgs)
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Decide whether one PR entry carries every coordinate a child run needs.
|
|
42
|
+
*
|
|
43
|
+
* A child converge run needs the PR's owner, repo, and number to address its
|
|
44
|
+
* GitHub calls, and the absolute worktree path the PR is checked out in to pin
|
|
45
|
+
* its agents there.
|
|
46
|
+
* @param {object} prEntry one element of the args.prs array
|
|
47
|
+
* @returns {boolean} true when owner, repo, prNumber, and a non-empty string repoPath are all present
|
|
48
|
+
*/
|
|
49
|
+
function isUsablePrEntry(prEntry) {
|
|
50
|
+
return (
|
|
51
|
+
prEntry != null &&
|
|
52
|
+
Boolean(prEntry.owner) &&
|
|
53
|
+
Boolean(prEntry.repo) &&
|
|
54
|
+
Boolean(prEntry.prNumber) &&
|
|
55
|
+
typeof prEntry.repoPath === 'string' &&
|
|
56
|
+
Boolean(prEntry.repoPath)
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validate the normalized multi-PR input into usable coordinates or a blocker.
|
|
62
|
+
*
|
|
63
|
+
* A fan-out run needs the absolute converge.mjs script path and a non-empty list
|
|
64
|
+
* of PR entries that each carry owner, repo, prNumber, and the absolute worktree
|
|
65
|
+
* path the PR is checked out in. A payload that fails to parse, a non-string
|
|
66
|
+
* convergeScriptPath, a missing or empty prs list, or any entry missing a
|
|
67
|
+
* coordinate yields a blocker the top-level run reports as
|
|
68
|
+
* {converged:false, blocker} rather than throwing on a missing field.
|
|
69
|
+
* @param {string|object} rawArgs the workflow args global (JSON string or object)
|
|
70
|
+
* @returns {{input: object|null, blocker: string|null}} usable coordinates or a blocker
|
|
71
|
+
*/
|
|
72
|
+
function classifyMultiInput(rawArgs) {
|
|
73
|
+
const candidate = normalizeMultiInput(rawArgs)
|
|
74
|
+
if (candidate == null) {
|
|
75
|
+
return {
|
|
76
|
+
input: null,
|
|
77
|
+
blocker: 'invalid run coordinates: the workflow args did not parse into an object',
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (typeof candidate.convergeScriptPath !== 'string' || !candidate.convergeScriptPath) {
|
|
81
|
+
return {
|
|
82
|
+
input: null,
|
|
83
|
+
blocker:
|
|
84
|
+
'invalid run coordinates: convergeScriptPath (absolute path to converge.mjs) is required',
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(candidate.prs) || candidate.prs.length === 0) {
|
|
88
|
+
return {
|
|
89
|
+
input: null,
|
|
90
|
+
blocker: 'invalid run coordinates: prs must be a non-empty array of PR entries',
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const unusableEntryCount = candidate.prs.filter(
|
|
94
|
+
(eachEntry) => !isUsablePrEntry(eachEntry),
|
|
95
|
+
).length
|
|
96
|
+
if (unusableEntryCount > 0) {
|
|
97
|
+
return {
|
|
98
|
+
input: null,
|
|
99
|
+
blocker: `invalid run coordinates: ${unusableEntryCount} PR entry/entries missing owner, repo, prNumber, or repoPath`,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { input: candidate, blocker: null }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const multiInput = classifyMultiInput(args)
|
|
106
|
+
if (multiInput.blocker) {
|
|
107
|
+
return { converged: false, prCount: 0, convergedCount: 0, results: [], blocker: multiInput.blocker }
|
|
108
|
+
}
|
|
109
|
+
const input = multiInput.input
|
|
110
|
+
|
|
111
|
+
phase('Converge all')
|
|
112
|
+
log(`autoconverge multi-PR: driving ${input.prs.length} PR(s) to ready in parallel`)
|
|
113
|
+
|
|
114
|
+
const childResults = await parallel(
|
|
115
|
+
input.prs.map((eachPr) => async () => {
|
|
116
|
+
const childOutcome = await workflow(
|
|
117
|
+
{ scriptPath: input.convergeScriptPath },
|
|
118
|
+
{
|
|
119
|
+
owner: eachPr.owner,
|
|
120
|
+
repo: eachPr.repo,
|
|
121
|
+
prNumber: eachPr.prNumber,
|
|
122
|
+
repoPath: eachPr.repoPath,
|
|
123
|
+
bugbotDisabled: Boolean(eachPr.bugbotDisabled),
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
return {
|
|
127
|
+
owner: eachPr.owner,
|
|
128
|
+
repo: eachPr.repo,
|
|
129
|
+
prNumber: eachPr.prNumber,
|
|
130
|
+
converged: Boolean(childOutcome && childOutcome.converged),
|
|
131
|
+
rounds: childOutcome && childOutcome.rounds !== undefined ? childOutcome.rounds : null,
|
|
132
|
+
finalSha: childOutcome && childOutcome.finalSha !== undefined ? childOutcome.finalSha : null,
|
|
133
|
+
blocker: childOutcome && childOutcome.blocker !== undefined ? childOutcome.blocker : null,
|
|
134
|
+
}
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const results = childResults.map((eachResult, eachIndex) =>
|
|
139
|
+
eachResult === null
|
|
140
|
+
? {
|
|
141
|
+
owner: input.prs[eachIndex].owner,
|
|
142
|
+
repo: input.prs[eachIndex].repo,
|
|
143
|
+
prNumber: input.prs[eachIndex].prNumber,
|
|
144
|
+
converged: false,
|
|
145
|
+
rounds: null,
|
|
146
|
+
finalSha: null,
|
|
147
|
+
blocker: 'child run threw or was skipped before returning an outcome',
|
|
148
|
+
}
|
|
149
|
+
: eachResult,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const convergedCount = results.filter((eachResult) => eachResult.converged).length
|
|
153
|
+
log(`autoconverge multi-PR done: ${convergedCount}/${results.length} PR(s) converged`)
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
converged: convergedCount === results.length,
|
|
157
|
+
prCount: results.length,
|
|
158
|
+
convergedCount,
|
|
159
|
+
results,
|
|
160
|
+
blocker: null,
|
|
161
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const workflowDirectory = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const multiSource = readFileSync(join(workflowDirectory, 'converge_multi.mjs'), 'utf8');
|
|
9
|
+
|
|
10
|
+
function sourceSliceBetween(startNeedle, endNeedle) {
|
|
11
|
+
const sliceStart = multiSource.indexOf(startNeedle);
|
|
12
|
+
assert.notEqual(sliceStart, -1, `expected ${startNeedle} to exist`);
|
|
13
|
+
const sliceEnd = multiSource.indexOf(endNeedle, sliceStart + startNeedle.length);
|
|
14
|
+
assert.notEqual(sliceEnd, -1, `expected ${endNeedle} to exist after ${startNeedle}`);
|
|
15
|
+
return multiSource.slice(sliceStart, sliceEnd);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const productionModule = new Function(
|
|
19
|
+
`${sourceSliceBetween('function normalizeMultiInput(', '\nconst multiInput =')}\n` +
|
|
20
|
+
'return { normalizeMultiInput, isUsablePrEntry, classifyMultiInput };',
|
|
21
|
+
)();
|
|
22
|
+
const { normalizeMultiInput, classifyMultiInput } = productionModule;
|
|
23
|
+
|
|
24
|
+
const SCRIPT_PATH = '/abs/skills/autoconverge/workflow/converge.mjs';
|
|
25
|
+
|
|
26
|
+
function validEntry(prNumber) {
|
|
27
|
+
return {
|
|
28
|
+
owner: 'JonEcho',
|
|
29
|
+
repo: 'python-automation',
|
|
30
|
+
prNumber,
|
|
31
|
+
repoPath: `/worktrees/pr-${prNumber}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validArgs() {
|
|
36
|
+
return { convergeScriptPath: SCRIPT_PATH, prs: [validEntry(398), validEntry(402)] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('an object payload passes through unchanged', () => {
|
|
40
|
+
const parsed = validArgs();
|
|
41
|
+
assert.deepEqual(normalizeMultiInput(parsed), parsed);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a JSON-encoded string payload is parsed into coordinates', () => {
|
|
45
|
+
assert.deepEqual(normalizeMultiInput(JSON.stringify(validArgs())), validArgs());
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a non-JSON string returns null rather than throwing', () => {
|
|
49
|
+
assert.equal(normalizeMultiInput('not json at all'), null);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('valid coordinates classify with no blocker and keep every PR entry', () => {
|
|
53
|
+
const classified = classifyMultiInput(validArgs());
|
|
54
|
+
assert.equal(classified.blocker, null);
|
|
55
|
+
assert.equal(classified.input.prs.length, 2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a missing convergeScriptPath is blocked', () => {
|
|
59
|
+
const classified = classifyMultiInput({ prs: [validEntry(398)] });
|
|
60
|
+
assert.equal(classified.input, null);
|
|
61
|
+
assert.match(classified.blocker, /convergeScriptPath/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('an empty prs array is blocked', () => {
|
|
65
|
+
const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH, prs: [] });
|
|
66
|
+
assert.equal(classified.input, null);
|
|
67
|
+
assert.match(classified.blocker, /non-empty array/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a missing prs list is blocked', () => {
|
|
71
|
+
const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH });
|
|
72
|
+
assert.equal(classified.input, null);
|
|
73
|
+
assert.match(classified.blocker, /non-empty array/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a non-array prs value is blocked', () => {
|
|
77
|
+
const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH, prs: 'nope' });
|
|
78
|
+
assert.equal(classified.input, null);
|
|
79
|
+
assert.match(classified.blocker, /non-empty array/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('an entry missing prNumber is blocked', () => {
|
|
83
|
+
const badEntry = { owner: 'JonEcho', repo: 'python-automation', repoPath: '/w/x' };
|
|
84
|
+
const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH, prs: [badEntry] });
|
|
85
|
+
assert.equal(classified.input, null);
|
|
86
|
+
assert.match(classified.blocker, /missing/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('an entry missing repoPath is blocked', () => {
|
|
90
|
+
const badEntry = { owner: 'JonEcho', repo: 'python-automation', prNumber: 398 };
|
|
91
|
+
const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH, prs: [badEntry] });
|
|
92
|
+
assert.equal(classified.input, null);
|
|
93
|
+
assert.match(classified.blocker, /missing/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a null payload is blocked', () => {
|
|
97
|
+
const classified = classifyMultiInput('not json at all');
|
|
98
|
+
assert.equal(classified.input, null);
|
|
99
|
+
assert.match(classified.blocker, /did not parse/);
|
|
100
|
+
});
|