From f7ebdd35c7567fdd77f4ec8f8da87bb08bdd7b44 Mon Sep 17 00:00:00 2001 From: Louise Lau Date: Thu, 2 Jul 2026 09:42:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(maintain):=20extract-helper=20scope=20(v8)?= =?UTF-8?q?=20=E2=80=94=20parameterized=20dedup=20with=20ZERO=20call-site?= =?UTF-8?q?=20risk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The near-dupe detection (find_similar_helpers) + parameterize proposal (v2) become an unattended maintain scope — in the only shape safe enough for 3am: - Pick ONE cluster that comes back WITH a mechanical parameterize proposal (bodies align token-for-token, ≤4 varying spots). No proposal → BLOCK — manual judgment isn't a nightly job. - Create the shared parameterized helper, then rewrite each original as a ONE-LINE delegating wrapper using the proposal's argument mapping. Signatures, exports, and every call site stay byte-for-byte compatible — the zero-caller-risk form of deduplication (unlike consolidate-dupes, which repoints callers and needs the full reference proof). - Skips clusters touching tests/generated code or with `dropped` (structurally different) members. - Ships through the shared verified-PR protocol: tests + types green or nothing lands. Parse aliases: extract-helper | helpers | parameterize. Demo scope card, docs/MAINTAIN.md v8 row, scope-list + prompt tests updated. --- docs/MAINTAIN.md | 1 + src/cli/maintain-demo.ts | 2 ++ src/schedule/recipes.ts | 28 +++++++++++++++++++++++--- test/maintain-demo.test.ts | 2 +- test/schedule-recipes-delivery.test.ts | 15 +++++++++++++- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/MAINTAIN.md b/docs/MAINTAIN.md index 460cae9..a82635a 100644 --- a/docs/MAINTAIN.md +++ b/docs/MAINTAIN.md @@ -40,6 +40,7 @@ All scopes live in `src/schedule/recipes.ts` as `*_SELECTION` constants. | `lint-fix` (v5) | Apply the linter's **autofixable** rules only | No behavior-changing fixers; bounded to a focus area, never `--fix` the whole repo | | `dep-bump` (v6) | Bump ONE dependency a patch/minor and prove tests pass | Requires a real test suite; never a major version | | `consolidate-dupes` (v7) | Merge ONE exact-duplicate helper pair, repointing callers | Bodies must be *exactly* equivalent (near-dupes out); every caller resolved via the code graph or it blocks — a missed caller breaks the build | +| `extract-helper` (v8) | Collapse ONE near-duplicate cluster into a parameterized shared helper; originals become thin delegating wrappers | Only clusters with a *mechanical* parameterize proposal (`find_similar_helpers`); **zero call-site edits** — signatures/exports unchanged; a wrapper that can't be pure delegation blocks | **Beyond exact duplicates:** the `find_similar_helpers` tool (`src/codegraph/helper-extract.ts`) detects *near*-duplicate helpers — copy-pasted-then-tweaked functions (same structure, a different diff --git a/src/cli/maintain-demo.ts b/src/cli/maintain-demo.ts index 4d7be60..f0c2862 100644 --- a/src/cli/maintain-demo.ts +++ b/src/cli/maintain-demo.ts @@ -22,6 +22,8 @@ const SCOPES: ScopeDemo[] = [ receipt: 'status: ✅ opened\nPR: https://github.com/you/app/pull/331\nverified: ✓ npm test (full suite)\nfiles: package.json (zod 3.22.4 → 3.23.8)' }, { name: 'consolidate-dupes', blurb: 'merge ONE exact-duplicate helper pair — code-graph proves every caller', verdict: 'opened', receipt: 'status: ✅ opened\nPR: https://github.com/you/app/pull/338\nverified: ✓ npm test · ✓ tsc\nfiles: src/format.ts (removed dup toKebab → src/util/case.ts), src/routes/api.ts' }, + { name: 'extract-helper', blurb: 'collapse ONE near-dupe cluster into a parameterized helper — originals become thin wrappers, zero call-site edits', verdict: 'opened', + receipt: 'status: ✅ opened\nPR: https://github.com/you/app/pull/341\nverified: ✓ npm test · ✓ tsc\nfiles: src/checks.ts (positive/negative/nonneg → addRangeCheck(kind, inclusive); signatures unchanged)' }, ]; const STEPS = ['Code-graph analysis', 'Prove safe (or block)', 'Sandbox branch', 'Verify (tests + types)', 'Open PR', 'Trust receipt']; diff --git a/src/schedule/recipes.ts b/src/schedule/recipes.ts index 1563a86..3b67141 100644 --- a/src/schedule/recipes.ts +++ b/src/schedule/recipes.ts @@ -71,8 +71,8 @@ function verifiedPrPrompt(goal: string): string { } /** The self-improving `maintain` recipe has SCOPES — each a conservative, provable cleanup. */ -export type MaintainScope = 'dead-code' | 'unused-imports' | 'unused-locals' | 'unused-params' | 'lint-fix' | 'dep-bump' | 'consolidate-dupes'; -export const MAINTAIN_SCOPES: readonly MaintainScope[] = ['dead-code', 'unused-imports', 'unused-locals', 'unused-params', 'lint-fix', 'dep-bump', 'consolidate-dupes'] as const; +export type MaintainScope = 'dead-code' | 'unused-imports' | 'unused-locals' | 'unused-params' | 'lint-fix' | 'dep-bump' | 'consolidate-dupes' | 'extract-helper'; +export const MAINTAIN_SCOPES: readonly MaintainScope[] = ['dead-code', 'unused-imports', 'unused-locals', 'unused-params', 'lint-fix', 'dep-bump', 'consolidate-dupes', 'extract-helper'] as const; /** Parse a maintain prompt into its scope + optional path focus + dry-run flag. PURE. * Forms: "" (→ dead-code) · "unused-imports src/" · "lint-fix --dry-run" · "dep-bump" · "src/utils". */ @@ -81,7 +81,7 @@ export function parseMaintainScope(prompt: string): { scope: MaintainScope; focu const dryRun = /(^|\s)(--dry-run|dry-run)(\s|$)/i.test(rest); rest = rest.replace(/(^|\s)(--dry-run|dry-run)(\s|$)/ig, ' ').trim(); let scope: MaintainScope = 'dead-code'; - const m = /^(unused[-_]?imports|imports|unused[-_]?locals|locals|unused[-_]?params|params|lint[-_]?fix|lint|dep[-_]?bump|deps?|dependenc(?:y|ies)|consolidate[-_]?dupes?|consolidate|duplicates?|dupes?|dedupe|dead[-_]?code)\b/i.exec(rest); + const m = /^(unused[-_]?imports|imports|unused[-_]?locals|locals|unused[-_]?params|params|lint[-_]?fix|lint|dep[-_]?bump|deps?|dependenc(?:y|ies)|consolidate[-_]?dupes?|consolidate|duplicates?|dupes?|dedupe|extract[-_]?helpers?|parameteri[sz]e|helpers?|dead[-_]?code)\b/i.exec(rest); if (m) { const k = m[1]!; scope = /imports/i.test(k) ? 'unused-imports' @@ -90,6 +90,7 @@ export function parseMaintainScope(prompt: string): { scope: MaintainScope; focu : /lint/i.test(k) ? 'lint-fix' : /dep/i.test(k) ? 'dep-bump' : /consolidate|duplicate|dupe|dedupe/i.test(k) ? 'consolidate-dupes' + : /extract|parameteri|helper/i.test(k) ? 'extract-helper' : 'dead-code'; rest = rest.slice(m[0].length).trim(); } @@ -203,6 +204,26 @@ const CONSOLIDATE_DUPES_SELECTION = [ 'e. This single consolidation IS your GOAL. Verification (tests + types) MUST pass or it does not ship.', ]; +const EXTRACT_HELPER_SELECTION = [ + 'SCOPE (v8 — SAFE HELPER EXTRACTION, parameterized): collapse ONE cluster of near-duplicate', + 'functions into a shared parameterized helper — WITHOUT touching a single call site. The', + 'originals become thin delegating wrappers, so every signature, export, and caller stays', + 'byte-for-byte compatible. This is the zero-caller-risk form of deduplication.', + '', + 'SELECTION — mechanical proposal or block:', + 'a. Run `find_similar_helpers`. Pick ONE cluster that comes back WITH a parameterize proposal', + ' (the tool emits one only when the bodies align token-for-token and ≤4 spots vary). No', + ' proposal on any cluster → BLOCK: manual judgment is required and 3am is not the time.', + 'b. Skip clusters touching test files, generated code, or members the proposal lists as', + ' `dropped` (structurally different) — the cluster must be fully covered by the proposal.', + 'c. Create the shared helper (private to the module or a sibling shared file — do NOT create a', + ' new public export unless the cluster spans files that already share an import path).', + 'd. Rewrite EACH original function as a one-line wrapper delegating to the helper with its', + ' argument mapping from the proposal. NEVER remove, rename, or re-sign the originals; NEVER', + ' edit a call site. If a wrapper cannot be a pure delegation, BLOCK.', + 'e. The helper + wrappers ARE your GOAL. Verification (tests + types) MUST pass or nothing ships.', +]; + /** * `maintain` — the self-improving codebase recipe. Each scope is the SAFEST improvement of its * kind, proven (code-graph / toolchain) and shipped through the verified-PR protocol. Conservative @@ -217,6 +238,7 @@ function maintainPrompt(prompt: string): string { : scope === 'lint-fix' ? LINT_FIX_SELECTION : scope === 'dep-bump' ? DEP_BUMP_SELECTION : scope === 'consolidate-dupes' ? CONSOLIDATE_DUPES_SELECTION + : scope === 'extract-helper' ? EXTRACT_HELPER_SELECTION : DEAD_CODE_SELECTION; const focusLine = focus ? `Focus area (optional hint): ${focus}.` : ''; const dryRunBlock = dryRun ? [ diff --git a/test/maintain-demo.test.ts b/test/maintain-demo.test.ts index 2d9a5a8..9f6eee6 100644 --- a/test/maintain-demo.test.ts +++ b/test/maintain-demo.test.ts @@ -12,7 +12,7 @@ describe('buildMaintainDemoHtml', () => { it('shows the nightly loop steps and all six scopes', () => { for (const step of ['Code-graph analysis', 'Verify (tests + types)', 'Open PR', 'Trust receipt']) expect(html).toContain(step); - for (const scope of ['dead-code', 'unused-imports', 'unused-locals', 'unused-params', 'lint-fix', 'dep-bump', 'consolidate-dupes']) expect(html).toContain(scope); + for (const scope of ['dead-code', 'unused-imports', 'unused-locals', 'unused-params', 'lint-fix', 'dep-bump', 'consolidate-dupes', 'extract-helper']) expect(html).toContain(scope); }); it('shows a trust receipt and the honesty claim (measured, not fabricated)', () => { diff --git a/test/schedule-recipes-delivery.test.ts b/test/schedule-recipes-delivery.test.ts index 2c0c843..b059a41 100644 --- a/test/schedule-recipes-delivery.test.ts +++ b/test/schedule-recipes-delivery.test.ts @@ -133,7 +133,20 @@ describe('recipes — Autonomous Verified PR', () => { expect(p).toMatch(/Near-duplicates .* are OUT of scope/i); // only exact equivalence expect(p).toMatch(/enumerate EVERY caller/i); // a missed caller breaks the build expect(p).toContain('```qodex-receipt'); - expect(MAINTAIN_SCOPES).toEqual(['dead-code', 'unused-imports', 'unused-locals', 'unused-params', 'lint-fix', 'dep-bump', 'consolidate-dupes']); + }); + + it('maintain scope=extract-helper: mechanical proposal only, delegating wrappers, zero call-site edits', () => { + expect(parseMaintainScope('extract-helper src/')).toEqual({ scope: 'extract-helper', focus: 'src/', dryRun: false }); + expect(parseMaintainScope('parameterize --dry-run')).toEqual({ scope: 'extract-helper', focus: '', dryRun: true }); + expect(parseMaintainScope('helpers')).toEqual({ scope: 'extract-helper', focus: '', dryRun: false }); + const p = buildRecipePrompt('maintain', 'extract-helper'); + expect(p).toContain('SAFE HELPER EXTRACTION'); + expect(p).toMatch(/WITHOUT touching a single call site/i); + expect(p).toMatch(/thin delegating wrappers/i); + expect(p).toMatch(/No[\s\S]*proposal on any cluster → BLOCK/i); // mechanical-or-block gate + expect(p).toMatch(/NEVER remove, rename, or re-sign/i); + expect(p).toContain('```qodex-receipt'); + expect(MAINTAIN_SCOPES).toEqual(['dead-code', 'unused-imports', 'unused-locals', 'unused-params', 'lint-fix', 'dep-bump', 'consolidate-dupes', 'extract-helper']); }); });