codz-cli 0.0.0-stage → 0.1.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 (117) hide show
  1. package/BETA.md +91 -0
  2. package/LICENSE +21 -0
  3. package/README.md +523 -2
  4. package/assets/gradle-wrapper/README.txt +2 -0
  5. package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
  6. package/assets/gradle-wrapper/gradlew +251 -0
  7. package/assets/gradle-wrapper/gradlew.bat +94 -0
  8. package/bin/codz.js +33 -0
  9. package/dist/adapters/android.js +321 -0
  10. package/dist/adapters/androidSetup.js +277 -0
  11. package/dist/adapters/common.js +114 -0
  12. package/dist/adapters/dartGen.js +83 -0
  13. package/dist/adapters/flutter.js +291 -0
  14. package/dist/adapters/flutterGen.js +204 -0
  15. package/dist/adapters/flutterScan.js +312 -0
  16. package/dist/adapters/flutterSetup.js +252 -0
  17. package/dist/adapters/flutterStyles.js +130 -0
  18. package/dist/adapters/generic.js +20 -0
  19. package/dist/adapters/getx.js +423 -0
  20. package/dist/adapters/index.js +28 -0
  21. package/dist/adapters/ios.js +440 -0
  22. package/dist/adapters/kotlinGen.js +110 -0
  23. package/dist/adapters/models.js +116 -0
  24. package/dist/adapters/nextjs.js +243 -0
  25. package/dist/adapters/nextjsGen.js +285 -0
  26. package/dist/adapters/nextjsScan.js +364 -0
  27. package/dist/adapters/nextjsSetup.js +119 -0
  28. package/dist/adapters/react.js +89 -0
  29. package/dist/adapters/reactNative.js +109 -0
  30. package/dist/adapters/reactNativeSetup.js +236 -0
  31. package/dist/adapters/reactQuery.js +89 -0
  32. package/dist/adapters/reactSetup.js +150 -0
  33. package/dist/adapters/source.js +165 -0
  34. package/dist/cli.js +1175 -0
  35. package/dist/core/cache.js +76 -0
  36. package/dist/core/changeset.js +404 -0
  37. package/dist/core/commands.js +239 -0
  38. package/dist/core/config.js +45 -0
  39. package/dist/core/diff.js +143 -0
  40. package/dist/core/events.js +41 -0
  41. package/dist/core/fsutil.js +122 -0
  42. package/dist/core/git.js +36 -0
  43. package/dist/core/jsonEdit.js +113 -0
  44. package/dist/core/merge.js +159 -0
  45. package/dist/core/proc.js +23 -0
  46. package/dist/core/safe.js +75 -0
  47. package/dist/core/templates.js +100 -0
  48. package/dist/core/tsAst.js +112 -0
  49. package/dist/core/types.js +12 -0
  50. package/dist/core/validateConfig.js +172 -0
  51. package/dist/core/workflow.js +72 -0
  52. package/dist/core/workspace.js +80 -0
  53. package/dist/core/yaml.js +126 -0
  54. package/dist/core/yamlDoc.js +367 -0
  55. package/dist/detector.js +95 -0
  56. package/dist/input/apiSpec.js +395 -0
  57. package/dist/input/curl.js +259 -0
  58. package/dist/input/endpoint.js +220 -0
  59. package/dist/input/har.js +120 -0
  60. package/dist/input/infer.js +174 -0
  61. package/dist/input/naming.js +145 -0
  62. package/dist/input/paste.js +130 -0
  63. package/dist/skills/androidSkeleton.js +292 -0
  64. package/dist/skills/androidTests.js +221 -0
  65. package/dist/skills/androidUi.js +561 -0
  66. package/dist/skills/androidViews.js +536 -0
  67. package/dist/skills/apiAdd.js +99 -0
  68. package/dist/skills/apiDiff.js +271 -0
  69. package/dist/skills/apiEdit.js +547 -0
  70. package/dist/skills/apiImport.js +127 -0
  71. package/dist/skills/apiList.js +35 -0
  72. package/dist/skills/apiSync.js +221 -0
  73. package/dist/skills/auth.js +1304 -0
  74. package/dist/skills/completion.js +142 -0
  75. package/dist/skills/config.js +93 -0
  76. package/dist/skills/create.js +358 -0
  77. package/dist/skills/doctor.js +304 -0
  78. package/dist/skills/doctorFix.js +214 -0
  79. package/dist/skills/explain.js +61 -0
  80. package/dist/skills/feature.js +418 -0
  81. package/dist/skills/featureEdit.js +225 -0
  82. package/dist/skills/featureLinks.js +179 -0
  83. package/dist/skills/init.js +66 -0
  84. package/dist/skills/iosUi.js +468 -0
  85. package/dist/skills/locale.js +335 -0
  86. package/dist/skills/mcp.js +175 -0
  87. package/dist/skills/mock.js +482 -0
  88. package/dist/skills/module.js +23 -0
  89. package/dist/skills/plans.js +66 -0
  90. package/dist/skills/prefill.js +84 -0
  91. package/dist/skills/reactNativeUi.js +388 -0
  92. package/dist/skills/reactUi.js +352 -0
  93. package/dist/skills/report.js +75 -0
  94. package/dist/skills/route.js +230 -0
  95. package/dist/skills/screen.js +597 -0
  96. package/dist/skills/screenForm.js +410 -0
  97. package/dist/skills/setup.js +28 -0
  98. package/dist/skills/setupDoctor.js +226 -0
  99. package/dist/skills/templatesCmd.js +32 -0
  100. package/dist/skills/testgen.js +331 -0
  101. package/dist/skills/theme.js +388 -0
  102. package/dist/starters/auth.js +144 -0
  103. package/dist/starters/commands.js +283 -0
  104. package/dist/starters/engine.js +315 -0
  105. package/dist/starters/home.js +42 -0
  106. package/dist/starters/http.js +93 -0
  107. package/dist/starters/manifest.js +285 -0
  108. package/dist/starters/paths.js +70 -0
  109. package/dist/starters/publish.js +150 -0
  110. package/dist/starters/registry.js +258 -0
  111. package/dist/starters/sign.js +72 -0
  112. package/dist/starters/source.js +98 -0
  113. package/dist/starters/strictJson.js +148 -0
  114. package/dist/starters/tar.js +180 -0
  115. package/docs/COMMANDS.md +527 -0
  116. package/examples/openapi.yaml +319 -0
  117. package/package.json +57 -4
