@dokebi/cli 0.4.2 → 0.4.27
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/AI.md +279 -57
- package/README.md +35 -10
- package/catalog.json +2520 -258
- package/describe.schema.json +748 -0
- package/dist/api-app.d.ts +2 -0
- package/dist/api-app.js +35 -0
- package/dist/app-urls.js +3 -2
- package/dist/bin.js +638 -37
- package/dist/capability-guide.d.ts +30 -0
- package/dist/capability-guide.js +122 -0
- package/dist/catalog.d.ts +16 -2
- package/dist/catalog.js +19 -7
- package/dist/check/asset-rules.d.ts +6 -0
- package/dist/check/asset-rules.js +163 -0
- package/dist/check/browser-sources.d.ts +20 -0
- package/dist/check/browser-sources.js +89 -0
- package/dist/check/console-rules.d.ts +6 -0
- package/dist/check/console-rules.js +40 -0
- package/dist/check/error-rules.d.ts +21 -0
- package/dist/check/error-rules.js +212 -0
- package/dist/check/form-rules.d.ts +12 -0
- package/dist/check/form-rules.js +69 -6
- package/dist/check/formatter-rules.d.ts +2 -0
- package/dist/check/formatter-rules.js +71 -0
- package/dist/check/i18n-rules.d.ts +27 -0
- package/dist/check/i18n-rules.js +353 -0
- package/dist/check/index.d.ts +15 -1
- package/dist/check/index.js +89 -11
- package/dist/check/mail-rules.d.ts +4 -0
- package/dist/check/mail-rules.js +72 -0
- package/dist/check/module-rules.d.ts +13 -0
- package/dist/check/module-rules.js +104 -0
- package/dist/check/packages.d.ts +8 -0
- package/dist/check/packages.js +16 -0
- package/dist/check/pool-rules.d.ts +8 -0
- package/dist/check/pool-rules.js +164 -0
- package/dist/check/problems.d.ts +6 -1
- package/dist/check/problems.js +100 -2
- package/dist/check/project-rules.d.ts +23 -3
- package/dist/check/project-rules.js +265 -23
- package/dist/check/seal-rules.d.ts +6 -0
- package/dist/check/seal-rules.js +68 -0
- package/dist/check/security-rules.d.ts +10 -0
- package/dist/check/security-rules.js +450 -0
- package/dist/check/shared-state-rules.d.ts +15 -0
- package/dist/check/shared-state-rules.js +117 -0
- package/dist/check/source-rules.d.ts +26 -3
- package/dist/check/source-rules.js +249 -28
- package/dist/check/structure-rules.d.ts +23 -0
- package/dist/check/structure-rules.js +502 -0
- package/dist/check/timer-rules.d.ts +7 -0
- package/dist/check/timer-rules.js +543 -0
- package/dist/check/typescript.d.ts +35 -5
- package/dist/check/typescript.js +163 -17
- package/dist/check/vite-rules.d.ts +2 -0
- package/dist/check/vite-rules.js +136 -0
- package/dist/check/wait-rules.d.ts +16 -0
- package/dist/check/wait-rules.js +337 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +381 -36
- package/dist/config-diff.d.ts +42 -0
- package/dist/config-diff.js +289 -0
- package/dist/config-edit.d.ts +35 -2
- package/dist/config-edit.js +145 -13
- package/dist/config-init.d.ts +15 -0
- package/dist/config-init.js +61 -0
- package/dist/config-paths.d.ts +22 -0
- package/dist/config-paths.js +82 -0
- package/dist/config-secrets.d.ts +46 -0
- package/dist/config-secrets.js +187 -0
- package/dist/config-show.d.ts +82 -0
- package/dist/config-show.js +563 -0
- package/dist/config-values.d.ts +8 -0
- package/dist/config-values.js +68 -0
- package/dist/configuration-scaffold.d.ts +42 -0
- package/dist/configuration-scaffold.js +258 -4
- package/dist/configuration.d.ts +63 -3
- package/dist/configuration.js +178 -4
- package/dist/deploy-scripts.d.ts +4 -0
- package/dist/deploy-scripts.js +361 -0
- package/dist/deploy-templates.d.ts +46 -0
- package/dist/deploy-templates.js +379 -0
- package/dist/deploy.d.ts +46 -0
- package/dist/deploy.js +592 -0
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.js +111 -0
- package/dist/describe-db.d.ts +22 -0
- package/dist/describe-db.js +176 -0
- package/dist/describe.d.ts +92 -0
- package/dist/describe.js +417 -0
- package/dist/dev-compose.d.ts +20 -0
- package/dist/dev-compose.js +154 -0
- package/dist/dev-guard.js +44 -0
- package/dist/dev-host.d.ts +13 -0
- package/dist/dev-host.js +55 -0
- package/dist/dev-infra.d.ts +72 -4
- package/dist/dev-infra.js +237 -17
- package/dist/dev-watch.d.ts +34 -0
- package/dist/dev-watch.js +192 -0
- package/dist/diagnostics.d.ts +10 -7
- package/dist/diagnostics.js +22 -4
- package/dist/drift.d.ts +50 -0
- package/dist/drift.js +218 -0
- package/dist/env-file.d.ts +25 -1
- package/dist/env-file.js +64 -6
- package/dist/errors.d.ts +7 -1
- package/dist/example-config.d.ts +2 -0
- package/dist/example-config.js +3 -0
- package/dist/first-admin.d.ts +43 -0
- package/dist/first-admin.js +175 -0
- package/dist/formatter-ignore.d.ts +37 -0
- package/dist/formatter-ignore.js +344 -0
- package/dist/generator.d.ts +45 -3
- package/dist/generator.js +205 -15
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +42 -21
- package/dist/install-module.d.ts +20 -2
- package/dist/install-module.js +45 -8
- package/dist/install-plan.js +5 -3
- package/dist/layout.d.ts +12 -1
- package/dist/layout.js +19 -6
- package/dist/log-writer-control.d.ts +2 -0
- package/dist/log-writer-control.js +17 -2
- package/dist/log-writer.js +43 -10
- package/dist/logging-config.d.ts +8 -3
- package/dist/logging-config.js +19 -5
- package/dist/manifest.d.ts +29 -1
- package/dist/manifest.js +84 -6
- package/dist/merge-install-defaults.d.ts +0 -1
- package/dist/merge-install-defaults.js +13 -47
- package/dist/npm-client.d.ts +40 -4
- package/dist/npm-client.js +152 -15
- package/dist/npm-paths.js +18 -4
- package/dist/openapi.d.ts +27 -0
- package/dist/openapi.js +649 -0
- package/dist/option-fixes.d.ts +39 -0
- package/dist/option-fixes.js +130 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +5 -5
- package/dist/plugin-lifecycle.d.ts +34 -2
- package/dist/plugin-lifecycle.js +195 -16
- package/dist/plugin-options.d.ts +19 -4
- package/dist/plugin-options.js +138 -6
- package/dist/plugins-command.js +44 -7
- package/dist/process-group.d.ts +39 -0
- package/dist/process-group.js +132 -0
- package/dist/production-doctor.d.ts +3 -0
- package/dist/production-doctor.js +176 -0
- package/dist/project-guide.d.ts +19 -1
- package/dist/project-guide.js +119 -20
- package/dist/project-inspector.d.ts +15 -0
- package/dist/project-inspector.js +497 -33
- package/dist/project-state.d.ts +5 -0
- package/dist/project-state.js +8 -0
- package/dist/project.d.ts +203 -19
- package/dist/project.js +3006 -333
- package/dist/recipe-facts.d.ts +127 -0
- package/dist/recipe-facts.js +563 -0
- package/dist/recipe-login.d.ts +63 -0
- package/dist/recipe-login.js +464 -0
- package/dist/recipe-mail.d.ts +21 -0
- package/dist/recipe-mail.js +214 -0
- package/dist/recipe-members.d.ts +35 -0
- package/dist/recipe-members.js +178 -0
- package/dist/recipe-record.d.ts +82 -7
- package/dist/recipe-record.js +226 -22
- package/dist/recipe-reset.d.ts +2 -0
- package/dist/recipe-reset.js +117 -0
- package/dist/recipe-site.d.ts +48 -0
- package/dist/recipe-site.js +396 -0
- package/dist/recipe-step.d.ts +42 -0
- package/dist/recipe-step.js +89 -0
- package/dist/recipe.d.ts +56 -20
- package/dist/recipe.js +703 -131
- package/dist/registry.d.ts +98 -2
- package/dist/registry.js +171 -5
- package/dist/reloadable.d.ts +35 -0
- package/dist/reloadable.js +257 -0
- package/dist/remote-verify.d.ts +31 -0
- package/dist/remote-verify.js +204 -0
- package/dist/server.d.ts +43 -4
- package/dist/server.js +601 -52
- package/dist/settings.d.ts +48 -0
- package/dist/settings.js +168 -0
- package/dist/site-auth.d.ts +39 -0
- package/dist/site-auth.js +195 -0
- package/dist/template-fixes.d.ts +26 -0
- package/dist/template-fixes.js +285 -0
- package/dist/test-reporter.d.ts +49 -0
- package/dist/test-reporter.js +190 -0
- package/dist/transaction.d.ts +47 -0
- package/dist/transaction.js +241 -7
- package/dist/verify-tests.d.ts +98 -0
- package/dist/verify-tests.js +449 -0
- package/dist/verify.d.ts +103 -3
- package/dist/verify.js +272 -21
- package/dist/view-choice.d.ts +58 -0
- package/dist/view-choice.js +131 -0
- package/dist/vite-watch.js +4 -2
- package/package.json +8 -6
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// Structure rules of `dokebi check` (D-0105 §2–§3), warnings over the TypeScript AST of the
|
|
2
|
+
// app's models, controllers and services (src/**/models|controllers|services/**):
|
|
3
|
+
// responsibility-size one file that carries several responsibilities, measured on the AI
|
|
4
|
+
// consumer rounds AI-M22..M24 (D-0105 §3 has the numbers):
|
|
5
|
+
// - a model with at least MODEL_LINES code lines that reaches at least
|
|
6
|
+
// MODEL_TABLES tables or collections (names after FROM, JOIN, INTO,
|
|
7
|
+
// UPDATE in SQL text, CTE names excluded; Mongo `.collection('x')` and
|
|
8
|
+
// a local helper that wraps it; Kysely selectFrom/insertInto/…),
|
|
9
|
+
// - a controller with at least CONTROLLER_HANDLERS route handlers
|
|
10
|
+
// (public methods and function-valued fields) or CONTROLLER_LINES code
|
|
11
|
+
// lines,
|
|
12
|
+
// - a service class whose methods mostly forward their parameters to one
|
|
13
|
+
// call on a field (`return this.model.list(user, filter)`): at least
|
|
14
|
+
// SERVICE_FORWARDS such methods and FORWARD_SHARE of all its methods.
|
|
15
|
+
// memory-filtered-page rows an awaited query returned that are filtered in code by an input of
|
|
16
|
+
// the function and then cut to a page: `rows.filter((row) => row.x ===
|
|
17
|
+
// input.x).slice(…)`, or a `for (const row of rows)` loop that skips rows
|
|
18
|
+
// with `if (row.x !== input.x) continue` and stops with `break` once a
|
|
19
|
+
// `.length` reaches a size. The filter belongs in the query (§6.2). A
|
|
20
|
+
// search that checks its other terms on a capped candidate set (§6.10)
|
|
21
|
+
// compares with term sets, not inputs, and is not matched.
|
|
22
|
+
// Code lines are lines with something besides whitespace and comments. Files that say they are
|
|
23
|
+
// generated (`@generated`, `generated by`, `do not edit` in their first lines) are not read.
|
|
24
|
+
import { field, nodes, property, } from './typescript.js';
|
|
25
|
+
/** D-0105 §3: the smallest measured model that carried several features had 486 code lines. */
|
|
26
|
+
export const MODEL_LINES = 400;
|
|
27
|
+
export const MODEL_TABLES = 8;
|
|
28
|
+
/**
|
|
29
|
+
* D-0105 §3: the controllers that took every route of their app had 22–34 handlers (176–467
|
|
30
|
+
* code lines); every other controller measured had at most 16 handlers and 214 code lines.
|
|
31
|
+
*/
|
|
32
|
+
export const CONTROLLER_HANDLERS = 20;
|
|
33
|
+
export const CONTROLLER_LINES = 300;
|
|
34
|
+
/** D-0105 §3: services that only forwarded had 10–15 such methods out of 15–21. */
|
|
35
|
+
export const SERVICE_FORWARDS = 5;
|
|
36
|
+
export const FORWARD_SHARE = 0.6;
|
|
37
|
+
/** The layer of an app file by its folder (D-0031, D-0032), or undefined outside them. */
|
|
38
|
+
export function layerOf(path) {
|
|
39
|
+
if (!path.startsWith('src/') || /(^|\/)(views|public)\//.test(path))
|
|
40
|
+
return undefined;
|
|
41
|
+
if (/\/models\//.test(path))
|
|
42
|
+
return 'model';
|
|
43
|
+
if (/\/controllers\//.test(path))
|
|
44
|
+
return 'controller';
|
|
45
|
+
if (/\/services\//.test(path))
|
|
46
|
+
return 'service';
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Lines with code: comments and blank lines do not count (string contents do, SQL is code). */
|
|
50
|
+
export function codeLines(text) {
|
|
51
|
+
let count = 0;
|
|
52
|
+
let code = false;
|
|
53
|
+
let index = 0;
|
|
54
|
+
const length = text.length;
|
|
55
|
+
while (index < length) {
|
|
56
|
+
const char = text[index];
|
|
57
|
+
const next = text[index + 1];
|
|
58
|
+
if (char === '\n') {
|
|
59
|
+
if (code)
|
|
60
|
+
count++;
|
|
61
|
+
code = false;
|
|
62
|
+
index++;
|
|
63
|
+
}
|
|
64
|
+
else if (char === '/' && next === '/') {
|
|
65
|
+
const end = text.indexOf('\n', index);
|
|
66
|
+
index = end === -1 ? length : end;
|
|
67
|
+
}
|
|
68
|
+
else if (char === '/' && next === '*') {
|
|
69
|
+
const end = text.indexOf('*/', index + 2);
|
|
70
|
+
const stop = end === -1 ? length : end + 2;
|
|
71
|
+
// Newlines inside the comment end lines that may already hold code.
|
|
72
|
+
for (let at = index; at < stop; at++)
|
|
73
|
+
if (text[at] === '\n') {
|
|
74
|
+
if (code)
|
|
75
|
+
count++;
|
|
76
|
+
code = false;
|
|
77
|
+
}
|
|
78
|
+
index = stop;
|
|
79
|
+
}
|
|
80
|
+
else if (char === "'" || char === '"' || char === '`') {
|
|
81
|
+
code = true;
|
|
82
|
+
let at = index + 1;
|
|
83
|
+
while (at < length && text[at] !== char) {
|
|
84
|
+
if (text[at] === '\\')
|
|
85
|
+
at++;
|
|
86
|
+
else if (text[at] === '\n') {
|
|
87
|
+
if (char !== '`')
|
|
88
|
+
break;
|
|
89
|
+
count++;
|
|
90
|
+
}
|
|
91
|
+
at++;
|
|
92
|
+
}
|
|
93
|
+
index = at + 1;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
if (char !== ' ' && char !== '\t' && char !== '\r')
|
|
97
|
+
code = true;
|
|
98
|
+
index++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (code)
|
|
102
|
+
count++;
|
|
103
|
+
return count;
|
|
104
|
+
}
|
|
105
|
+
const GENERATED = /@generated|generated by|do not edit/i;
|
|
106
|
+
const SQL = /\bselect\b[\s\S]*\bfrom\b|\binsert\s+into\b|\bupdate\s+[\w."`]+\s+set\b|\bdelete\s+from\b/i;
|
|
107
|
+
const TABLE = /\b(from|join|into|update)\s+(?:only\s+)?([`"]?)([A-Za-z_]\w*)\2(?:\.([`"]?)([A-Za-z_]\w*)\4)?(\s*\()?/gi;
|
|
108
|
+
const CTE = /(?:\bwith\s+(?:recursive\s+)?|,\s*)([A-Za-z_]\w*)\s+as\s+(?:(?:not\s+)?materialized\s+)?\(/gi;
|
|
109
|
+
/** Words after FROM/JOIN/INTO/UPDATE that are SQL, not a table. */
|
|
110
|
+
const NOT_TABLES = new Set([
|
|
111
|
+
'select',
|
|
112
|
+
'set',
|
|
113
|
+
'lateral',
|
|
114
|
+
'skip',
|
|
115
|
+
'nowait',
|
|
116
|
+
'of',
|
|
117
|
+
'only',
|
|
118
|
+
'dual',
|
|
119
|
+
'values',
|
|
120
|
+
'the',
|
|
121
|
+
'a',
|
|
122
|
+
'an',
|
|
123
|
+
]);
|
|
124
|
+
const BUILDER_METHODS = new Set([
|
|
125
|
+
'collection',
|
|
126
|
+
'selectFrom',
|
|
127
|
+
'insertInto',
|
|
128
|
+
'updateTable',
|
|
129
|
+
'deleteFrom',
|
|
130
|
+
'replaceInto',
|
|
131
|
+
'mergeInto',
|
|
132
|
+
]);
|
|
133
|
+
/** Table names in one piece of SQL text (EXTRACT(… FROM x) and CTE names excluded). */
|
|
134
|
+
export function sqlTables(sql, into) {
|
|
135
|
+
if (!SQL.test(sql))
|
|
136
|
+
return;
|
|
137
|
+
const source = sql
|
|
138
|
+
.replace(/\b(?:extract|substring|trim|position|overlay)\s*\([^()]*\)/gi, ' ')
|
|
139
|
+
// An upsert's UPDATE names columns: ON DUPLICATE KEY UPDATE x = …, ON CONFLICT … DO UPDATE SET.
|
|
140
|
+
.replace(/\b(?:duplicate\s+key|do)\s+update\b/gi, ' ');
|
|
141
|
+
const ctes = new Set();
|
|
142
|
+
for (const match of source.matchAll(CTE))
|
|
143
|
+
ctes.add(match[1].toLowerCase());
|
|
144
|
+
for (const match of source.matchAll(TABLE)) {
|
|
145
|
+
// FROM unnest(…), JOIN generate_series(…) read a function; INTO t(a, b) names columns.
|
|
146
|
+
if (match[6] && !/^into$/i.test(match[1]))
|
|
147
|
+
continue;
|
|
148
|
+
const table = (match[5] ?? match[3] ?? '').toLowerCase();
|
|
149
|
+
if (!table || NOT_TABLES.has(table) || ctes.has(table))
|
|
150
|
+
continue;
|
|
151
|
+
into.add(table);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export function structureRules(file, layer, kinds, report) {
|
|
155
|
+
const text = file.text;
|
|
156
|
+
if (GENERATED.test(text.split('\n', 3).join('\n')))
|
|
157
|
+
return;
|
|
158
|
+
const name = (node) => node && (node.kind === kinds.Identifier || node.kind === kinds.PrivateIdentifier)
|
|
159
|
+
? String(property(node, 'text'))
|
|
160
|
+
: undefined;
|
|
161
|
+
const literal = (node) => node && (node.kind === kinds.StringLiteral || node.kind === kinds.NoSubstitutionTemplateLiteral)
|
|
162
|
+
? String(property(node, 'text'))
|
|
163
|
+
: undefined;
|
|
164
|
+
const unwrap = (node) => {
|
|
165
|
+
let current = node;
|
|
166
|
+
while (current &&
|
|
167
|
+
(current.kind === kinds.ParenthesizedExpression ||
|
|
168
|
+
current.kind === kinds.AwaitExpression ||
|
|
169
|
+
current.kind === kinds.AsExpression ||
|
|
170
|
+
current.kind === kinds.NonNullExpression))
|
|
171
|
+
current = field(current, 'expression');
|
|
172
|
+
return current;
|
|
173
|
+
};
|
|
174
|
+
const methodOf = (call) => {
|
|
175
|
+
const callee = field(call, 'expression');
|
|
176
|
+
return callee?.kind === kinds.PropertyAccessExpression
|
|
177
|
+
? name(field(callee, 'name'))
|
|
178
|
+
: undefined;
|
|
179
|
+
};
|
|
180
|
+
const functional = (node) => node.kind === kinds.ArrowFunction ||
|
|
181
|
+
node.kind === kinds.FunctionExpression ||
|
|
182
|
+
node.kind === kinds.FunctionDeclaration ||
|
|
183
|
+
node.kind === kinds.MethodDeclaration;
|
|
184
|
+
const lines = layer === 'service' ? 0 : codeLines(text);
|
|
185
|
+
const first = nodes(file, 'statements')[0] ?? file;
|
|
186
|
+
if (layer === 'model' && lines >= MODEL_LINES) {
|
|
187
|
+
const tables = new Set();
|
|
188
|
+
// Local helpers that wrap `.collection(name)`: `c('tickets')` names a collection too.
|
|
189
|
+
const helpers = new Set();
|
|
190
|
+
const findHelpers = (node) => {
|
|
191
|
+
if (functional(node)) {
|
|
192
|
+
let wraps = false;
|
|
193
|
+
const look = (inner) => {
|
|
194
|
+
if (inner.kind === kinds.CallExpression &&
|
|
195
|
+
methodOf(inner) === 'collection' &&
|
|
196
|
+
!literal(nodes(inner, 'arguments')[0]))
|
|
197
|
+
wraps = true;
|
|
198
|
+
if (!wraps)
|
|
199
|
+
inner.forEachChild(look);
|
|
200
|
+
return undefined;
|
|
201
|
+
};
|
|
202
|
+
node.forEachChild(look);
|
|
203
|
+
const own = name(field(node, 'name')) ??
|
|
204
|
+
(node.parent?.kind === kinds.VariableDeclaration ||
|
|
205
|
+
node.parent?.kind === kinds.PropertyDeclaration
|
|
206
|
+
? name(field(node.parent, 'name'))
|
|
207
|
+
: undefined);
|
|
208
|
+
if (wraps && own)
|
|
209
|
+
helpers.add(own);
|
|
210
|
+
}
|
|
211
|
+
node.forEachChild(findHelpers);
|
|
212
|
+
return undefined;
|
|
213
|
+
};
|
|
214
|
+
findHelpers(file);
|
|
215
|
+
const walk = (node) => {
|
|
216
|
+
const kind = node.kind;
|
|
217
|
+
if (kind === kinds.StringLiteral || kind === kinds.NoSubstitutionTemplateLiteral)
|
|
218
|
+
sqlTables(String(property(node, 'text')), tables);
|
|
219
|
+
else if (kind === kinds.TemplateExpression) {
|
|
220
|
+
const head = field(node, 'head');
|
|
221
|
+
const parts = [head ? String(property(head, 'text')) : ''];
|
|
222
|
+
for (const span of nodes(node, 'templateSpans')) {
|
|
223
|
+
const piece = field(span, 'literal');
|
|
224
|
+
parts.push(piece ? String(property(piece, 'text')) : '');
|
|
225
|
+
}
|
|
226
|
+
sqlTables(parts.join(' ? '), tables);
|
|
227
|
+
}
|
|
228
|
+
else if (kind === kinds.CallExpression) {
|
|
229
|
+
const callee = field(node, 'expression');
|
|
230
|
+
const method = callee?.kind === kinds.PropertyAccessExpression
|
|
231
|
+
? name(field(callee, 'name'))
|
|
232
|
+
: name(callee);
|
|
233
|
+
const argument = literal(nodes(node, 'arguments')[0]);
|
|
234
|
+
if (argument && method && (BUILDER_METHODS.has(method) || helpers.has(method)))
|
|
235
|
+
tables.add(argument.toLowerCase());
|
|
236
|
+
}
|
|
237
|
+
node.forEachChild(walk);
|
|
238
|
+
return undefined;
|
|
239
|
+
};
|
|
240
|
+
walk(file);
|
|
241
|
+
if (tables.size >= MODEL_TABLES)
|
|
242
|
+
report(first, 'responsibility-size', `This model has ${lines} code lines over ${tables.size} tables or collections (${[...tables].sort().slice(0, 12).join(', ')}${tables.size > 12 ? ', …' : ''}): one file carries several features, and every change touches all of them`, 'split it by feature, one model per table group (src/apps/<app>/models/<feature>.ts or src/shared/modules/<feature>/models/), each with its own queries; the service that runs one use case passes its transaction to each model');
|
|
243
|
+
}
|
|
244
|
+
if (layer === 'controller') {
|
|
245
|
+
// Route handlers: public methods and function-valued fields of the controller classes.
|
|
246
|
+
let handlers = 0;
|
|
247
|
+
const countHandlers = (node) => {
|
|
248
|
+
if (node.kind === kinds.ClassDeclaration || node.kind === kinds.ClassExpression)
|
|
249
|
+
for (const member of nodes(node, 'members')) {
|
|
250
|
+
if (member.kind === kinds.PrivateIdentifier ||
|
|
251
|
+
field(member, 'name')?.kind === kinds.PrivateIdentifier)
|
|
252
|
+
continue;
|
|
253
|
+
if (nodes(member, 'modifiers').some((modifier) => modifier.kind === kinds.PrivateKeyword ||
|
|
254
|
+
modifier.kind === kinds.ProtectedKeyword ||
|
|
255
|
+
modifier.kind === kinds.StaticKeyword))
|
|
256
|
+
continue;
|
|
257
|
+
const initializer = unwrap(field(member, 'initializer'));
|
|
258
|
+
if (member.kind === kinds.MethodDeclaration ||
|
|
259
|
+
(member.kind === kinds.PropertyDeclaration &&
|
|
260
|
+
initializer !== undefined &&
|
|
261
|
+
functional(initializer)))
|
|
262
|
+
handlers++;
|
|
263
|
+
}
|
|
264
|
+
node.forEachChild(countHandlers);
|
|
265
|
+
return undefined;
|
|
266
|
+
};
|
|
267
|
+
countHandlers(file);
|
|
268
|
+
if (handlers >= CONTROLLER_HANDLERS || lines >= CONTROLLER_LINES)
|
|
269
|
+
report(first, 'responsibility-size', `This controller has ${handlers} handlers in ${lines} code lines: one controller that takes most routes of the app mixes the HTTP handling of unrelated features`, 'split it by feature or resource (one controller per resource in controllers/, or a feature module with npx dokebi module add <feature>); a handler reads the request, calls one service method and shapes the reply');
|
|
270
|
+
}
|
|
271
|
+
if (layer === 'service') {
|
|
272
|
+
const visitClass = (node) => {
|
|
273
|
+
if (node.kind === kinds.ClassDeclaration || node.kind === kinds.ClassExpression) {
|
|
274
|
+
let methods = 0;
|
|
275
|
+
let forwarding = 0;
|
|
276
|
+
for (const member of nodes(node, 'members')) {
|
|
277
|
+
if (member.kind !== kinds.MethodDeclaration)
|
|
278
|
+
continue;
|
|
279
|
+
if (nodes(member, 'modifiers').some((modifier) => modifier.kind === kinds.StaticKeyword))
|
|
280
|
+
continue;
|
|
281
|
+
methods++;
|
|
282
|
+
if (forwards(member))
|
|
283
|
+
forwarding++;
|
|
284
|
+
}
|
|
285
|
+
if (forwarding >= SERVICE_FORWARDS && forwarding / methods >= FORWARD_SHARE)
|
|
286
|
+
report(node, 'responsibility-size', `${forwarding} of the ${methods} methods of ${name(field(node, 'name')) ?? 'this service'} only pass their arguments on to a model: the service adds a layer without owning the use case, so the work lands in the controller or the model`, 'give the service the use case (validation of the inputs it takes, the transaction across models, the DTO it returns); call a model directly from another service only, never from a controller');
|
|
287
|
+
}
|
|
288
|
+
node.forEachChild(visitClass);
|
|
289
|
+
return undefined;
|
|
290
|
+
};
|
|
291
|
+
/** A method whose one statement returns `this.<field>.<method>(…its own parameters…)`. */
|
|
292
|
+
const forwards = (method) => {
|
|
293
|
+
const body = field(method, 'body');
|
|
294
|
+
const statements = body ? nodes(body, 'statements') : [];
|
|
295
|
+
if (statements.length !== 1)
|
|
296
|
+
return false;
|
|
297
|
+
const only = statements[0];
|
|
298
|
+
if (only.kind !== kinds.ReturnStatement && only.kind !== kinds.ExpressionStatement)
|
|
299
|
+
return false;
|
|
300
|
+
const call = unwrap(field(only, 'expression'));
|
|
301
|
+
if (call?.kind !== kinds.CallExpression)
|
|
302
|
+
return false;
|
|
303
|
+
const callee = field(call, 'expression');
|
|
304
|
+
const holder = callee?.kind === kinds.PropertyAccessExpression ? field(callee, 'expression') : undefined;
|
|
305
|
+
if (holder?.kind !== kinds.PropertyAccessExpression ||
|
|
306
|
+
field(holder, 'expression')?.kind !== kinds.ThisKeyword)
|
|
307
|
+
return false;
|
|
308
|
+
const parameters = new Set(nodes(method, 'parameters').flatMap((parameter) => name(field(parameter, 'name')) ?? []));
|
|
309
|
+
const rootOf = (node) => {
|
|
310
|
+
let current = node;
|
|
311
|
+
while (current?.kind === kinds.PropertyAccessExpression)
|
|
312
|
+
current = field(current, 'expression');
|
|
313
|
+
return current;
|
|
314
|
+
};
|
|
315
|
+
return nodes(call, 'arguments').every((argument) => {
|
|
316
|
+
const value = argument.kind === kinds.SpreadElement ? field(argument, 'expression') : argument;
|
|
317
|
+
const root = rootOf(value);
|
|
318
|
+
if (root?.kind === kinds.ThisKeyword)
|
|
319
|
+
return true; // a setting of the service
|
|
320
|
+
return root?.kind === kinds.Identifier && parameters.has(name(root));
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
visitClass(file);
|
|
324
|
+
}
|
|
325
|
+
if (layer === 'model' || layer === 'service')
|
|
326
|
+
memoryPages(file, kinds, report, { name, unwrap, methodOf, functional });
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* memory-filtered-page: rows from an awaited query, skipped in code by comparing a field of the
|
|
330
|
+
* row with an input of the function (`t.status !== f.status`), then cut to a page.
|
|
331
|
+
*/
|
|
332
|
+
function memoryPages(file, kinds, report, help) {
|
|
333
|
+
const { name, unwrap, methodOf, functional } = help;
|
|
334
|
+
const message = 'Rows the query returned are filtered here by an input of the function and then cut to a page: the database read a fixed window, so a page comes back short or empty while matches remain, and a rare filter value pages through hundreds of empty pages';
|
|
335
|
+
const fix = 'put the filter in the query (WHERE … with an index that starts with the equality filters and ends with the sort key) and LIMIT the page size + 1 (@dokebi/db AI.md 6.2); a capped candidate check of the other terms of a search (6.10) gets // dokebi-check: allow memory-filtered-page <reason>';
|
|
336
|
+
const COMPARE = new Set([
|
|
337
|
+
kinds.EqualsEqualsEqualsToken,
|
|
338
|
+
kinds.ExclamationEqualsEqualsToken,
|
|
339
|
+
kinds.EqualsEqualsToken,
|
|
340
|
+
kinds.ExclamationEqualsToken,
|
|
341
|
+
kinds.LessThanToken,
|
|
342
|
+
kinds.GreaterThanToken,
|
|
343
|
+
kinds.LessThanEqualsToken,
|
|
344
|
+
kinds.GreaterThanEqualsToken,
|
|
345
|
+
]);
|
|
346
|
+
/** The identifier a chain of calls and property reads starts from (`pages` of `pages.flat()`). */
|
|
347
|
+
const chainRoot = (node) => {
|
|
348
|
+
let current = unwrap(node);
|
|
349
|
+
while (current &&
|
|
350
|
+
(current.kind === kinds.CallExpression || current.kind === kinds.PropertyAccessExpression))
|
|
351
|
+
current = unwrap(field(current, 'expression'));
|
|
352
|
+
return current;
|
|
353
|
+
};
|
|
354
|
+
const rootName = (node) => {
|
|
355
|
+
const root = chainRoot(node);
|
|
356
|
+
return root?.kind === kinds.Identifier ? name(root) : undefined;
|
|
357
|
+
};
|
|
358
|
+
/** Names bound by a declaration name or a parameter (destructuring included). */
|
|
359
|
+
const bound = (target, into) => {
|
|
360
|
+
if (!target)
|
|
361
|
+
return;
|
|
362
|
+
const own = name(target);
|
|
363
|
+
if (own)
|
|
364
|
+
into.add(own);
|
|
365
|
+
else
|
|
366
|
+
for (const element of nodes(target, 'elements'))
|
|
367
|
+
bound(field(element, 'name'), into);
|
|
368
|
+
};
|
|
369
|
+
/** Code under `node` that runs in the same function (nested functions are their own). */
|
|
370
|
+
const within = (node, check) => {
|
|
371
|
+
check(node);
|
|
372
|
+
node.forEachChild((child) => {
|
|
373
|
+
if (!functional(child))
|
|
374
|
+
within(child, check);
|
|
375
|
+
return undefined;
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
/** A comparison of a field of a row (`row.x`) with an input of the function (`input.y`). */
|
|
379
|
+
const comparesInput = (condition, row, inputs) => {
|
|
380
|
+
let found = false;
|
|
381
|
+
const look = (node) => {
|
|
382
|
+
if (found)
|
|
383
|
+
return undefined;
|
|
384
|
+
if (node.kind === kinds.BinaryExpression &&
|
|
385
|
+
COMPARE.has(field(node, 'operatorToken')?.kind ?? -1)) {
|
|
386
|
+
const left = field(node, 'left');
|
|
387
|
+
const right = field(node, 'right');
|
|
388
|
+
const sides = [left, right].map((side) => ({
|
|
389
|
+
root: rootName(side),
|
|
390
|
+
read: unwrap(side)?.kind === kinds.PropertyAccessExpression,
|
|
391
|
+
}));
|
|
392
|
+
const [a, b] = sides;
|
|
393
|
+
const rowField = (side) => side.read && side.root !== undefined && row.has(side.root);
|
|
394
|
+
const input = (side) => side.root !== undefined && inputs.has(side.root);
|
|
395
|
+
if ((rowField(a) && input(b)) || (rowField(b) && input(a)))
|
|
396
|
+
found = true;
|
|
397
|
+
}
|
|
398
|
+
node.forEachChild(look);
|
|
399
|
+
return undefined;
|
|
400
|
+
};
|
|
401
|
+
look(condition);
|
|
402
|
+
return found;
|
|
403
|
+
};
|
|
404
|
+
const visitFunction = (fn) => {
|
|
405
|
+
const inputs = new Set();
|
|
406
|
+
for (const parameter of nodes(fn, 'parameters'))
|
|
407
|
+
bound(field(parameter, 'name'), inputs);
|
|
408
|
+
if (!inputs.size)
|
|
409
|
+
return;
|
|
410
|
+
const rows = new Set();
|
|
411
|
+
const fromRows = (value) => {
|
|
412
|
+
if (!value)
|
|
413
|
+
return false;
|
|
414
|
+
if (value.kind === kinds.AwaitExpression || unwrap(value)?.kind === kinds.AwaitExpression)
|
|
415
|
+
return true;
|
|
416
|
+
const root = rootName(value);
|
|
417
|
+
return root !== undefined && rows.has(root);
|
|
418
|
+
};
|
|
419
|
+
within(fn, (node) => {
|
|
420
|
+
if (node.kind === kinds.VariableDeclaration && fromRows(field(node, 'initializer')))
|
|
421
|
+
bound(field(node, 'name'), rows);
|
|
422
|
+
else if (node.kind === kinds.BinaryExpression &&
|
|
423
|
+
field(node, 'operatorToken')?.kind === kinds.EqualsToken &&
|
|
424
|
+
fromRows(field(node, 'right')))
|
|
425
|
+
bound(field(node, 'left'), rows);
|
|
426
|
+
});
|
|
427
|
+
if (!rows.size)
|
|
428
|
+
return;
|
|
429
|
+
const isRows = (node) => {
|
|
430
|
+
const root = rootName(node);
|
|
431
|
+
return root !== undefined && rows.has(root);
|
|
432
|
+
};
|
|
433
|
+
within(fn, (node) => {
|
|
434
|
+
if (node.kind === kinds.CallExpression && methodOf(node) === 'slice') {
|
|
435
|
+
// rows.filter((row) => row.status === input.status).slice(0, size)
|
|
436
|
+
const receiver = unwrap(field(field(node, 'expression'), 'expression'));
|
|
437
|
+
if (receiver?.kind !== kinds.CallExpression || methodOf(receiver) !== 'filter')
|
|
438
|
+
return;
|
|
439
|
+
if (!isRows(field(field(receiver, 'expression'), 'expression')))
|
|
440
|
+
return;
|
|
441
|
+
const callback = unwrap(nodes(receiver, 'arguments')[0]);
|
|
442
|
+
if (!callback || !functional(callback))
|
|
443
|
+
return;
|
|
444
|
+
const row = new Set();
|
|
445
|
+
for (const parameter of nodes(callback, 'parameters'))
|
|
446
|
+
bound(field(parameter, 'name'), row);
|
|
447
|
+
const body = field(callback, 'body');
|
|
448
|
+
if (row.size && body && comparesInput(body, row, inputs))
|
|
449
|
+
report(node, 'memory-filtered-page', message, fix);
|
|
450
|
+
}
|
|
451
|
+
else if (node.kind === kinds.ForOfStatement && isRows(field(node, 'expression'))) {
|
|
452
|
+
// for (const row of rows) { if (row.status !== input.status) continue; …; if (page.length === size) break; }
|
|
453
|
+
const row = new Set();
|
|
454
|
+
const declared = field(node, 'initializer');
|
|
455
|
+
for (const declaration of declared
|
|
456
|
+
? nodes(field(declared, 'declarationList') ?? declared, 'declarations')
|
|
457
|
+
: [])
|
|
458
|
+
bound(field(declaration, 'name'), row);
|
|
459
|
+
let skips = false;
|
|
460
|
+
let stops = false;
|
|
461
|
+
const body = field(node, 'statement');
|
|
462
|
+
const exits = (statement, kind) => statement?.kind === kind ||
|
|
463
|
+
(statement?.kind === kinds.Block &&
|
|
464
|
+
nodes(statement, 'statements').some((inner) => inner.kind === kind));
|
|
465
|
+
const look = (child) => {
|
|
466
|
+
if (functional(child) ||
|
|
467
|
+
child.kind === kinds.ForOfStatement ||
|
|
468
|
+
child.kind === kinds.ForStatement ||
|
|
469
|
+
child.kind === kinds.ForInStatement ||
|
|
470
|
+
child.kind === kinds.WhileStatement ||
|
|
471
|
+
child.kind === kinds.DoStatement)
|
|
472
|
+
return undefined;
|
|
473
|
+
if (child.kind === kinds.IfStatement) {
|
|
474
|
+
const condition = field(child, 'expression');
|
|
475
|
+
const consequent = field(child, 'thenStatement');
|
|
476
|
+
if (condition &&
|
|
477
|
+
exits(consequent, kinds.ContinueStatement) &&
|
|
478
|
+
comparesInput(condition, row, inputs))
|
|
479
|
+
skips = true;
|
|
480
|
+
if (condition &&
|
|
481
|
+
exits(consequent, kinds.BreakStatement) &&
|
|
482
|
+
/\.length\b/.test(file.text.slice(condition.getStart(), condition.end)))
|
|
483
|
+
stops = true;
|
|
484
|
+
}
|
|
485
|
+
child.forEachChild(look);
|
|
486
|
+
return undefined;
|
|
487
|
+
};
|
|
488
|
+
if (body && row.size)
|
|
489
|
+
look(body);
|
|
490
|
+
if (skips && stops)
|
|
491
|
+
report(node, 'memory-filtered-page', message, fix);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
};
|
|
495
|
+
const visit = (node) => {
|
|
496
|
+
if (functional(node))
|
|
497
|
+
visitFunction(node);
|
|
498
|
+
node.forEachChild(visit);
|
|
499
|
+
return undefined;
|
|
500
|
+
};
|
|
501
|
+
visit(file);
|
|
502
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Kinds, type TsNode, type TsSourceFile } from './typescript.js';
|
|
2
|
+
export type Report = (node: TsNode, message: string, fix: string, rule?: 'background-timer' | 'polling-loop') => void;
|
|
3
|
+
/** Paths the rule reads: server code under src/, without views (browser code) and tests. */
|
|
4
|
+
export declare function isServerCode(path: string): boolean;
|
|
5
|
+
export declare const TIMER_FIX = "run periodic work as a job: register the handler in the setup of the module that owns it (context.getDependency(job).queue(<queue>).handle(<type>, async (payload, { signal }) => \u2026), src/shared/modules/<module>/module.ts) and add its entry to options.dokebi-scheduler.schedules in dokebi.yaml (npx dokebi add @dokebi/job, then @dokebi/scheduler); inside one job use await setTimeout(ms, undefined, { signal }) from node:timers/promises between bounded batches. Per-process upkeep of in-memory state that no job can reach: // dokebi-check: allow background-timer <reason>";
|
|
6
|
+
export declare const POLLING_FIX = "move the loop to background work the framework runs: an @dokebi/outbox handler (one coalesced item per owner and window with a dedupeKey, its handler doing one bounded batch through context.db and enqueuing the next while work remains) or a @dokebi/job handler with an options.dokebi-scheduler.schedules entry; a dokebi-check marker does not silence this rule";
|
|
7
|
+
export declare function timerRules(file: TsSourceFile, kinds: Kinds, report: Report): void;
|