Skip to content

Commit a8e3f86

Browse files
committed
feat(apply): ship apply-engine steps 2–12 in one slice
Extend codemap apply from rename-preview-only to a full substrate-shaped fix executor: three diff-shape recipes, rename-preview call/re-export CTEs, auto_fixable gating with --force, agent row/diff inputs, fixpoint loop, optional git commit, and config allowlist. Adds apply_rows MCP/HTTP tool.
1 parent 8c7064a commit a8e3f86

37 files changed

Lines changed: 1382 additions & 214 deletions

docs/plans/apply-engine-direction.md

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Apply-engine direction — diff-shape recipes + agent-in-the-loop substrate
22

3-
> **Status:** open · substrate growth (Steps 5–7 partial) lives in [`substrate-extraction.md`](./substrate-extraction.md) (tiers 1–6 shipped). This plan owns the **apply-engine / diff-shape recipe** half of the richer-index synthesis.
3+
> **Status:** closing — Steps 2–4, 6–12 shipped (`feat/apply-engine-slices`). Substrate Steps 5–7 live in [`substrate-extraction.md`](./substrate-extraction.md) (tiers 1–6 shipped). This plan owns the **apply-engine / diff-shape recipe** half of the richer-index synthesis.
44
>
55
> **Motivator:** extend `codemap apply` from recipe-driven single-file hunks toward agent-in-the-loop row contracts (`apply --rows -`, diff-input, fixpoint loops) without crossing [Moat A](../roadmap.md#moats-load-bearing) (SQL/recipe API) or [Moat B](../roadmap.md#moats-load-bearing) (no re-extraction in apply).
66
>
@@ -20,47 +20,47 @@
2020

2121
## Open steps (implementation sequence)
2222

23-
### Step 2 — Three diff-shape recipes (S × 3)
23+
_All steps below shipped on branch `feat/apply-engine-slices` (single PR). Wave-2 recipes (`organize-imports`, `missing-exports`, …) remain backlog per synthesis § 4.3._
2424

25-
Ship `replace-marker-kind`, `migrate-import-source`, `add-jsdoc-deprecated` — pure SQL + frontmatter; no engine/schema change.
25+
### Step 2 — Three diff-shape recipes (S × 3) — ✓ Shipped
2626

27-
Open: sequential PRs per [tracer-bullets](../../.agents/rules/tracer-bullets.md); hold `organize-imports` / `missing-exports` for wave 2.
27+
`replace-marker-kind`, `migrate-import-source`, `add-jsdoc-deprecated` — bundled SQL + frontmatter + golden scenarios.
2828

29-
### Step 3 — Per-row `actions[].command` template (S)
29+
### Step 3 — Per-row `actions[].command` template (S) — ✓ Shipped
3030

31-
Extend `recipes-loader.ts` / `query-recipes.ts` so `actions[]` carries a rendered `command` template (reuse `recipe-params.ts`; block-list shape only).
31+
`RecipeAction.command` + `renderRecipeActionCommands` / `getQueryRecipeActionsRendered`.
3232

33-
### Step 4 — `auto_fixable` gating (S)
33+
### Step 4 — `auto_fixable` gating (S) — ✓ Shipped
3434

35-
Enforce `actions[].auto_fixable: true` (or `--force`) before write. `--force` bypasses gate only, not phase-1 conflict detection.
35+
`assertApplyAutoFixable` + CLI/MCP `--force`.
3636

37-
### Step 6 — App-wide rename recipe (S; depends Step 5 substrate ✓)
37+
### Step 6 — App-wide rename recipe (S) — ✓ Shipped
3838

39-
Extend `rename-preview.sql` with `call_rows` CTE. Bias: extend existing recipe id, not a second rename recipe. Preserve honest v1 gap list (re-exports, JSX, default-import binds).
39+
`rename-preview.sql` `call_rows` CTE; golden updated with `call_site` rows.
4040

41-
### Step 7 (recipe) — `rename-preview` `re_export_rows` CTE (S)
41+
### Step 7 (recipe) — `rename-preview` `re_export_rows` CTE (S) — ✓ Shipped
4242

43-
Substrate shipped; **recipe extension open**. Single-hop alias chains in v1; recursive CTE if gap appears.
43+
Single-hop `re_export_rows` CTE; gated by `include_re_exports`.
4444

45-
### Step 8 — `apply --rows -` + `apply_rows` MCP/HTTP tool (M)
45+
### Step 8 — `apply --rows -` + `apply_rows` MCP/HTTP tool (M) — ✓ Shipped
4646

47-
JSON array of `{file_path, line_start, before_pattern, after_pattern}` from stdin/args; existing phase-1/2 validation. Separate MCP tool (not polymorphic with recipe id).
47+
CLI `--rows`; MCP/HTTP `apply_rows` tool.
4848

49-
### Step 9 — `apply --diff-input <file>` (S)
49+
### Step 9 — `apply --diff-input <file>` (S) — ✓ Shipped
5050

51-
Unified diff → row contract → same engine. Hand-roll parser; per-file atomicity; cross-file rollback deferred.
51+
`parseUnifiedDiffToRows` + CLI `--diff-input`.
5252

53-
### Step 10 — `apply --commit "<msg>"` (S)
53+
### Step 10 — `apply --commit "<msg>"` (S) — ✓ Shipped
5454

55-
`git add` only files apply touched, then commit. Message templating deferred.
55+
`gitCommitAppliedFiles` + CLI `--commit`.
5656

57-
### Step 11 — `--until-empty` + `--max-passes N` (S)
57+
### Step 11 — `--until-empty` + `--max-passes N` (S) — ✓ Shipped
5858

59-
Apply → reindex → re-run recipe loop. Abort whole loop on phase-1 conflicts. Extend result envelope with `passes` + `terminated_by`.
59+
`runApplyUntilEmpty` + envelope `passes` / `terminated_by` (CLI only).
6060

61-
### Step 12 — `apply.autoApplyRecipes` allowlist (S)
61+
### Step 12 — `apply.autoApplyRecipes` allowlist (S) — ✓ Shipped
6262

63-
Config list of recipe ids allowed with `--yes` without interactive confirm. `--force` bypasses allowlist.
63+
`codemapUserConfigSchema.apply.autoApplyRecipes` + `assertApplyAllowlist`.
6464

6565
---
6666

docs/roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ Predicate-as-API only — enrich row shape and audit deltas; no standalone pass/
105105

106106
- [ ] **C.9 framework plugin layer** — static entry-point hints on `files` to sharpen reachability-predicate recipes (`untested-and-dead`, `unimported-exports`, future `dead-files-by-reachability`). Plan: [`plans/c9-plugin-layer.md`](./plans/c9-plugin-layer.md). Effort: XL; ships last in the impact-vs-cadence sequence (see plan § Shipping cadence).
107107
- [ ] **LSP diagnostic-push + VSCode extension** — recipes-as-`Diagnostic[]` server + paired extension; explicitly **not** a go-to-def / references shim (`tsserver` covers those). Plan: [`plans/lsp-diagnostic-push.md`](./plans/lsp-diagnostic-push.md). Effort: XL; soft ordering after C.9 for cleaner squigglies on framework files.
108-
- [ ] **Apply-engine direction** — diff-shape recipes, per-row `actions[].command`, `apply --rows` / `--diff-input` / fixpoint loop. Substrate tiers 1–6 shipped; open steps 2–12. Plan: [`plans/apply-engine-direction.md`](./plans/apply-engine-direction.md).
108+
- [x] **Apply-engine direction** — diff-shape recipes, `actions[].command`, `auto_fixable`/`--force`, `rename-preview` call/re-export CTEs, `apply --rows` / `apply_rows`, `--diff-input`, `--commit`, `--until-empty`, `apply.autoApplyRecipes`. Plan: [`plans/apply-engine-direction.md`](./plans/apply-engine-direction.md).
109109
- [ ] **`history` table** (deferred — revisit-triggered) — temporal queries: "when did symbol X get `@deprecated`?", "coverage trend over last 50 commits", "files that became dead this week". `audit --base <ref>` covers the most-common temporal question (PR-scoped diff) without schema growth, so the table earns its place only when bigger questions emerge. Two shapes (per-commit snapshots ~N × DB size; append-only event log heavier CTE walks); both pay an N-reindexes backfill cost (~30s per reindex). **Revisit triggers:** two consumers ship `jq`-based "audit-runs-over-time" workflows, OR `query_baselines` evolution becomes a recurring agent need.
110110
- [ ] **`codemap audit` verdict + thresholds** (v1.x) — `verdict: "pass" | "warn" | "fail"` driven by an `audit.deltas[<key>].{added_max, action}` field on the config object (`.codemap/config.{ts,js,json}`). Triggers: two consumers ship `jq`-based threshold scripts with similar shapes, OR one consumer asks with a concrete config sketch. Until then, raw deltas + consumer-side `jq` is the CI exit-code idiom. **Likely accelerant:** the Marketplace Action (next item) shipping is the most plausible path to firing the trigger — once `- uses: stainless-code/codemap@v1` is the dominant CI path, real `jq` threshold scripts will surface.
111111
- [ ] **GitHub Marketplace Action — publish + listing finish** — core Action implementation is in-tree: root `action.yml`, `query --ci`, `audit --format sarif` / `--ci`, package-manager detection, dogfood smoke, and opt-in `pr-comment` summary renderer have shipped. Remaining work is the release/listing slice: `MARKETPLACE.md`, `v1.0.0` / floating `v1` tags, Marketplace setup, sacrificial-repo smoke, and making `action-smoke` blocking once the Action tag exists. Action version stream is independent of CLI version (`package.json` currently drives CLI/npm version; Action publishes at its own `v1.0.0`). Plan: [`plans/github-marketplace-action.md`](./plans/github-marketplace-action.md). Effort: S.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[
2+
{
3+
"file_path": "src/api/client.ts",
4+
"line_start": 20,
5+
"line_end": 20,
6+
"before_pattern": "export function createClient",
7+
"after_pattern": "/** @deprecated Use createClientV2 */\nexport function createClient",
8+
"location_kind": "definition",
9+
"chain_depth": 0
10+
}
11+
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[
2+
{
3+
"file_path": "src/components/shop/ApiBridge.tsx",
4+
"line_start": 1,
5+
"line_end": 1,
6+
"before_pattern": "~/api/client",
7+
"after_pattern": "~/api/client-v2",
8+
"location_kind": "import_source",
9+
"chain_depth": 0
10+
},
11+
{
12+
"file_path": "src/consumer.ts",
13+
"line_start": 1,
14+
"line_end": 1,
15+
"before_pattern": "~/api/client",
16+
"after_pattern": "~/api/client-v2",
17+
"location_kind": "import_source",
18+
"chain_depth": 0
19+
}
20+
]

fixtures/golden/minimal/rename-preview.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@
88
"location_kind": "import_specifier",
99
"chain_depth": 0
1010
},
11+
{
12+
"file_path": "src/components/shop/ProductCard.tsx",
13+
"line_start": 11,
14+
"line_end": 11,
15+
"before_pattern": "usePermissions",
16+
"after_pattern": "useAccess",
17+
"location_kind": "call_site",
18+
"chain_depth": 0
19+
},
1120
{
1221
"file_path": "src/components/shop/ShopButton.tsx",
1322
"line_start": 1,
@@ -17,6 +26,15 @@
1726
"location_kind": "import_specifier",
1827
"chain_depth": 0
1928
},
29+
{
30+
"file_path": "src/components/shop/ShopButton.tsx",
31+
"line_start": 9,
32+
"line_end": 9,
33+
"before_pattern": "usePermissions",
34+
"after_pattern": "useAccess",
35+
"location_kind": "call_site",
36+
"chain_depth": 0
37+
},
2038
{
2139
"file_path": "src/usePermissions.ts",
2240
"line_start": 1,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[]

fixtures/golden/scenarios.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,35 @@
723723
"id": "try-catch-rethrow-heuristics",
724724
"prompt": "catch_rethrows: nested arrow throw vs direct rethrow (complexity-fixture)",
725725
"sql": "SELECT s.name, t.catch_rethrows FROM try_catch t JOIN symbols s ON s.file_path = t.file_path AND s.kind = 'function' AND t.try_line_start >= s.line_start AND t.try_line_start <= s.line_end WHERE t.file_path = 'src/lib/complexity-fixture.ts' AND s.name IN ('catchInnerArrowRethrow', 'catchDirectRethrow') ORDER BY s.name"
726+
},
727+
{
728+
"id": "replace-marker-kind",
729+
"prompt": "Replace TODO markers with FIXME in notes",
730+
"recipe": "replace-marker-kind",
731+
"params": {
732+
"from_kind": "TODO",
733+
"to_kind": "FIXME",
734+
"in_file": "src/notes"
735+
}
736+
},
737+
{
738+
"id": "migrate-import-source",
739+
"prompt": "Rewrite consumer import source path",
740+
"recipe": "migrate-import-source",
741+
"params": {
742+
"old_source": "~/api/client",
743+
"new_source": "~/api/client-v2"
744+
}
745+
},
746+
{
747+
"id": "add-jsdoc-deprecated",
748+
"prompt": "Insert @deprecated JSDoc above createClient",
749+
"recipe": "add-jsdoc-deprecated",
750+
"params": {
751+
"name": "createClient",
752+
"replacement": "createClientV2",
753+
"in_file": "src/api/"
754+
}
726755
}
727756
]
728757
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import { renderRecipeActionCommands } from "./apply-command-template";
4+
5+
describe("renderRecipeActionCommands", () => {
6+
it("substitutes {{param}} placeholders", () => {
7+
const out = renderRecipeActionCommands(
8+
[
9+
{
10+
type: "apply-rename",
11+
command:
12+
"codemap apply rename-preview --params old={{old}},new={{new}} --yes",
13+
},
14+
],
15+
{ old: "foo", new: "bar" },
16+
);
17+
expect(out?.[0]?.command).toBe(
18+
"codemap apply rename-preview --params old=foo,new=bar --yes",
19+
);
20+
});
21+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { RecipeParamValues } from "./recipe-params";
2+
import type { RecipeAction } from "./recipes-loader";
3+
4+
/**
5+
* Renders `{{param}}` placeholders in recipe action `command` templates using
6+
* bound param values (same names as recipe frontmatter `params:`).
7+
*/
8+
export function renderRecipeActionCommands(
9+
actions: RecipeAction[] | undefined,
10+
paramValues: RecipeParamValues,
11+
): RecipeAction[] | undefined {
12+
if (actions === undefined || actions.length === 0) return actions;
13+
const valueByName = new Map<string, string>();
14+
for (const [name, value] of Object.entries(paramValues)) {
15+
if (value === null) continue;
16+
valueByName.set(name, String(value));
17+
}
18+
return actions.map((action) => {
19+
if (action.command === undefined) return action;
20+
return {
21+
...action,
22+
command: renderCommandTemplate(action.command, valueByName),
23+
};
24+
});
25+
}
26+
27+
function renderCommandTemplate(
28+
template: string,
29+
valueByName: Map<string, string>,
30+
): string {
31+
return template.replace(
32+
/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g,
33+
(_m, name: string) => {
34+
const value = valueByName.get(name);
35+
return value ?? "";
36+
},
37+
);
38+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import { parseUnifiedDiffToRows } from "./apply-diff-input";
4+
5+
describe("parseUnifiedDiffToRows", () => {
6+
it("pairs single-line -/+ hunks", () => {
7+
const diff = `diff --git a/src/foo.ts b/src/foo.ts
8+
--- a/src/foo.ts
9+
+++ b/src/foo.ts
10+
@@ -2,1 +2,1 @@
11+
-oldName
12+
+newName
13+
`;
14+
const rows = parseUnifiedDiffToRows(diff);
15+
expect(rows).toEqual([
16+
{
17+
file_path: "src/foo.ts",
18+
line_start: 2,
19+
before_pattern: "oldName",
20+
after_pattern: "newName",
21+
},
22+
]);
23+
});
24+
});

0 commit comments

Comments
 (0)