@@ -0,0 +1,127 @@
1
+ // api import: every operation in an OpenAPI, Swagger, Postman or HAR file,
2
+ // planned one after another into a single change set. Each endpoint builds on
3
+ // the files and models planned before it, so shared schemas become one model
4
+ // and endpoints of one resource land in one file.
5
+ //
6
+ // Filters: --tag, --only, --path <glob>, --method, --max. --list shows what
7
+ // would be imported and stops. Endpoints that cannot be planned (a model name
8
+ // clash, say) stop the import unless --skip-conflicts applies the rest.
9
+ import { join } from 'node:path';
10
+ import { loadMap } from "../core/cache.js";
11
+ import { buildChangeSet } from "../core/changeset.js";
12
+ import { loadConfig } from "../core/config.js";
13
+ import { readText } from "../core/fsutil.js";
14
+ import { ToolError } from "../core/types.js";
15
+ import { readApiFile } from "../input/apiSpec.js";
16
+ export const LARGE = 50;
17
+ /** Glob for API paths: * matches one segment, ** any number; {id} and :id match any parameter. */
18
+ export function pathMatches(glob, path) {
19
+ const norm = (p) => p.replace(/:([\w.-]+)/g, '{$1}').replace(/\/+$/, '') || '/';
20
+ const g = norm(glob.startsWith('/') ? glob : `/${glob}`);
21
+ const re = g
22
+ .split('/')
23
+ .map((seg) => (seg === '**' ? '\u0000' : seg === '*' ? '[^/]+' : /^\{[^/]+\}$/.test(seg) ? '\\{[^/]+\\}' : seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')))
24
+ .join('/')
25
+ .replace(/\/\u0000/g, '(?:/.*)?')
26
+ .replace(/\u0000/g, '.*');
27
+ return new RegExp(`^${re}$`).test(norm(path));
28
+ }
29
+ function readOperations(opts) {
30
+ const text = readText(opts.file);
31
+ if (text === null)
32
+ throw new ToolError('no_file', `Cannot read ${opts.file}.`);
33
+ const doc = readApiFile(text, { includeErrors: opts.includeErrors, baseUrl: opts.baseUrl });
34
+ const wantTags = new Set((opts.tags ?? []).map((t) => t.toLowerCase()));
35
+ const wantNames = new Set((opts.only ?? []).map((n) => n.toLowerCase()));
36
+ const wantMethods = new Set((opts.methods ?? []).map((m) => m.toUpperCase()));
37
+ let ops = doc.operations.filter((o) => (!wantTags.size || o.tags.some((t) => wantTags.has(t.toLowerCase()))) &&
38
+ (!wantNames.size || wantNames.has(o.spec.name.toLowerCase())) &&
39
+ (!wantMethods.size || wantMethods.has(o.spec.method)) &&
40
+ (!opts.paths?.length || opts.paths.some((g) => pathMatches(g, o.spec.path))));
41
+ if (!ops.length) {
42
+ const filtered = wantTags.size || wantNames.size || wantMethods.size || opts.paths?.length;
43
+ throw new ToolError('no_endpoints', `No operations matched in ${opts.file} (${doc.operations.length} in the file).`, filtered ? 'Check the --tag, --only, --method and --path values, or see everything with --list.' : undefined);
44
+ }
45
+ if (opts.max !== undefined) {
46
+ if (ops.length > opts.max)
47
+ doc.warnings.push(`Importing the first ${opts.max} of ${ops.length} matching operations (--max ${opts.max}).`);
48
+ ops = ops.slice(0, opts.max);
49
+ }
50
+ return { doc, ops };
51
+ }
52
+ /** --list: what the file holds after filters, without planning anything. */
53
+ export function listApiImport(opts) {
54
+ const { doc, ops } = readOperations(opts);
55
+ return {
56
+ format: doc.format,
57
+ title: doc.title,
58
+ total: doc.operations.length,
59
+ operations: ops.map((o) => ({ name: o.spec.name, method: o.spec.method, path: o.spec.path, tags: o.tags, source: o.source, response: o.spec.response ? (o.spec.response.kind === 'object' ? o.spec.response.model : o.spec.response.kind === 'array' && o.spec.response.item.kind === 'object' ? `${o.spec.response.item.model}[]` : o.spec.response.kind) : undefined })),
60
+ warnings: doc.warnings,
61
+ };
62
+ }
63
+ export async function planApiImport(root, adapter, opts) {
64
+ if (!adapter.planEndpoint)
65
+ throw new ToolError('not_supported', `api import needs a framework adapter; this project uses the ${adapter.id} adapter.`);
66
+ const file = readText(join(root, opts.file)) !== null ? join(root, opts.file) : opts.file;
67
+ const { doc, ops } = readOperations({ ...opts, file });
68
+ if (ops.length > LARGE && opts.max === undefined && !opts.yes) {
69
+ throw new ToolError('too_many_endpoints', `${ops.length} operations matched, more than ${LARGE} in one change.`, `Narrow it with --tag, --path or --method, cap it with --max <n>, or pass --yes to import all ${ops.length}. See them first with --list.`);
70
+ }
71
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
72
+ const working = structuredClone(map);
73
+ let overlay = new Map();
74
+ const added = [];
75
+ const existing = [];
76
+ const skipped = [];
77
+ const warnings = new Set(doc.warnings);
78
+ const generated = [];
79
+ for (const [i, op] of ops.entries()) {
80
+ opts.onProgress?.(i, ops.length, op.spec.name);
81
+ try {
82
+ const edits = await adapter.planEndpoint({ root, map: working }, op.spec, overlay);
83
+ if (!edits.endpoints) {
84
+ existing.push(op.spec.name);
85
+ continue;
86
+ }
87
+ overlay = edits.files;
88
+ working.endpoints.push(...edits.endpoints);
89
+ working.models.push(...(edits.models ?? []));
90
+ generated.push(...edits.generated);
91
+ for (const w of edits.warnings) {
92
+ // Per-endpoint reuse notes are noise in a bulk import.
93
+ // Project-wide notes (a missing package, say) are shown once, not per operation.
94
+ if (/^Reusing the existing model/.test(w))
95
+ continue;
96
+ if (/is not in pubspec\.yaml|is not in package\.json/.test(w))
97
+ warnings.add(w);
98
+ else
99
+ warnings.add(`${op.spec.name}: ${w}`);
100
+ }
101
+ added.push(op.spec.name);
102
+ }
103
+ catch (e) {
104
+ if (!(e instanceof ToolError))
105
+ throw e;
106
+ skipped.push({ name: op.spec.name, reason: e.hint ? `${e.message} ${e.hint}` : e.message });
107
+ }
108
+ }
109
+ opts.onProgress?.(ops.length, ops.length, '');
110
+ if (skipped.length && !opts.skipConflicts) {
111
+ throw new ToolError('import_conflicts', `${skipped.length} of ${ops.length} operations cannot be added, so nothing was planned:\n${skipped.map((s) => ` ${s.name}: ${s.reason}`).join('\n')}`, `Pass --skip-conflicts to import the other ${ops.length - skipped.length}, or leave those out with --only / --path.`);
112
+ }
113
+ const label = { openapi: 'OpenAPI', swagger: 'Swagger', postman: 'Postman', har: 'HAR' }[doc.format];
114
+ const summary = [
115
+ `Import ${ops.length} operation${ops.length === 1 ? '' : 's'} from "${doc.title}" (${label}): ${added.length} added, ${existing.length} already present, ${skipped.length} skipped`,
116
+ ...added.map((n) => {
117
+ const ep = working.endpoints.find((e) => e.name === n);
118
+ return ` ${ep.method.padEnd(6)} ${ep.path} → ${n}() in ${ep.file}`;
119
+ }),
120
+ ];
121
+ for (const s of skipped)
122
+ warnings.add(`Skipped ${s.name}: ${s.reason}`);
123
+ if (doc.format === 'har')
124
+ warnings.add('Response bodies from the HAR file were used only to work out types; none of their values were written.');
125
+ const changeSet = buildChangeSet(root, 'api import', { files: overlay, summary, warnings: [...warnings], generated });
126
+ return { changeSet, added, existing, skipped };
127
+ }
@@ -0,0 +1,35 @@
1
+ // api list and api show: what endpoints the project has, read from the project map.
2
+ import { loadMap } from "../core/cache.js";
3
+ import { loadConfig } from "../core/config.js";
4
+ import { ToolError } from "../core/types.js";
5
+ import { endpointUses } from "./apiEdit.js";
6
+ export async function apiList(root, adapter, opts = {}) {
7
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
8
+ const f = opts.filter?.toLowerCase();
9
+ return map.endpoints
10
+ .filter((e) => !f || e.name.toLowerCase().includes(f) || e.path.toLowerCase().includes(f) || e.method.toLowerCase() === f)
11
+ .map((e) => ({ ...e, uses: new Set(e.calledFrom).size }))
12
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
13
+ }
14
+ export async function apiShow(root, adapter, name) {
15
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
16
+ const ep = map.endpoints.find((e) => e.name === name) ?? map.endpoints.find((e) => e.name.toLowerCase() === name.toLowerCase());
17
+ if (!ep) {
18
+ const close = map.endpoints.filter((e) => e.name.toLowerCase().includes(name.toLowerCase())).map((e) => e.name);
19
+ throw new ToolError('no_endpoint', `No endpoint called ${name} was found.`, close.length ? `Did you mean: ${close.join(', ')}?` : 'List them with: codz api list');
20
+ }
21
+ // Models the response mentions, and the models those refer to.
22
+ const models = [];
23
+ const visit = (type) => {
24
+ for (const m of map.models) {
25
+ if (models.includes(m) || !new RegExp(`\\b${m.name}\\b`).test(type))
26
+ continue;
27
+ models.push(m);
28
+ for (const f of m.fields)
29
+ visit(f.type);
30
+ }
31
+ };
32
+ visit(ep.response ?? '');
33
+ const u = endpointUses(root, adapter, ep);
34
+ return { endpoint: ep, declLine: u.declLine, signature: u.signature, models, uses: u.uses };
35
+ }
@@ -0,0 +1,221 @@
1
+ // api sync <spec>: brings the project's endpoints in line with an OpenAPI,
2
+ // Swagger, Postman or HAR file in one change.
3
+ //
4
+ // in both rewritten from the spec (response and request models,
5
+ // parameters); unchanged ones are left alone
6
+ // only in spec listed; added with --add
7
+ // only in project listed; removed with --remove
8
+ //
9
+ // Breaking changes (a response field removed or changed type, a new required
10
+ // parameter) are reported with the lines that use them. --check plans
11
+ // everything and reports drift without writing (exit 1 when out of sync).
12
+ import { join } from 'node:path';
13
+ import { loadMap } from "../core/cache.js";
14
+ import { buildChangeSet } from "../core/changeset.js";
15
+ import { loadConfig } from "../core/config.js";
16
+ import { readText } from "../core/fsutil.js";
17
+ import { ToolError } from "../core/types.js";
18
+ import { readApiFile } from "../input/apiSpec.js";
19
+ import { findDeclaration, planApiRemove, planEndpointUpdate } from "./apiEdit.js";
20
+ import { pathMatches } from "./apiImport.js";
21
+ const shape = (p) => p.replace(/\{[^/}]+\}/g, '{}').replace(/\/+$/, '') || '/';
22
+ /** The spec operation for a project endpoint: same method, and the same path or the same path under a base prefix (/v1). */
23
+ function matchOperation(ep, ops) {
24
+ const same = ops.filter((o) => o.spec.method === ep.method);
25
+ const p = shape(ep.path);
26
+ return same.find((o) => shape(o.spec.path) === p) ?? same.find((o) => shape(o.spec.path).endsWith(p) && p !== '/');
27
+ }
28
+ function renameModel(t, from, to) {
29
+ if (!t)
30
+ return t;
31
+ if (t.kind === 'object')
32
+ return t.model === from ? { kind: 'object', model: to } : t;
33
+ if (t.kind === 'array')
34
+ return { kind: 'array', item: renameModel(t.item, from, to) };
35
+ return t;
36
+ }
37
+ function rootModel(t) {
38
+ if (!t)
39
+ return undefined;
40
+ if (t.kind === 'object')
41
+ return t.model;
42
+ if (t.kind === 'array')
43
+ return rootModel(t.item);
44
+ return undefined;
45
+ }
46
+ export async function planApiSync(root, adapter, opts) {
47
+ if (!adapter.planEndpoint)
48
+ throw new ToolError('not_supported', `api sync needs a framework adapter; this project uses the ${adapter.id} adapter.`);
49
+ const file = readText(join(root, opts.file)) !== null ? join(root, opts.file) : opts.file;
50
+ const text = readText(file);
51
+ if (text === null)
52
+ throw new ToolError('no_file', `Cannot read ${opts.file}.`);
53
+ const doc = readApiFile(text, { baseUrl: opts.baseUrl });
54
+ const wantTags = new Set((opts.tags ?? []).map((t) => t.toLowerCase()));
55
+ const ops = doc.operations.filter((o) => (!wantTags.size || o.tags.some((t) => wantTags.has(t.toLowerCase()))) && (!opts.paths?.length || opts.paths.some((g) => pathMatches(g, o.spec.path))));
56
+ if (!ops.length)
57
+ throw new ToolError('no_endpoints', `No operations matched in ${opts.file}.`);
58
+ const lang = adapter.id === 'flutter' ? 'dart' : 'ts';
59
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
60
+ const suffix = lang === 'dart' ? map.conventions.modelClassSuffix ?? '' : '';
61
+ const working = structuredClone(map);
62
+ let overlay = new Map();
63
+ const report = { updated: [], unchanged: [], added: [], newInSpec: [], removed: [], missingFromSpec: [], breaking: [] };
64
+ const summary = [];
65
+ const warnings = new Set(doc.warnings);
66
+ const generated = [];
67
+ const matched = new Set();
68
+ // Prefixes the project's paths leave out (/v1 when the base URL already has it), learned from matches.
69
+ const prefixes = new Map();
70
+ // With path filters, only endpoints under those paths are compared.
71
+ const inScope = (ep) => !opts.paths?.length || opts.paths.some((g) => pathMatches(g, ep.path) || ops.some((o) => shape(o.spec.path).endsWith(shape(ep.path))));
72
+ for (const ep of map.endpoints.filter(inScope)) {
73
+ const op = matchOperation(ep, ops);
74
+ if (!op) {
75
+ report.missingFromSpec.push(ep.name);
76
+ continue;
77
+ }
78
+ matched.add(op);
79
+ const specShape = shape(op.spec.path);
80
+ const prefix = specShape.slice(0, specShape.length - shape(ep.path).length);
81
+ prefixes.set(prefix, (prefixes.get(prefix) ?? 0) + 1);
82
+ const spec = structuredClone(op.spec);
83
+ spec.name = ep.name;
84
+ // The project's path (relative to its base URL) and parameter names stay; the spec decides types.
85
+ const projectParams = [...ep.path.matchAll(/\{([^/}]+)\}/g)].map((m) => m[1]);
86
+ if (projectParams.length === spec.pathParams.length)
87
+ spec.pathParams = spec.pathParams.map((p, i) => ({ ...p, name: projectParams[i] }));
88
+ spec.path = ep.path;
89
+ spec.origin = '';
90
+ delete spec.serverPrefix;
91
+ // Fields the project already has keep their order, so the diff shows only what changed.
92
+ for (const m of spec.models) {
93
+ const old = map.models.find((x) => x.name === (suffix && !m.name.endsWith(suffix) ? m.name + suffix : m.name));
94
+ if (!old)
95
+ continue;
96
+ const order = new Map(old.fields.map((f, i) => [f.name, i]));
97
+ m.fields = [...m.fields].sort((a, b) => (order.get(a.jsonName) ?? 1e6) - (order.get(b.jsonName) ?? 1e6));
98
+ }
99
+ // The project's response class keeps its name (the spec may call it UserDto).
100
+ const rootClass = (ep.response ?? '').replace(/^Promise<(.*)>$/, '$1').replace(/^List<(.*)>$/, '$1').replace(/\[\]$/, '').replace(/\?$/, '');
101
+ const specRoot = rootModel(spec.response);
102
+ const keep = rootClass && map.models.some((m) => m.name === rootClass) ? (suffix && rootClass.endsWith(suffix) ? rootClass.slice(0, -suffix.length) : rootClass) : undefined;
103
+ if (specRoot && keep && specRoot !== keep && !spec.models.some((m) => m.name === keep)) {
104
+ spec.response = renameModel(spec.response, specRoot, keep);
105
+ for (const m of spec.models) {
106
+ if (m.name === specRoot)
107
+ m.name = keep;
108
+ m.fields = m.fields.map((f) => ({ ...f, type: renameModel(f.type, specRoot, keep) }));
109
+ }
110
+ }
111
+ const before = new Map(overlay);
112
+ try {
113
+ const r = await planEndpointUpdate(root, adapter, working, overlay, ep, spec, rootClass, `from ${doc.format === 'har' ? 'the HAR file' : 'the spec'}`);
114
+ const changedFiles = [...r.files].filter(([f, t]) => (before.get(f) ?? readText(join(root, f))) !== t);
115
+ if (!changedFiles.length) {
116
+ report.unchanged.push(ep.name);
117
+ continue;
118
+ }
119
+ overlay = r.files;
120
+ report.updated.push(ep.name);
121
+ summary.push(...r.summary);
122
+ for (const w of r.warnings)
123
+ warnings.add(`${ep.name}: ${w}`);
124
+ generated.push(...r.generated);
125
+ for (const m of r.models) {
126
+ const i = working.models.findIndex((x) => x.name === m.name);
127
+ if (i === -1)
128
+ working.models.push(m);
129
+ else
130
+ working.models[i] = m;
131
+ }
132
+ if (r.changes.removed.length)
133
+ report.breaking.push(`${ep.name}: ${rootClass || 'response'} lost ${r.changes.removed.join(', ')}`);
134
+ // A field that became non-nullable is not breaking for code that reads it; anything else is.
135
+ const risky = r.changes.changed.filter((c) => {
136
+ const m = /^[^:]+:\s*(.+?)\s*→\s*(.+)$/.exec(c);
137
+ return !m || !(m[1].replace(/\?$/, '') === m[2].replace(/\?$/, '') && m[1].endsWith('?') && !m[2].endsWith('?'));
138
+ });
139
+ if (risky.length)
140
+ report.breaking.push(`${ep.name}: ${risky.join('; ')}`);
141
+ const oldText = readText(join(root, ep.file)) ?? '';
142
+ const oldDecl = findDeclaration(oldText, ep.name, lang)?.params ?? '';
143
+ const newPathParams = spec.pathParams.map((p) => p.name).filter((n) => !new RegExp(`\\b${n}\\b`).test(oldDecl));
144
+ if (newPathParams.length)
145
+ report.breaking.push(`${ep.name}: new required parameter${newPathParams.length === 1 ? '' : 's'} ${newPathParams.join(', ')}`);
146
+ if (spec.request && !/\bbody\b/.test(oldDecl))
147
+ report.breaking.push(`${ep.name}: now takes a request body`);
148
+ }
149
+ catch (e) {
150
+ if (!(e instanceof ToolError))
151
+ throw e;
152
+ warnings.add(`Could not update ${ep.name}: ${e.message}`);
153
+ }
154
+ }
155
+ // New in the spec.
156
+ for (const op of ops) {
157
+ if (matched.has(op))
158
+ continue;
159
+ if (working.endpoints.some((e) => e.name === op.spec.name && e.method === op.spec.method))
160
+ continue;
161
+ if (!opts.add) {
162
+ report.newInSpec.push(op.spec.name);
163
+ continue;
164
+ }
165
+ try {
166
+ const spec = structuredClone(op.spec);
167
+ const [prefix] = [...prefixes.entries()].sort((a, b) => b[1] - a[1])[0] ?? [''];
168
+ if (prefix && spec.path.startsWith(`${prefix}/`)) {
169
+ spec.path = spec.path.slice(prefix.length);
170
+ spec.origin = '';
171
+ delete spec.serverPrefix;
172
+ }
173
+ const edits = await adapter.planEndpoint({ root, map: working }, spec, overlay);
174
+ if (!edits.endpoints)
175
+ continue;
176
+ overlay = edits.files;
177
+ working.endpoints.push(...edits.endpoints);
178
+ working.models.push(...(edits.models ?? []));
179
+ generated.push(...edits.generated);
180
+ report.added.push(op.spec.name);
181
+ summary.push(`Add ${spec.name}() for ${spec.method} ${spec.path}`);
182
+ }
183
+ catch (e) {
184
+ if (!(e instanceof ToolError))
185
+ throw e;
186
+ warnings.add(`Could not add ${op.spec.name}: ${e.message}`);
187
+ }
188
+ }
189
+ // Gone from the spec.
190
+ if (opts.remove && report.missingFromSpec.length) {
191
+ for (const name of report.missingFromSpec) {
192
+ // Removal works on files as they are on disk; it is planned only when the file is not also being updated.
193
+ const ep = map.endpoints.find((e) => e.name === name);
194
+ if (overlay.has(ep.file)) {
195
+ warnings.add(`${name} is not in the spec, but ${ep.file} also has updates; run api remove ${name} after this sync.`);
196
+ continue;
197
+ }
198
+ const cs = await planApiRemove(root, adapter, name);
199
+ for (const ch of cs.changes)
200
+ if (ch.after !== null)
201
+ overlay.set(ch.path, ch.after);
202
+ summary.push(...cs.summary);
203
+ for (const w of cs.warnings)
204
+ warnings.add(w);
205
+ report.removed.push(name);
206
+ }
207
+ }
208
+ if (report.newInSpec.length)
209
+ warnings.add(`In the spec but not in the project: ${report.newInSpec.join(', ')}. Pass --add to add them.`);
210
+ const notRemoved = report.missingFromSpec.filter((n) => !report.removed.includes(n));
211
+ if (notRemoved.length && !opts.remove)
212
+ warnings.add(`In the project but not in the spec: ${notRemoved.join(', ')}. Pass --remove to delete them (only if they belong to this API).`);
213
+ if (report.breaking.length)
214
+ warnings.add(`Breaking changes for callers:\n${report.breaking.map((b) => ` ${b}`).join('\n')}`);
215
+ if (!summary.length)
216
+ summary.push(`Everything matches ${opts.file}: ${report.unchanged.length} endpoint${report.unchanged.length === 1 ? '' : 's'} checked.`);
217
+ else
218
+ summary.unshift(`Sync with ${doc.title || opts.file}: ${report.updated.length} updated, ${report.added.length} added, ${report.removed.length} removed, ${report.unchanged.length} unchanged`);
219
+ const changeSet = buildChangeSet(root, 'api sync', { files: overlay, summary, warnings: [...warnings], generated });
220
+ return { changeSet, ...report };
221
+ }