@decocms/blocks-cli 7.44.0 → 7.46.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/package.json +4 -3
- package/scripts/migrate/delete-sets.ts +75 -0
- package/scripts/migrate/phase-analyze.ts +8 -55
- package/scripts/reconcile.test.ts +162 -0
- package/scripts/reconcile.ts +301 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.46.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
|
|
6
6
|
"repository": {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"deco-audit-observability": "./scripts/audit-observability-config.ts",
|
|
18
18
|
"deco-migrate-blocks-to-kv": "./scripts/migrate-blocks-to-kv.ts",
|
|
19
19
|
"deco-sync-blocks-to-kv": "./scripts/sync-blocks-to-kv.ts",
|
|
20
|
-
"deco-upgrade-6-to-7": "./scripts/upgrade-6-to-7.ts"
|
|
20
|
+
"deco-upgrade-6-to-7": "./scripts/upgrade-6-to-7.ts",
|
|
21
|
+
"deco-reconcile": "./scripts/reconcile.ts"
|
|
21
22
|
},
|
|
22
23
|
"//exports": "Deliberately narrow. ./generate is the ONE public module entry (unified orchestrator); ./generate-blocks stays only because @decocms/tanstack's vite plugin tsImports it (readBlockDelta + programmatic generateBlocks) — each surviving entry must name its consumer here. The other scripts remain shipped FILES (the orchestrator spawns them; sites' existing `tsx node_modules/@decocms/blocks-cli/scripts/generate-*.ts` invocations keep working) but are internal implementation details of ./generate, not module subpaths. CLIs are exposed via bin, not exports.",
|
|
23
24
|
"exports": {
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
"lint:unused": "knip"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@decocms/blocks": "7.
|
|
35
|
+
"@decocms/blocks": "7.46.0",
|
|
35
36
|
"ts-morph": "^27.0.0",
|
|
36
37
|
"tsx": "^4.22.5"
|
|
37
38
|
},
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paths the migration deletes outright. Single source of truth: `decideAction`
|
|
3
|
+
* (phase-analyze) decides with them, and `deco-reconcile` filters with them — an
|
|
4
|
+
* upstream change to a file the migration deleted has no target equivalent, so
|
|
5
|
+
* reporting it just makes the agent consider migrating a file that must not exist.
|
|
6
|
+
*
|
|
7
|
+
* Only the unambiguous sets live here. Rules that are conditional on context
|
|
8
|
+
* (routes/ and apps/ are rescaffolded, root-level docs) stay in `decideAction`:
|
|
9
|
+
* reconcile must stay conservative, since dropping a real upstream change is a
|
|
10
|
+
* worse failure than showing one file too many.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Files that are generated and should be deleted */
|
|
14
|
+
export const GENERATED_FILES = new Set([
|
|
15
|
+
"fresh.gen.ts",
|
|
16
|
+
"manifest.gen.ts",
|
|
17
|
+
"fresh.config.ts",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/** SDK files that have framework equivalents or are scaffolded fresh */
|
|
21
|
+
export const SDK_DELETE = new Set([
|
|
22
|
+
"sdk/clx.ts",
|
|
23
|
+
"sdk/useId.ts",
|
|
24
|
+
// sdk/useOffer.ts — kept: sites often customize offer logic
|
|
25
|
+
// sdk/useVariantPossiblities.ts — kept: sites often customize variant logic
|
|
26
|
+
"sdk/usePlatform.tsx",
|
|
27
|
+
"sdk/signal.ts",
|
|
28
|
+
"sdk/format.ts",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/** Component files that are scaffolded fresh (old versions must not overwrite) */
|
|
32
|
+
export const COMPONENT_DELETE = new Set([
|
|
33
|
+
"components/ui/Image.tsx",
|
|
34
|
+
"components/ui/Picture.tsx",
|
|
35
|
+
"components/ui/Video.tsx",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** Loaders that depend on deleted admin tooling */
|
|
39
|
+
export const LOADER_DELETE = new Set([
|
|
40
|
+
"loaders/availableIcons.ts",
|
|
41
|
+
"loaders/icons.ts",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** Root config/infra files to delete */
|
|
45
|
+
export const ROOT_DELETE = new Set([
|
|
46
|
+
"main.ts",
|
|
47
|
+
"dev.ts",
|
|
48
|
+
"deno.json",
|
|
49
|
+
"deno.lock",
|
|
50
|
+
"tailwind.css",
|
|
51
|
+
"tailwind.config.ts",
|
|
52
|
+
"runtime.ts",
|
|
53
|
+
"constants.ts",
|
|
54
|
+
"fresh.gen.ts",
|
|
55
|
+
"manifest.gen.ts",
|
|
56
|
+
"fresh.config.ts",
|
|
57
|
+
"browserslist",
|
|
58
|
+
"bw_stats.json",
|
|
59
|
+
"islands.ts",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/** Static files that are code/tooling, not assets — should be deleted */
|
|
63
|
+
export const STATIC_DELETE = new Set([
|
|
64
|
+
"static/adminIcons.ts",
|
|
65
|
+
"static/generate-icons.ts",
|
|
66
|
+
"static/tailwind.css",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** True when the migration deletes this source path outright. */
|
|
70
|
+
export function isDeletedByMigration(relPath: string): boolean {
|
|
71
|
+
return GENERATED_FILES.has(relPath) || ROOT_DELETE.has(relPath) ||
|
|
72
|
+
SDK_DELETE.has(relPath) || COMPONENT_DELETE.has(relPath) ||
|
|
73
|
+
LOADER_DELETE.has(relPath) || STATIC_DELETE.has(relPath) ||
|
|
74
|
+
relPath.startsWith("sdk/cart/") || relPath.startsWith("apps/deco/");
|
|
75
|
+
}
|
|
@@ -11,6 +11,14 @@ import { extractSectionMetadata } from "./analyzers/section-metadata";
|
|
|
11
11
|
import { classifyIslands } from "./analyzers/island-classifier";
|
|
12
12
|
import { inventoryLoaders } from "./analyzers/loader-inventory";
|
|
13
13
|
import { extractTailwindConfig } from "./analyzers/tailwind-config";
|
|
14
|
+
import {
|
|
15
|
+
COMPONENT_DELETE,
|
|
16
|
+
GENERATED_FILES,
|
|
17
|
+
LOADER_DELETE,
|
|
18
|
+
ROOT_DELETE,
|
|
19
|
+
SDK_DELETE,
|
|
20
|
+
STATIC_DELETE,
|
|
21
|
+
} from "./delete-sets";
|
|
14
22
|
|
|
15
23
|
const PATTERN_DETECTORS: Array<[DetectedPattern, RegExp]> = [
|
|
16
24
|
["preact-hooks", /from\s+["']preact\/hooks["']/],
|
|
@@ -78,61 +86,6 @@ const SKIP_FILES = new Set([
|
|
|
78
86
|
"bun.lockb",
|
|
79
87
|
]);
|
|
80
88
|
|
|
81
|
-
/** Files that are generated and should be deleted */
|
|
82
|
-
const GENERATED_FILES = new Set([
|
|
83
|
-
"fresh.gen.ts",
|
|
84
|
-
"manifest.gen.ts",
|
|
85
|
-
"fresh.config.ts",
|
|
86
|
-
]);
|
|
87
|
-
|
|
88
|
-
/** SDK files that have framework equivalents or are scaffolded fresh */
|
|
89
|
-
const SDK_DELETE = new Set([
|
|
90
|
-
"sdk/clx.ts",
|
|
91
|
-
"sdk/useId.ts",
|
|
92
|
-
// sdk/useOffer.ts — kept: sites often customize offer logic
|
|
93
|
-
// sdk/useVariantPossiblities.ts — kept: sites often customize variant logic
|
|
94
|
-
"sdk/usePlatform.tsx",
|
|
95
|
-
"sdk/signal.ts",
|
|
96
|
-
"sdk/format.ts",
|
|
97
|
-
]);
|
|
98
|
-
|
|
99
|
-
/** Component files that are scaffolded fresh (old versions must not overwrite) */
|
|
100
|
-
const COMPONENT_DELETE = new Set([
|
|
101
|
-
"components/ui/Image.tsx",
|
|
102
|
-
"components/ui/Picture.tsx",
|
|
103
|
-
"components/ui/Video.tsx",
|
|
104
|
-
]);
|
|
105
|
-
|
|
106
|
-
/** Loaders that depend on deleted admin tooling */
|
|
107
|
-
const LOADER_DELETE = new Set([
|
|
108
|
-
"loaders/availableIcons.ts",
|
|
109
|
-
"loaders/icons.ts",
|
|
110
|
-
]);
|
|
111
|
-
|
|
112
|
-
/** Root config/infra files to delete */
|
|
113
|
-
const ROOT_DELETE = new Set([
|
|
114
|
-
"main.ts",
|
|
115
|
-
"dev.ts",
|
|
116
|
-
"deno.json",
|
|
117
|
-
"deno.lock",
|
|
118
|
-
"tailwind.css",
|
|
119
|
-
"tailwind.config.ts",
|
|
120
|
-
"runtime.ts",
|
|
121
|
-
"constants.ts",
|
|
122
|
-
"fresh.gen.ts",
|
|
123
|
-
"manifest.gen.ts",
|
|
124
|
-
"fresh.config.ts",
|
|
125
|
-
"browserslist",
|
|
126
|
-
"bw_stats.json",
|
|
127
|
-
"islands.ts",
|
|
128
|
-
]);
|
|
129
|
-
|
|
130
|
-
/** Static files that are code/tooling, not assets — should be deleted */
|
|
131
|
-
const STATIC_DELETE = new Set([
|
|
132
|
-
"static/adminIcons.ts",
|
|
133
|
-
"static/generate-icons.ts",
|
|
134
|
-
"static/tailwind.css",
|
|
135
|
-
]);
|
|
136
89
|
|
|
137
90
|
/**
|
|
138
91
|
* Scan file content for inline npm: imports and return { name: version } pairs.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
detectTargetSnapshot,
|
|
8
|
+
isSkipped,
|
|
9
|
+
parseNameStatus,
|
|
10
|
+
type ReconcileManifest,
|
|
11
|
+
targetCandidates,
|
|
12
|
+
} from "./reconcile";
|
|
13
|
+
|
|
14
|
+
describe("parseNameStatus", () => {
|
|
15
|
+
it("splits plain and rename entries", () => {
|
|
16
|
+
expect(parseNameStatus("M\tsections/A.tsx\nR094\tislands/B.tsx\tislands/C.tsx\n"))
|
|
17
|
+
.toEqual([
|
|
18
|
+
{ status: "M", path: "sections/A.tsx" },
|
|
19
|
+
{ status: "R094", path: "islands/C.tsx", oldPath: "islands/B.tsx" },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("isSkipped", () => {
|
|
25
|
+
it("drops CMS content, lockfiles and binaries but keeps source", () => {
|
|
26
|
+
expect(isSkipped(".deco/blocks/pages-home.json")).toBe(true);
|
|
27
|
+
expect(isSkipped("deno.lock")).toBe(true);
|
|
28
|
+
expect(isSkipped("static/logo.png")).toBe(true);
|
|
29
|
+
expect(isSkipped("sections/Header.tsx")).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("drops what the migration deletes — no target file to port into", () => {
|
|
33
|
+
expect(isSkipped("fresh.gen.ts")).toBe(true);
|
|
34
|
+
expect(isSkipped("manifest.gen.ts")).toBe(true);
|
|
35
|
+
expect(isSkipped("deno.json")).toBe(true);
|
|
36
|
+
expect(isSkipped("sdk/cart/vtex.ts")).toBe(true);
|
|
37
|
+
// routes/ is rescaffolded but a NEW upstream route is a real change — keep it.
|
|
38
|
+
expect(isSkipped("routes/blog.tsx")).toBe(false);
|
|
39
|
+
expect(isSkipped("redirects.csv")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("targetCandidates", () => {
|
|
44
|
+
it("ranks the conventional guess first, keeps other basename matches", () => {
|
|
45
|
+
const byBasename = new Map([
|
|
46
|
+
["Cart.tsx", ["src/sections/Cart.tsx", "src/components/Cart.tsx"]],
|
|
47
|
+
]);
|
|
48
|
+
expect(targetCandidates("islands/Cart.tsx", byBasename)).toEqual([
|
|
49
|
+
"src/components/Cart.tsx",
|
|
50
|
+
"src/sections/Cart.tsx",
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns nothing when the file does not exist on the target", () => {
|
|
55
|
+
expect(targetCandidates("sections/New.tsx", new Map())).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("detectTargetSnapshot", () => {
|
|
60
|
+
it("takes the OLDEST add — git log is newest-first, re-adds must not win", () => {
|
|
61
|
+
expect(detectTargetSnapshot("bbb\naaa\n")).toBe("aaa");
|
|
62
|
+
expect(detectTargetSnapshot("")).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("reconcile end to end", () => {
|
|
67
|
+
let tmp: string;
|
|
68
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
69
|
+
execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8" }).trim();
|
|
70
|
+
|
|
71
|
+
const write = (repo: string, rel: string, body: string) => {
|
|
72
|
+
fs.mkdirSync(path.join(repo, path.dirname(rel)), { recursive: true });
|
|
73
|
+
fs.writeFileSync(path.join(repo, rel), body);
|
|
74
|
+
};
|
|
75
|
+
const commit = (repo: string, msg: string) => {
|
|
76
|
+
git(repo, "add", "-A");
|
|
77
|
+
git(repo, "commit", "-m", msg);
|
|
78
|
+
return git(repo, "rev-parse", "HEAD");
|
|
79
|
+
};
|
|
80
|
+
const init = (name: string) => {
|
|
81
|
+
const repo = path.join(tmp, name);
|
|
82
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
83
|
+
git(repo, "init", "-q", "-b", "main");
|
|
84
|
+
git(repo, "config", "user.email", "t@t.t");
|
|
85
|
+
git(repo, "config", "user.name", "t");
|
|
86
|
+
return repo;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
// realpath: macOS /var → /private/var, which git resolves and we compare against.
|
|
91
|
+
tmp = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "reconcile-"));
|
|
92
|
+
});
|
|
93
|
+
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
94
|
+
|
|
95
|
+
it("auto-detects the target snapshot, emits one patch per file, flags hand-fixes", () => {
|
|
96
|
+
const source = init("source");
|
|
97
|
+
write(source, "sections/Header.tsx", "export default () => <h1>a</h1>;\n");
|
|
98
|
+
write(source, "sections/Footer.tsx", "export default () => <footer/>;\n");
|
|
99
|
+
write(source, "deno.lock", "{}\n");
|
|
100
|
+
const snapshot = commit(source, "cut");
|
|
101
|
+
|
|
102
|
+
write(source, "sections/Header.tsx", "export default () => <h1>b</h1>;\n");
|
|
103
|
+
write(source, "sections/Footer.tsx", "export default () => <footer id='f'/>;\n");
|
|
104
|
+
write(source, "deno.lock", '{"v":2}\n');
|
|
105
|
+
const sourceHead = commit(source, "upstream work");
|
|
106
|
+
|
|
107
|
+
const target = init("target");
|
|
108
|
+
write(target, "src/sections/Header.tsx", "export default () => <h1>a</h1>;\n");
|
|
109
|
+
// The island/section re-export pair — Header has TWO target candidates.
|
|
110
|
+
write(target, "src/components/Header.tsx", "export default () => <h1>a</h1>;\n");
|
|
111
|
+
write(target, "src/sections/Footer.tsx", "export default () => <footer/>;\n");
|
|
112
|
+
// The marker deco-migrate leaves behind — this is what auto-detection finds.
|
|
113
|
+
write(target, "MIGRATION_REPORT.md", "# Migration\n");
|
|
114
|
+
const targetSnapshot = commit(target, "migrate to tanstack");
|
|
115
|
+
|
|
116
|
+
// One hand-fix touching both candidates → must count as ONE collision.
|
|
117
|
+
write(target, "src/sections/Header.tsx", "export default () => <h1>a fixed</h1>;\n");
|
|
118
|
+
write(target, "src/components/Header.tsx", "export default () => <h1>a fixed</h1>;\n");
|
|
119
|
+
commit(target, "fix: codemod ate the heading");
|
|
120
|
+
|
|
121
|
+
const out = path.join(tmp, "out");
|
|
122
|
+
execFileSync(
|
|
123
|
+
path.join(__dirname, "../../../node_modules/.bin/tsx"),
|
|
124
|
+
[
|
|
125
|
+
path.join(__dirname, "reconcile.ts"),
|
|
126
|
+
"--source", source,
|
|
127
|
+
"--target", target,
|
|
128
|
+
"--snapshot", snapshot,
|
|
129
|
+
// no --target-snapshot: exercise the MIGRATION_REPORT.md auto-detection
|
|
130
|
+
"--out", out,
|
|
131
|
+
],
|
|
132
|
+
{ encoding: "utf8" },
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const manifest: ReconcileManifest = JSON.parse(
|
|
136
|
+
fs.readFileSync(path.join(out, "manifest.json"), "utf8"),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(manifest.sourceHead).toBe(sourceHead);
|
|
140
|
+
expect(manifest.targetSnapshot).toBe(targetSnapshot);
|
|
141
|
+
// deno.lock filtered out.
|
|
142
|
+
expect(manifest.files.map((f) => f.sourcePath).sort()).toEqual([
|
|
143
|
+
"sections/Footer.tsx",
|
|
144
|
+
"sections/Header.tsx",
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const header = manifest.files.find((f) => f.sourcePath === "sections/Header.tsx")!;
|
|
148
|
+
expect(header.targetCandidates).toEqual([
|
|
149
|
+
"src/sections/Header.tsx", // conventional guess ranks first
|
|
150
|
+
"src/components/Header.tsx",
|
|
151
|
+
]);
|
|
152
|
+
expect(header.collision).toHaveLength(1); // deduped by SHA, not 2
|
|
153
|
+
expect(header.collision[0]).toContain("codemod ate the heading");
|
|
154
|
+
expect(fs.readFileSync(path.join(out, header.patch), "utf8")).toContain("<h1>b</h1>");
|
|
155
|
+
|
|
156
|
+
const footer = manifest.files.find((f) => f.sourcePath === "sections/Footer.tsx")!;
|
|
157
|
+
expect(footer.collision).toEqual([]);
|
|
158
|
+
expect(footer.done).toBe(false);
|
|
159
|
+
|
|
160
|
+
expect(fs.readFileSync(path.join(out, "INDEX.md"), "utf8")).toContain(sourceHead);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Snapshot reconcile — produces a per-file diff of everything that landed on the
|
|
4
|
+
* Fresh/Deno SOURCE repo since the migration cut, plus the context an agent needs
|
|
5
|
+
* to port each change into the already-migrated TanStack TARGET repo.
|
|
6
|
+
*
|
|
7
|
+
* Migrations take weeks. During that window the source team keeps shipping and
|
|
8
|
+
* the migration team hand-fixes what the codemod got wrong. Reconciling is a
|
|
9
|
+
* REBASE, not a re-migration: re-running `deco-migrate` over the tree overwrites
|
|
10
|
+
* the hand-fixes. So this script makes NO judgement and writes NOTHING to the
|
|
11
|
+
* target — it emits one patch per file and lets the agent (see the
|
|
12
|
+
* `deco-reconcile-snapshot` skill) work them one at a time.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* npx -p @decocms/blocks-cli deco-reconcile \
|
|
16
|
+
* --source ../site-fresh --target ../site-tanstack --snapshot <cut sha>
|
|
17
|
+
*
|
|
18
|
+
* Options:
|
|
19
|
+
* --source <dir> Fresh/Deno repo checkout (required)
|
|
20
|
+
* --target <dir> Migrated TanStack repo checkout (required)
|
|
21
|
+
* --snapshot <sha> Last reconciled source commit — the cut (required)
|
|
22
|
+
* --target-snapshot <sha> The migration commit on the target. Defaults to the
|
|
23
|
+
* commit that added MIGRATION_REPORT.md, else HEAD.
|
|
24
|
+
* Everything after it counts as a hand-fix, so a wrong
|
|
25
|
+
* value silently empties the collision list.
|
|
26
|
+
* --out <dir> Output dir (default: <target>/.reconcile/<sourceHead:7>)
|
|
27
|
+
* --verbose Log every file
|
|
28
|
+
* --help, -h Show this help
|
|
29
|
+
*
|
|
30
|
+
* Output: <out>/manifest.json (machine, also the resume state via `done`),
|
|
31
|
+
* <out>/INDEX.md (human), <out>/patches/NNN-<slug>.patch (one per file).
|
|
32
|
+
*
|
|
33
|
+
* Exit codes: 0 ok; 2 bad args / git failure.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { execFileSync } from "node:child_process";
|
|
37
|
+
import * as fs from "node:fs";
|
|
38
|
+
import { isDeletedByMigration } from "./migrate/delete-sets";
|
|
39
|
+
import * as path from "node:path";
|
|
40
|
+
|
|
41
|
+
/** Paths whose upstream changes never reconcile into the target. */
|
|
42
|
+
const SKIP = [
|
|
43
|
+
/^\.deco\//, // CMS content — syncs through its own channel
|
|
44
|
+
/^\.github\//, // target has its own scaffolded workflows
|
|
45
|
+
/^_fresh\//,
|
|
46
|
+
/(^|\/)(deno\.lock|package-lock\.json|bun\.lock|bun\.lockb|yarn\.lock)$/,
|
|
47
|
+
/\.(png|jpe?g|gif|webp|avif|ico|woff2?|ttf|eot|mp4|webm|pdf|zip)$/i,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export interface ReconcileFile {
|
|
51
|
+
/** git --name-status letter: A/M/D/R… */
|
|
52
|
+
status: string;
|
|
53
|
+
sourcePath: string;
|
|
54
|
+
/** Previous path, for renames only. */
|
|
55
|
+
oldPath?: string;
|
|
56
|
+
/** Patch file, relative to the output dir. */
|
|
57
|
+
patch: string;
|
|
58
|
+
/** Where this MIGHT live on the target. Candidates, not a verdict. */
|
|
59
|
+
targetCandidates: string[];
|
|
60
|
+
/** Target commits touching a candidate since the migration commit. */
|
|
61
|
+
collision: string[];
|
|
62
|
+
done: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ReconcileManifest {
|
|
66
|
+
source: string;
|
|
67
|
+
target: string;
|
|
68
|
+
snapshot: string;
|
|
69
|
+
/** Feed this back as --snapshot on the next round. */
|
|
70
|
+
sourceHead: string;
|
|
71
|
+
targetSnapshot: string;
|
|
72
|
+
files: ReconcileFile[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function git(cwd: string, args: string[]): string {
|
|
76
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function slug(p: string): string {
|
|
83
|
+
return p.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parse `git diff --name-status -M` output. Renames carry two tab-separated paths. */
|
|
87
|
+
export function parseNameStatus(
|
|
88
|
+
out: string,
|
|
89
|
+
): Array<{ status: string; path: string; oldPath?: string }> {
|
|
90
|
+
return out.split("\n").filter(Boolean).map((line) => {
|
|
91
|
+
const [status, a, b] = line.split("\t");
|
|
92
|
+
return b ? { status, path: b, oldPath: a } : { status, path: a };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isSkipped(relPath: string): boolean {
|
|
97
|
+
return SKIP.some((re) => re.test(relPath)) || isDeletedByMigration(relPath);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Guess where a source file lives on the target. The migration moved things
|
|
102
|
+
* around (islands/→src/components/, static/→public/, sections stay under src/),
|
|
103
|
+
* so this is basename matching plus the conventional `src/` prefix — deliberately
|
|
104
|
+
* a shortlist for the agent to confirm against the target tree, not a mapping.
|
|
105
|
+
*/
|
|
106
|
+
export function targetCandidates(
|
|
107
|
+
relPath: string,
|
|
108
|
+
byBasename: Map<string, string[]>,
|
|
109
|
+
): string[] {
|
|
110
|
+
const guesses = [
|
|
111
|
+
`src/${relPath}`,
|
|
112
|
+
`src/${relPath.replace(/^islands\//, "components/")}`,
|
|
113
|
+
relPath.replace(/^static\//, "public/"),
|
|
114
|
+
];
|
|
115
|
+
const found = byBasename.get(path.basename(relPath)) ?? [];
|
|
116
|
+
// Only real target paths survive; the guesses just rank the likely one first.
|
|
117
|
+
return [...new Set([...guesses.filter((g) => found.includes(g)), ...found])];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function usage(msg?: string): never {
|
|
121
|
+
if (msg) console.error(`error: ${msg}\n`);
|
|
122
|
+
console.error(
|
|
123
|
+
"usage: deco-reconcile --source <dir> --target <dir> --snapshot <sha> [--target-snapshot <sha>] [--out <dir>] [--verbose]",
|
|
124
|
+
);
|
|
125
|
+
process.exit(msg ? 2 : 0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The migration commit on the target — everything after it is a hand-fix.
|
|
130
|
+
* `deco-migrate` leaves no provenance except MIGRATION_REPORT.md, so the commit
|
|
131
|
+
* that added it is the marker. No marker: fall back to HEAD, which makes every
|
|
132
|
+
* collision range empty — loudly, because a silent empty list reads as "nobody
|
|
133
|
+
* touched anything" and that is exactly the wrong thing to believe.
|
|
134
|
+
*/
|
|
135
|
+
export function detectTargetSnapshot(log: string): string | undefined {
|
|
136
|
+
return log.split("\n").filter(Boolean).pop();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function main() {
|
|
140
|
+
const argv = process.argv.slice(2);
|
|
141
|
+
if (argv.includes("--help") || argv.includes("-h")) usage();
|
|
142
|
+
const flag = (name: string) => {
|
|
143
|
+
const i = argv.indexOf(`--${name}`);
|
|
144
|
+
return i === -1 ? undefined : argv[i + 1];
|
|
145
|
+
};
|
|
146
|
+
const verbose = argv.includes("--verbose");
|
|
147
|
+
|
|
148
|
+
const source = flag("source");
|
|
149
|
+
const target = flag("target");
|
|
150
|
+
const snapshot = flag("snapshot");
|
|
151
|
+
if (!source || !target || !snapshot) {
|
|
152
|
+
usage("--source, --target and --snapshot are all required");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const targetSnapshot = flag("target-snapshot") ??
|
|
156
|
+
detectTargetSnapshot(
|
|
157
|
+
git(target, [
|
|
158
|
+
"log",
|
|
159
|
+
"--diff-filter=A",
|
|
160
|
+
"--format=%H",
|
|
161
|
+
"--",
|
|
162
|
+
"MIGRATION_REPORT.md",
|
|
163
|
+
]),
|
|
164
|
+
) ?? git(target, ["rev-parse", "HEAD"]).trim();
|
|
165
|
+
if (!flag("target-snapshot")) {
|
|
166
|
+
console.log(
|
|
167
|
+
`--target-snapshot not given, using ${targetSnapshot.slice(0, 7)} (${
|
|
168
|
+
git(target, ["log", "-1", "--format=%s", targetSnapshot]).trim()
|
|
169
|
+
})`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const sourceHead = git(source, ["rev-parse", "HEAD"]).trim();
|
|
174
|
+
if (sourceHead === git(source, ["rev-parse", snapshot]).trim()) {
|
|
175
|
+
console.log("Nothing to reconcile — source HEAD is already the snapshot.");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const outDir = flag("out") ??
|
|
180
|
+
path.join(target, ".reconcile", sourceHead.slice(0, 7));
|
|
181
|
+
fs.mkdirSync(path.join(outDir, "patches"), { recursive: true });
|
|
182
|
+
|
|
183
|
+
const byBasename = new Map<string, string[]>();
|
|
184
|
+
for (const p of git(target, ["ls-files"]).split("\n").filter(Boolean)) {
|
|
185
|
+
const key = path.basename(p);
|
|
186
|
+
byBasename.set(key, [...(byBasename.get(key) ?? []), p]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Submodules (mode 160000) diff as a one-line gitlink bump — no source to port.
|
|
190
|
+
const submodules = new Set(
|
|
191
|
+
git(source, ["ls-files", "--stage"]).split("\n")
|
|
192
|
+
.filter((l) => l.startsWith("160000"))
|
|
193
|
+
.map((l) => l.split("\t")[1]),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const changes = parseNameStatus(
|
|
197
|
+
git(source, ["diff", "--name-status", "-M", `${snapshot}..${sourceHead}`]),
|
|
198
|
+
);
|
|
199
|
+
const files: ReconcileFile[] = [];
|
|
200
|
+
|
|
201
|
+
for (const change of changes) {
|
|
202
|
+
if (isSkipped(change.path) || submodules.has(change.path)) {
|
|
203
|
+
if (verbose) console.log(` skip ${change.path}`);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const n = String(files.length + 1).padStart(3, "0");
|
|
207
|
+
const patch = `patches/${n}-${slug(change.path)}.patch`;
|
|
208
|
+
fs.writeFileSync(
|
|
209
|
+
path.join(outDir, patch),
|
|
210
|
+
git(source, [
|
|
211
|
+
"diff",
|
|
212
|
+
"-M",
|
|
213
|
+
`${snapshot}..${sourceHead}`,
|
|
214
|
+
"--",
|
|
215
|
+
...(change.oldPath ? [change.oldPath, change.path] : [change.path]),
|
|
216
|
+
]),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const candidates = targetCandidates(change.path, byBasename);
|
|
220
|
+
// A target file touched since the migration commit is a hand-fix. The agent
|
|
221
|
+
// must reconcile hunk by hunk instead of applying the upstream change whole.
|
|
222
|
+
// Deduped by SHA: one commit touching two candidates (the island/section
|
|
223
|
+
// re-export pair) is one hand-fix, not two — an inflated count misreads the
|
|
224
|
+
// "does this fit one human review sitting" gate.
|
|
225
|
+
const bySha = new Map<string, string>();
|
|
226
|
+
for (const c of candidates) {
|
|
227
|
+
for (
|
|
228
|
+
const line of git(target, [
|
|
229
|
+
"log",
|
|
230
|
+
"--format=%h %s",
|
|
231
|
+
`${targetSnapshot}..HEAD`,
|
|
232
|
+
"--",
|
|
233
|
+
c,
|
|
234
|
+
]).split("\n").filter(Boolean)
|
|
235
|
+
) {
|
|
236
|
+
const sha = line.split(" ")[0];
|
|
237
|
+
if (!bySha.has(sha)) bySha.set(sha, `${c}: ${line}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const collision = [...bySha.values()];
|
|
241
|
+
|
|
242
|
+
files.push({
|
|
243
|
+
status: change.status,
|
|
244
|
+
sourcePath: change.path,
|
|
245
|
+
oldPath: change.oldPath,
|
|
246
|
+
patch,
|
|
247
|
+
targetCandidates: candidates,
|
|
248
|
+
collision,
|
|
249
|
+
done: false,
|
|
250
|
+
});
|
|
251
|
+
if (verbose) {
|
|
252
|
+
console.log(
|
|
253
|
+
` ${change.status.padEnd(4)} ${change.path} → ${
|
|
254
|
+
candidates.join(", ") || "(no target match — new file?)"
|
|
255
|
+
}${collision.length ? ` [COLLISION x${collision.length}]` : ""}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const manifest: ReconcileManifest = {
|
|
261
|
+
source,
|
|
262
|
+
target,
|
|
263
|
+
snapshot,
|
|
264
|
+
sourceHead,
|
|
265
|
+
targetSnapshot,
|
|
266
|
+
files,
|
|
267
|
+
};
|
|
268
|
+
fs.writeFileSync(
|
|
269
|
+
path.join(outDir, "manifest.json"),
|
|
270
|
+
JSON.stringify(manifest, null, 2) + "\n",
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const collided = files.filter((f) => f.collision.length);
|
|
274
|
+
fs.writeFileSync(
|
|
275
|
+
path.join(outDir, "INDEX.md"),
|
|
276
|
+
[
|
|
277
|
+
`# Reconcile ${snapshot.slice(0, 7)}..${sourceHead.slice(0, 7)}`,
|
|
278
|
+
"",
|
|
279
|
+
`SOURCE_HEAD \`${sourceHead}\` — feed this back as \`--snapshot\` next round.`,
|
|
280
|
+
"",
|
|
281
|
+
`Target snapshot \`${targetSnapshot}\` — commits after it count as hand-fixes.`,
|
|
282
|
+
"",
|
|
283
|
+
`${files.length} files, ${collided.length} with collisions.`,
|
|
284
|
+
"",
|
|
285
|
+
"| # | status | source | target candidates | collision |",
|
|
286
|
+
"|---|---|---|---|---|",
|
|
287
|
+
...files.map((f, i) =>
|
|
288
|
+
`| ${i + 1} | ${f.status} | \`${f.sourcePath}\` | ${
|
|
289
|
+
f.targetCandidates.map((c) => `\`${c}\``).join("<br>") || "—"
|
|
290
|
+
} | ${f.collision.length || ""} |`
|
|
291
|
+
),
|
|
292
|
+
"",
|
|
293
|
+
].join("\n"),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
console.log(
|
|
297
|
+
`${files.length} files (${collided.length} colliding) → ${outDir}\nSOURCE_HEAD ${sourceHead}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (process.argv[1] && /reconcile\.ts$/.test(process.argv[1])) main();
|