@hozu/cli 0.10.0 → 0.12.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.
Files changed (126) hide show
  1. package/dist/commands/add-widget.d.ts.map +1 -1
  2. package/dist/commands/add-widget.js +5 -4
  3. package/dist/commands/add-widget.js.map +1 -1
  4. package/dist/commands/app.d.ts.map +1 -1
  5. package/dist/commands/app.js +10 -0
  6. package/dist/commands/app.js.map +1 -1
  7. package/dist/commands/build.d.ts.map +1 -1
  8. package/dist/commands/build.js +5 -1
  9. package/dist/commands/build.js.map +1 -1
  10. package/dist/commands/call.d.ts +16 -0
  11. package/dist/commands/call.d.ts.map +1 -0
  12. package/dist/commands/call.js +100 -0
  13. package/dist/commands/call.js.map +1 -0
  14. package/dist/commands/check.d.ts +10 -1
  15. package/dist/commands/check.d.ts.map +1 -1
  16. package/dist/commands/check.js +43 -28
  17. package/dist/commands/check.js.map +1 -1
  18. package/dist/commands/explain.d.ts.map +1 -1
  19. package/dist/commands/explain.js +2 -1
  20. package/dist/commands/explain.js.map +1 -1
  21. package/dist/commands/impact.js +1 -1
  22. package/dist/commands/impact.js.map +1 -1
  23. package/dist/commands/inspect.d.ts.map +1 -1
  24. package/dist/commands/inspect.js +9 -0
  25. package/dist/commands/inspect.js.map +1 -1
  26. package/dist/commands/map.d.ts.map +1 -1
  27. package/dist/commands/map.js +7 -2
  28. package/dist/commands/map.js.map +1 -1
  29. package/dist/commands/migrate.d.ts +17 -16
  30. package/dist/commands/migrate.d.ts.map +1 -1
  31. package/dist/commands/migrate.js +205 -297
  32. package/dist/commands/migrate.js.map +1 -1
  33. package/dist/commands/scaffold.d.ts.map +1 -1
  34. package/dist/commands/scaffold.js +8 -4
  35. package/dist/commands/scaffold.js.map +1 -1
  36. package/dist/contract.d.ts +84 -1
  37. package/dist/contract.d.ts.map +1 -1
  38. package/dist/load.d.ts +2 -1
  39. package/dist/load.d.ts.map +1 -1
  40. package/dist/load.js +5 -4
  41. package/dist/load.js.map +1 -1
  42. package/dist/main.d.ts.map +1 -1
  43. package/dist/main.js +33 -5
  44. package/dist/main.js.map +1 -1
  45. package/dist/migrate/ast.d.ts +31 -0
  46. package/dist/migrate/ast.d.ts.map +1 -0
  47. package/dist/migrate/ast.js +61 -0
  48. package/dist/migrate/ast.js.map +1 -0
  49. package/dist/migrate/record-ir.d.ts +2 -0
  50. package/dist/migrate/record-ir.d.ts.map +1 -0
  51. package/dist/migrate/record-ir.js +16 -0
  52. package/dist/migrate/record-ir.js.map +1 -0
  53. package/dist/migrate/step-0.11.d.ts +15 -0
  54. package/dist/migrate/step-0.11.d.ts.map +1 -0
  55. package/dist/migrate/step-0.11.js +88 -0
  56. package/dist/migrate/step-0.11.js.map +1 -0
  57. package/dist/migrate/step-0.12.d.ts +6 -0
  58. package/dist/migrate/step-0.12.d.ts.map +1 -0
  59. package/dist/migrate/step-0.12.js +14 -0
  60. package/dist/migrate/step-0.12.js.map +1 -0
  61. package/dist/migrate/steps.d.ts +28 -0
  62. package/dist/migrate/steps.d.ts.map +1 -0
  63. package/dist/migrate/steps.js +42 -0
  64. package/dist/migrate/steps.js.map +1 -0
  65. package/dist/output.d.ts.map +1 -1
  66. package/dist/output.js +2 -0
  67. package/dist/output.js.map +1 -1
  68. package/package.json +8 -8
  69. package/schema/call.schema.json +136 -0
  70. package/schema/check.schema.json +26 -2
  71. package/schema/explain.schema.json +14 -1
  72. package/schema/impact.schema.json +22 -1
  73. package/schema/inspect.schema.json +75 -4
  74. package/schema/map.schema.json +17 -0
  75. package/schema/migrate.schema.json +740 -0
  76. package/schema/plan.schema.json +4 -2
  77. package/schema/render.schema.json +3 -1
  78. package/schema/validate.schema.json +3 -1
  79. package/dist/commands/migrate-agent.d.ts +0 -12
  80. package/dist/commands/migrate-agent.d.ts.map +0 -1
  81. package/dist/commands/migrate-agent.js +0 -6
  82. package/dist/commands/migrate-agent.js.map +0 -1
  83. package/dist/commands/migrate-app.d.ts +0 -15
  84. package/dist/commands/migrate-app.d.ts.map +0 -1
  85. package/dist/commands/migrate-app.js +0 -334
  86. package/dist/commands/migrate-app.js.map +0 -1
  87. package/dist/commands/migrate-ast.d.ts +0 -63
  88. package/dist/commands/migrate-ast.d.ts.map +0 -1
  89. package/dist/commands/migrate-ast.js +0 -257
  90. package/dist/commands/migrate-ast.js.map +0 -1
  91. package/dist/commands/migrate-forms.d.ts +0 -4
  92. package/dist/commands/migrate-forms.d.ts.map +0 -1
  93. package/dist/commands/migrate-forms.js +0 -120
  94. package/dist/commands/migrate-forms.js.map +0 -1
  95. package/dist/commands/migrate-freshness.d.ts +0 -4
  96. package/dist/commands/migrate-freshness.d.ts.map +0 -1
  97. package/dist/commands/migrate-freshness.js +0 -47
  98. package/dist/commands/migrate-freshness.js.map +0 -1
  99. package/dist/commands/migrate-head.d.ts +0 -8
  100. package/dist/commands/migrate-head.d.ts.map +0 -1
  101. package/dist/commands/migrate-head.js +0 -132
  102. package/dist/commands/migrate-head.js.map +0 -1
  103. package/dist/commands/migrate-links.d.ts +0 -5
  104. package/dist/commands/migrate-links.d.ts.map +0 -1
  105. package/dist/commands/migrate-links.js +0 -55
  106. package/dist/commands/migrate-links.js.map +0 -1
  107. package/dist/commands/migrate-lists.d.ts +0 -9
  108. package/dist/commands/migrate-lists.d.ts.map +0 -1
  109. package/dist/commands/migrate-lists.js +0 -225
  110. package/dist/commands/migrate-lists.js.map +0 -1
  111. package/dist/commands/migrate-normalize.d.ts +0 -27
  112. package/dist/commands/migrate-normalize.d.ts.map +0 -1
  113. package/dist/commands/migrate-normalize.js +0 -252
  114. package/dist/commands/migrate-normalize.js.map +0 -1
  115. package/dist/commands/migrate-ops.d.ts +0 -4
  116. package/dist/commands/migrate-ops.d.ts.map +0 -1
  117. package/dist/commands/migrate-ops.js +0 -192
  118. package/dist/commands/migrate-ops.js.map +0 -1
  119. package/dist/commands/migrate-parts.d.ts +0 -7
  120. package/dist/commands/migrate-parts.d.ts.map +0 -1
  121. package/dist/commands/migrate-parts.js +0 -166
  122. package/dist/commands/migrate-parts.js.map +0 -1
  123. package/dist/commands/migrate-stale.d.ts +0 -24
  124. package/dist/commands/migrate-stale.d.ts.map +0 -1
  125. package/dist/commands/migrate-stale.js +0 -114
  126. package/dist/commands/migrate-stale.js.map +0 -1
