@kedge/cli 0.0.0-stage → 0.10.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.
- package/LICENSE +21 -0
- package/bin/kedge.mjs +8 -0
- package/kedge-manifest.json +418 -0
- package/package.json +43 -3
- package/src/cf.ts +729 -0
- package/src/check.ts +45 -0
- package/src/cli-session.ts +57 -0
- package/src/code-lint.ts +339 -0
- package/src/commands.ts +959 -0
- package/src/compose.ts +637 -0
- package/src/d1-batch.ts +66 -0
- package/src/deploy.ts +1682 -0
- package/src/doctor.ts +701 -0
- package/src/fork.ts +67 -0
- package/src/index.ts +12 -0
- package/src/invariants.ts +168 -0
- package/src/keys-save.ts +298 -0
- package/src/local-state.ts +110 -0
- package/src/main.ts +470 -0
- package/src/manifest.ts +163 -0
- package/src/n1.ts +217 -0
- package/src/ops.ts +373 -0
- package/src/order.ts +7 -0
- package/src/output.ts +35 -0
- package/src/plugin-lint.ts +145 -0
- package/src/preview.ts +1038 -0
- package/src/schema.ts +72 -0
- package/src/smoke.ts +218 -0
- package/src/sqlite-verify.ts +333 -0
- package/src/store-api.ts +36 -0
- package/src/store.ts +132 -0
- package/src/tar.ts +125 -0
- package/src/target.ts +81 -0
- package/src/tz.ts +79 -0
- package/src/units.ts +185 -0
- package/src/upgrade.ts +235 -0
- package/src/worker-config.ts +81 -0
- package/README.md +0 -3
package/src/check.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// `kedge check` (docs/proposals/p0.8-cli-agent-kit.md §4.4): the guard an agent
|
|
2
|
+
// runs before it stops, in order, stopping at the first failure. `--fast`
|
|
3
|
+
// (the Stop hook's) needs no Cloudflare and finishes in seconds; the full one
|
|
4
|
+
// adds the store's tests in workerd, with the invariant suite after each.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { childStdio } from "./output";
|
|
9
|
+
|
|
10
|
+
export interface CheckInput {
|
|
11
|
+
cwd: string;
|
|
12
|
+
fast: boolean;
|
|
13
|
+
generate: (cwd: string) => Promise<number>;
|
|
14
|
+
doctor: (cwd: string) => Promise<number>;
|
|
15
|
+
/** Runs a tool from the store's node_modules/.bin; tests replace it. */
|
|
16
|
+
run?: (bin: string, args: string[], cwd: string) => number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const runBin = (bin: string, args: string[], cwd: string): number => {
|
|
20
|
+
const path = join(cwd, "node_modules", ".bin", bin);
|
|
21
|
+
if (!existsSync(path)) {
|
|
22
|
+
console.error(`${bin} isn't installed in this store: run pnpm install`);
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
return spawnSync(path, args, { cwd, stdio: childStdio() }).status ?? 1;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function check(input: CheckInput): Promise<number> {
|
|
29
|
+
const run = input.run ?? runBin;
|
|
30
|
+
const steps: [string, () => Promise<number> | number][] = [
|
|
31
|
+
["typecheck", () => run("tsc", ["--noEmit", "-p", "."], input.cwd)],
|
|
32
|
+
["kedge generate --check", () => input.generate(input.cwd)],
|
|
33
|
+
["kedge doctor", () => input.doctor(input.cwd)],
|
|
34
|
+
...(input.fast ? [] : ([["tests", () => run("vitest", ["run"], input.cwd)]] as [string, () => number][])),
|
|
35
|
+
];
|
|
36
|
+
for (const [name, step] of steps) {
|
|
37
|
+
const code = await step();
|
|
38
|
+
if (code !== 0) {
|
|
39
|
+
console.error(`\nkedge check: ${name} failed. Fix it and run kedge check${input.fast ? " --fast" : ""} again.`);
|
|
40
|
+
return code;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
console.log(`kedge check${input.fast ? " --fast" : ""} passed`);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// A CLI session for one command (docs/proposals/p0.10-agent-path.md §6.2):
|
|
2
|
+
// whoever holds the store's Cloudflare credentials writes the row, one audited
|
|
3
|
+
// `system:cli` batch, to the target's own database and no other, and the
|
|
4
|
+
// command sends the token to that target alone. The token lives in this
|
|
5
|
+
// process's memory only: never printed, logged or saved. The row is deleted
|
|
6
|
+
// when the command ends; one a crash leaves expires within ten minutes.
|
|
7
|
+
import { type CliBatch, cliSessionEndSql, cliSessionSql } from "@kedge/db";
|
|
8
|
+
import { type Cloudflare, UnknownOutcome } from "./cf";
|
|
9
|
+
import { DeployRefused } from "./deploy";
|
|
10
|
+
import type { StoreTarget } from "./target";
|
|
11
|
+
|
|
12
|
+
/** What an older Worker's or database's refusal of a CLI session means for the person running it. */
|
|
13
|
+
export const OLDER_KEDGE = "this store runs a Kedge older than this CLI, without CLI sessions: deploy first";
|
|
14
|
+
|
|
15
|
+
export interface SessionDeps {
|
|
16
|
+
cloudflare: () => Promise<Cloudflare>;
|
|
17
|
+
/** `wrangler d1 execute --local` in the store's directory: `kedge dev`'s database. */
|
|
18
|
+
executeLocal: (b: CliBatch) => number;
|
|
19
|
+
now?: () => number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function write(target: StoreTarget, b: CliBatch, d: SessionDeps): Promise<void> {
|
|
23
|
+
if (target.kind === "local") {
|
|
24
|
+
if (d.executeLocal(b) !== 0) throw new DeployRefused("writing the CLI session to kedge dev's database failed");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const cf = await d.cloudflare();
|
|
28
|
+
try {
|
|
29
|
+
await cf.executeD1File({ database: target.database.name, databaseId: target.database.id as string, sql: b.sql });
|
|
30
|
+
} catch (e) {
|
|
31
|
+
// A database from before P0.10 has no table to hold it.
|
|
32
|
+
if (e instanceof UnknownOutcome && /no such table: cli_session/.test(e.message))
|
|
33
|
+
throw new DeployRefused(OLDER_KEDGE);
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Runs `work` with a session holding `grants` on `target`, ending it after, whatever happened. */
|
|
39
|
+
export async function withCliSession<T>(
|
|
40
|
+
target: StoreTarget,
|
|
41
|
+
grants: readonly string[],
|
|
42
|
+
d: SessionDeps,
|
|
43
|
+
work: (auth: Record<string, string>) => Promise<T>,
|
|
44
|
+
): Promise<T> {
|
|
45
|
+
const now = d.now ?? Date.now;
|
|
46
|
+
const s = await cliSessionSql(grants, now());
|
|
47
|
+
await write(target, s, d);
|
|
48
|
+
try {
|
|
49
|
+
return await work({ authorization: `Bearer ${s.token}` });
|
|
50
|
+
} finally {
|
|
51
|
+
// Ending is housekeeping: a row left behind is useless without the token, and expires anyway.
|
|
52
|
+
await write(target, cliSessionEndSql(s.id, now()), d).catch(() => undefined);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether a request's credential was a CLI session, so a 401 can be read as an older Worker. */
|
|
57
|
+
export const isCliSession = (auth: Record<string, string>) => auth.authorization?.startsWith("Bearer kcs_") === true;
|
package/src/code-lint.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// The code lint (docs/proposals/p0.8-cli-agent-kit.md §4.5, K11, K12): what SQL
|
|
2
|
+
// lint and types can't see. An effect sent around the journal is repeated
|
|
3
|
+
// after a retry or a restore and is invisible to a preview's sinks; a
|
|
4
|
+
// provider's reply or a customer's data in an error or a log line lands where
|
|
5
|
+
// erasure can't reach (customer-erasure.md §3). It parses every local module
|
|
6
|
+
// reachable from kedge.config.ts and the Worker entry, because a hook can be
|
|
7
|
+
// written inline in the config and a helper can live outside src/.
|
|
8
|
+
//
|
|
9
|
+
// It's a lint, so it can be fooled (a `fetch` through an alias passes); a
|
|
10
|
+
// preview without production credentials is the backstop. An exception is
|
|
11
|
+
// `// kedge-allow <rule>: <why>` on the line or the line above, which doctor
|
|
12
|
+
// lists, so an exception is visible rather than impossible.
|
|
13
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
15
|
+
import { parse } from "@babel/parser";
|
|
16
|
+
|
|
17
|
+
export const CODE_RULES = [
|
|
18
|
+
"no-direct-network",
|
|
19
|
+
"no-raw-provider-error",
|
|
20
|
+
"no-dynamic-throw",
|
|
21
|
+
"no-console",
|
|
22
|
+
"no-classified-log",
|
|
23
|
+
] as const;
|
|
24
|
+
export type CodeRule = (typeof CODE_RULES)[number];
|
|
25
|
+
|
|
26
|
+
export interface CodeIssue {
|
|
27
|
+
file: string;
|
|
28
|
+
line: number;
|
|
29
|
+
rule: CodeRule;
|
|
30
|
+
message: string;
|
|
31
|
+
/** The `kedge-allow` reason, when the line carries one: listed, not failed. */
|
|
32
|
+
allowed?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Packages that reach a provider over the network: importing one outside a port is a direct call. */
|
|
36
|
+
export const PROVIDER_SDKS: readonly (string | RegExp)[] = [
|
|
37
|
+
"stripe",
|
|
38
|
+
"twilio",
|
|
39
|
+
"nodemailer",
|
|
40
|
+
"resend",
|
|
41
|
+
"postmark",
|
|
42
|
+
"mailgun.js",
|
|
43
|
+
"razorpay",
|
|
44
|
+
"braintree",
|
|
45
|
+
"square",
|
|
46
|
+
"shippo",
|
|
47
|
+
"axios",
|
|
48
|
+
"node-fetch",
|
|
49
|
+
"undici",
|
|
50
|
+
"got",
|
|
51
|
+
"ky",
|
|
52
|
+
"superagent",
|
|
53
|
+
"firebase-admin",
|
|
54
|
+
/^@sendgrid\//,
|
|
55
|
+
/^@aws-sdk\//,
|
|
56
|
+
/^@google-cloud\//,
|
|
57
|
+
/^@paypal\//,
|
|
58
|
+
/^@easypost\//,
|
|
59
|
+
/^@mailchimp\//,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** A port's methods: the only places an external call belongs (K12). */
|
|
63
|
+
const PORT_METHODS = new Set(["call", "observe", "credentialMode"]);
|
|
64
|
+
const NETWORK_CONSTRUCTORS = new Set(["WebSocket", "XMLHttpRequest", "EventSource"]);
|
|
65
|
+
|
|
66
|
+
// biome-ignore lint/suspicious/noExplicitAny: Babel's AST, walked generically
|
|
67
|
+
type Node = any;
|
|
68
|
+
|
|
69
|
+
const isSdk = (source: string) => PROVIDER_SDKS.some((s) => (typeof s === "string" ? source === s : s.test(source)));
|
|
70
|
+
const keyName = (n: Node): string | null =>
|
|
71
|
+
n?.type === "Identifier" ? n.name : n?.type === "StringLiteral" ? n.value : null;
|
|
72
|
+
const isFunction = (n: Node) =>
|
|
73
|
+
n?.type === "FunctionExpression" || n?.type === "ArrowFunctionExpression" || n?.type === "ObjectMethod";
|
|
74
|
+
/** A plain string: a literal, or a template with nothing interpolated. */
|
|
75
|
+
const isLiteralText = (n: Node) =>
|
|
76
|
+
n === undefined || n.type === "StringLiteral" || (n.type === "TemplateLiteral" && n.expressions.length === 0);
|
|
77
|
+
|
|
78
|
+
function calleeName(n: Node): string | null {
|
|
79
|
+
if (n?.type === "Identifier") return n.name;
|
|
80
|
+
if (n?.type === "MemberExpression" && !n.computed) return keyName(n.property);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether `name` appears as an identifier anywhere in `n`. */
|
|
85
|
+
function mentions(n: Node, name: string): boolean {
|
|
86
|
+
let found = false;
|
|
87
|
+
walk(n, [], (m) => {
|
|
88
|
+
if (m.type === "Identifier" && m.name === name) found = true;
|
|
89
|
+
});
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function walk(n: Node, ancestors: Node[], visit: (n: Node, ancestors: Node[]) => void): void {
|
|
94
|
+
if (!n || typeof n.type !== "string") return;
|
|
95
|
+
visit(n, ancestors);
|
|
96
|
+
ancestors.push(n);
|
|
97
|
+
for (const [k, v] of Object.entries(n)) {
|
|
98
|
+
if (k === "loc" || k === "leadingComments" || k === "trailingComments" || k === "innerComments" || k === "extra")
|
|
99
|
+
continue;
|
|
100
|
+
if (Array.isArray(v)) for (const c of v) walk(c, ancestors, visit);
|
|
101
|
+
else if (v && typeof v === "object") walk(v, ancestors, visit);
|
|
102
|
+
}
|
|
103
|
+
ancestors.pop();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The port method a node is lexically inside, if any. */
|
|
107
|
+
function inPortMethod(ancestors: readonly Node[]): boolean {
|
|
108
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
109
|
+
const a = ancestors[i];
|
|
110
|
+
if (!isFunction(a)) continue;
|
|
111
|
+
if (a.type === "ObjectMethod" && PORT_METHODS.has(keyName(a.key) ?? "")) return true;
|
|
112
|
+
const parent = ancestors[i - 1];
|
|
113
|
+
if (parent?.type === "ObjectProperty" && parent.value === a && PORT_METHODS.has(keyName(parent.key) ?? ""))
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `// kedge-allow <rule>: <why>`: at the end of a line, for that line; on a
|
|
121
|
+
* line of its own, for the whole statement below its run of comments, since a
|
|
122
|
+
* formatter moves a loop body's comment above the loop.
|
|
123
|
+
*/
|
|
124
|
+
function allowances(
|
|
125
|
+
text: string,
|
|
126
|
+
statementEnd: ReadonlyMap<number, number>,
|
|
127
|
+
): { from: number; to: number; rule: string; reason: string }[] {
|
|
128
|
+
const out: { from: number; to: number; rule: string; reason: string }[] = [];
|
|
129
|
+
const lines = text.split("\n");
|
|
130
|
+
const commentOnly = (l: string | undefined) => l !== undefined && /^\s*(\/\/|\/\*|\*)/.test(l);
|
|
131
|
+
lines.forEach((l, i) => {
|
|
132
|
+
for (const m of l.matchAll(/kedge-allow\s+([a-z-]+):\s*(\S[^*]*?)\s*(?:\*\/|$)/g)) {
|
|
133
|
+
let at = i;
|
|
134
|
+
if (commentOnly(l)) while (commentOnly(lines[at])) at++;
|
|
135
|
+
const from = at + 1;
|
|
136
|
+
const to = commentOnly(l) ? (statementEnd.get(from) ?? from) : from;
|
|
137
|
+
out.push({ from, to, rule: m[1] as string, reason: m[2] as string });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function lintSource(file: string, text: string): CodeIssue[] {
|
|
144
|
+
const ast = parse(text, {
|
|
145
|
+
sourceType: "module",
|
|
146
|
+
plugins: extname(file) === ".tsx" ? ["typescript", "jsx"] : ["typescript"],
|
|
147
|
+
errorRecovery: false,
|
|
148
|
+
});
|
|
149
|
+
const issues: CodeIssue[] = [];
|
|
150
|
+
const add = (n: Node, rule: CodeRule, message: string) =>
|
|
151
|
+
issues.push({ file, line: n.loc?.start.line ?? 0, rule, message });
|
|
152
|
+
const socketConnects = new Set<string>();
|
|
153
|
+
|
|
154
|
+
walk(ast.program, [], (n, ancestors) => {
|
|
155
|
+
switch (n.type) {
|
|
156
|
+
case "ImportDeclaration": {
|
|
157
|
+
const source = n.source.value as string;
|
|
158
|
+
if (source === "cloudflare:sockets")
|
|
159
|
+
for (const s of n.specifiers)
|
|
160
|
+
if (s.imported && keyName(s.imported) === "connect") socketConnects.add(s.local.name);
|
|
161
|
+
if (isSdk(source))
|
|
162
|
+
add(
|
|
163
|
+
n,
|
|
164
|
+
"no-direct-network",
|
|
165
|
+
`imports "${source}", a provider's SDK: call it only inside a port's call, observe or credentialMode`,
|
|
166
|
+
);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case "CallExpression": {
|
|
170
|
+
const c = n.callee;
|
|
171
|
+
const direct =
|
|
172
|
+
(c.type === "Identifier" && (c.name === "fetch" || socketConnects.has(c.name))) ||
|
|
173
|
+
(c.type === "MemberExpression" &&
|
|
174
|
+
!c.computed &&
|
|
175
|
+
keyName(c.property) === "fetch" &&
|
|
176
|
+
c.object.type === "Identifier" &&
|
|
177
|
+
["globalThis", "self", "window"].includes(c.object.name));
|
|
178
|
+
if (direct && !inPortMethod(ancestors))
|
|
179
|
+
add(
|
|
180
|
+
n,
|
|
181
|
+
"no-direct-network",
|
|
182
|
+
"calls the network outside a port: an effect sent here bypasses the journal and repeats after a retry or a restore",
|
|
183
|
+
);
|
|
184
|
+
if (c.type === "MemberExpression" && c.object.type === "Identifier" && c.object.name === "console") {
|
|
185
|
+
if (!n.arguments.every(isLiteralText))
|
|
186
|
+
add(
|
|
187
|
+
n,
|
|
188
|
+
"no-console",
|
|
189
|
+
"logs something other than literal text: a log line can carry a customer's data, and erasure can't reach logs",
|
|
190
|
+
);
|
|
191
|
+
const objects = n.arguments.flatMap((a: Node) =>
|
|
192
|
+
a.type === "CallExpression" && calleeName(a.callee) === "stringify" ? a.arguments : [a],
|
|
193
|
+
);
|
|
194
|
+
if (
|
|
195
|
+
objects.some(
|
|
196
|
+
(a: Node) =>
|
|
197
|
+
a.type === "Identifier" ||
|
|
198
|
+
(a.type === "ObjectExpression" && a.properties.some((p: Node) => p.type === "SpreadElement")),
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
add(
|
|
202
|
+
n,
|
|
203
|
+
"no-classified-log",
|
|
204
|
+
"logs a whole value: name each field logged, so nothing classified rides along",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case "NewExpression": {
|
|
210
|
+
if (n.callee.type === "Identifier" && NETWORK_CONSTRUCTORS.has(n.callee.name) && !inPortMethod(ancestors))
|
|
211
|
+
add(n, "no-direct-network", `opens a ${n.callee.name} outside a port`);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case "ThrowStatement": {
|
|
215
|
+
const e = n.argument;
|
|
216
|
+
if (e?.type !== "NewExpression" && e?.type !== "CallExpression") break;
|
|
217
|
+
const name = calleeName(e.callee) ?? "";
|
|
218
|
+
// Two kinds don't reach a log (§4.5's build note): a config error is
|
|
219
|
+
// thrown while a config resolves at boot, from its declarations,
|
|
220
|
+
// before any request exists; and a KedgeError is the requester's own
|
|
221
|
+
// refusal, rendered into its response, while core logs and records
|
|
222
|
+
// only its code (the server's onError, the relay's failureCode).
|
|
223
|
+
// A constructor (`new X()` or `X()` for a class name), not a helper
|
|
224
|
+
// like mapD1Error(e) that returns an error it already built.
|
|
225
|
+
if (!/^(?:[A-Z]\w*)?Error$/.test(name) || /ConfigError$/.test(name) || name === "KedgeError") break;
|
|
226
|
+
const texts = [e.arguments[0]];
|
|
227
|
+
if (!texts.every(isLiteralText))
|
|
228
|
+
add(
|
|
229
|
+
n,
|
|
230
|
+
"no-dynamic-throw",
|
|
231
|
+
`throws a ${name} whose message isn't literal text: an error's message reaches logs and Workflow history, which erasure can't reach`,
|
|
232
|
+
);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "CatchClause": {
|
|
236
|
+
if (!inPortMethod(ancestors) || n.param?.type !== "Identifier") break;
|
|
237
|
+
const p = n.param.name as string;
|
|
238
|
+
walk(n.body, [], (m) => {
|
|
239
|
+
if (m.type === "ThrowStatement" && m.argument?.type === "Identifier" && m.argument.name === p)
|
|
240
|
+
add(m, "no-raw-provider-error", "rethrows a provider's error from a port: its text can name a customer");
|
|
241
|
+
if (
|
|
242
|
+
m.type === "NewExpression" &&
|
|
243
|
+
calleeName(m.callee) === "KedgeProviderError" &&
|
|
244
|
+
m.arguments.some((a: Node) => mentions(a, p))
|
|
245
|
+
)
|
|
246
|
+
add(
|
|
247
|
+
m,
|
|
248
|
+
"no-raw-provider-error",
|
|
249
|
+
"builds a KedgeProviderError from the provider's error: map it to a declared failure code instead",
|
|
250
|
+
);
|
|
251
|
+
if (
|
|
252
|
+
m.type === "CallExpression" &&
|
|
253
|
+
m.callee.type === "MemberExpression" &&
|
|
254
|
+
m.callee.object.type === "Identifier" &&
|
|
255
|
+
m.callee.object.name === "console" &&
|
|
256
|
+
m.arguments.some((a: Node) => mentions(a, p))
|
|
257
|
+
)
|
|
258
|
+
add(m, "no-raw-provider-error", "logs the provider's error from a port");
|
|
259
|
+
});
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Where each statement starting on a line ends: the outermost one wins.
|
|
266
|
+
const statementEnd = new Map<number, number>();
|
|
267
|
+
walk(ast.program, [], (n) => {
|
|
268
|
+
if (!/(Statement|Declaration)$/.test(n.type) || !n.loc) return;
|
|
269
|
+
const { start, end } = n.loc;
|
|
270
|
+
statementEnd.set(start.line, Math.max(statementEnd.get(start.line) ?? 0, end.line));
|
|
271
|
+
});
|
|
272
|
+
const allowed = allowances(text, statementEnd);
|
|
273
|
+
return issues.map((i) => {
|
|
274
|
+
const a = allowed.find((x) => x.rule === i.rule && i.line >= x.from && i.line <= x.to);
|
|
275
|
+
return a ? { ...i, allowed: a.reason } : i;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const RESOLVE = ["", ".ts", ".tsx", ".mts", "/index.ts", "/index.tsx"];
|
|
280
|
+
|
|
281
|
+
/** A relative import to its file, or null: packages and anything outside the store aren't the store's code. */
|
|
282
|
+
export function resolveImport(from: string, spec: string, root: string): string | null {
|
|
283
|
+
if (!spec.startsWith(".")) return null;
|
|
284
|
+
const base = resolve(dirname(from), spec);
|
|
285
|
+
const candidates = [...RESOLVE.map((x) => base + x), base.replace(/\.js$/, ".ts"), base.replace(/\.mjs$/, ".mts")];
|
|
286
|
+
for (const c of candidates) {
|
|
287
|
+
if (relative(root, c).startsWith("..") || c.includes(`${"/"}node_modules/`)) continue;
|
|
288
|
+
if (existsSync(c) && statSync(c).isFile()) return c;
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Every local module reachable from the entries by static, re-export and literal dynamic imports. */
|
|
294
|
+
export function reachableModules(entries: readonly string[], root: string): string[] {
|
|
295
|
+
const seen = new Set<string>();
|
|
296
|
+
const queue = entries.filter((e) => existsSync(e));
|
|
297
|
+
while (queue.length) {
|
|
298
|
+
const file = queue.pop() as string;
|
|
299
|
+
if (seen.has(file)) continue;
|
|
300
|
+
seen.add(file);
|
|
301
|
+
let ast: Node;
|
|
302
|
+
try {
|
|
303
|
+
ast = parse(readFileSync(file, "utf8"), { sourceType: "module", plugins: ["typescript", "jsx"] });
|
|
304
|
+
} catch {
|
|
305
|
+
continue; // reported by lintSource
|
|
306
|
+
}
|
|
307
|
+
walk(ast.program, [], (n) => {
|
|
308
|
+
const spec =
|
|
309
|
+
n.type === "ImportDeclaration" || n.type === "ExportNamedDeclaration" || n.type === "ExportAllDeclaration"
|
|
310
|
+
? n.source?.value
|
|
311
|
+
: n.type === "CallExpression" && n.callee.type === "Import" && n.arguments[0]?.type === "StringLiteral"
|
|
312
|
+
? n.arguments[0].value
|
|
313
|
+
: n.type === "ImportExpression" && n.source?.type === "StringLiteral"
|
|
314
|
+
? n.source.value
|
|
315
|
+
: null;
|
|
316
|
+
if (typeof spec !== "string") return;
|
|
317
|
+
const r = resolveImport(file, spec, root);
|
|
318
|
+
if (r && !seen.has(r)) queue.push(r);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return [...seen].sort();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Lints the store's code: the config, the Worker entry and everything local they reach. */
|
|
325
|
+
export function lintStore(root: string, entries: readonly string[]): CodeIssue[] {
|
|
326
|
+
const out: CodeIssue[] = [];
|
|
327
|
+
for (const f of reachableModules(
|
|
328
|
+
entries.map((e) => join(root, e)),
|
|
329
|
+
root,
|
|
330
|
+
)) {
|
|
331
|
+
const rel = relative(root, f);
|
|
332
|
+
try {
|
|
333
|
+
out.push(...lintSource(rel, readFileSync(f, "utf8")));
|
|
334
|
+
} catch (e) {
|
|
335
|
+
out.push({ file: rel, line: 0, rule: "no-direct-network", message: `doesn't parse: ${(e as Error).message}` });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|