@dokebi/cli 0.2.5 → 0.4.1

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 (81) hide show
  1. package/AI.md +181 -87
  2. package/README.md +55 -24
  3. package/catalog.json +2203 -0
  4. package/dist/app-urls.d.ts +21 -0
  5. package/dist/app-urls.js +94 -0
  6. package/dist/banner-art.d.ts +4 -0
  7. package/dist/banner-art.js +31 -0
  8. package/dist/banner.d.ts +25 -0
  9. package/dist/banner.js +202 -0
  10. package/dist/bin.d.ts +2 -1
  11. package/dist/bin.js +475 -46
  12. package/dist/catalog.d.ts +161 -0
  13. package/dist/catalog.js +110 -0
  14. package/dist/check/form-rules.d.ts +13 -0
  15. package/dist/check/form-rules.js +224 -0
  16. package/dist/check/index.d.ts +23 -0
  17. package/dist/check/index.js +70 -0
  18. package/dist/check/packages.d.ts +17 -0
  19. package/dist/check/packages.js +161 -0
  20. package/dist/check/problems.d.ts +19 -0
  21. package/dist/check/problems.js +22 -0
  22. package/dist/check/project-rules.d.ts +10 -0
  23. package/dist/check/project-rules.js +142 -0
  24. package/dist/check/source-rules.d.ts +28 -0
  25. package/dist/check/source-rules.js +273 -0
  26. package/dist/check/typescript.d.ts +46 -0
  27. package/dist/check/typescript.js +168 -0
  28. package/dist/commands.d.ts +18 -0
  29. package/dist/commands.js +372 -0
  30. package/dist/config-edit.d.ts +5 -4
  31. package/dist/config-edit.js +9 -8
  32. package/dist/configuration.d.ts +1 -0
  33. package/dist/configuration.js +2 -1
  34. package/dist/dev-infra.d.ts +62 -0
  35. package/dist/dev-infra.js +215 -0
  36. package/dist/env-file.d.ts +24 -0
  37. package/dist/env-file.js +100 -0
  38. package/dist/generator.d.ts +24 -1
  39. package/dist/generator.js +57 -47
  40. package/dist/guide.d.ts +29 -0
  41. package/dist/guide.js +90 -0
  42. package/dist/install-plan.d.ts +8 -0
  43. package/dist/install-plan.js +21 -4
  44. package/dist/layout.d.ts +5 -4
  45. package/dist/layout.js +8 -7
  46. package/dist/log-writer-control.d.ts +55 -0
  47. package/dist/log-writer-control.js +327 -0
  48. package/dist/log-writer.js +53 -12
  49. package/dist/manifest.d.ts +28 -0
  50. package/dist/manifest.js +77 -2
  51. package/dist/npm-client.d.ts +49 -0
  52. package/dist/npm-client.js +162 -41
  53. package/dist/output.d.ts +1 -1
  54. package/dist/output.js +14 -8
  55. package/dist/plugin-lifecycle.d.ts +7 -0
  56. package/dist/plugin-lifecycle.js +21 -3
  57. package/dist/plugins-command.d.ts +47 -0
  58. package/dist/plugins-command.js +189 -0
  59. package/dist/project-guide.d.ts +36 -1
  60. package/dist/project-guide.js +142 -18
  61. package/dist/project-inspector.d.ts +10 -0
  62. package/dist/project-inspector.js +51 -2
  63. package/dist/project-state.d.ts +13 -2
  64. package/dist/project-state.js +8 -6
  65. package/dist/project.d.ts +89 -7
  66. package/dist/project.js +835 -85
  67. package/dist/recipe-record.d.ts +24 -0
  68. package/dist/recipe-record.js +67 -0
  69. package/dist/recipe.d.ts +120 -0
  70. package/dist/recipe.js +801 -0
  71. package/dist/registry.d.ts +79 -0
  72. package/dist/registry.js +212 -0
  73. package/dist/rotating-log.d.ts +12 -0
  74. package/dist/rotating-log.js +19 -0
  75. package/dist/server.d.ts +94 -4
  76. package/dist/server.js +624 -64
  77. package/dist/verify.d.ts +62 -0
  78. package/dist/verify.js +380 -0
  79. package/dist/vite-host-process.d.ts +20 -0
  80. package/dist/vite-host-process.js +117 -0
  81. package/package.json +30 -6
