codz-cli 0.0.0-stage → 0.1.2
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/BETA.md +91 -0
- package/LICENSE +21 -0
- package/README.md +525 -2
- package/assets/gradle-wrapper/README.txt +2 -0
- package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
- package/assets/gradle-wrapper/gradlew +251 -0
- package/assets/gradle-wrapper/gradlew.bat +94 -0
- package/bin/codz.js +33 -0
- package/dist/adapters/android.js +321 -0
- package/dist/adapters/androidSetup.js +277 -0
- package/dist/adapters/common.js +114 -0
- package/dist/adapters/dartGen.js +83 -0
- package/dist/adapters/flutter.js +291 -0
- package/dist/adapters/flutterGen.js +204 -0
- package/dist/adapters/flutterScan.js +312 -0
- package/dist/adapters/flutterSetup.js +252 -0
- package/dist/adapters/flutterStyles.js +130 -0
- package/dist/adapters/generic.js +20 -0
- package/dist/adapters/getx.js +423 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/ios.js +440 -0
- package/dist/adapters/kotlinGen.js +110 -0
- package/dist/adapters/models.js +116 -0
- package/dist/adapters/nextjs.js +243 -0
- package/dist/adapters/nextjsGen.js +285 -0
- package/dist/adapters/nextjsScan.js +364 -0
- package/dist/adapters/nextjsSetup.js +119 -0
- package/dist/adapters/react.js +89 -0
- package/dist/adapters/reactNative.js +109 -0
- package/dist/adapters/reactNativeSetup.js +236 -0
- package/dist/adapters/reactQuery.js +89 -0
- package/dist/adapters/reactSetup.js +150 -0
- package/dist/adapters/source.js +165 -0
- package/dist/cli.js +1175 -0
- package/dist/core/cache.js +76 -0
- package/dist/core/changeset.js +404 -0
- package/dist/core/commands.js +239 -0
- package/dist/core/config.js +45 -0
- package/dist/core/diff.js +143 -0
- package/dist/core/events.js +41 -0
- package/dist/core/fsutil.js +122 -0
- package/dist/core/git.js +36 -0
- package/dist/core/jsonEdit.js +113 -0
- package/dist/core/merge.js +159 -0
- package/dist/core/proc.js +23 -0
- package/dist/core/safe.js +75 -0
- package/dist/core/templates.js +100 -0
- package/dist/core/tsAst.js +112 -0
- package/dist/core/types.js +12 -0
- package/dist/core/validateConfig.js +172 -0
- package/dist/core/workflow.js +72 -0
- package/dist/core/workspace.js +80 -0
- package/dist/core/yaml.js +126 -0
- package/dist/core/yamlDoc.js +367 -0
- package/dist/detector.js +95 -0
- package/dist/input/apiSpec.js +395 -0
- package/dist/input/curl.js +259 -0
- package/dist/input/endpoint.js +220 -0
- package/dist/input/har.js +120 -0
- package/dist/input/infer.js +174 -0
- package/dist/input/naming.js +145 -0
- package/dist/input/paste.js +130 -0
- package/dist/skills/androidSkeleton.js +292 -0
- package/dist/skills/androidTests.js +221 -0
- package/dist/skills/androidUi.js +561 -0
- package/dist/skills/androidViews.js +536 -0
- package/dist/skills/apiAdd.js +99 -0
- package/dist/skills/apiDiff.js +271 -0
- package/dist/skills/apiEdit.js +547 -0
- package/dist/skills/apiImport.js +127 -0
- package/dist/skills/apiList.js +35 -0
- package/dist/skills/apiSync.js +221 -0
- package/dist/skills/auth.js +1304 -0
- package/dist/skills/completion.js +142 -0
- package/dist/skills/config.js +93 -0
- package/dist/skills/create.js +638 -0
- package/dist/skills/createDemo.js +1026 -0
- package/dist/skills/doctor.js +304 -0
- package/dist/skills/doctorFix.js +214 -0
- package/dist/skills/explain.js +61 -0
- package/dist/skills/feature.js +418 -0
- package/dist/skills/featureEdit.js +225 -0
- package/dist/skills/featureLinks.js +179 -0
- package/dist/skills/init.js +66 -0
- package/dist/skills/iosUi.js +468 -0
- package/dist/skills/locale.js +335 -0
- package/dist/skills/mcp.js +175 -0
- package/dist/skills/mock.js +482 -0
- package/dist/skills/module.js +23 -0
- package/dist/skills/plans.js +66 -0
- package/dist/skills/prefill.js +84 -0
- package/dist/skills/reactNativeUi.js +388 -0
- package/dist/skills/reactUi.js +352 -0
- package/dist/skills/report.js +75 -0
- package/dist/skills/route.js +230 -0
- package/dist/skills/screen.js +597 -0
- package/dist/skills/screenForm.js +410 -0
- package/dist/skills/setup.js +28 -0
- package/dist/skills/setupDoctor.js +226 -0
- package/dist/skills/templatesCmd.js +32 -0
- package/dist/skills/testgen.js +331 -0
- package/dist/skills/theme.js +388 -0
- package/dist/starters/auth.js +144 -0
- package/dist/starters/commands.js +283 -0
- package/dist/starters/engine.js +315 -0
- package/dist/starters/home.js +42 -0
- package/dist/starters/http.js +93 -0
- package/dist/starters/manifest.js +285 -0
- package/dist/starters/paths.js +70 -0
- package/dist/starters/publish.js +150 -0
- package/dist/starters/registry.js +258 -0
- package/dist/starters/sign.js +72 -0
- package/dist/starters/source.js +98 -0
- package/dist/starters/strictJson.js +148 -0
- package/dist/starters/tar.js +180 -0
- package/docs/COMMANDS.md +527 -0
- package/examples/openapi.yaml +319 -0
- package/package.json +57 -4
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
// api update, api remove and api rename: changing endpoints that already exist.
|
|
2
|
+
//
|
|
3
|
+
// update rebuilds the response model from new samples (in place, same file),
|
|
4
|
+
// rewrites the function or method where it was, and reports which
|
|
5
|
+
// files use fields that were removed or changed type.
|
|
6
|
+
// remove deletes the function or method, drops imports it alone needed, and
|
|
7
|
+
// lists callers that will break.
|
|
8
|
+
// rename renames the declaration and its call sites.
|
|
9
|
+
import { join, posix } 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, walk } from "../core/fsutil.js";
|
|
14
|
+
import { ToolError } from "../core/types.js";
|
|
15
|
+
import { maskCode, matchBrace } from "../adapters/source.js";
|
|
16
|
+
import { mergeDart, mergeTsDocs } from "../core/merge.js";
|
|
17
|
+
import { loadTypeScript, tsIdentifierUses } from "../core/tsAst.js";
|
|
18
|
+
import { buildEndpoint } from "../input/endpoint.js";
|
|
19
|
+
import { camel, DART_RESERVED, TS_RESERVED } from "../input/naming.js";
|
|
20
|
+
import { KOTLIN_RESERVED } from "../adapters/kotlinGen.js";
|
|
21
|
+
const langOf = (adapter) => (adapter.id === 'flutter' ? 'dart' : adapter.id === 'android' ? 'kt' : 'ts');
|
|
22
|
+
/** Kotlin strings and comments mask like Dart (string templates, line and block comments). */
|
|
23
|
+
const ml = (lang) => (lang === 'kt' ? 'dart' : lang);
|
|
24
|
+
function findEndpoint(map, name) {
|
|
25
|
+
const ep = map.endpoints.find((e) => e.name === name) ?? map.endpoints.find((e) => e.name.toLowerCase() === name.toLowerCase());
|
|
26
|
+
if (!ep) {
|
|
27
|
+
const names = map.endpoints.map((e) => e.name);
|
|
28
|
+
throw new ToolError('no_endpoint', `No endpoint called ${name} was found.`, names.length ? `Endpoints in this project: ${names.join(', ')}` : undefined);
|
|
29
|
+
}
|
|
30
|
+
return ep;
|
|
31
|
+
}
|
|
32
|
+
/** Locates a function (TS) or method (Dart) declaration, including the doc comment above it. */
|
|
33
|
+
export function findDeclaration(text, name, lang) {
|
|
34
|
+
if (lang === 'kt')
|
|
35
|
+
return findKotlinDeclaration(text, name);
|
|
36
|
+
const re = lang === 'ts'
|
|
37
|
+
? new RegExp(`(^|\\n)((?:[ \\t]*(?:/\\*\\*(?:(?!\\*/)[^])*\\*/|//[^\\n]*)\\n)*)[ \\t]*export\\s+(?:async\\s+)?function\\s+${name}\\s*(?:<[^>()]*>)?\\s*\\(`)
|
|
38
|
+
: new RegExp(`(^|\\n)((?:[ \\t]*(?:///[^\\n]*|@\\w+(?:\\([^\\n]*\\))?)\\n)*)[ \\t]*Future<([^\\n]*)>\\s+${name}\\s*\\(`);
|
|
39
|
+
const m = re.exec(text);
|
|
40
|
+
if (!m)
|
|
41
|
+
return null;
|
|
42
|
+
const open = m.index + m[0].length - 1;
|
|
43
|
+
const closeParams = matchBrace(text, open, ml(lang));
|
|
44
|
+
if (closeParams === -1)
|
|
45
|
+
return null;
|
|
46
|
+
const params = text.slice(open + 1, closeParams);
|
|
47
|
+
let i = closeParams + 1;
|
|
48
|
+
// An abstract method (Retrofit) ends right after its parameters.
|
|
49
|
+
if (lang === 'dart' && /^\s*;/.test(text.slice(i)))
|
|
50
|
+
return { start: m.index + (m[1] ? 1 : 0), end: text.indexOf(';', i) + 1, params, returnType: m[3].trim() };
|
|
51
|
+
// Return type (TS) and async (Dart) up to the body.
|
|
52
|
+
const bodyOpen = text.indexOf('{', i);
|
|
53
|
+
const arrow = text.indexOf('=>', i);
|
|
54
|
+
let end;
|
|
55
|
+
let returnType = lang === 'dart' ? m[3].trim() : '';
|
|
56
|
+
if (arrow !== -1 && (bodyOpen === -1 || arrow < bodyOpen)) {
|
|
57
|
+
end = text.indexOf(';', arrow);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
if (lang === 'ts')
|
|
61
|
+
returnType = text.slice(i, bodyOpen).replace(/^\s*:\s*/, '').trim();
|
|
62
|
+
end = matchBrace(text, bodyOpen, ml(lang));
|
|
63
|
+
}
|
|
64
|
+
if (end === -1)
|
|
65
|
+
return null;
|
|
66
|
+
return { start: m.index + (m[1] ? 1 : 0), end: end + 1, params, returnType };
|
|
67
|
+
}
|
|
68
|
+
/** A Kotlin `fun` (an abstract Retrofit method ends with its line), with the doc comment and annotations above it. */
|
|
69
|
+
function findKotlinDeclaration(text, name) {
|
|
70
|
+
const m = new RegExp(`(^|\\n)((?:[ \\t]*(?:/\\*\\*(?:(?!\\*/)[^])*\\*/|//[^\\n]*|@\\w+(?:\\([^\\n]*\\))?)[ \\t]*\\n)*)[ \\t]*(?:(?:override|suspend|public|internal)\\s+)*fun\\s+${name}\\s*\\(`).exec(text);
|
|
71
|
+
if (!m)
|
|
72
|
+
return null;
|
|
73
|
+
const open = m.index + m[0].length - 1;
|
|
74
|
+
const close = matchBrace(text, open, 'dart');
|
|
75
|
+
if (close === -1)
|
|
76
|
+
return null;
|
|
77
|
+
const params = text.slice(open + 1, close);
|
|
78
|
+
const lineEnd = text.indexOf('\n', close) === -1 ? text.length : text.indexOf('\n', close);
|
|
79
|
+
const tail = text.slice(close + 1, lineEnd);
|
|
80
|
+
const returnType = /^\s*:\s*([^={]+?)\s*(?:[={]|$)/.exec(tail)?.[1]?.trim() ?? 'Unit';
|
|
81
|
+
let end = lineEnd;
|
|
82
|
+
const brace = tail.indexOf('{');
|
|
83
|
+
if (brace !== -1)
|
|
84
|
+
end = matchBrace(text, close + 1 + brace, 'dart') + 1;
|
|
85
|
+
return { start: m.index + (m[1] ? 1 : 0), end, params, returnType };
|
|
86
|
+
}
|
|
87
|
+
/** Removes a block and tidies the blank lines around it. */
|
|
88
|
+
function cut(text, start, end) {
|
|
89
|
+
const before = text.slice(0, start).replace(/\n[ \t]*\n[ \t]*$/, '\n').replace(/[ \t]*$/, '');
|
|
90
|
+
let after = text.slice(end).replace(/^[ \t]*\n/, '');
|
|
91
|
+
if (!before.endsWith('\n') && after)
|
|
92
|
+
after = '\n' + after;
|
|
93
|
+
return (before + after).replace(/\n{3,}/g, '\n\n');
|
|
94
|
+
}
|
|
95
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
96
|
+
/** The local name an import specifier binds: `type User` → User, `User as U` → U. */
|
|
97
|
+
function localName(spec) {
|
|
98
|
+
return spec.replace(/^type\s+/, '').split(/\s+as\s+/).pop().trim();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Drops imports that the removal left unused. An import that was already unused
|
|
102
|
+
* before the change is not touched (that is not this command's business), and
|
|
103
|
+
* usage is only counted in code, never in strings or comments.
|
|
104
|
+
*/
|
|
105
|
+
function pruneImports(before, after, lang, file, map) {
|
|
106
|
+
if (lang === 'ts') {
|
|
107
|
+
const re = /^import\s+(type\s+)?\{([^}]*)\}\s*from\s*(['"][^'"]+['"]);?[ \t]*\n/gm;
|
|
108
|
+
const usedIn = (text, line, name) => new RegExp(`\\b${esc(name)}\\b`).test(maskCode(text.replace(line, ''), 'ts'));
|
|
109
|
+
return after.replace(re, (line, _typeKw, names) => {
|
|
110
|
+
const specs = names.split(',').map((n) => n.trim()).filter(Boolean);
|
|
111
|
+
const keep = specs.filter((sp) => {
|
|
112
|
+
const local = localName(sp);
|
|
113
|
+
// Unused before too: leave it alone.
|
|
114
|
+
if (!before.includes(line) || !usedIn(before, line, local))
|
|
115
|
+
return true;
|
|
116
|
+
return usedIn(after, line, local);
|
|
117
|
+
});
|
|
118
|
+
if (keep.length === specs.length)
|
|
119
|
+
return line;
|
|
120
|
+
if (!keep.length)
|
|
121
|
+
return '';
|
|
122
|
+
return line.replace(/\{[^}]*\}/, `{ ${keep.join(', ')} }`);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (lang === 'kt') {
|
|
126
|
+
// import pkg.Class: unused when Class no longer appears (and it did before).
|
|
127
|
+
return after.replace(/^import\s+([\w.]+)\.(\w+)[ \t]*\n/gm, (line, _pkg, cls) => {
|
|
128
|
+
const used = (text) => new RegExp(`\\b${esc(cls)}\\b`).test(maskCode(text.replace(line, ''), 'dart'));
|
|
129
|
+
if (!used(before))
|
|
130
|
+
return line;
|
|
131
|
+
return used(after) ? line : '';
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// Dart: an import of a model file is unused when none of its classes appear.
|
|
135
|
+
return after.replace(/^import\s+'([^']+)';[ \t]*\n/gm, (line, uri) => {
|
|
136
|
+
let target = null;
|
|
137
|
+
const pkg = map.conventions.packageName;
|
|
138
|
+
if (pkg && uri.startsWith(`package:${pkg}/`))
|
|
139
|
+
target = `lib/${uri.slice(pkg.length + 9)}`;
|
|
140
|
+
else if (!uri.includes(':'))
|
|
141
|
+
target = posix.normalize(posix.join(posix.dirname(file), uri));
|
|
142
|
+
if (!target)
|
|
143
|
+
return line;
|
|
144
|
+
const classes = map.models.filter((m) => m.file === target).map((m) => m.name);
|
|
145
|
+
if (!classes.length)
|
|
146
|
+
return line;
|
|
147
|
+
const used = (text) => classes.some((c) => new RegExp(`\\b${esc(c)}\\b`).test(maskCode(text.replace(line, ''), 'dart')));
|
|
148
|
+
if (!used(before))
|
|
149
|
+
return line;
|
|
150
|
+
return used(after) ? line : '';
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function codeFiles(root, adapter) {
|
|
154
|
+
if (adapter.id === 'android')
|
|
155
|
+
return walk(root, '.', { exts: ['.kt'], extraIgnored: ['build', '.gradle'] });
|
|
156
|
+
return adapter.id === 'flutter'
|
|
157
|
+
? walk(root, 'lib', { exts: ['.dart'] }).filter((f) => !/\.(g|freezed)\.dart$/.test(f))
|
|
158
|
+
: walk(root, '.', { exts: ['.ts', '.tsx', '.js', '.jsx', '.mjs'] }).filter((f) => !f.endsWith('.d.ts'));
|
|
159
|
+
}
|
|
160
|
+
/** Offset of the declared name in a declaration (the name right before its parameter list). */
|
|
161
|
+
function declNameIndex(masked, name, decl) {
|
|
162
|
+
const m = new RegExp(`\\b${esc(name)}(?=\\s*(?:<[^>()]*>)?\\s*\\()`).exec(masked.slice(decl.start, decl.end));
|
|
163
|
+
return m ? decl.start + m.index : -1;
|
|
164
|
+
}
|
|
165
|
+
/** The Dart class that declares the endpoint (the last `class X` before the method). */
|
|
166
|
+
function enclosingClass(text, at) {
|
|
167
|
+
return [...text.slice(0, at).matchAll(/\b(?:class|interface)\s+(\w+)/g)].pop()?.[1];
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Every place that refers to the endpoint: calls, tear-offs, imports and named
|
|
171
|
+
* re-exports, including other functions in the declaring file. The declaration
|
|
172
|
+
* itself is not included. Strings and comments never count.
|
|
173
|
+
*
|
|
174
|
+
* TypeScript: bare `name` (not `.name` on some other object).
|
|
175
|
+
* Dart: `.name` only in files that use the declaring class or its provider, and
|
|
176
|
+
* bare `name(` / `this.name` inside the declaring class's own file.
|
|
177
|
+
*/
|
|
178
|
+
function sites(root, adapter, ep, declText, decl) {
|
|
179
|
+
const out = [];
|
|
180
|
+
const lang = langOf(adapter);
|
|
181
|
+
const cls = lang !== 'ts' ? enclosingClass(declText, decl.start) : undefined;
|
|
182
|
+
const clsRefs = cls ? new RegExp(`\\b(${esc(cls)}|${esc(cls[0].toLowerCase() + cls.slice(1))}Provider)\\b`) : null;
|
|
183
|
+
const lineText = (t, i) => {
|
|
184
|
+
const lineStart = t.lastIndexOf('\n', i) + 1;
|
|
185
|
+
const lineEnd = t.indexOf('\n', i);
|
|
186
|
+
return t.slice(lineStart, lineEnd === -1 ? t.length : lineEnd).trim();
|
|
187
|
+
};
|
|
188
|
+
// With the project's TypeScript compiler, uses come from the syntax tree:
|
|
189
|
+
// shadowing locals, object keys and other objects' members are told apart
|
|
190
|
+
// exactly, and other files only count when they import the name.
|
|
191
|
+
const ts = lang === 'ts' ? loadTypeScript(root) : null;
|
|
192
|
+
for (const f of codeFiles(root, adapter)) {
|
|
193
|
+
const t = f === ep.file ? declText : readText(join(root, f)) ?? '';
|
|
194
|
+
if (!t.includes(ep.name))
|
|
195
|
+
continue;
|
|
196
|
+
const masked = maskCode(t, ml(lang));
|
|
197
|
+
const own = f === ep.file;
|
|
198
|
+
if (ts) {
|
|
199
|
+
let uses;
|
|
200
|
+
try {
|
|
201
|
+
uses = tsIdentifierUses(ts, f, t, ep.name, own ? { declStart: declNameIndex(masked, ep.name, decl) } : {});
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
uses = null;
|
|
205
|
+
}
|
|
206
|
+
if (uses) {
|
|
207
|
+
// In another file, a bare name that is not imported is that file's own binding.
|
|
208
|
+
const imported = own || uses.some((u) => u.kind === 'import' || u.kind === 'export') || /\bimport\s+\*\s+as\b/.test(masked);
|
|
209
|
+
if (imported)
|
|
210
|
+
for (const u of uses)
|
|
211
|
+
out.push({ file: f, index: u.index, line: u.line, text: lineText(t, u.index) });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (lang !== 'ts' && !own && !(clsRefs && clsRefs.test(masked)))
|
|
216
|
+
continue;
|
|
217
|
+
for (const m of masked.matchAll(new RegExp(`\\b${esc(ep.name)}\\b`, 'g'))) {
|
|
218
|
+
const i = m.index;
|
|
219
|
+
if (own && i === declNameIndex(masked, ep.name, decl))
|
|
220
|
+
continue;
|
|
221
|
+
const before = masked.slice(0, i).replace(/\s+$/, '');
|
|
222
|
+
const dotted = before.endsWith('.') || before.endsWith('?.');
|
|
223
|
+
if (lang === 'ts') {
|
|
224
|
+
// obj.getUser is some other object's member, unless obj is a namespace import.
|
|
225
|
+
if (dotted && !/\b\w+\s*\.$/.test(before))
|
|
226
|
+
continue;
|
|
227
|
+
if (dotted) {
|
|
228
|
+
const obj = /(\w+)\s*\??\.$/.exec(before)?.[1];
|
|
229
|
+
if (!obj || !new RegExp(`import\\s+\\*\\s+as\\s+${esc(obj)}\\b`).test(masked))
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
// Property keys in object literals / types: { getUser: ... } that are not shorthand references.
|
|
233
|
+
if (/^\s*\??:(?!:)/.test(masked.slice(i + ep.name.length)) && /[{,]\s*$/.test(before) && !/\bexport\s*\{[^}]*$/.test(before) && !/\bimport\s*(type\s*)?\{[^}]*$/.test(before))
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
if (own) {
|
|
238
|
+
if (dotted && !/\bthis\s*\.$/.test(before))
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
else if (!dotted)
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
out.push({ file: f, index: i, line: t.slice(0, i).split('\n').length, text: lineText(t, i) });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
// ---------------- remove ----------------
|
|
250
|
+
export async function planApiRemove(root, adapter, name) {
|
|
251
|
+
const lang = langOf(adapter);
|
|
252
|
+
const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
|
|
253
|
+
const ep = findEndpoint(map, name);
|
|
254
|
+
const text = readText(join(root, ep.file)) ?? '';
|
|
255
|
+
const decl = findDeclaration(text, ep.name, lang);
|
|
256
|
+
if (!decl)
|
|
257
|
+
throw new ToolError('no_endpoint', `Could not find the declaration of ${ep.name} in ${ep.file}.`);
|
|
258
|
+
let next = cut(text, decl.start, decl.end);
|
|
259
|
+
next = pruneImports(text, next, lang, ep.file, map);
|
|
260
|
+
const files = new Map([[ep.file, next]]);
|
|
261
|
+
const summary = [`Remove ${ep.name}() (${ep.method} ${ep.path}) from ${ep.file}`];
|
|
262
|
+
const warnings = [];
|
|
263
|
+
const calls = sites(root, adapter, ep, text, decl);
|
|
264
|
+
if (calls.length) {
|
|
265
|
+
warnings.push(`These places still use ${ep.name} and will stop compiling until you change them:\n${calls.map((c) => ` ${c.file}:${c.line} ${c.text}`).join('\n')}`);
|
|
266
|
+
}
|
|
267
|
+
// Models only this endpoint used.
|
|
268
|
+
const others = map.endpoints.filter((e) => e !== ep).map((e) => e.response ?? '').join(' ');
|
|
269
|
+
for (const m of map.models) {
|
|
270
|
+
if (ep.response && new RegExp(`\\b${m.name}\\b`).test(ep.response) && !new RegExp(`\\b${m.name}\\b`).test(others)) {
|
|
271
|
+
warnings.push(`${m.name} (${m.file}) is no longer returned by any endpoint. Delete it if nothing else uses it.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return buildChangeSet(root, 'api remove', { files, summary, warnings, generated: [] });
|
|
275
|
+
}
|
|
276
|
+
// ---------------- rename ----------------
|
|
277
|
+
export async function planApiRename(root, adapter, from, to) {
|
|
278
|
+
const lang = langOf(adapter);
|
|
279
|
+
const newName = camel(to);
|
|
280
|
+
if (!newName || /^[0-9]/.test(newName) || TS_RESERVED.has(newName) || DART_RESERVED.has(newName) || KOTLIN_RESERVED.has(newName))
|
|
281
|
+
throw new ToolError('bad_name', `"${to}" cannot be used as a function name.`);
|
|
282
|
+
const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
|
|
283
|
+
const ep = findEndpoint(map, from);
|
|
284
|
+
if (map.endpoints.some((e) => e.name === newName))
|
|
285
|
+
throw new ToolError('name_conflict', `An endpoint called ${newName} already exists.`);
|
|
286
|
+
const declText = readText(join(root, ep.file)) ?? '';
|
|
287
|
+
const d = findDeclaration(declText, ep.name, lang);
|
|
288
|
+
if (!d)
|
|
289
|
+
throw new ToolError('no_endpoint', `Could not find the declaration of ${ep.name} in ${ep.file}.`);
|
|
290
|
+
// Edit positions: the declaration's name plus every site, per file, from the end.
|
|
291
|
+
const edits = new Map();
|
|
292
|
+
const headAt = declNameIndex(maskCode(declText, ml(lang)), ep.name, d);
|
|
293
|
+
if (headAt !== -1)
|
|
294
|
+
edits.set(ep.file, [headAt]);
|
|
295
|
+
const found = sites(root, adapter, ep, declText, d);
|
|
296
|
+
for (const s of found)
|
|
297
|
+
edits.set(s.file, [...(edits.get(s.file) ?? []), s.index]);
|
|
298
|
+
const files = new Map();
|
|
299
|
+
for (const [f, positions] of edits) {
|
|
300
|
+
let t = f === ep.file ? declText : readText(join(root, f)) ?? '';
|
|
301
|
+
for (const i of [...new Set(positions)].sort((a, b) => b - a))
|
|
302
|
+
t = t.slice(0, i) + newName + t.slice(i + ep.name.length);
|
|
303
|
+
// Generated doc comments name the call ("via userRepository.getUser").
|
|
304
|
+
t = t.replace(new RegExp(`(via \\w+\\.)${esc(ep.name)}\\b`, 'g'), `$1${newName}`);
|
|
305
|
+
files.set(f, t);
|
|
306
|
+
}
|
|
307
|
+
const others = [...files.keys()].filter((f) => f !== ep.file);
|
|
308
|
+
const sameFile = found.filter((s) => s.file === ep.file).length;
|
|
309
|
+
const summary = [
|
|
310
|
+
`Rename ${ep.name}() to ${newName}() in ${ep.file}${sameFile ? ` (and ${sameFile} use${sameFile === 1 ? '' : 's'} in the same file)` : ''}`,
|
|
311
|
+
...others.map((c) => `Update ${c}: ${found.filter((s) => s.file === c).length} use${found.filter((s) => s.file === c).length === 1 ? '' : 's'} of ${ep.name}`),
|
|
312
|
+
];
|
|
313
|
+
return buildChangeSet(root, 'api rename', { files, summary, warnings: [], generated: [] });
|
|
314
|
+
}
|
|
315
|
+
function diffFields(before, after) {
|
|
316
|
+
const norm = (s) => s.replace(/[_\-\s]/g, '').toLowerCase();
|
|
317
|
+
const b = new Map((before?.fields ?? []).map((f) => [norm(f.name), f]));
|
|
318
|
+
const a = new Map((after?.fields ?? []).map((f) => [norm(f.name), f]));
|
|
319
|
+
const simplify = (t) => t.replace(/\s|\?|\|null/g, '').replace(/^(int|double|num)$/, 'number').toLowerCase();
|
|
320
|
+
return {
|
|
321
|
+
added: [...a.keys()].filter((k) => !b.has(k)).map((k) => a.get(k).name),
|
|
322
|
+
removed: [...b.keys()].filter((k) => !a.has(k)).map((k) => b.get(k).name),
|
|
323
|
+
changed: [...a.keys()].filter((k) => b.has(k) && (simplify(b.get(k).type) !== simplify(a.get(k).type) || b.get(k).nullable !== a.get(k).nullable)).map((k) => `${a.get(k).name}: ${b.get(k).type}${b.get(k).nullable ? '?' : ''} → ${a.get(k).type}${a.get(k).nullable ? '?' : ''}`),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/** Reads path-parameter types, query parameters and the body type from an existing declaration. */
|
|
327
|
+
function readSignature(params, lang) {
|
|
328
|
+
const pathTypes = new Map();
|
|
329
|
+
const query = [];
|
|
330
|
+
let bodyType;
|
|
331
|
+
const kind = (t) => (/^(int|number)$/.test(t) ? { kind: 'int' } : /^double$/.test(t) ? { kind: 'double' } : /^(bool|boolean)$/.test(t) ? { kind: 'bool' } : { kind: 'string' });
|
|
332
|
+
if (lang === 'ts') {
|
|
333
|
+
const q = /params\?\s*:\s*\{([^}]*)\}/.exec(params);
|
|
334
|
+
if (q)
|
|
335
|
+
for (const part of q[1].split(';')) {
|
|
336
|
+
const m = /^\s*['"]?([\w-]+)['"]?\??\s*:\s*(\w+)/.exec(part);
|
|
337
|
+
if (m)
|
|
338
|
+
query.push({ name: m[1], type: kind(m[2]) });
|
|
339
|
+
}
|
|
340
|
+
for (const m of params.replace(/params\?\s*:\s*\{[^}]*\}/, '').matchAll(/(\w+)\s*:\s*([\w[\]<>]+)/g)) {
|
|
341
|
+
if (m[1] === 'body')
|
|
342
|
+
bodyType = m[2];
|
|
343
|
+
else
|
|
344
|
+
pathTypes.set(m[1], m[2] === 'number' ? 'int' : 'string');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else if (lang === 'kt') {
|
|
348
|
+
// @Path("id") id: Int, @Query("page") page: Int? = null, @Body body: CreateUser
|
|
349
|
+
for (const part of params.split(/,(?![^<(]*[>)])/)) {
|
|
350
|
+
const m = /^\s*@(\w+)(?:\(\s*"([^"]*)"\s*\))?\s*`?(\w+)`?\s*:\s*([\w<>?, .]+?)\s*(?:=.*)?$/.exec(part.replace(/\s+/g, ' '));
|
|
351
|
+
if (!m)
|
|
352
|
+
continue;
|
|
353
|
+
const t = m[4].replace(/\?$/, '');
|
|
354
|
+
if (m[1] === 'Body')
|
|
355
|
+
bodyType = t;
|
|
356
|
+
else if (m[1] === 'Path')
|
|
357
|
+
pathTypes.set(m[2] ?? m[3], /^(Int|Long)$/.test(t) ? 'int' : 'string');
|
|
358
|
+
else if (m[1] === 'Query')
|
|
359
|
+
query.push({ name: m[2] ?? m[3], type: /^(Int|Long)$/.test(t) ? { kind: 'int' } : t === 'Double' ? { kind: 'double' } : t === 'Boolean' ? { kind: 'bool' } : { kind: 'string' } });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
// Retrofit annotations (@Path('id'), @Query('page'), @Body()) carry the wire names.
|
|
364
|
+
const strip = (x) => x.replace(/@\w+(?:\([^)]*\))?\s*/g, '');
|
|
365
|
+
const named = /\{([^}]*)\}/.exec(params);
|
|
366
|
+
if (named)
|
|
367
|
+
for (const part of named[1].split(',')) {
|
|
368
|
+
const key = /@Query\(\s*['"]([^'"]+)['"]/.exec(part)?.[1];
|
|
369
|
+
const m = /^\s*([\w<>]+)\??\s+(\w+)/.exec(strip(part).trim());
|
|
370
|
+
if (m)
|
|
371
|
+
query.push({ name: key ?? m[2], type: kind(m[1]) });
|
|
372
|
+
}
|
|
373
|
+
for (const part of params.replace(/\{[^}]*\}/, '').split(',')) {
|
|
374
|
+
const m = /^\s*([\w<>]+)\s+(\w+)\s*$/.exec(strip(part));
|
|
375
|
+
if (!m)
|
|
376
|
+
continue;
|
|
377
|
+
if (m[2] === 'body')
|
|
378
|
+
bodyType = m[1];
|
|
379
|
+
else
|
|
380
|
+
pathTypes.set(m[2], m[1] === 'int' ? 'int' : 'string');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return { pathTypes, query, bodyType };
|
|
384
|
+
}
|
|
385
|
+
function readSample(root, file) {
|
|
386
|
+
const text = readText(join(root, file)) ?? readText(file);
|
|
387
|
+
if (text === null)
|
|
388
|
+
throw new ToolError('no_sample', `Cannot read the sample file ${file}.`);
|
|
389
|
+
try {
|
|
390
|
+
return JSON.parse(text);
|
|
391
|
+
}
|
|
392
|
+
catch (e) {
|
|
393
|
+
throw new ToolError('bad_sample', `${file} is not valid JSON: ${e.message}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
export async function planApiUpdate(root, adapter, name, samples, opts = {}) {
|
|
397
|
+
if (!adapter.planEndpoint)
|
|
398
|
+
throw new ToolError('not_supported', `api update needs a framework adapter; this project uses the ${adapter.id} adapter.`);
|
|
399
|
+
if (!samples.length)
|
|
400
|
+
throw new ToolError('no_sample', 'api update needs at least one new response sample.', 'Pass --sample <file.json> (repeat it for several samples).');
|
|
401
|
+
const lang = langOf(adapter);
|
|
402
|
+
const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
|
|
403
|
+
const ep = findEndpoint(map, name);
|
|
404
|
+
const text = readText(join(root, ep.file)) ?? '';
|
|
405
|
+
const decl = findDeclaration(text, ep.name, lang);
|
|
406
|
+
if (!decl)
|
|
407
|
+
throw new ToolError('no_endpoint', `Could not find the declaration of ${ep.name} in ${ep.file}.`);
|
|
408
|
+
const sig = readSignature(decl.params, lang);
|
|
409
|
+
// The existing response model keeps its name.
|
|
410
|
+
const { rootClass, modelName } = responseModelName(map, ep, lang);
|
|
411
|
+
const loaded = samples.map((f) => readSample(root, f));
|
|
412
|
+
const url = new URL(`https://api.invalid${ep.path.replace(/\{([^/{}]+)\}/g, ':$1')}`);
|
|
413
|
+
const spec = buildEndpoint({ method: ep.method, url, responseSamples: loaded, name: ep.name, modelName, unwrap: opts.noUnwrap ? 'never' : 'auto' });
|
|
414
|
+
spec.pathParams = spec.pathParams.map((p) => ({ ...p, type: sig.pathTypes.get(p.name) ?? p.type }));
|
|
415
|
+
spec.queryParams = sig.query;
|
|
416
|
+
// A sample shows a field present, not that it always is: a field that was optional stays optional
|
|
417
|
+
// (making it required would also break code that checks it, like dueAt?.isBefore(...)).
|
|
418
|
+
const before = map.models.find((m) => m.name === rootClass);
|
|
419
|
+
const inferred = spec.models.find((m) => m.name === modelName) ?? spec.models[spec.models.length - 1];
|
|
420
|
+
if (before && inferred)
|
|
421
|
+
for (const f of inferred.fields)
|
|
422
|
+
if (before.fields.find((b) => b.name === f.jsonName)?.nullable)
|
|
423
|
+
f.nullable = true;
|
|
424
|
+
if (sig.bodyType) {
|
|
425
|
+
const suffix = lang === 'dart' ? map.conventions.modelClassSuffix ?? '' : '';
|
|
426
|
+
const bodyModel = suffix && sig.bodyType.endsWith(suffix) ? sig.bodyType.slice(0, -suffix.length) : sig.bodyType;
|
|
427
|
+
spec.request = { kind: 'object', model: bodyModel };
|
|
428
|
+
}
|
|
429
|
+
const r = await planEndpointUpdate(root, adapter, map, new Map(), ep, spec, rootClass, `from ${samples.length} new sample${samples.length === 1 ? '' : 's'}`);
|
|
430
|
+
const changeSet = buildChangeSet(root, 'api update', { files: r.files, summary: r.summary, warnings: r.warnings, generated: r.generated });
|
|
431
|
+
return { changeSet, changes: r.changes };
|
|
432
|
+
}
|
|
433
|
+
/** The class an endpoint returns (User for Promise<User[]>), and the inferred-model name that maps to it. */
|
|
434
|
+
function responseModelName(map, ep, lang) {
|
|
435
|
+
const suffix = lang === 'dart' ? map.conventions.modelClassSuffix ?? '' : '';
|
|
436
|
+
const rootClass = (ep.response ?? '').replace(/^Promise<(.*)>$/, '$1').replace(/^List<(.*)>$/, '$1').replace(/\[\]$/, '');
|
|
437
|
+
const oldModel = map.models.find((m) => m.name === rootClass);
|
|
438
|
+
return { rootClass, modelName: oldModel ? (suffix && rootClass.endsWith(suffix) ? rootClass.slice(0, -suffix.length) : rootClass) : undefined };
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Rewrites one endpoint from a new spec, in place: the declaration where it
|
|
442
|
+
* was, its models with hand-written members kept, and a report of fields that
|
|
443
|
+
* were added, removed or changed type with the lines that use them. `overlay`
|
|
444
|
+
* holds files already planned (api sync chains several updates).
|
|
445
|
+
*/
|
|
446
|
+
export async function planEndpointUpdate(root, adapter, map, overlay, ep, spec, rootClass, label) {
|
|
447
|
+
const lang = langOf(adapter);
|
|
448
|
+
const current = (f) => overlay.get(f) ?? readText(join(root, f));
|
|
449
|
+
const text = current(ep.file) ?? '';
|
|
450
|
+
const decl = findDeclaration(text, ep.name, lang);
|
|
451
|
+
if (!decl)
|
|
452
|
+
throw new ToolError('no_endpoint', `Could not find the declaration of ${ep.name} in ${ep.file}.`);
|
|
453
|
+
const oldModel = map.models.find((m) => m.name === rootClass);
|
|
454
|
+
// Take the old declaration out, plan the new one, then put it back where the old one was.
|
|
455
|
+
const nextDecl = findNextDeclarationName(text, decl.end, lang);
|
|
456
|
+
const working = structuredClone(map);
|
|
457
|
+
working.endpoints = working.endpoints.filter((e) => e.name !== ep.name);
|
|
458
|
+
const edits = await adapter.planEndpoint({ root, map: working }, spec, new Map([...overlay, [ep.file, cut(text, decl.start, decl.end)]]), { replaceModels: true, targetFile: ep.file });
|
|
459
|
+
const planned = edits.files.get(ep.file);
|
|
460
|
+
if (planned && nextDecl) {
|
|
461
|
+
const nd = findDeclaration(planned, ep.name, lang);
|
|
462
|
+
const block = planned.slice(nd.start, nd.end);
|
|
463
|
+
const without = cut(planned, nd.start, nd.end);
|
|
464
|
+
const anchor = findDeclaration(without, nextDecl, lang);
|
|
465
|
+
if (anchor)
|
|
466
|
+
edits.files.set(ep.file, `${without.slice(0, anchor.start)}${block}\n\n${without.slice(anchor.start)}`.replace(/\n{3,}/g, '\n\n'));
|
|
467
|
+
}
|
|
468
|
+
// Hand-written members of regenerated models come back (Dart), and field comments stay (TypeScript).
|
|
469
|
+
const keptNotes = [];
|
|
470
|
+
for (const [f, next] of edits.files) {
|
|
471
|
+
const before = current(f);
|
|
472
|
+
if (before === null || f === ep.file || before === next)
|
|
473
|
+
continue;
|
|
474
|
+
if (lang === 'dart') {
|
|
475
|
+
const r = mergeDart(before, next);
|
|
476
|
+
edits.files.set(f, r.text);
|
|
477
|
+
for (const [cls, names] of r.report.kept)
|
|
478
|
+
keptNotes.push(`Kept your members of ${cls}: ${names.join(', ')}`);
|
|
479
|
+
for (const [cls, list] of r.report.mentionsRemoved)
|
|
480
|
+
for (const x of list)
|
|
481
|
+
keptNotes.push(`! ${cls}.${x.member} still uses removed field${x.fields.length === 1 ? '' : 's'} ${x.fields.join(', ')}; update it by hand.`);
|
|
482
|
+
}
|
|
483
|
+
else if (lang === 'ts')
|
|
484
|
+
edits.files.set(f, mergeTsDocs(before, next));
|
|
485
|
+
}
|
|
486
|
+
const newModel = edits.models?.find((m) => m.name === rootClass) ?? edits.models?.[edits.models.length - 1];
|
|
487
|
+
const changes = diffFields(oldModel, newModel);
|
|
488
|
+
const summary = [`Update ${ep.name}() (${ep.method} ${ep.path}) ${label}`];
|
|
489
|
+
if (changes.added.length)
|
|
490
|
+
summary.push(`Fields added to ${rootClass || 'the response'}: ${changes.added.join(', ')}`);
|
|
491
|
+
if (changes.removed.length)
|
|
492
|
+
summary.push(`Fields removed from ${rootClass || 'the response'}: ${changes.removed.join(', ')}`);
|
|
493
|
+
if (changes.changed.length)
|
|
494
|
+
summary.push(`Fields that changed type: ${changes.changed.join('; ')}`);
|
|
495
|
+
if (!changes.added.length && !changes.removed.length && !changes.changed.length && oldModel)
|
|
496
|
+
summary.push(`${rootClass} has the same fields as before.`);
|
|
497
|
+
summary.push(...edits.summary.filter((s) => /^(Create|Update) .*(interface|class) /.test(s)));
|
|
498
|
+
const warnings = edits.warnings.filter((w) => !/^Updating the model|^Reusing the existing model/.test(w));
|
|
499
|
+
summary.push(...keptNotes.filter((n) => !n.startsWith('! ')));
|
|
500
|
+
warnings.push(...keptNotes.filter((n) => n.startsWith('! ')).map((n) => n.slice(2)));
|
|
501
|
+
// Who uses what disappeared or changed?
|
|
502
|
+
const broken = [...changes.removed, ...changes.changed.map((c) => c.split(':')[0])];
|
|
503
|
+
if (broken.length) {
|
|
504
|
+
const hits = [];
|
|
505
|
+
for (const f of codeFiles(root, adapter)) {
|
|
506
|
+
if (map.models.some((m) => m.file === f))
|
|
507
|
+
continue;
|
|
508
|
+
const t = current(f) ?? '';
|
|
509
|
+
// Only files that can hold this model: they name it or call the endpoint.
|
|
510
|
+
if (!new RegExp(`\\b(${[rootClass, ep.name].filter(Boolean).join('|')})\\b`).test(t))
|
|
511
|
+
continue;
|
|
512
|
+
t.split('\n').forEach((l, i) => {
|
|
513
|
+
for (const field of broken) {
|
|
514
|
+
const access = lang !== 'ts' ? camel(field) : field;
|
|
515
|
+
if (new RegExp(`\\.${access}\\b|\\[['"]${field}['"]\\]`).test(l))
|
|
516
|
+
hits.push(` ${f}:${i + 1} ${l.trim()} (${field})`);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
if (hits.length)
|
|
521
|
+
warnings.push(`These lines use fields that were removed or changed type:\n${hits.slice(0, 30).join('\n')}${hits.length > 30 ? `\n ... and ${hits.length - 30} more` : ''}`);
|
|
522
|
+
else
|
|
523
|
+
warnings.push(`No code outside the models uses the removed or changed fields (${broken.join(', ')}).`);
|
|
524
|
+
}
|
|
525
|
+
return { files: edits.files, summary, warnings, generated: edits.generated, changes, models: edits.models ?? [] };
|
|
526
|
+
}
|
|
527
|
+
function findNextDeclarationName(text, from, lang) {
|
|
528
|
+
const rest = text.slice(from);
|
|
529
|
+
const m = lang === 'ts' ? /export\s+(?:async\s+)?function\s+(\w+)/.exec(rest) : lang === 'kt' ? /\bfun\s+(\w+)\s*\(/.exec(rest) : /Future<[^\n]*>\s+(\w+)\s*\(/.exec(rest);
|
|
530
|
+
return m ? m[1] : null;
|
|
531
|
+
}
|
|
532
|
+
/** Where an endpoint is declared and every place that uses it (for `api show`). */
|
|
533
|
+
export function endpointUses(root, adapter, ep) {
|
|
534
|
+
const lang = langOf(adapter);
|
|
535
|
+
const text = readText(join(root, ep.file)) ?? '';
|
|
536
|
+
const decl = findDeclaration(text, ep.name, lang);
|
|
537
|
+
if (!decl)
|
|
538
|
+
return { uses: [] };
|
|
539
|
+
const at = declNameIndex(maskCode(text, ml(lang)), ep.name, decl);
|
|
540
|
+
const lineStart = text.lastIndexOf('\n', at) + 1;
|
|
541
|
+
const signature = text.slice(lineStart, text.indexOf('\n', at)).trim().replace(/\s*\{$/, '');
|
|
542
|
+
return {
|
|
543
|
+
declLine: text.slice(0, at).split('\n').length,
|
|
544
|
+
signature,
|
|
545
|
+
uses: sites(root, adapter, ep, text, decl).map(({ file, line, text: t }) => ({ file, line, text: t })),
|
|
546
|
+
};
|
|
547
|
+
}
|