@@ -1,26 +1,32 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
3
4
  import { dirname, extname, join, relative, resolve } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { runnerOf, writeAgentFiles } from 'create-hozu';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { HozuCliError } from '../errors.js';
7
7
  import { load } from '../load.js';
8
+ import { chain, compareMinor, minorOf, OLDEST } from '../migrate/steps.js';
8
9
  import { runCheck } from './check.js';
9
- import { migrateGuide, skillTargets } from './migrate-agent.js';
10
- import { migrateApp, migratePackage } from './migrate-app.js';
11
- import { forget, lineOf, parse, walk } from './migrate-ast.js';
12
- import { migrateForms } from './migrate-forms.js';
13
- import { migrateFreshness } from './migrate-freshness.js';
14
- import { migrateHead } from './migrate-head.js';
15
- import { migrateLinks } from './migrate-links.js';
16
- import { migrateLists } from './migrate-lists.js';
17
- import { differences, normalize07 } from './migrate-normalize.js';
18
- import { migrateOps } from './migrate-ops.js';
19
- import { migrateParts } from './migrate-parts.js';
10
+ import { runSkill } from './skill.js';
20
11
  const SKIP = /(^|\/)(node_modules|dist|\.[^/]+)(\/|$)/;
21
- const RECORD = '.hozu/migrate-0.7.json';
12
+ const cliVersion = () => {
13
+ const pkg = new URL('../../package.json', import.meta.url);
14
+ return JSON.parse(readFileSync(fileURLToPath(pkg), 'utf8')).version;
15
+ };
16
+ export function installedCore(config) {
17
+ let d = dirname(config);
18
+ for (;;) {
19
+ const pkg = join(d, 'node_modules/@hozu/core/package.json');
20
+ if (existsSync(pkg))
21
+ return JSON.parse(readFileSync(pkg, 'utf8')).version;
22
+ const up = dirname(d);
23
+ if (up === d)
24
+ return null;
25
+ d = up;
26
+ }
27
+ }
22
28
  export function sources(dir) {
23
- const out = new Map();
29
+ const out = [];
24
30
  const visit = (d) => {
25
31
  for (const name of readdirSync(d).sort()) {
26
32
  const path = join(d, name);
@@ -29,329 +35,231 @@ export function sources(dir) {
29
35
  if (statSync(path).isDirectory())
30
36
  visit(path);
31
37
  else if (extname(name) === '.ts' && !name.endsWith('.d.ts'))
32
- out.set(path, readFileSync(path, 'utf8'));
38
+ out.push(path);
33
39
  }
34
40
  };
35
41
  visit(dir);
36
42
  return out;
37
43
  }
38
- export function checkOn07(config) {
39
- const self = fileURLToPath(import.meta.url);
40
- const child = spawnSync(process.execPath, ['--no-warnings', join(dirname(self), `migrate-stale${extname(self)}`), config], { cwd: dirname(config), maxBuffer: 1 << 28, env: { ...process.env, NODE_OPTIONS: '' } });
44
+ /** The IR as the app's installed Hozu builds it, in a child process with the app's own transform hook. */
45
+ function recordWithInstalled(config) {
46
+ const require = createRequire(config);
47
+ let register;
41
48
  try {
42
- return JSON.parse(child.stdout.toString());
49
+ register = require.resolve('@hozu/transform/register');
43
50
  }
44
51
  catch {
45
- return {
46
- core: null,
47
- irVersion: null,
48
- skipped: `the stale check failed: ${child.stderr.toString().split('\n').find(Boolean) ?? 'no output'}`,
49
- stale: [],
50
- routes: {},
51
- errors: [],
52
- ir: null,
53
- };
52
+ throw new HozuCliError('config', 'hozu migrate needs the app’s installed @hozu/transform to read the old IR', ['npm install, then run hozu migrate again']);
54
53
  }
55
- }
56
- const PRINTS = [
57
- [
58
- 'any',
59
- /AsyncLocalStorage/,
60
- 'request state in AsyncLocalStorage (hand-made i18n?): the URL holds the language (site.locales)',
61
- 'i18n',
62
- ],
63
- [
64
- 'any',
65
- /accept-language/i,
66
- 'hand-made language negotiation: the URL holds the language (site.locales), no Accept-Language redirect',
67
- 'i18n',
68
- ],
69
- [
70
- 'any',
71
- /Session\s*=\s*z\.object\(\{[^}]*\b(?:lang|locale|language)\s*:/,
72
- 'a language in the session: the URL holds the language (site.locales), not the session',
73
- 'i18n',
74
- ],
75
- [
76
- 'resolvers',
77
- /<html\b[^>]*\blang\b|text\/html|<!doctype/i,
78
- 'HTML from a resolver is HZ053: make it a ui.page with head.failed',
79
- 'pages',
80
- ],
81
- ];
82
- const REDIRECT = "a redirect Response: declare output: 'redirect' and return redirect(ui.link(…))";
83
- const statusOf = (n) => {
84
- if (n?.type === 'Literal' && typeof n.value === 'number')
85
- return n.value;
86
- if (n?.type !== 'ObjectExpression')
87
- return null;
88
- const p = n.properties.find((x) => x.type === 'Property' && (x.key.name ?? x.key.value) === 'status');
89
- return p?.value.type === 'Literal' && typeof p.value.value === 'number' ? p.value.value : null;
90
- };
91
- function responses(file, source) {
92
- let program;
54
+ const self = fileURLToPath(import.meta.url);
55
+ const script = join(dirname(self), `../migrate/record-ir${extname(self)}`);
56
+ const child = spawnSync(process.execPath, ['--no-warnings', '--import', pathToFileURL(register).href, script, config], { cwd: dirname(config), maxBuffer: 1 << 28, env: { ...process.env, NODE_OPTIONS: '' } });
93
57
  try {
94
- program = parse(source);
58
+ return JSON.parse(child.stdout.toString());
95
59
  }
96
60
  catch {
97
- return [];
61
+ throw new HozuCliError('build', `hozu migrate could not build the app with its installed Hozu: ${child.stderr.toString().split('\n').find(Boolean) ?? 'no output'}`, ['npx hozu check # fix the app on its current version first']);
98
62
  }
63
+ }
64
+ /** JSON pointers where two IRs differ, each with both values, at most 50. */
65
+ export function differences(before, after, at = '') {
99
66
  const out = [];
100
- walk(program, (n) => {
101
- const c = n.callee;
102
- const built = n.type === 'NewExpression' && c.type === 'Identifier' && c.name === 'Response';
103
- const helper = n.type === 'CallExpression' &&
104
- c.type === 'MemberExpression' &&
105
- c.object.type === 'Identifier' &&
106
- c.object.name === 'Response' &&
107
- ['json', 'redirect'].includes(c.property.name);
108
- if (!built && !helper)
67
+ const show = (v) => {
68
+ const s = v === undefined ? 'absent' : JSON.stringify(v);
69
+ return s.length > 80 ? `${s.slice(0, 77)}…` : s;
70
+ };
71
+ const visit = (a, b, path) => {
72
+ if (out.length >= 50)
109
73
  return;
110
- const status = statusOf(n.arguments[1]) ?? (helper && c.property.name === 'redirect' ? 302 : null);
111
- if (status === null || status < 300 || status >= 500)
74
+ if (JSON.stringify(a) === JSON.stringify(b))
112
75
  return;
113
- const message = status < 400
114
- ? REDIRECT
115
- : `a ${status} Response: declare errors: { Name: schema } and failed: { Name: ${status} }, and return fail('Name', …)`;
116
- out.push({
117
- file,
118
- line: lineOf(source, n.start),
119
- rule: 'print',
120
- message,
121
- see: 'endpoints',
122
- behaviour: true,
123
- });
124
- });
76
+ const objects = a && b && typeof a === 'object' && typeof b === 'object' && Array.isArray(a) === Array.isArray(b);
77
+ if (!objects) {
78
+ out.push(`${path || '/'}: ${show(a)} → ${show(b)}`);
79
+ return;
80
+ }
81
+ const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
82
+ for (const k of keys)
83
+ visit(a[k], b[k], `${path}/${k.replace(/~/g, '~0').replace(/\//g, '~1')}`);
84
+ };
85
+ visit(before, after, at);
125
86
  return out;
126
87
  }
127
- /** What migrate cannot rewrite and check cannot see (ADR 0043 Migration). */
128
- export function printed(file, source) {
88
+ function raisePackages(pkgPath, version, write) {
89
+ if (!existsSync(pkgPath))
90
+ return [];
91
+ const source = readFileSync(pkgPath, 'utf8');
92
+ const pkg = JSON.parse(source);
129
93
  const out = [];
130
- const resolvers = /\bimplement\s*\(/.test(source);
131
- for (const [where, re, message, see] of PRINTS) {
132
- if (where === 'resolvers' && !resolvers)
133
- continue;
134
- const m = re.exec(source);
135
- if (m)
136
- out.push({ file, line: lineOf(source, m.index), rule: 'print', message, see, behaviour: true });
137
- }
138
- return resolvers ? [...out, ...responses(file, source)] : out;
139
- }
140
- /** Every source rewrite of `hozu migrate 0.8`, in memory: removed files are absent from `files`. */
141
- export function rewriteSources(dir, config, before, routes) {
142
- const files = new Map(before);
143
- const notes = [];
144
- const passes = [
145
- migrateOps,
146
- migrateHead,
147
- migrateFreshness,
148
- (s, f) => migrateLinks(s, f, routes),
149
- migrateForms,
150
- migrateLists,
151
- ];
152
- for (const pass of passes)
153
- for (const [file, source] of files) {
154
- forget();
155
- const r = pass(source, file);
156
- files.set(file, r.code);
157
- notes.push(...r.notes);
94
+ for (const field of ['dependencies', 'devDependencies'])
95
+ for (const [name, range] of Object.entries(pkg[field] ?? {})) {
96
+ if (!name.startsWith('@hozu/') && name !== 'create-hozu')
97
+ continue;
98
+ if (/^(workspace|file|link):/.test(range))
99
+ continue;
100
+ const next = `^${version}`;
101
+ if (range === next)
102
+ continue;
103
+ pkg[field][name] = next;
104
+ out.push({ name, from: range, to: next });
158
105
  }
159
- for (let round = 0; round < 5; round++) {
160
- forget();
161
- const parts = migrateParts(files);
162
- for (const [f, s] of parts.files)
163
- files.set(f, s);
164
- notes.push(...parts.notes.filter((n) => !notes.some((m) => m.file === n.file && m.message === n.message)));
165
- if (!parts.files.size)
166
- break;
167
- }
168
- forget();
169
- const app = migrateApp(dir, config, files);
170
- for (const [f, s] of app.write)
171
- files.set(f, s);
172
- notes.push(...app.notes);
173
- for (const f of app.remove)
174
- files.delete(f);
175
- for (const [file, source] of files)
176
- notes.push(...printed(file, source));
177
- forget();
178
- return { files, notes, app };
106
+ if (out.length && write)
107
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
108
+ return out;
179
109
  }
180
- export async function runMigrate(cwd, configArg, version, options = {}) {
181
- if (version !== '0.8')
182
- throw new HozuCliError('usage', 'hozu migrate upgrades a 0.7 app: hozu migrate 0.8', ['hozu migrate 0.8']);
183
- const config = resolve(cwd, configArg ?? 'hozu.config.ts');
110
+ const installCommand = (dir) => existsSync(join(dir, 'pnpm-lock.yaml'))
111
+ ? 'pnpm install'
112
+ : existsSync(join(dir, 'yarn.lock'))
113
+ ? 'yarn install'
114
+ : 'npm install';
115
+ export async function runMigrate(cwd, options) {
116
+ const config = resolve(cwd, options.config ?? 'hozu.config.ts');
184
117
  if (!existsSync(config))
185
118
  throw new HozuCliError('config', `No config found at ${config}`, [
186
- 'Run hozu migrate 0.8 in the app directory',
119
+ 'run hozu migrate in the app directory',
187
120
  ]);
188
121
  const dir = dirname(config);
189
- const rel = (f) => relative(cwd, f) || '.';
190
- const on07 = checkOn07(config);
191
- const record = join(dir, RECORD);
192
- if (on07.ir) {
193
- mkdirSync(dirname(record), { recursive: true });
194
- writeFileSync(record, JSON.stringify(on07.ir));
195
- }
196
- const before = sources(dir);
197
- const { files, notes, app } = rewriteSources(dir, config, before, new Map(Object.entries(on07.routes)));
198
- const changed = [];
199
- const write = (file, code) => {
200
- if (file.endsWith('hozu.lock.json'))
201
- return;
202
- writeFileSync(file, code);
203
- changed.push(rel(file));
122
+ const rel = (p) => relative(cwd, p) || '.';
123
+ const installed = options.versions?.installed ?? installedCore(config);
124
+ if (!installed)
125
+ throw new HozuCliError('config', 'No installed @hozu/core: install the app’s dependencies first', [
126
+ installCommand(dir),
127
+ ]);
128
+ const target = options.versions?.target ?? cliVersion();
129
+ const from = minorOf(installed);
130
+ const to = minorOf(target);
131
+ if (compareMinor(from, OLDEST) < 0)
132
+ throw new HozuCliError('usage', `hozu migrate starts at ${OLDEST}.0; this app is on ${installed}. Upgrade it to ${OLDEST} by hand with the CHANGELOG first`, ['https://github.com/olevatorr/Hozu/blob/main/CHANGELOG.md']);
133
+ if (compareMinor(from, to) > 0)
134
+ throw new HozuCliError('usage', `The app is on ${installed}, newer than this CLI (${target})`, [
135
+ `npx @hozu/cli@${installed} …`,
136
+ ]);
137
+ const record = join(dir, `.hozu/migrate-${from}.json`);
138
+ const out = {
139
+ ok: true,
140
+ from: installed,
141
+ to: target,
142
+ phase: 'current',
143
+ dryRun: options.dryRun,
144
+ steps: [],
145
+ changed: [],
146
+ notes: [],
147
+ packages: [],
148
+ record: null,
149
+ ir: { compared: false, skipped: null, differences: [] },
150
+ guide: [],
151
+ check: null,
152
+ next: [],
204
153
  };
205
- for (const [file, code] of files)
206
- if (before.get(file) !== code)
207
- write(file, code);
208
- for (const file of app.remove)
209
- rmSync(file, { force: true });
210
- const pkgPath = join(dir, 'package.json');
211
- if (existsSync(pkgPath)) {
212
- const source = readFileSync(pkgPath, 'utf8');
213
- const pkg = migratePackage(source, app.entry);
214
- if (pkg.code !== source)
215
- write(pkgPath, pkg.code);
216
- notes.push(...pkg.notes.map((n) => ({ ...n, file: pkgPath })));
217
- }
218
- const guide = [];
219
- const targets = skillTargets(dir);
220
- if (targets.length) {
221
- const runner = runnerOf(process.env.npm_config_user_agent);
222
- const agent = targets.length === 2 ? 'both' : targets[0].guide === 'CLAUDE.md' ? 'claude' : 'agents';
223
- const existing = new Map(targets.map((t) => [
224
- t.guide,
225
- existsSync(join(dir, t.guide)) ? readFileSync(join(dir, t.guide), 'utf8') : null,
226
- ]));
227
- const skills = new Map(targets.map((t) => [t.skill, contentOf(join(dir, t.skill))]));
228
- await writeAgentFiles(dir, agent, { name: dir.split('/').pop(), runner });
229
- for (const t of targets) {
230
- if (contentOf(join(dir, t.skill)) !== skills.get(t.skill))
231
- changed.push(rel(join(dir, t.skill)));
232
- const file = join(dir, t.guide);
233
- const r = migrateGuide(existing.get(t.guide) ?? null, t, dir, runner);
234
- if (r.kind === 'custom') {
235
- guide.push({ file: rel(file), state: 'custom', block: r.block });
236
- }
237
- else {
238
- if (r.kind !== 'current')
239
- write(file, r.code);
240
- guide.push({ file: rel(file), state: r.kind, block: null });
154
+ if (compareMinor(from, to) < 0) {
155
+ const plan = chain(from, to);
156
+ if (!plan)
157
+ throw new HozuCliError('usage', `No migration path from ${from} to ${to}`, []);
158
+ out.phase = 'rewrite';
159
+ out.steps = plan.map((s) => ({ from: s.from, to: s.to, summary: s.summary }));
160
+ const ir = (options.recordIR ?? recordWithInstalled)(config);
161
+ for (const file of sources(dir)) {
162
+ let code = readFileSync(file, 'utf8');
163
+ const original = code;
164
+ let edits = 0;
165
+ for (const step of plan) {
166
+ const r = step.rewrite(rel(file), code);
167
+ code = r.code;
168
+ edits += r.count;
169
+ out.notes.push(...r.notes);
241
170
  }
171
+ if (code === original)
172
+ continue;
173
+ out.changed.push({ file: rel(file), edits });
174
+ if (!options.dryRun)
175
+ writeFileSync(file, code);
242
176
  }
243
- }
244
- const installed = on07.core ?? installedCore(config);
245
- const next = [];
246
- let check = null;
247
- const ir = { compared: false, skipped: null, differences: [] };
248
- if (on07.irVersion !== 2) {
249
- next.push(`upgrade every @hozu/* dependency to 0.8: ${upgradeCommand(dir)}`);
250
- next.push('npx hozu migrate 0.8 # again after the upgrade: nothing to rewrite, then it runs hozu check');
251
- if (on07.ir)
252
- next.push(`keep ${RECORD} until that second run: it holds the 0.7 IR it compares with (a reinstall keeps it; delete it afterwards)`);
253
- }
254
- else if (options.runCheck !== false) {
255
- const loaded = await load(configArg, cwd);
256
- if (existsSync(record)) {
257
- const before = normalize07(JSON.parse(readFileSync(record, 'utf8')));
258
- ir.compared = true;
259
- ir.differences = differences(before, loaded.build(false).ir);
260
- next.push(`delete ${RECORD}: the IR comparison with 0.7 is done`);
177
+ for (const step of plan)
178
+ out.changed.push(...(step.files?.(dir, !options.dryRun) ?? []));
179
+ out.packages = raisePackages(join(dir, 'package.json'), target, !options.dryRun);
180
+ if (!options.dryRun) {
181
+ mkdirSync(dirname(record), { recursive: true });
182
+ writeFileSync(join(dir, `.hozu/migrate-${to}.json`), JSON.stringify({ from, to, ir }));
261
183
  }
262
- else
263
- ir.skipped = `${RECORD} is missing: it is written by the run on the 0.7 app, before the upgrade`;
264
- check = await runCheck(loaded, cwd, false);
265
- if (!check.ok)
266
- next.push('npx hozu check # fix each diagnostic; accept the lock with --update-lock');
184
+ out.record = rel(join(dir, `.hozu/migrate-${to}.json`));
185
+ out.next.push(`${installCommand(dir)} # installs Hozu ${target}`, 'npx hozu migrate # again: compares the IR with the old one and runs hozu check');
186
+ return out;
187
+ }
188
+ const saved = join(dir, `.hozu/migrate-${to}.json`);
189
+ if (!existsSync(saved)) {
190
+ out.next.push('Nothing to migrate: the app is on this CLI’s version.');
191
+ return out;
267
192
  }
268
- const ok = !guide.some((g) => g.state === 'custom') && (check?.ok ?? true);
269
- return {
270
- ok,
271
- installed,
272
- stale: { skipped: on07.skipped, entries: on07.stale },
273
- changed: [...new Set(changed)].sort(),
274
- removed: app.remove.map(rel).sort(),
275
- notes: notes.map((n) => ({ ...n, file: rel(n.file) })),
276
- guide,
277
- ir,
278
- next,
279
- check,
193
+ out.phase = 'verify';
194
+ out.record = rel(saved);
195
+ const { from: was, ir: before } = JSON.parse(readFileSync(saved, 'utf8'));
196
+ const plan = chain(was, to) ?? [];
197
+ out.steps = plan.map((s) => ({ from: s.from, to: s.to, summary: s.summary }));
198
+ const loaded = await load(options.config, cwd);
199
+ const normalized = plan.reduce((ir, step) => step.normalize(ir), before);
200
+ out.ir = {
201
+ compared: true,
202
+ skipped: null,
203
+ differences: differences(normalized, loaded.build(false).ir),
280
204
  };
281
- }
282
- function contentOf(dir) {
283
- const out = [];
284
- const visit = (d) => {
285
- for (const name of readdirSync(d).sort()) {
286
- const path = join(d, name);
287
- if (statSync(path).isDirectory())
288
- visit(path);
289
- else
290
- out.push(`${relative(dir, path)}\n${readFileSync(path, 'utf8')}`);
205
+ if (!options.dryRun) {
206
+ const agents = ['.claude/skills/hozu', '.agents/skills/hozu'].filter((p) => existsSync(join(dir, p)));
207
+ if (agents.length) {
208
+ const written = await runSkill(dir, undefined);
209
+ out.guide = written.written.map((f) => rel(resolve(dir, f)));
210
+ for (const c of written.custom)
211
+ out.notes.push({
212
+ file: rel(resolve(dir, c.guide)),
213
+ line: 1,
214
+ message: `not a Hozu template: replace its Hozu section with this block\n${c.block}`,
215
+ see: null,
216
+ });
291
217
  }
292
- };
293
- if (existsSync(dir))
294
- visit(dir);
295
- return out.join('\u0000');
296
- }
297
- function installedCore(config) {
298
- let d = dirname(config);
299
- for (;;) {
300
- const pkg = join(d, 'node_modules/@hozu/core/package.json');
301
- if (existsSync(pkg))
302
- return JSON.parse(readFileSync(pkg, 'utf8')).version;
303
- const up = dirname(d);
304
- if (up === d)
305
- return null;
306
- d = up;
307
218
  }
219
+ if (options.check !== false) {
220
+ out.check = await runCheck(loaded, cwd, false);
221
+ if (!out.check.ok)
222
+ out.next.push('npx hozu check # fix each diagnostic; accept intended lock changes with --update-lock');
223
+ }
224
+ out.ok = out.ir.differences.length === 0 && (out.check?.ok ?? true);
225
+ if (out.ir.differences.length)
226
+ out.next.push('Review each IR difference: it is a behaviour change the migration did not declare');
227
+ if (out.ok && !options.dryRun) {
228
+ rmSync(saved);
229
+ out.next.push('Done. Commit the migration; hozu.lock.json was not touched.');
230
+ }
231
+ return out;
308
232
  }
309
- function upgradeCommand(dir) {
310
- const pkg = existsSync(join(dir, 'package.json'))
311
- ? JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
312
- : {};
313
- const names = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]
314
- .filter((n) => n.startsWith('@hozu/'))
315
- .sort();
316
- const tool = existsSync(join(dir, 'pnpm-lock.yaml'))
317
- ? 'pnpm add'
318
- : existsSync(join(dir, 'yarn.lock'))
319
- ? 'yarn add'
320
- : 'npm install';
321
- return `${tool} ${names.map((n) => `${n}@^0.8.0`).join(' ')}`;
322
- }
233
+ const typesOf = (t) => t.skipped ? 'skipped (npm install -D typescript)' : t.ok ? 'ok' : `${t.errors.length} errors`;
323
234
  export function describeMigrate(r) {
324
235
  const lines = [];
325
- lines.push(r.stale.skipped
326
- ? `1. stale under 0.7: skipped (${r.stale.skipped})`
327
- : `1. stale under 0.7: ${r.stale.entries.length} lock ${r.stale.entries.length === 1 ? 'entry' : 'entries'}${r.stale.entries.length ? ' (review each before --update-lock):' : ''}`);
328
- for (const e of r.stale.entries)
329
- lines.push(` ${e.feature} ${e.id} [${e.kind}]${e.was ? `\n was: ${e.was}` : ''}${e.now ? `\n now: ${e.now}` : ''}`);
330
- lines.push(`2. rewrote ${r.changed.length} ${r.changed.length === 1 ? 'path' : 'paths'}${r.removed.length ? `, removed ${r.removed.join(', ')}` : ''}`);
331
- const behaviour = r.notes.filter((n) => n.behaviour);
332
- const rest = r.notes.filter((n) => !n.behaviour);
333
- for (const n of rest)
334
- lines.push(` ${n.file}:${n.line} ${n.message}${n.see ? ` (hozu docs ${n.see})` : ''}`);
335
- if (behaviour.length) {
336
- lines.push(' Behaviour changes and code to move by hand:');
337
- for (const n of behaviour)
338
- lines.push(` ! ${n.file}:${n.line} ${n.message}${n.see ? ` (hozu docs ${n.see})` : ''}`);
236
+ const title = r.phase === 'rewrite' ? 'rewrite' : r.phase === 'verify' ? 'verify' : 'nothing to do';
237
+ lines.push(`hozu migrate ${r.from} → ${r.to}: ${title}${r.dryRun ? ' (dry run, nothing written)' : ''}`);
238
+ for (const s of r.steps)
239
+ lines.push(` ${s.from} → ${s.to}: ${s.summary}`);
240
+ if (r.phase === 'rewrite') {
241
+ lines.push(` rewrote ${r.changed.length} ${r.changed.length === 1 ? 'file' : 'files'}`);
242
+ for (const c of r.changed)
243
+ lines.push(` ${c.file} (${c.edits} ${c.edits === 1 ? 'edit' : 'edits'})`);
244
+ for (const p of r.packages)
245
+ lines.push(` ${p.name}: ${p.from} → ${p.to}`);
246
+ if (r.record)
247
+ lines.push(` ${r.dryRun ? 'would save' : 'saved'} the old IR to ${r.record}`);
339
248
  }
340
- for (const g of r.guide)
341
- lines.push(g.state === 'custom'
342
- ? ` ${g.file} is not a Hozu template: replace its Hozu section with this block (the markers keep it current):\n\n${g.block}`
343
- : ` ${g.file}: Hozu block ${g.state}`);
344
- let step = 3;
345
- if (r.ir.skipped)
346
- lines.push(`${step++}. IR comparison with 0.7: skipped (${r.ir.skipped})`);
249
+ if (r.notes.length)
250
+ lines.push(' by hand:');
251
+ for (const n of r.notes)
252
+ lines.push(` ${n.file}:${n.line} ${n.message}${n.see ? ` (hozu docs ${n.see})` : ''}`);
347
253
  if (r.ir.compared)
348
254
  lines.push(r.ir.differences.length
349
- ? `${step++}. the IR differs from 0.7 (after the mapping every rewrite allows) at ${r.ir.differences.length} ${r.ir.differences.length === 1 ? 'place' : 'places'}; each is a behaviour change to review:\n${r.ir.differences.map((d) => ` ! ${d}`).join('\n')}`
350
- : `${step++}. the IR equals the 0.7 IR apart from the mapping every rewrite allows (ADR 0043 Migration)`);
255
+ ? ` the IR differs from ${r.from === r.to ? 'the saved one' : r.from} after the declared mapping, at ${r.ir.differences.length} ${r.ir.differences.length === 1 ? 'place' : 'places'}:\n${r.ir.differences.map((d) => ` ! ${d}`).join('\n')}`
256
+ : ' the IR equals the old one after the declared mapping');
257
+ for (const g of r.guide)
258
+ lines.push(` updated ${g}`);
351
259
  if (r.check)
352
- lines.push(`${step++}. hozu check: ${r.check.ok ? 'ok' : 'failed'} · ${r.check.validate.summary.errors} errors, ${r.check.validate.summary.warnings} warnings · lock ${r.check.validate.lock}`);
260
+ lines.push(` hozu check: ${r.check.ok ? 'ok' : 'failed'} · types ${typesOf(r.check.types)} · ${r.check.validate.summary.errors} errors, ${r.check.validate.summary.warnings} warnings`);
353
261
  for (const n of r.next)
354
- lines.push(`${step++}. ${n}`);
262
+ lines.push(`next: ${n}`);
355
263
  lines.push('Migrate never writes hozu.lock.json and never deletes a contract.');
356
264
  return `${lines.join('\n')}\n`;
357
265
  }