@@ -0,0 +1,161 @@
1
+ /** One variant of a package (a database, a view kind) with the data recipes need. */
2
+ export interface CatalogVariant {
3
+ readonly name: string;
4
+ readonly capability: string;
5
+ readonly adapter?: string;
6
+ readonly driver?: {
7
+ readonly package: string;
8
+ readonly version: string;
9
+ };
10
+ readonly types?: {
11
+ readonly package: string;
12
+ readonly version: string;
13
+ };
14
+ readonly migrate?: boolean;
15
+ readonly alsoInstalls?: readonly string[];
16
+ readonly needs?: readonly string[];
17
+ readonly devInfra?: {
18
+ readonly docker?: {
19
+ readonly image: string;
20
+ readonly port: number;
21
+ readonly env: Readonly<Record<string, string>>;
22
+ readonly healthcheck: readonly string[];
23
+ readonly url: string;
24
+ };
25
+ };
26
+ }
27
+ export interface CatalogPackage {
28
+ readonly id: string | null;
29
+ readonly package: string;
30
+ readonly version: string;
31
+ readonly kind: 'core' | 'cli' | 'required' | 'plugin' | 'driver' | 'library';
32
+ readonly summary: string;
33
+ readonly description: string;
34
+ readonly keywords: readonly string[];
35
+ readonly maturity: 'experimental' | 'preview' | 'stable';
36
+ readonly core: string | null;
37
+ readonly provides: readonly string[];
38
+ readonly variants: readonly CatalogVariant[];
39
+ readonly doesNot: readonly string[];
40
+ readonly recipes: readonly string[];
41
+ readonly requires: readonly {
42
+ id: string;
43
+ range: string;
44
+ package?: string;
45
+ }[];
46
+ readonly optional: readonly {
47
+ id: string;
48
+ range: string;
49
+ }[];
50
+ readonly driverOf?: string;
51
+ readonly optionKeys: readonly string[];
52
+ readonly docs: {
53
+ readonly ai: string | null;
54
+ readonly readme: string | null;
55
+ };
56
+ readonly install: string;
57
+ }
58
+ export interface CatalogOption {
59
+ readonly value: string;
60
+ readonly label: string;
61
+ readonly kind?: 'none';
62
+ readonly capability: string | null;
63
+ readonly available: boolean;
64
+ readonly absent: boolean;
65
+ readonly why?: string;
66
+ readonly plugins: readonly string[];
67
+ readonly alsoInstalls: readonly string[];
68
+ readonly needs: readonly string[];
69
+ readonly absentFeatures: readonly string[];
70
+ readonly pros: readonly string[];
71
+ readonly cons: readonly string[];
72
+ }
73
+ export interface CatalogChoice {
74
+ readonly id: string;
75
+ readonly flag: string;
76
+ readonly from: 'variants' | 'fixed';
77
+ readonly when?: Readonly<Record<string, string>>;
78
+ readonly question: string;
79
+ readonly default: string;
80
+ readonly defaultReason: string;
81
+ readonly options: readonly CatalogOption[];
82
+ }
83
+ export interface CatalogRecipe {
84
+ readonly name: string;
85
+ readonly summary: string;
86
+ readonly match: readonly string[];
87
+ readonly capabilities: readonly string[];
88
+ readonly questions: readonly string[];
89
+ readonly fixed?: Readonly<Record<string, string>>;
90
+ readonly module?: {
91
+ readonly name: string;
92
+ readonly views?: boolean;
93
+ readonly why: string;
94
+ };
95
+ /**
96
+ * `forms`: the app takes HTML form posts (a write form); with a server-rendered view the recipe
97
+ * turns dokebi-web `formBody` on and reports that there is no CSRF protection (csrf is absent).
98
+ */
99
+ readonly web: {
100
+ readonly port: number;
101
+ readonly domain: string;
102
+ readonly forms?: boolean;
103
+ };
104
+ readonly then?: readonly string[];
105
+ }
106
+ export interface CatalogAbsent {
107
+ readonly capability: string;
108
+ readonly summary: string;
109
+ readonly instead: string;
110
+ readonly verify: readonly string[];
111
+ }
112
+ export interface Catalog {
113
+ readonly schemaVersion: 1;
114
+ readonly cli: string;
115
+ readonly core: {
116
+ readonly package: string;
117
+ readonly version: string;
118
+ };
119
+ readonly capabilities: Readonly<Record<string, {
120
+ summary: string;
121
+ en: string;
122
+ }>>;
123
+ readonly packages: readonly CatalogPackage[];
124
+ readonly recipes: readonly CatalogRecipe[];
125
+ readonly choices: readonly CatalogChoice[];
126
+ readonly absent: readonly CatalogAbsent[];
127
+ }
128
+ /** The snapshot shipped with this CLI (packages/cli/catalog.json, built by scripts/catalog). */
129
+ export declare function loadCatalog(): Promise<Catalog>;
130
+ /** A package by plugin id (`dokebi-db`), short name (`db`) or npm name (`@dokebi/db`). */
131
+ export declare function findPackage(catalog: Catalog, key: string): CatalogPackage | undefined;
132
+ /**
133
+ * The catalog capability of each install-time host role (D-0070 `dokebi.hosts`): which plugin to
134
+ * suggest when a project has no host for `dokebi app …` or `dokebi module …` yet.
135
+ */
136
+ export declare const HOST_CAPABILITY: {
137
+ readonly apps: 'web.apps';
138
+ readonly modules: 'web.modules';
139
+ };
140
+ /** Packages that provide a capability (in `provides` or one of their variants). */
141
+ export declare function providersOf(catalog: Catalog, capability: string): CatalogPackage[];
142
+ export interface SearchResult {
143
+ readonly query: string;
144
+ /** found: packages matched; absent: none matched but Dokebi knows it lacks this; unknown. */
145
+ readonly status: 'found' | 'absent' | 'unknown';
146
+ readonly matches: readonly CatalogPackage[];
147
+ readonly recipes: readonly {
148
+ name: string;
149
+ summary: string;
150
+ }[];
151
+ readonly absent: readonly (CatalogAbsent & {
152
+ message: string;
153
+ })[];
154
+ }
155
+ /**
156
+ * Searches packages by words (every word must appear in the id, name, keywords, summary,
157
+ * description or capability names and descriptions) or by an exact capability name. Matching
158
+ * recipes are listed too. When no package matches, the absent entries any word matches are
159
+ * returned so "not found" differs from "does not exist".
160
+ */
161
+ export declare function searchCatalog(catalog: Catalog, query: string): SearchResult;
@@ -0,0 +1,110 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { debug } from './diagnostics.js';
6
+ import { CliError } from './errors.js';
7
+ let cached;
8
+ /** The catalog file next to this package's package.json (dist/ and the test build both work). */
9
+ function catalogPath() {
10
+ let directory = dirname(fileURLToPath(import.meta.url));
11
+ for (let depth = 0; depth < 5; depth++) {
12
+ if (existsSync(join(directory, 'catalog.json')) && existsSync(join(directory, 'package.json')))
13
+ return join(directory, 'catalog.json');
14
+ directory = dirname(directory);
15
+ }
16
+ throw new CliError('CATALOG_MISSING', 'The CLI package has no catalog.json (reinstall it)');
17
+ }
18
+ /** The snapshot shipped with this CLI (packages/cli/catalog.json, built by scripts/catalog). */
19
+ export async function loadCatalog() {
20
+ if (cached)
21
+ return cached;
22
+ const path = catalogPath();
23
+ try {
24
+ cached = JSON.parse(await readFile(path, 'utf8'));
25
+ }
26
+ catch (cause) {
27
+ throw new CliError('CATALOG_INVALID', `Cannot read ${path}`, { cause });
28
+ }
29
+ if (cached.schemaVersion !== 1)
30
+ throw new CliError('CATALOG_INVALID', `Unsupported catalog schema ${cached.schemaVersion}`);
31
+ debug('Catalog loaded', { path, packages: cached.packages.length });
32
+ return cached;
33
+ }
34
+ /** A package by plugin id (`dokebi-db`), short name (`db`) or npm name (`@dokebi/db`). */
35
+ export function findPackage(catalog, key) {
36
+ // A version suffix (`@dokebi/db@0.4.0`, `db@1`) is ignored.
37
+ const trimmed = key.trim().toLowerCase();
38
+ const wanted = /^(@[^/@]+\/[^@]+|[^@]+)(?:@.*)?$/.exec(trimmed)?.[1] ?? trimmed;
39
+ return catalog.packages.find((item) => item.package === wanted ||
40
+ item.id === wanted ||
41
+ item.package === `@dokebi/${wanted}` ||
42
+ item.id === `dokebi-${wanted}`);
43
+ }
44
+ /**
45
+ * The catalog capability of each install-time host role (D-0070 `dokebi.hosts`): which plugin to
46
+ * suggest when a project has no host for `dokebi app …` or `dokebi module …` yet.
47
+ */
48
+ export const HOST_CAPABILITY = { apps: 'web.apps', modules: 'web.modules' };
49
+ /** Packages that provide a capability (in `provides` or one of their variants). */
50
+ export function providersOf(catalog, capability) {
51
+ return catalog.packages.filter((item) => item.provides.includes(capability));
52
+ }
53
+ function words(input) {
54
+ return input
55
+ .toLowerCase()
56
+ .split(/[\s,]+/)
57
+ .map((word) => word.trim())
58
+ .filter(Boolean);
59
+ }
60
+ /**
61
+ * Searches packages by words (every word must appear in the id, name, keywords, summary,
62
+ * description or capability names and descriptions) or by an exact capability name. Matching
63
+ * recipes are listed too. When no package matches, the absent entries any word matches are
64
+ * returned so "not found" differs from "does not exist".
65
+ */
66
+ export function searchCatalog(catalog, query) {
67
+ const terms = words(query);
68
+ const capabilityText = (name) => {
69
+ const entry = catalog.capabilities[name];
70
+ return `${name} ${entry?.summary ?? ''} ${entry?.en ?? ''}`;
71
+ };
72
+ const exact = terms.length === 1 && terms[0] ? terms[0] : undefined;
73
+ const matches = catalog.packages.filter((item) => {
74
+ if (exact && item.provides.includes(exact))
75
+ return true;
76
+ const text = [
77
+ item.id ?? '',
78
+ item.package,
79
+ item.keywords.join(' '),
80
+ item.summary,
81
+ item.description,
82
+ ...item.provides.map(capabilityText),
83
+ ]
84
+ .join(' ')
85
+ .toLowerCase();
86
+ return terms.length > 0 && terms.every((term) => text.includes(term));
87
+ });
88
+ const recipes = catalog.recipes
89
+ .filter((recipe) => {
90
+ const text = `${recipe.name} ${recipe.match.join(' ')} ${recipe.summary}`.toLowerCase();
91
+ return terms.some((term) => text.includes(term));
92
+ })
93
+ .map((recipe) => ({ name: recipe.name, summary: recipe.summary }));
94
+ const absent = catalog.absent
95
+ .filter((entry) => {
96
+ const text = `${capabilityText(entry.capability)} ${entry.summary}`.toLowerCase();
97
+ return terms.some((term) => text.includes(term));
98
+ })
99
+ .map((entry) => ({
100
+ ...entry,
101
+ message: `Dokebi has no ${entry.capability}: do not invent a plugin, flag or API for it. ${entry.instead}`,
102
+ }));
103
+ return {
104
+ query,
105
+ status: matches.length ? 'found' : absent.length ? 'absent' : 'unknown',
106
+ matches,
107
+ recipes,
108
+ absent: matches.length ? absent.filter((entry) => terms.includes(entry.capability)) : absent,
109
+ };
110
+ }
@@ -0,0 +1,13 @@
1
+ import type { Problem } from './problems.js';
2
+ /**
3
+ * The text with comments and string contents replaced by spaces (newlines kept), so positions and
4
+ * lines stay those of the source. Quotes stay, so an empty string is still an argument.
5
+ */
6
+ export declare function maskCode(text: string): string;
7
+ /** The problems of one file's text (the path is project-relative, with `/`). */
8
+ export declare function inertiaFormProblems(path: string, text: string): Problem[];
9
+ /** The form rule over the project's `src/` (D-0074). */
10
+ export declare function formProblems(root: string, skip: (path: string) => boolean): Promise<{
11
+ problems: Problem[];
12
+ files: number;
13
+ }>;
@@ -0,0 +1,224 @@
1
+ // The Inertia form rule of `dokebi check` (D-0074 §6, a warning):
2
+ // inertia-form-only an Inertia form submit — `useForm(...).post/put/patch/delete/submit(...)`
3
+ // or `router.post/put/patch/delete(...)` — whose options are missing or an
4
+ // object literal without `only`. Without `only` the GET after the redirect
5
+ // is a full visit, and a full visit must send every prop of the page again
6
+ // (Inertia replaces all props), so a refused or saved form costs a whole page.
7
+ // Vue files are not in the TypeScript program, so this rule reads the text of every source file
8
+ // under src/ that imports from `@inertiajs/`: the `<script>` blocks of .vue and .svelte files,
9
+ // the whole of .ts/.tsx/.js/.jsx/.mjs files. Comments and string contents are masked first.
10
+ // Known limits (documented in the CLI AI.md): calls written in a template attribute are not read;
11
+ // options passed as a variable or with a spread are taken as fine (they cannot be judged); a form
12
+ // helper that is not assigned from `useForm(...)` in the same file is not recognised.
13
+ import { readdir, readFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { debug } from '../diagnostics.js';
16
+ import { readMarkers } from './source-rules.js';
17
+ const RULE = 'inertia-form-only';
18
+ const SOURCE = /\.(?:vue|svelte|ts|tsx|js|jsx|mjs)$/;
19
+ const SKIP_DIRECTORIES = new Set(['node_modules', 'dist', '.vite', '.git']);
20
+ /** Files larger than this are not read (generated bundles, data). */
21
+ const MAX_BYTES = 1024 * 1024;
22
+ const SUBMIT = new Set(['post', 'put', 'patch', 'delete']);
23
+ const IDENTIFIER = '[A-Za-z_$][\\w$]*';
24
+ /**
25
+ * The text with comments and string contents replaced by spaces (newlines kept), so positions and
26
+ * lines stay those of the source. Quotes stay, so an empty string is still an argument.
27
+ */
28
+ export function maskCode(text) {
29
+ const out = text.split('');
30
+ const blank = (from, to) => {
31
+ for (let index = from; index < to; index++)
32
+ if (out[index] !== '\n')
33
+ out[index] = ' ';
34
+ };
35
+ let index = 0;
36
+ while (index < text.length) {
37
+ const char = text[index];
38
+ const next = text[index + 1];
39
+ if (char === '/' && next === '/') {
40
+ const end = text.indexOf('\n', index);
41
+ const stop = end === -1 ? text.length : end;
42
+ blank(index, stop);
43
+ index = stop;
44
+ }
45
+ else if (char === '/' && next === '*') {
46
+ const end = text.indexOf('*/', index + 2);
47
+ const stop = end === -1 ? text.length : end + 2;
48
+ blank(index, stop);
49
+ index = stop;
50
+ }
51
+ else if (char === "'" || char === '"' || char === '`') {
52
+ let at = index + 1;
53
+ while (at < text.length && text[at] !== char) {
54
+ if (text[at] === '\\')
55
+ at++;
56
+ else if (char !== '`' && text[at] === '\n')
57
+ break;
58
+ at++;
59
+ }
60
+ blank(index + 1, Math.min(at, text.length));
61
+ index = at + 1;
62
+ }
63
+ else
64
+ index++;
65
+ }
66
+ return out.join('');
67
+ }
68
+ /** The script parts of a single-file component: `[offset, text]`; the whole file otherwise. */
69
+ function scripts(path, text) {
70
+ if (!/\.(?:vue|svelte)$/.test(path))
71
+ return [[0, text]];
72
+ const parts = [];
73
+ for (const match of text.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) {
74
+ const body = match[1] ?? '';
75
+ parts.push([(match.index ?? 0) + match[0].indexOf(body), body]);
76
+ }
77
+ return parts;
78
+ }
79
+ /** The top-level arguments of the call whose `(` is at `open`: `[start, end)` ranges. */
80
+ function argumentsOf(code, open) {
81
+ const ranges = [];
82
+ let depth = 0;
83
+ let start = open + 1;
84
+ for (let index = open; index < code.length; index++) {
85
+ const char = code[index];
86
+ if (char === '(' || char === '[' || char === '{')
87
+ depth++;
88
+ else if (char === ')' || char === ']' || char === '}') {
89
+ depth--;
90
+ if (depth === 0) {
91
+ if (code.slice(start, index).trim())
92
+ ranges.push([start, index]);
93
+ return ranges;
94
+ }
95
+ }
96
+ else if (char === ',' && depth === 1) {
97
+ ranges.push([start, index]);
98
+ start = index + 1;
99
+ }
100
+ }
101
+ return undefined;
102
+ }
103
+ /** Local names bound by `import { name [as alias] } from '@inertiajs/…'`. */
104
+ function imported(code, name) {
105
+ const names = [];
106
+ for (const match of code.matchAll(/import\s*(?:type\s+)?\{([^}]*)\}\s*from\s*(['"])@inertiajs\//g))
107
+ for (const part of (match[1] ?? '').split(',')) {
108
+ const [original, alias] = part.trim().split(/\s+as\s+/);
109
+ if (original?.trim() === name)
110
+ names.push((alias ?? original).trim());
111
+ }
112
+ return names;
113
+ }
114
+ /** The problems of one file's text (the path is project-relative, with `/`). */
115
+ export function inertiaFormProblems(path, text) {
116
+ if (!text.includes('@inertiajs/'))
117
+ return [];
118
+ const problems = [];
119
+ const markers = readMarkers(text).filter((marker) => marker.rule === RULE);
120
+ const lineAt = (position) => {
121
+ let line = 1;
122
+ for (let index = 0; index < position; index++)
123
+ if (text.charCodeAt(index) === 10)
124
+ line++;
125
+ return line;
126
+ };
127
+ for (const [offset, part] of scripts(path, text)) {
128
+ const code = maskCode(part);
129
+ const routers = new Set(imported(part, 'router'));
130
+ const helpers = imported(part, 'useForm');
131
+ const forms = new Set();
132
+ for (const helper of helpers)
133
+ for (const match of code.matchAll(new RegExp(`\\b(?:const|let|var)\\s+(${IDENTIFIER})\\s*=\\s*${helper.replace(/\$/g, '\\$')}\\s*(?:<[^>]*>)?\\s*\\(`, 'g')))
134
+ forms.add(match[1]);
135
+ const names = [...routers, ...forms];
136
+ if (!names.length)
137
+ continue;
138
+ const call = new RegExp(`(?<![\\w$.])(${names.map((name) => name.replace(/\$/g, '\\$')).join('|')})\\s*\\.\\s*(post|put|patch|delete|submit)\\s*\\(`, 'g');
139
+ for (const match of code.matchAll(call)) {
140
+ const receiver = match[1];
141
+ const method = match[2];
142
+ const router = routers.has(receiver);
143
+ if (router && !SUBMIT.has(method))
144
+ continue;
145
+ const open = (match.index ?? 0) + match[0].length - 1;
146
+ const args = argumentsOf(code, open);
147
+ if (!args)
148
+ continue;
149
+ // router.post(url, data, options), router.delete(url, options);
150
+ // form.post(url, options), form.submit(method, url, options) or form.submit(route, options).
151
+ const at = router
152
+ ? method === 'delete'
153
+ ? 1
154
+ : 2
155
+ : method === 'submit'
156
+ ? args.length >= 3
157
+ ? 2
158
+ : 1
159
+ : 1;
160
+ const range = args[at];
161
+ if (range) {
162
+ const options = code.slice(range[0], range[1]).trim();
163
+ // A variable, a call or a spread cannot be judged here: taken as fine.
164
+ if (!options.startsWith('{') || options.includes('...'))
165
+ continue;
166
+ if (/[{,\s]only\s*[:,}(]/.test(options))
167
+ continue;
168
+ }
169
+ const line = lineAt(offset + (match.index ?? 0));
170
+ const marker = markers.find((item) => item.line === line || (item.line === line - 1 && item.ownLine));
171
+ if (marker?.reason)
172
+ continue;
173
+ problems.push({
174
+ rule: RULE,
175
+ severity: 'warning',
176
+ file: path,
177
+ line,
178
+ message: `${receiver}.${method}(…) submits an Inertia form without \`only\`: the GET after its redirect is a full visit that sends every prop of the page again${marker ? ' (the dokebi-check marker has no reason, so it does not count)' : ''}`,
179
+ fix: `pass only with the props this submit changes, for example ${receiver}.${method}(${router ? (method === 'delete' ? 'url, ' : 'url, data, ') : method === 'submit' ? "'post', url, " : 'url, '}{ only: ['items'] }) (errors always comes along); when the page really needs every prop again (a login), write // dokebi-check: allow ${RULE} <reason>`,
180
+ });
181
+ }
182
+ }
183
+ return problems;
184
+ }
185
+ /** Every source file under `src/`, project-relative with `/`. */
186
+ async function sources(root, directory = 'src') {
187
+ let entries;
188
+ try {
189
+ entries = await readdir(join(root, directory), { withFileTypes: true });
190
+ }
191
+ catch (error) {
192
+ // A project without src/ (or an unreadable folder) has no form code to check.
193
+ if (error.code === 'ENOENT')
194
+ return [];
195
+ throw error;
196
+ }
197
+ const files = [];
198
+ for (const entry of entries) {
199
+ const path = `${directory}/${entry.name}`;
200
+ if (entry.isDirectory()) {
201
+ if (!SKIP_DIRECTORIES.has(entry.name))
202
+ files.push(...(await sources(root, path)));
203
+ }
204
+ else if (entry.isFile() && SOURCE.test(entry.name) && !entry.name.endsWith('.d.ts'))
205
+ files.push(path);
206
+ }
207
+ return files;
208
+ }
209
+ /** The form rule over the project's `src/` (D-0074). */
210
+ export async function formProblems(root, skip) {
211
+ const problems = [];
212
+ let files = 0;
213
+ for (const path of await sources(root)) {
214
+ if (skip(path))
215
+ continue;
216
+ const text = await readFile(join(root, path), 'utf8');
217
+ if (text.length > MAX_BYTES || !text.includes('@inertiajs/'))
218
+ continue;
219
+ files++;
220
+ problems.push(...inertiaFormProblems(path, text));
221
+ }
222
+ debug('check: inertia forms', { files, problems: problems.length });
223
+ return { problems, files };
224
+ }
@@ -0,0 +1,23 @@
1
+ import { type Inspection } from '../project-inspector.js';
2
+ import { type Problem } from './problems.js';
3
+ export { type Problem, RULES, type RuleName } from './problems.js';
4
+ export interface CheckResult {
5
+ readonly ok: boolean;
6
+ readonly problems: Problem[];
7
+ /** Source files the rules read (the tsconfig program under the root). */
8
+ readonly files: number;
9
+ readonly rules: readonly string[];
10
+ readonly typescript: string;
11
+ /** total: the whole check; typescript: starting tsgo and loading the program (in total). */
12
+ readonly ms: {
13
+ readonly total: number;
14
+ readonly typescript: number;
15
+ };
16
+ }
17
+ export interface CheckOptions {
18
+ /** doctor's inspection when the caller already ran it (verify), else check runs doctor. */
19
+ readonly inspection?: Inspection;
20
+ }
21
+ export declare function check(directory: string, options?: CheckOptions): Promise<CheckResult>;
22
+ /** The human output: one block per problem, then a summary line. */
23
+ export declare function checkText(result: CheckResult): string;
@@ -0,0 +1,70 @@
1
+ // `dokebi check` (D-0072): the user-code convention gate of a project made by `dokebi init`.
2
+ // Seven error rules (problems.ts), each matching only code that is wrong, with a fix per problem,
3
+ // and one warning (inertia-form-only, D-0074) that reports without failing.
4
+ // Project rules reuse doctor's inspection (one source for CLI-owned files and plugin checks);
5
+ // source rules walk the AST of the project's own TypeScript 7 program (typescript.ts); the form
6
+ // rule reads the text of src/ (Vue files are not in the program, form-rules.ts).
7
+ import { realpath } from 'node:fs/promises';
8
+ import { loadCatalog } from '../catalog.js';
9
+ import { debug } from '../diagnostics.js';
10
+ import { ProjectInspector } from '../project-inspector.js';
11
+ import { formProblems } from './form-rules.js';
12
+ import { DokebiImports } from './packages.js';
13
+ import { byPlace, RULES } from './problems.js';
14
+ import { projectProblems } from './project-rules.js';
15
+ import { sourceProblems } from './source-rules.js';
16
+ import { openTypeScript } from './typescript.js';
17
+ export { RULES } from './problems.js';
18
+ /** CLI-generated TypeScript: judged by doctor (generated-file), not by the source rules. */
19
+ const GENERATED_SOURCES = new Set(['src/dokebi.generated.ts']);
20
+ export async function check(directory, options = {}) {
21
+ const began = performance.now();
22
+ const root = await realpath(directory);
23
+ const inspection = options.inspection ?? (await new ProjectInspector().inspect(root, { runChecks: true }));
24
+ const problems = await projectProblems(root, inspection);
25
+ const typescript = await openTypeScript(root);
26
+ let files = 0;
27
+ try {
28
+ const found = await sourceProblems(typescript, root, new DokebiImports(await loadCatalog()), (path) => GENERATED_SOURCES.has(path));
29
+ problems.push(...found.problems);
30
+ files = found.files;
31
+ }
32
+ finally {
33
+ typescript.close();
34
+ }
35
+ problems.push(...(await formProblems(root, (path) => GENERATED_SOURCES.has(path))).problems);
36
+ problems.sort(byPlace);
37
+ const ms = {
38
+ total: Math.round(performance.now() - began),
39
+ typescript: typescript.loadMs,
40
+ };
41
+ const errors = problems.filter((problem) => problem.severity !== 'warning').length;
42
+ debug('check: finished', {
43
+ root,
44
+ problems: problems.length,
45
+ warnings: problems.length - errors,
46
+ files,
47
+ ...ms,
48
+ });
49
+ return {
50
+ // Warnings (inertia-form-only) are reported but never fail check or verify.
51
+ ok: errors === 0,
52
+ problems,
53
+ files,
54
+ rules: RULES,
55
+ typescript: typescript.version,
56
+ ms,
57
+ };
58
+ }
59
+ /** The human output: one block per problem, then a summary line. */
60
+ export function checkText(result) {
61
+ const lines = result.problems.map((problem) => `${problem.file}${problem.line ? `:${problem.line}` : ''} ${problem.rule}${problem.severity === 'warning' ? ' (warning)' : ''} ${problem.message}\n fix: ${problem.fix}`);
62
+ const timing = `${result.files} files, ${result.ms.total} ms (TypeScript ${result.typescript} load ${result.ms.typescript} ms)`;
63
+ const warnings = result.problems.filter((problem) => problem.severity === 'warning').length;
64
+ const errors = result.problems.length - warnings;
65
+ const warned = warnings ? `, ${warnings} warning${warnings === 1 ? '' : 's'}` : '';
66
+ lines.push(result.ok
67
+ ? `check ok${warned}: ${timing}`
68
+ : `check FAILED: ${errors} problem${errors === 1 ? '' : 's'}${warned}, ${timing}`);
69
+ return lines.join('\n');
70
+ }
@@ -0,0 +1,17 @@
1
+ import { type Catalog } from '../catalog.js';
2
+ import type { RuleName } from './problems.js';
3
+ export interface ImportVerdict {
4
+ readonly rule: Extract<RuleName, 'private-import' | 'missing-package'>;
5
+ readonly message: string;
6
+ readonly fix: string;
7
+ }
8
+ /** Whether `subpath` (`.` or `./x`) is a public path of an `exports` value (Node rules). */
9
+ export declare function isExported(exports: unknown, subpath: string): boolean;
10
+ /** The public specifiers of a package (for the fix text). */
11
+ export declare function publicPaths(name: string, exports: unknown): string[];
12
+ export declare class DokebiImports {
13
+ #private;
14
+ constructor(catalog: Catalog);
15
+ /** The problem with one module specifier of `file`, if any. */
16
+ verdict(file: string, specifier: string): Promise<ImportVerdict | undefined>;
17
+ }