@dokebi/cli 0.2.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/AI.md +181 -87
  2. package/README.md +55 -24
  3. package/catalog.json +2199 -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 +141 -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 +793 -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
+ // `@dokebi/*` import specifiers in user code (D-0072 rules private-import and missing-package).
2
+ // A specifier is checked against the installed package's package.json `exports` the way Node
3
+ // resolves it (nearest node_modules upward from the importing file), and a package that is not
4
+ // installed is looked up in the CLI catalog: a Dokebi package to add, something Dokebi lacks
5
+ // (absent.yaml, with its `instead`), or a name that does not exist.
6
+ import { readFile } from 'node:fs/promises';
7
+ import { dirname, join } from 'node:path';
8
+ import { findPackage, searchCatalog } from '../catalog.js';
9
+ import { debug, isMissing } from '../diagnostics.js';
10
+ import { CliError } from '../errors.js';
11
+ const SCOPE = '@dokebi/';
12
+ const PACKAGE = /^@dokebi\/([a-z0-9][a-z0-9._-]*)(\/.*)?$/;
13
+ /** Whether `subpath` (`.` or `./x`) is a public path of an `exports` value (Node rules). */
14
+ export function isExported(exports, subpath) {
15
+ if (exports === undefined)
16
+ return true; // no exports: Node lets every file be imported
17
+ if (exports === null)
18
+ return false;
19
+ if (typeof exports === 'string' || Array.isArray(exports))
20
+ return subpath === '.';
21
+ if (typeof exports !== 'object')
22
+ return false;
23
+ const map = exports;
24
+ const keys = Object.keys(map);
25
+ if (!keys.some((key) => key.startsWith('.')))
26
+ return subpath === '.'; // conditions only
27
+ if (Object.hasOwn(map, subpath))
28
+ return map[subpath] !== null;
29
+ let best;
30
+ for (const key of keys) {
31
+ const star = key.indexOf('*');
32
+ if (star === -1)
33
+ continue;
34
+ const before = key.slice(0, star);
35
+ const after = key.slice(star + 1);
36
+ if (subpath.length >= before.length + after.length &&
37
+ subpath.startsWith(before) &&
38
+ subpath.endsWith(after) &&
39
+ (!best || before.length > best.prefix))
40
+ best = { prefix: before.length, value: map[key] };
41
+ }
42
+ return best !== undefined && best.value !== null;
43
+ }
44
+ /** The public specifiers of a package (for the fix text). */
45
+ export function publicPaths(name, exports) {
46
+ if (exports === undefined || typeof exports !== 'object' || exports === null)
47
+ return [name];
48
+ if (Array.isArray(exports))
49
+ return [name];
50
+ const keys = Object.keys(exports).filter((key) => key.startsWith('.') && exports[key] !== null);
51
+ if (!keys.length)
52
+ return [name];
53
+ return keys.filter((key) => key !== './package.json').map((key) => name + key.slice(1));
54
+ }
55
+ export class DokebiImports {
56
+ #catalog;
57
+ /** `<directory>\0<package>` → installed package (or null when no node_modules has it). */
58
+ #found = new Map();
59
+ constructor(catalog) {
60
+ this.#catalog = catalog;
61
+ }
62
+ async #manifest(path) {
63
+ let text;
64
+ try {
65
+ text = await readFile(path, 'utf8');
66
+ }
67
+ catch (error) {
68
+ if (isMissing(error))
69
+ return null;
70
+ throw new CliError('READ_FAILED', `Cannot read ${path}`, { cause: error });
71
+ }
72
+ try {
73
+ return JSON.parse(text);
74
+ }
75
+ catch (cause) {
76
+ throw new CliError('INVALID_PACKAGE', `${path} is not JSON`, { cause });
77
+ }
78
+ }
79
+ /** Nearest `node_modules/<name>` from the importing file's folder upward (Node resolution). */
80
+ async #installed(from, name) {
81
+ const visited = [];
82
+ let directory = dirname(from);
83
+ let result = null;
84
+ for (;;) {
85
+ const key = `${directory}\0${name}`;
86
+ const cached = this.#found.get(key);
87
+ if (cached !== undefined) {
88
+ result = cached;
89
+ break;
90
+ }
91
+ visited.push(key);
92
+ const candidate = join(directory, 'node_modules', name);
93
+ const manifest = await this.#manifest(join(candidate, 'package.json'));
94
+ if (manifest) {
95
+ result = { directory: candidate, exports: manifest.exports };
96
+ break;
97
+ }
98
+ const parent = dirname(directory);
99
+ if (parent === directory)
100
+ break;
101
+ directory = parent;
102
+ }
103
+ for (const key of visited)
104
+ this.#found.set(key, result);
105
+ return result;
106
+ }
107
+ /** The problem with one module specifier of `file`, if any. */
108
+ async verdict(file, specifier) {
109
+ const viaPath = /(^|\/)node_modules\/(@dokebi\/[^/]+)/.exec(specifier);
110
+ if (viaPath && !specifier.startsWith(SCOPE))
111
+ return {
112
+ rule: 'private-import',
113
+ message: `"${specifier}" reaches ${viaPath[2]} through node_modules by path; the package boundary (its exports) is skipped`,
114
+ fix: `import "${viaPath[2]}" (or one of its exported subpaths) by package name`,
115
+ };
116
+ const match = PACKAGE.exec(specifier);
117
+ if (!match)
118
+ return undefined;
119
+ const name = `${SCOPE}${match[1]}`;
120
+ const subpath = `.${match[2] ?? ''}`;
121
+ const installed = await this.#installed(file, name);
122
+ if (installed) {
123
+ if (isExported(installed.exports, subpath))
124
+ return undefined;
125
+ const internal = /^\.\/(dist|src|lib|build)(\/|$)/.test(subpath);
126
+ return {
127
+ rule: 'private-import',
128
+ message: `"${specifier}" is not an exported path of ${name}${internal ? ' (it reaches into the build output)' : ''}: Node refuses it (ERR_PACKAGE_PATH_NOT_EXPORTED) and it can change in any release`,
129
+ fix: `import from ${publicPaths(name, installed.exports).join(' or ')}`,
130
+ };
131
+ }
132
+ const known = this.#catalog.packages.find((item) => item.package === name);
133
+ if (known)
134
+ return {
135
+ rule: 'missing-package',
136
+ message: `${name} is a Dokebi package this project has not installed`,
137
+ fix: `npx dokebi add ${name} --dry-run --json, then without --dry-run (read its AI.md first)`,
138
+ };
139
+ const wanted = match[1].replace(/[._]/g, '-');
140
+ const search = searchCatalog(this.#catalog, wanted);
141
+ debug('check: unknown Dokebi package', { name, status: search.status });
142
+ const absent = search.absent[0];
143
+ if (absent)
144
+ return {
145
+ rule: 'missing-package',
146
+ message: `${name} does not exist: ${absent.summary} (Dokebi has no ${absent.capability}; do not invent a package or API for it)`,
147
+ fix: absent.instead,
148
+ };
149
+ const similar = search.matches
150
+ .map((item) => item.package)
151
+ .concat(findPackage(this.#catalog, wanted)?.package ?? [])
152
+ .filter((value, index, list) => list.indexOf(value) === index);
153
+ return {
154
+ rule: 'missing-package',
155
+ message: `${name} is not a Dokebi package`,
156
+ fix: similar.length
157
+ ? `use ${similar.slice(0, 3).join(' or ')} (npx dokebi plugins info <id> --json)`
158
+ : `npx dokebi plugins search ${wanted} --json; if nothing matches, write it in app code`,
159
+ };
160
+ }
161
+ }
@@ -0,0 +1,19 @@
1
+ export declare const RULES: readonly ['empty-catch', 'floating-promise', 'non-erasable-syntax', 'private-import', 'generated-file', 'secret-in-config', 'missing-package', 'inertia-form-only'];
2
+ export type RuleName = (typeof RULES)[number];
3
+ /** Rules a `// dokebi-check: allow <rule> <reason>` comment may silence (D-0072 §4, markers). */
4
+ export declare const MARKABLE: ReadonlySet<RuleName>;
5
+ /** Rules that warn: reported with `severity: 'warning'`, they do not fail check or verify. */
6
+ export declare const WARNING_RULES: ReadonlySet<RuleName>;
7
+ export interface Problem {
8
+ readonly rule: RuleName;
9
+ /** `warning` for a rule of WARNING_RULES (absent: an error that fails check). */
10
+ readonly severity?: 'warning';
11
+ /** Project-relative path with `/`. */
12
+ readonly file: string;
13
+ /** 1-based; 0 when the problem is about the whole file. */
14
+ readonly line: number;
15
+ readonly message: string;
16
+ /** What to do instead, one line. */
17
+ readonly fix: string;
18
+ }
19
+ export declare function byPlace(a: Problem, b: Problem): number;
@@ -0,0 +1,22 @@
1
+ // `dokebi check` problem shape and rule names (D-0072; inertia-form-only D-0074).
2
+ export const RULES = [
3
+ 'empty-catch',
4
+ 'floating-promise',
5
+ 'non-erasable-syntax',
6
+ 'private-import',
7
+ 'generated-file',
8
+ 'secret-in-config',
9
+ 'missing-package',
10
+ 'inertia-form-only',
11
+ ];
12
+ /** Rules a `// dokebi-check: allow <rule> <reason>` comment may silence (D-0072 §4, markers). */
13
+ export const MARKABLE = new Set([
14
+ 'empty-catch',
15
+ 'floating-promise',
16
+ 'inertia-form-only',
17
+ ]);
18
+ /** Rules that warn: reported with `severity: 'warning'`, they do not fail check or verify. */
19
+ export const WARNING_RULES = new Set(['inertia-form-only']);
20
+ export function byPlace(a, b) {
21
+ return a.file.localeCompare(b.file) || a.line - b.line || a.rule.localeCompare(b.rule);
22
+ }
@@ -0,0 +1,10 @@
1
+ import type { Inspection } from '../project-inspector.js';
2
+ import type { Problem } from './problems.js';
3
+ /**
4
+ * Whether dokebi.yaml can end up in version control: git tracks it, or git does not ignore it.
5
+ * Without git (or outside a repository) the project's .gitignore must list it.
6
+ */
7
+ export declare function configReachesGit(root: string): Promise<boolean>;
8
+ /** 1-based line of `options.<plugin>.<path…>` in dokebi.yaml: its deepest existing key (0: none). */
9
+ export declare function optionLine(source: string, path: readonly string[]): number;
10
+ export declare function projectProblems(root: string, inspection: Inspection): Promise<Problem[]>;
@@ -0,0 +1,142 @@
1
+ // Project rules of `dokebi check` (D-0072): they read doctor's inspection instead of judging
2
+ // files again, so doctor stays the one source for CLI-owned files and plugin checks.
3
+ // generated-file doctor MODIFIED_REGISTRATION (src/dokebi.generated.ts),
4
+ // GUIDE_BLOCK_MODIFIED (the AGENTS.md installed list between its markers) and
5
+ // MODIFIED_TEMPLATE (a plugin file declared `owner: 'cli'`, D-0065 §11).
6
+ // Files scaffolded for the developer (`owner: 'user'`) are never reported.
7
+ // secret-in-config a plugin check's SECRET_IN_CONFIG (doctor PLUGIN_CHECK) when dokebi.yaml
8
+ // can reach version control (tracked, or not ignored by git)
9
+ import { execFile } from 'node:child_process';
10
+ import { readFile } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { isMap, isScalar, parseDocument } from 'yaml';
13
+ import { CONFIG_FILE, CONFIG_IGNORES } from '../configuration.js';
14
+ import { debug, describeError, isMissing } from '../diagnostics.js';
15
+ import { CliError } from '../errors.js';
16
+ import { AGENTS_FILE, INSTALLED_START } from '../project-guide.js';
17
+ /** git's answer for one path: 0/1 exit codes, or undefined when git or the repository is absent. */
18
+ function git(root, args) {
19
+ return new Promise((resolve) => {
20
+ execFile('git', args, { cwd: root, timeout: 10_000 }, (error) => {
21
+ if (!error)
22
+ return resolve(0);
23
+ const code = error.code;
24
+ if (code === 1)
25
+ return resolve(1);
26
+ // 128: not a repository; ENOENT: no git. Both mean "ask .gitignore instead".
27
+ debug('check: git gave no answer', { args, error: describeError(error) });
28
+ resolve(undefined);
29
+ });
30
+ });
31
+ }
32
+ /**
33
+ * Whether dokebi.yaml can end up in version control: git tracks it, or git does not ignore it.
34
+ * Without git (or outside a repository) the project's .gitignore must list it.
35
+ */
36
+ export async function configReachesGit(root) {
37
+ const tracked = await git(root, ['ls-files', '--error-unmatch', '--', CONFIG_FILE]);
38
+ if (tracked === 0)
39
+ return true;
40
+ const ignored = await git(root, ['check-ignore', '-q', '--', CONFIG_FILE]);
41
+ if (ignored !== undefined)
42
+ return ignored !== 0;
43
+ let text = '';
44
+ try {
45
+ text = await readFile(join(root, '.gitignore'), 'utf8');
46
+ }
47
+ catch (error) {
48
+ if (!isMissing(error))
49
+ throw new CliError('READ_FAILED', 'Cannot read .gitignore', { cause: error });
50
+ }
51
+ const entry = CONFIG_IGNORES[0];
52
+ return !text
53
+ .split(/\r?\n/)
54
+ .map((line) => line.trim())
55
+ .some((line) => line === entry || line === `/${entry}`);
56
+ }
57
+ /** 1-based line of `options.<plugin>.<path…>` in dokebi.yaml: its deepest existing key (0: none). */
58
+ export function optionLine(source, path) {
59
+ const document = parseDocument(source, { version: '1.2' });
60
+ let node = document.contents;
61
+ let offset;
62
+ for (const key of path) {
63
+ if (!isMap(node))
64
+ break;
65
+ const pair = node.items.find((item) => isScalar(item.key) && String(item.key.value) === key);
66
+ if (!pair)
67
+ break;
68
+ offset = isScalar(pair.key) ? pair.key.range?.[0] : undefined;
69
+ node = pair.value;
70
+ }
71
+ if (offset === undefined)
72
+ return 0;
73
+ let line = 1;
74
+ for (let index = 0; index < offset; index++)
75
+ if (source.charCodeAt(index) === 10)
76
+ line++;
77
+ return line;
78
+ }
79
+ function lineOf(text, needle) {
80
+ const index = text.indexOf(needle);
81
+ return index === -1 ? 0 : text.slice(0, index).split('\n').length;
82
+ }
83
+ export async function projectProblems(root, inspection) {
84
+ const problems = [];
85
+ const codes = (code) => inspection.diagnostics.filter((item) => item.code === code);
86
+ for (const item of codes('MODIFIED_REGISTRATION'))
87
+ problems.push({
88
+ rule: 'generated-file',
89
+ file: item.path,
90
+ line: 1,
91
+ message: `${item.path} is generated by the CLI and was edited (doctor MODIFIED_REGISTRATION); add, upgrade and remove refuse to run until it is restored`,
92
+ fix: 'undo the edit (for example git checkout -- src/dokebi.generated.ts); change plugins with npx dokebi add/remove/app/module and options in dokebi.yaml',
93
+ });
94
+ for (const item of codes('MODIFIED_TEMPLATE'))
95
+ problems.push({
96
+ rule: 'generated-file',
97
+ file: item.path,
98
+ line: 0,
99
+ message: `${item.path} is a CLI-owned plugin file (owner: cli) and was edited (doctor MODIFIED_TEMPLATE); an upgrade of ${item.plugin ?? 'its plugin'} keeps the edit but no longer updates the file`,
100
+ fix: item.hint ?? 'restore the file and keep your changes in a user-owned file',
101
+ });
102
+ for (const item of codes('GUIDE_BLOCK_MODIFIED')) {
103
+ let text = '';
104
+ try {
105
+ text = await readFile(join(root, AGENTS_FILE), 'utf8');
106
+ }
107
+ catch (error) {
108
+ if (!isMissing(error))
109
+ throw new CliError('READ_FAILED', `Cannot read ${AGENTS_FILE}`, { cause: error });
110
+ }
111
+ problems.push({
112
+ rule: 'generated-file',
113
+ file: item.path,
114
+ line: lineOf(text, INSTALLED_START),
115
+ message: `The installed list between the dokebi:installed markers of ${item.path} was edited (doctor GUIDE_BLOCK_MODIFIED); the next add, upgrade or remove rewrites it and drops the edit`,
116
+ fix: item.hint ?? 'move your notes outside the markers',
117
+ });
118
+ }
119
+ const secrets = inspection.diagnostics.filter((item) => item.code === 'PLUGIN_CHECK' &&
120
+ item.details?.code === 'SECRET_IN_CONFIG');
121
+ if (secrets.length && (await configReachesGit(root))) {
122
+ let source = '';
123
+ try {
124
+ source = await readFile(join(root, CONFIG_FILE), 'utf8');
125
+ }
126
+ catch (error) {
127
+ if (!isMissing(error))
128
+ throw new CliError('READ_FAILED', `Cannot read ${CONFIG_FILE}`, { cause: error });
129
+ }
130
+ for (const item of secrets) {
131
+ const path = ['options', item.plugin ?? '', ...(item.details.optionPath ?? [])];
132
+ problems.push({
133
+ rule: 'secret-in-config',
134
+ file: CONFIG_FILE,
135
+ line: optionLine(source, path),
136
+ message: `${path.slice(1).join('.')} holds a secret (doctor PLUGIN_CHECK SECRET_IN_CONFIG) and ${CONFIG_FILE} can reach version control (git tracks it or does not ignore it)`,
137
+ fix: `${item.hint ? `${item.hint}; or ` : ''}keep ${CONFIG_FILE} out of git: list it in .gitignore (dokebi init does) and git rm --cached ${CONFIG_FILE}`,
138
+ });
139
+ }
140
+ }
141
+ return problems;
142
+ }
@@ -0,0 +1,28 @@
1
+ import type { DokebiImports } from './packages.js';
2
+ import { type Problem } from './problems.js';
3
+ import { type Kinds, type TsNode, type TsSourceFile, type TypeScriptProject } from './typescript.js';
4
+ interface Marker {
5
+ readonly line: number;
6
+ readonly rule: string;
7
+ readonly reason: string;
8
+ /** Nothing but the comment on its line: then it also covers the line below. */
9
+ readonly ownLine: boolean;
10
+ }
11
+ export declare function readMarkers(text: string): Marker[];
12
+ /** A candidate statement whose expression's type decides the floating-promise rule. */
13
+ export interface FloatingCandidate {
14
+ readonly expression: TsNode;
15
+ readonly problem: Problem;
16
+ }
17
+ export interface FileFindings {
18
+ readonly problems: Problem[];
19
+ readonly candidates: FloatingCandidate[];
20
+ }
21
+ /** Walks one file: all syntactic problems, and the statements the checker must judge. */
22
+ export declare function inspectFile(file: TsSourceFile, kinds: Kinds, root: string, imports: DokebiImports): Promise<FileFindings>;
23
+ /** Every program file: syntactic problems, then one batched checker call for the candidates. */
24
+ export declare function sourceProblems(typescript: TypeScriptProject, root: string, imports: DokebiImports, skip: (path: string) => boolean): Promise<{
25
+ problems: Problem[];
26
+ files: number;
27
+ }>;
28
+ export {};
@@ -0,0 +1,273 @@
1
+ // Source rules of `dokebi check` (D-0072): one walk over each file's TypeScript 7 AST. Each rule
2
+ // matches only shapes that are wrong whatever the surrounding code is (no heuristics on names):
3
+ // empty-catch `catch {}`, `.catch(() => {})`, `.then(ok, () => {})`, an empty
4
+ // `on('error', …)` listener (D-0047)
5
+ // floating-promise an expression statement whose call returns a Promise (the checker's
6
+ // type) that is neither awaited, returned, `void`ed nor `.catch`ed;
7
+ // node:test's test()/describe() are exempt (their promise never rejects)
8
+ // non-erasable-syntax what Node's type stripping refuses or cannot run: enum, namespace with
9
+ // values, parameter properties, decorators, `import x =`, `export =`, `<T>x`
10
+ // private-import an `@dokebi/*` path outside the package exports (packages.ts)
11
+ // missing-package an `@dokebi/*` package that is not installed, absent or unknown
12
+ import { relative, sep } from 'node:path';
13
+ import { MARKABLE } from './problems.js';
14
+ import { field, nodes, property, } from './typescript.js';
15
+ const LISTEN = new Set([
16
+ 'on',
17
+ 'once',
18
+ 'addListener',
19
+ 'prependListener',
20
+ 'prependOnceListener',
21
+ 'addEventListener',
22
+ ]);
23
+ const SNIPPET = 60;
24
+ /** `// dokebi-check: allow <rule> <reason>` (also inside a block comment). */
25
+ const MARKER = /dokebi-check:\s*allow\s+([a-z-]+)[ \t]*(?:[:\-—–][ \t]*)?([^\n]*?)[ \t]*(?:\*\/|$)/gm;
26
+ export function readMarkers(text) {
27
+ const markers = [];
28
+ let lineStart = 0;
29
+ let line = 1;
30
+ for (const match of text.matchAll(MARKER)) {
31
+ const index = match.index ?? 0;
32
+ for (; lineStart < index; lineStart++)
33
+ if (text.charCodeAt(lineStart) === 10)
34
+ line++;
35
+ const before = text.slice(text.lastIndexOf('\n', index) + 1, index);
36
+ markers.push({
37
+ line,
38
+ rule: match[1],
39
+ reason: (match[2] ?? '').replace(/^[\s:\-—–]+/, '').trim(),
40
+ ownLine: /^\s*(\/\/|\/\*+|\*)?\s*$/.test(before),
41
+ });
42
+ }
43
+ return markers;
44
+ }
45
+ /** Walks one file: all syntactic problems, and the statements the checker must judge. */
46
+ export async function inspectFile(file, kinds, root, imports) {
47
+ const path = relative(root, file.fileName.split('/').join(sep)).split(sep).join('/');
48
+ const text = file.text;
49
+ const markers = readMarkers(text);
50
+ const lineOf = (position) => file.getLineAndCharacterOfPosition(position).line + 1;
51
+ const problems = [];
52
+ const candidates = [];
53
+ const specifiers = [];
54
+ /** The problem, or nothing when a marker with a reason sits on or just above its lines. */
55
+ const report = (node, rule, message, fix) => {
56
+ const first = lineOf(node.getStart());
57
+ const last = lineOf(Math.max(node.getStart(), node.end - 1));
58
+ let note = '';
59
+ if (MARKABLE.has(rule))
60
+ for (const marker of markers)
61
+ if (marker.rule === rule &&
62
+ marker.line <= last &&
63
+ (marker.line >= first || (marker.line === first - 1 && marker.ownLine))) {
64
+ if (marker.reason)
65
+ return undefined;
66
+ note = ' (the dokebi-check marker has no reason, so it does not count)';
67
+ }
68
+ const problem = { rule, file: path, line: first, message: message + note, fix };
69
+ return problem;
70
+ };
71
+ const push = (problem) => {
72
+ if (problem)
73
+ problems.push(problem);
74
+ };
75
+ const snippet = (node) => {
76
+ const source = text.slice(node.getStart(), node.end).replace(/\s+/g, ' ');
77
+ return source.length > SNIPPET ? `${source.slice(0, SNIPPET - 1)}…` : source;
78
+ };
79
+ const stringValue = (node) => node && (node.kind === kinds.StringLiteral || node.kind === kinds.NoSubstitutionTemplateLiteral)
80
+ ? String(property(node, 'text'))
81
+ : undefined;
82
+ const hasModifier = (node, kind) => nodes(node, 'modifiers').some((modifier) => modifier.kind === kind);
83
+ /** An arrow or function expression whose body is a block without statements. */
84
+ const emptyFunction = (node) => {
85
+ if (!node || (node.kind !== kinds.ArrowFunction && node.kind !== kinds.FunctionExpression))
86
+ return false;
87
+ const body = field(node, 'body');
88
+ return body?.kind === kinds.Block && nodes(body, 'statements').length === 0;
89
+ };
90
+ /** The method name of `a.b(...)`, if the callee is a property access. */
91
+ const methodName = (call) => {
92
+ const callee = field(call, 'expression');
93
+ if (callee?.kind !== kinds.PropertyAccessExpression)
94
+ return undefined;
95
+ const name = field(callee, 'name');
96
+ return name?.kind === kinds.Identifier ? String(property(name, 'text')) : undefined;
97
+ };
98
+ const unwrap = (node) => {
99
+ let current = node;
100
+ while (current?.kind === kinds.ParenthesizedExpression)
101
+ current = field(current, 'expression');
102
+ return current;
103
+ };
104
+ /** Namespaces with only types (or `declare`) are erased; anything else makes a value. */
105
+ const instantiated = (body) => {
106
+ if (!body)
107
+ return false;
108
+ if (body.kind === kinds.ModuleDeclaration)
109
+ return !hasModifier(body, kinds.DeclareKeyword) && instantiated(field(body, 'body'));
110
+ return nodes(body, 'statements').some((statement) => statement.kind !== kinds.InterfaceDeclaration &&
111
+ statement.kind !== kinds.TypeAliasDeclaration &&
112
+ !hasModifier(statement, kinds.DeclareKeyword) &&
113
+ (statement.kind !== kinds.ModuleDeclaration || instantiated(field(statement, 'body'))));
114
+ };
115
+ const parameterModifiers = new Set([
116
+ kinds.PublicKeyword,
117
+ kinds.PrivateKeyword,
118
+ kinds.ProtectedKeyword,
119
+ kinds.ReadonlyKeyword,
120
+ kinds.OverrideKeyword,
121
+ ]);
122
+ const unsupported = (node, what, fix) => push(report(node, 'non-erasable-syntax', `${what} is TypeScript that Node's type stripping refuses (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX): the app does not start`, fix));
123
+ // node:test's test(), it(), describe(), suite() return promises that never reject (a failed
124
+ // test is reported, not thrown), and calling them without await is the documented form.
125
+ const testNames = new Set();
126
+ for (const statement of nodes(file, 'statements')) {
127
+ if (statement.kind !== kinds.ImportDeclaration)
128
+ continue;
129
+ const from = stringValue(field(statement, 'moduleSpecifier'));
130
+ if (from !== 'node:test' && from !== 'test')
131
+ continue;
132
+ const clause = field(statement, 'importClause');
133
+ const bindings = clause && field(clause, 'namedBindings');
134
+ for (const name of [
135
+ clause && field(clause, 'name'),
136
+ bindings && field(bindings, 'name'),
137
+ ...(bindings ? nodes(bindings, 'elements').map((element) => field(element, 'name')) : []),
138
+ ])
139
+ if (name?.kind === kinds.Identifier)
140
+ testNames.add(String(property(name, 'text')));
141
+ }
142
+ /** The identifier a call chain starts from: `test` of `test.skip(…)`. */
143
+ const calleeRoot = (call) => {
144
+ let current = field(call, 'expression');
145
+ while (current?.kind === kinds.PropertyAccessExpression)
146
+ current = field(current, 'expression');
147
+ return current?.kind === kinds.Identifier ? String(property(current, 'text')) : undefined;
148
+ };
149
+ const visit = (node) => {
150
+ const kind = node.kind;
151
+ if (kind === kinds.CatchClause) {
152
+ const block = field(node, 'block');
153
+ if (block && nodes(block, 'statements').length === 0)
154
+ push(report(node, 'empty-catch', 'An empty catch block hides the error (D-0047: no silent catch)', 'rethrow it, turn it into a coded error for the caller, or log it at error level (fixed message, code, error); if ignoring is really right, write // dokebi-check: allow empty-catch <reason> in the block'));
155
+ }
156
+ else if (kind === kinds.CallExpression) {
157
+ const args = nodes(node, 'arguments');
158
+ const method = methodName(node);
159
+ const callee = field(node, 'expression');
160
+ if (method === 'catch' && emptyFunction(args[0]))
161
+ push(report(node, 'empty-catch', `${snippet(node)} drops the rejection with an empty handler (D-0047)`, 'handle or log the error in the handler (error level, code, error); to keep only an unhandled-rejection guard on a promise you also return, write // dokebi-check: allow empty-catch <reason>'));
162
+ else if (method === 'then' && emptyFunction(args[1]))
163
+ push(report(node, 'empty-catch', `${snippet(node)} drops the rejection with an empty onRejected handler (D-0047)`, 'handle or log the error in the second handler, or remove it and let the caller see the rejection'));
164
+ else if (method &&
165
+ LISTEN.has(method) &&
166
+ stringValue(args[0]) === 'error' &&
167
+ emptyFunction(args[1]))
168
+ push(report(node, 'empty-catch', `An empty '${method}('error', …)' listener hides every error of that emitter (D-0047)`, 'log the error (error level, code, error) and close or replace the resource; write // dokebi-check: allow empty-catch <reason> only for a deliberate drop'));
169
+ if (callee?.kind === kinds.ImportKeyword) {
170
+ const value = stringValue(args[0]);
171
+ if (value !== undefined)
172
+ specifiers.push({ node: args[0], value });
173
+ }
174
+ }
175
+ else if (kind === kinds.ExpressionStatement) {
176
+ const expression = unwrap(field(node, 'expression'));
177
+ if (expression &&
178
+ (expression.kind === kinds.CallExpression || expression.kind === kinds.NewExpression)) {
179
+ const method = expression.kind === kinds.CallExpression ? methodName(expression) : undefined;
180
+ const args = nodes(expression, 'arguments');
181
+ // `.catch(handler)` and `.then(ok, onRejected)` handle the rejection themselves.
182
+ const handled = (method === 'catch' && args.length >= 1) ||
183
+ (method === 'then' && args.length >= 2) ||
184
+ (expression.kind === kinds.CallExpression && testNames.has(calleeRoot(expression) ?? ''));
185
+ if (!handled) {
186
+ const problem = report(node, 'floating-promise', `${snippet(expression)} returns a Promise that nothing awaits: its rejection becomes an unhandled rejection and stops the process`, 'await it (or return it to the caller); for deliberate background work end it with .catch(error => <log at error level>)');
187
+ if (problem)
188
+ candidates.push({ expression, problem });
189
+ }
190
+ }
191
+ }
192
+ else if (kind === kinds.EnumDeclaration) {
193
+ if (!hasModifier(node, kinds.DeclareKeyword))
194
+ unsupported(node, `enum ${snippet(field(node, 'name') ?? node)}`, "use a const object and a union type: export const Color = { Red: 'red' } as const; export type Color = (typeof Color)[keyof typeof Color];");
195
+ }
196
+ else if (kind === kinds.ModuleDeclaration) {
197
+ const name = field(node, 'name');
198
+ if (name?.kind === kinds.Identifier &&
199
+ !hasModifier(node, kinds.DeclareKeyword) &&
200
+ node.parent?.kind !== kinds.ModuleDeclaration &&
201
+ instantiated(field(node, 'body')))
202
+ unsupported(node, `namespace ${String(property(name, 'text'))} with values`, 'use an ES module (its own file) or a plain object; namespaces with only types are fine');
203
+ }
204
+ else if (kind === kinds.Parameter) {
205
+ if (node.parent?.kind === kinds.Constructor &&
206
+ nodes(node, 'modifiers').some((modifier) => parameterModifiers.has(modifier.kind)))
207
+ unsupported(node, `The parameter property "${snippet(node)}"`, 'declare the field in the class and assign it in the constructor: readonly name: T; constructor(name: T) { this.name = name; }');
208
+ }
209
+ else if (kind === kinds.Decorator) {
210
+ push(report(node, 'non-erasable-syntax', `The decorator ${snippet(node)} cannot run: Node has no decorators (SyntaxError at start)`, 'call the function yourself (wrap the class or method, or register it explicitly)'));
211
+ }
212
+ else if (kind === kinds.ImportEqualsDeclaration) {
213
+ if (property(node, 'isTypeOnly') !== true)
214
+ unsupported(node, `"${snippet(node)}"`, "use import x from '…' (or createRequire from node:module for CommonJS)");
215
+ const reference = field(node, 'moduleReference');
216
+ const value = reference?.kind === kinds.ExternalModuleReference
217
+ ? stringValue(field(reference, 'expression'))
218
+ : undefined;
219
+ if (value !== undefined && reference)
220
+ specifiers.push({ node: reference, value });
221
+ }
222
+ else if (kind === kinds.ExportAssignment) {
223
+ if (property(node, 'isExportEquals') === true)
224
+ unsupported(node, '"export ="', 'use export default …');
225
+ }
226
+ else if (kind === kinds.TypeAssertionExpression) {
227
+ unsupported(node, `The type assertion ${snippet(node)}`, 'write value as T');
228
+ }
229
+ else if (kind === kinds.ImportDeclaration || kind === kinds.ExportDeclaration) {
230
+ const specifier = field(node, 'moduleSpecifier');
231
+ const value = stringValue(specifier);
232
+ if (value !== undefined && specifier)
233
+ specifiers.push({ node: specifier, value });
234
+ }
235
+ else if (kind === kinds.ImportType) {
236
+ const argument = field(node, 'argument');
237
+ const literal = argument?.kind === kinds.LiteralType ? field(argument, 'literal') : undefined;
238
+ const value = stringValue(literal);
239
+ if (value !== undefined && literal)
240
+ specifiers.push({ node: literal, value });
241
+ }
242
+ node.forEachChild(visit);
243
+ return undefined;
244
+ };
245
+ file.forEachChild(visit);
246
+ for (const { node, value } of specifiers) {
247
+ const verdict = await imports.verdict(file.fileName, value);
248
+ if (verdict)
249
+ push(report(node, verdict.rule, verdict.message, verdict.fix));
250
+ }
251
+ return { problems, candidates };
252
+ }
253
+ /** Every program file: syntactic problems, then one batched checker call for the candidates. */
254
+ export async function sourceProblems(typescript, root, imports, skip) {
255
+ const problems = [];
256
+ const candidates = [];
257
+ let files = 0;
258
+ for (const file of typescript.files) {
259
+ const path = relative(root, file.fileName.split('/').join(sep)).split(sep).join('/');
260
+ if (skip(path))
261
+ continue;
262
+ files++;
263
+ const found = await inspectFile(file, typescript.kinds, root, imports);
264
+ problems.push(...found.problems);
265
+ candidates.push(...found.candidates);
266
+ }
267
+ const verdicts = typescript.promises(candidates.map((candidate) => candidate.expression));
268
+ candidates.forEach((candidate, index) => {
269
+ if (verdicts[index])
270
+ problems.push(candidate.problem);
271
+ });
272
+ return { problems, files };
273
+ }