@cosmicdrift/kumiko-guards 0.369.0 → 0.371.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/hooks/pre-push +6 -2
- package/package.json +2 -2
- package/src/_lib/roots.ts +18 -1
- package/src/changes.json +30 -0
- package/src/guard-import-extensions.ts +84 -0
- package/src/guard-no-direct-process-env.ts +3 -3
- package/src/guard-public-exports-changelog.ts +18 -4
- package/src/guard-test-timeouts.ts +12 -4
- package/src/index.ts +1 -0
- package/src/run-guards.ts +2 -0
- package/src/runtime-isolation-classify.ts +99 -33
package/hooks/pre-push
CHANGED
|
@@ -11,8 +11,9 @@ if [ -z "$REPO_ROOT" ]; then
|
|
|
11
11
|
exit 1
|
|
12
12
|
fi
|
|
13
13
|
|
|
14
|
-
# Walk up
|
|
15
|
-
#
|
|
14
|
+
# Walk up so a worktree without its own node_modules resolves via the parent
|
|
15
|
+
# install, but not past an enclosing repository: a bin above that boundary
|
|
16
|
+
# belongs to another project. `[ -x ]` also skips a broken (uninstalled) symlink.
|
|
16
17
|
BIN=""
|
|
17
18
|
DIR="$REPO_ROOT"
|
|
18
19
|
while true; do
|
|
@@ -24,6 +25,9 @@ while true; do
|
|
|
24
25
|
if [ "$DIR" = "/" ]; then
|
|
25
26
|
break
|
|
26
27
|
fi
|
|
28
|
+
if [ "$DIR" != "$REPO_ROOT" ] && [ -e "$DIR/.git" ]; then
|
|
29
|
+
break
|
|
30
|
+
fi
|
|
27
31
|
DIR="$(dirname "$DIR")"
|
|
28
32
|
done
|
|
29
33
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.371.0",
|
|
4
4
|
"description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"kumiko-pre-push": "./src/pre-push.sh"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@cosmicdrift/kumiko-repo-manifest": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.371.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/_lib/roots.ts
CHANGED
|
@@ -183,10 +183,25 @@ function isDerivedAppFallback(root: RepoRoot): boolean {
|
|
|
183
183
|
return root.manifestSource === "derived" && root.kind === "app";
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/** The repo of the nearest `kumiko.json` at or above `start`, which is the app root in a git tree that holds several. */
|
|
187
|
+
function nearestManifestRepo(start: string): RepoRoot | undefined {
|
|
188
|
+
for (let curr = start; ; curr = dirname(curr)) {
|
|
189
|
+
if (existsSync(join(curr, REPO_MANIFEST_FILE))) {
|
|
190
|
+
const repo = repoAt(curr);
|
|
191
|
+
if (repo) return repo;
|
|
192
|
+
}
|
|
193
|
+
if (curr === dirname(curr)) return undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
186
197
|
/**
|
|
187
198
|
* The repository `cwd` belongs to.
|
|
188
199
|
*
|
|
189
|
-
*
|
|
200
|
+
* A `kumiko.json` file wins over everything: the nearest one above `cwd` is the
|
|
201
|
+
* repo root even when the git checkout root lies further up (an app inside a
|
|
202
|
+
* larger checkout).
|
|
203
|
+
*
|
|
204
|
+
* Otherwise git first: `rev-parse --show-toplevel` is the checkout root from any
|
|
190
205
|
* subdirectory, package or worktree, independent of folder names. The
|
|
191
206
|
* package.json walk remains for non-git trees; there a derived-manifest "app"
|
|
192
207
|
* candidate (no kumiko.json, just a bare `src/` layout) is only a fallback, so
|
|
@@ -195,6 +210,8 @@ function isDerivedAppFallback(root: RepoRoot): boolean {
|
|
|
195
210
|
*/
|
|
196
211
|
export function findLocalRepo(cwd: string = process.cwd()): RepoRoot | undefined {
|
|
197
212
|
const start = resolve(cwd);
|
|
213
|
+
const fromManifest = nearestManifestRepo(start);
|
|
214
|
+
if (fromManifest) return fromManifest;
|
|
198
215
|
const toplevel = gitToplevelOf(start);
|
|
199
216
|
const fromGit = toplevel !== undefined ? repoAt(toplevel) : undefined;
|
|
200
217
|
if (fromGit) return fromGit;
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.371.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "test-timeouts guard flags hook timeouts and scans recipes",
|
|
6
|
+
"detail": "The test-timeouts guard now also fails on a timeout argument to beforeAll, beforeEach, afterAll and afterEach, as a number or as a { timeout } object, including renamed and namespace imports from bun:test. The samples/recipes exemption is gone, recipe tests and their e2e dirs are scanned like every other test. The kumiko.json of the framework repo scans tests under scripts and bin as well. There is no baseline."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.371.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "import-extensions guard skips imports of build output",
|
|
12
|
+
"detail": "A relative specifier with a dist/ path segment, such as a published bin importing ../dist/cli.js, is no longer reported. The .js there is the only file that exists."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.371.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "Pre-push shim stops at the repo boundary, nearest kumiko.json wins, process.env guard covers bin",
|
|
18
|
+
"detail": "The pre-push shim searches upward only through ancestors up to the git root and fails with a message when nothing is found. findLocalRepo prefers the nearest kumiko.json over the git root. guard-no-direct-process-env scans bin/**/*.ts including bin/ops."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.370.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "import-extensions guard reports relative .js and .jsx specifiers",
|
|
24
|
+
"detail": "The import-extensions guard fails on a relative specifier that ends in .js or .jsx in a .ts or .tsx file (import, export from, import type and import()). Declaration files and fixtures are skipped. The message points to the import-extensions codemod. There is no baseline."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.370.0",
|
|
28
|
+
"type": "improvement",
|
|
29
|
+
"title": "Runtime-isolation guard resolves imports through the TypeScript resolver",
|
|
30
|
+
"detail": "The runtime-isolation guard resolves import and re-export targets with ts.resolveModuleName, so path aliases and package exports are followed, and classifies e2e directories as test code. resolveImportTargetPath is exported for other guards."
|
|
31
|
+
},
|
|
2
32
|
{
|
|
3
33
|
"version": "0.366.0",
|
|
4
34
|
"type": "improvement",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: relative imports in TypeScript sources name the source file (`./foo.ts`),
|
|
4
|
+
* not the emitted one (`./foo.js`).
|
|
5
|
+
*
|
|
6
|
+
* The tsconfig sets `allowImportingTsExtensions` and `rewriteRelativeImportExtensions`, so tsc
|
|
7
|
+
* writes `.js` into the published dist. A `.js` specifier in a source file works under bundler
|
|
8
|
+
* resolution, but it makes the source tree read two ways and hides missing files from editors
|
|
9
|
+
* that jump to the importee.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* bun guards/guard-import-extensions.ts
|
|
13
|
+
*
|
|
14
|
+
* Exit 1 on violations, 0 when clean.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
18
|
+
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
19
|
+
|
|
20
|
+
const SCAN: ScanSpec = {
|
|
21
|
+
scope: "source+tests",
|
|
22
|
+
extensions: ["ts", "tsx"],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const EXCLUDE = /(\.d\.ts$|\/fixtures\/)/;
|
|
26
|
+
const RELATIVE_JS_SPECIFIER = /^\.{1,2}\/.*\.jsx?$/;
|
|
27
|
+
// A published bin that imports its own build output (`../dist/cli.js`) has no
|
|
28
|
+
// `.ts` sibling to name; the `.js` there is the only file that exists.
|
|
29
|
+
const BUILD_OUTPUT_SEGMENT = /(^|\/)dist\//;
|
|
30
|
+
|
|
31
|
+
interface Finding {
|
|
32
|
+
readonly line: number;
|
|
33
|
+
readonly specifier: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function relativeJsSpecifier(node: Node | undefined): string | undefined {
|
|
37
|
+
const literal = node?.asKind(SyntaxKind.StringLiteral);
|
|
38
|
+
const value = literal?.getLiteralValue();
|
|
39
|
+
if (value === undefined || !RELATIVE_JS_SPECIFIER.test(value)) return undefined;
|
|
40
|
+
return BUILD_OUTPUT_SEGMENT.test(value) ? undefined : value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findJsSpecifiers(sf: SourceFile): Finding[] {
|
|
44
|
+
const findings: Finding[] = [];
|
|
45
|
+
const add = (node: Node | undefined): void => {
|
|
46
|
+
const specifier = relativeJsSpecifier(node);
|
|
47
|
+
if (node !== undefined && specifier !== undefined) {
|
|
48
|
+
findings.push({ line: node.getStartLineNumber(), specifier });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
for (const declaration of [...sf.getImportDeclarations(), ...sf.getExportDeclarations()]) {
|
|
52
|
+
add(declaration.getModuleSpecifier());
|
|
53
|
+
}
|
|
54
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
55
|
+
if (call.getExpression().getKind() === SyntaxKind.ImportKeyword) add(call.getArguments()[0]);
|
|
56
|
+
}
|
|
57
|
+
return findings;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const guard: AstGuard = {
|
|
61
|
+
name: "Import-Extensions Guard",
|
|
62
|
+
scan: SCAN,
|
|
63
|
+
hint:
|
|
64
|
+
"Relative imports name the source file: `./foo.js` -> `./foo.ts`, `./Comp.jsx` -> `./Comp.tsx`. " +
|
|
65
|
+
"Run the import-extensions codemod: `bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/import-extensions.ts . --write`. " +
|
|
66
|
+
"The tsconfig needs `allowImportingTsExtensions` (and `rewriteRelativeImportExtensions` when tsc emits).",
|
|
67
|
+
run(files) {
|
|
68
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
69
|
+
for (const sf of files) {
|
|
70
|
+
const file = sf.getFilePath();
|
|
71
|
+
if (EXCLUDE.test(file)) continue;
|
|
72
|
+
for (const { line, specifier } of findJsSpecifiers(sf)) {
|
|
73
|
+
violations.push({
|
|
74
|
+
file,
|
|
75
|
+
line,
|
|
76
|
+
message: `relative import "${specifier}" ends in .js/.jsx; use the .ts/.tsx source (codemod: import-extensions)`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { violations };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Apps with composed env-schemas (`bin/env.ts`) must read runtime config from
|
|
6
6
|
* the validated `env` export — not scattered `process.env.X` in prod paths.
|
|
7
7
|
*
|
|
8
|
-
* Scans bin/main.ts, other bin ts files (excl. allowlist), and src (excl. tests)
|
|
8
|
+
* Scans bin/main.ts, other bin ts files incl. subfolders such as bin/ops/ (excl. allowlist), and src (excl. tests)
|
|
9
9
|
* in every repo that ships `bin/env.ts`. Allowed raw process.env:
|
|
10
10
|
* - bin/env.ts — sole parseEnv input
|
|
11
11
|
* - bin/server.ts — dev entrypoint (runDevApp)
|
|
@@ -21,9 +21,9 @@ import { scanLinesForPredicate } from "./_lib/scan-lines";
|
|
|
21
21
|
// .tsx deliberately excluded — client screens have no runtime process.env
|
|
22
22
|
// access (bundler boundary), only .ts server/bin code reads process.env
|
|
23
23
|
// directly. Add .tsx here if that ever stops being true.
|
|
24
|
-
const SCAN_PATTERNS: ReadonlyArray<string> = ["bin
|
|
24
|
+
const SCAN_PATTERNS: ReadonlyArray<string> = ["bin/**/*.ts", "src/**/*.ts"];
|
|
25
25
|
|
|
26
|
-
/** bin
|
|
26
|
+
/** bin ts paths that may read process.env directly (see header). */
|
|
27
27
|
export const ALLOWED_BIN_FILES: ReadonlySet<string> = new Set([
|
|
28
28
|
"bin/env.ts",
|
|
29
29
|
"bin/server.ts",
|
|
@@ -606,6 +606,21 @@ export function isCoveredByPendingChangeset(
|
|
|
606
606
|
return texts.length > 0 && everyNameMentioned(texts, droppedNames);
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
// A removed feature folder has no changes.json the release fold could write to,
|
|
610
|
+
// so its dropped exports are named by the breaking block of whichever feature
|
|
611
|
+
// took them over; every name must still be mentioned by one block.
|
|
612
|
+
export function pendingFeaturesFor(
|
|
613
|
+
cwd: string,
|
|
614
|
+
pkg: PackageName,
|
|
615
|
+
key: string,
|
|
616
|
+
pending: PendingBreakingChanges,
|
|
617
|
+
): readonly string[] {
|
|
618
|
+
const feature = changesetFeatureFor(pkg, key);
|
|
619
|
+
if (pkg === "framework") return [feature];
|
|
620
|
+
if (existsSync(join(cwd, "packages/bundled-features/src", feature))) return [feature];
|
|
621
|
+
return [...pending.keys()];
|
|
622
|
+
}
|
|
623
|
+
|
|
609
624
|
// ── Write baseline ────────────────────────────────────────────────────────
|
|
610
625
|
|
|
611
626
|
// An absent, unreadable or format-drifted old baseline means "nothing frozen
|
|
@@ -635,11 +650,10 @@ function uncoveredDrops(
|
|
|
635
650
|
const uncovered: UncoveredFinding[] = [];
|
|
636
651
|
for (const m of missing) {
|
|
637
652
|
if (!isPackageName(m.pkg)) continue;
|
|
653
|
+
const coveringFeatures = pendingFeaturesFor(cwd, m.pkg, m.key, pendingBreakingChanges);
|
|
638
654
|
if (
|
|
639
|
-
|
|
640
|
-
pendingBreakingChanges,
|
|
641
|
-
changesetFeatureFor(m.pkg, m.key),
|
|
642
|
-
m.names,
|
|
655
|
+
coveringFeatures.some((feature) =>
|
|
656
|
+
isCoveredByPendingChangeset(pendingBreakingChanges, feature, m.names),
|
|
643
657
|
)
|
|
644
658
|
) {
|
|
645
659
|
continue;
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* `test`/`it`/`describe` calls (`test(name, fn, 30_000)`,
|
|
8
8
|
* `test(name, fn, { timeout })`, also via `.skip/.only/.if()/.each()()`
|
|
9
9
|
* and renamed/namespace `bun:test` imports) — raising a timeout hides
|
|
10
|
-
* the cause of a slow or flaky test
|
|
10
|
+
* the cause of a slow or flaky test. The same holds for a timeout
|
|
11
|
+
* argument on hooks (`beforeAll/beforeEach/afterAll/afterEach(fn, 60_000)`
|
|
12
|
+
* or `(fn, { timeout })`).
|
|
11
13
|
* (b) `<page>.waitForTimeout(...)` — a fixed sleep instead of a condition
|
|
12
14
|
* (c) sleep loops — a `while`/`do`/`for`/`for…of`/`for…in` whose body calls
|
|
13
15
|
* `sleep(...)`/`Bun.sleep(...)` or awaits a `new Promise` that only
|
|
@@ -20,7 +22,7 @@
|
|
|
20
22
|
* without an issue number or a reason does not count and is called out in
|
|
21
23
|
* the message.
|
|
22
24
|
*
|
|
23
|
-
*
|
|
25
|
+
* No baseline, no ratchet: any finding
|
|
24
26
|
* fails. #3118's transition period (a `.kumiko-test-timeouts-baseline.json`
|
|
25
27
|
* grandfathering pre-existing sleep loops) is over — every consumer must fix
|
|
26
28
|
* or mark the cause, not accumulate a pinned count.
|
|
@@ -52,13 +54,13 @@ const SCAN: ScanSpec = {
|
|
|
52
54
|
"packages/*/e2e/**/*.{ts,tsx}",
|
|
53
55
|
"packages/*/src/e2e/**/*.{ts,tsx}",
|
|
54
56
|
"samples/e2e/**/*.{ts,tsx}",
|
|
57
|
+
"samples/recipes/*/e2e/**/*.{ts,tsx}",
|
|
55
58
|
"samples/apps/*/e2e/**/*.{ts,tsx}",
|
|
56
59
|
],
|
|
57
60
|
};
|
|
58
61
|
|
|
59
62
|
const EXCEPTION_TAG = "@timeout-exception";
|
|
60
63
|
const COMPLETE_EXCEPTION = /@timeout-exception:\s*#\d+\s+\S/;
|
|
61
|
-
const RECIPES_DIR = "/samples/recipes/";
|
|
62
64
|
|
|
63
65
|
const LOOP_LABELS: ReadonlyMap<SyntaxKind, string> = new Map([
|
|
64
66
|
[SyntaxKind.WhileStatement, "while"],
|
|
@@ -77,6 +79,8 @@ const TEST_FRAMEWORK_MODULES: ReadonlySet<string> = new Set(["bun:test", "@playw
|
|
|
77
79
|
const TEST_DECLARATION_CALLEE =
|
|
78
80
|
/^(test|it|describe)(\.(skip|only|todo|if|each|failing|concurrent|serial|skipIf|runIf|todoIf))*$/;
|
|
79
81
|
|
|
82
|
+
const HOOK_DECLARATION_CALLEE = /^(beforeAll|beforeEach|afterAll|afterEach)$/;
|
|
83
|
+
|
|
80
84
|
export interface Finding {
|
|
81
85
|
file: string;
|
|
82
86
|
line: number;
|
|
@@ -191,6 +195,11 @@ function declarationTimeoutReason(
|
|
|
191
195
|
aliases: ReadonlyMap<string, string>,
|
|
192
196
|
): string | undefined {
|
|
193
197
|
const base = canonicalCalleeText(declarationBaseCallee(call.getExpression()).getText(), aliases);
|
|
198
|
+
if (HOOK_DECLARATION_CALLEE.test(base)) {
|
|
199
|
+
const [, ...hookExtra] = call.getArguments();
|
|
200
|
+
if (!hookExtra.some(isTimeoutArgument)) return undefined;
|
|
201
|
+
return `${base}(…, timeout) raises the timeout instead of fixing the cause`;
|
|
202
|
+
}
|
|
194
203
|
if (!TEST_DECLARATION_CALLEE.test(base)) return undefined;
|
|
195
204
|
const [, , ...extra] = call.getArguments();
|
|
196
205
|
if (!extra.some(isTimeoutArgument)) return undefined;
|
|
@@ -229,7 +238,6 @@ export function scanTimeouts(
|
|
|
229
238
|
sf: SourceFile,
|
|
230
239
|
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
231
240
|
): Finding[] {
|
|
232
|
-
if (sf.getFilePath().includes(RECIPES_DIR)) return [];
|
|
233
241
|
const file = relFile(sf, roots);
|
|
234
242
|
const findings: Finding[] = [];
|
|
235
243
|
const add = (node: Node, message: string): void => {
|
package/src/index.ts
CHANGED
|
@@ -34,3 +34,4 @@ export { writeSecurityBaselines } from "./_lib/security-baseline-cli";
|
|
|
34
34
|
export { GUARD_FLAGS, GUARDS, runGuardsCli } from "./run-guards";
|
|
35
35
|
export { REPO_CHECK_FLAGS, REPO_CHECKS, runRepoChecksCli } from "./run-repo-checks";
|
|
36
36
|
export { runUiGuardsCli, UI_GUARD_FLAGS, UI_GUARDS } from "./run-ui-guards";
|
|
37
|
+
export { resolveImportTargetPath } from "./runtime-isolation-classify";
|
package/src/run-guards.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { guard as htmlEscape } from "./guard-html-escape";
|
|
|
38
38
|
import { guard as i18nKeys } from "./guard-i18n-keys";
|
|
39
39
|
import { guard as i18nLocaleMount } from "./guard-i18n-locale-mount";
|
|
40
40
|
import { guard as i18nLocaleTerminology } from "./guard-i18n-locale-terminology";
|
|
41
|
+
import { guard as importExtensions } from "./guard-import-extensions";
|
|
41
42
|
import { guard as libTestCoverage } from "./guard-lib-test-coverage";
|
|
42
43
|
import { guard as loadallEvents } from "./guard-loadall-events";
|
|
43
44
|
import { guard as noDateApi } from "./guard-no-date-api";
|
|
@@ -73,6 +74,7 @@ export const GUARDS = [
|
|
|
73
74
|
unsafeJsonParse,
|
|
74
75
|
silentSkip,
|
|
75
76
|
htmlEscape,
|
|
77
|
+
importExtensions,
|
|
76
78
|
crossFeatureImports,
|
|
77
79
|
noDateApi,
|
|
78
80
|
noTemporalPolyfillImport,
|
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
|
|
14
14
|
import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
-
import
|
|
16
|
+
import {
|
|
17
|
+
type ExportDeclaration,
|
|
18
|
+
type ImportDeclaration,
|
|
19
|
+
type Project,
|
|
20
|
+
type SourceFile,
|
|
21
|
+
ts,
|
|
22
|
+
} from "ts-morph";
|
|
17
23
|
|
|
18
24
|
const RUNTIME_KINDS = ["runtime", "client", "prod", "dev", "tooling", "test"] as const;
|
|
19
25
|
|
|
@@ -34,26 +40,24 @@ export const COMPAT: Record<Runtime, ReadonlySet<Runtime>> = {
|
|
|
34
40
|
test: new Set(["runtime", "client", "prod", "dev", "tooling", "test"]),
|
|
35
41
|
};
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (/\/drizzle\/[^/]+\.ts$/.test(rel)) return "tooling";
|
|
51
|
-
if (/\/drizzle\.config\.[tj]s$/.test(rel)) return "tooling";
|
|
43
|
+
// First match wins, so the test rules stay ahead of the tooling and client carve-outs.
|
|
44
|
+
const PATH_RULES: readonly (readonly [RegExp, Runtime])[] = [
|
|
45
|
+
[/\/(__tests__|testing)\//, "test"],
|
|
46
|
+
[/\/testing\.tsx?$/, "test"],
|
|
47
|
+
[/\.(test|integration|e2e|spec)\.[tj]sx?$/, "test"],
|
|
48
|
+
// Playwright suites live in e2e/ at the repo root, next to a package or a
|
|
49
|
+
// sample and import kumiko-testing/e2e; without this rule they read as
|
|
50
|
+
// runtime code importing the test tier. Deliberately not any nested e2e/.
|
|
51
|
+
[/^(?:e2e|packages\/[^/]+\/e2e|samples\/(?:.+\/)?e2e)\//, "test"],
|
|
52
|
+
[/(?:^|\/)scripts\//, "tooling"],
|
|
53
|
+
[/(?:^|\/)bin\//, "tooling"],
|
|
54
|
+
[/\/drizzle\/[^/]+\.ts$/, "tooling"],
|
|
55
|
+
[/\/drizzle\.config\.[tj]s$/, "tooling"],
|
|
52
56
|
|
|
53
57
|
// Shared UI types are used by both client and runtime. In the kumiko isolation
|
|
54
58
|
// model, "client" is the most permissive production category that "runtime"
|
|
55
59
|
// can also import.
|
|
56
|
-
|
|
60
|
+
[/(?:^|\/)ui-types\//, "client"],
|
|
57
61
|
|
|
58
62
|
// Same reasoning, two more shapes: a `web.ts`/`web/` subpath is the
|
|
59
63
|
// established convention (locale-de, locale-es, bundled-features) for the
|
|
@@ -68,8 +72,9 @@ export function classifyByPath(repoRelativePath: string): Runtime | null {
|
|
|
68
72
|
// `utils`/`errors` are common enough directory names elsewhere that a
|
|
69
73
|
// repo-wide match risks misclassifying an unrelated server-only folder in
|
|
70
74
|
// some other package.
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
[/(?:^|\/)web\//, "client"],
|
|
76
|
+
[/\/web\.tsx?$/, "client"],
|
|
77
|
+
[/^packages\/framework\/src\/(?:time|utils|engine\/types|errors)\//, "client"],
|
|
73
78
|
|
|
74
79
|
// More single-file carve-outs, same "package marker is coarser than the
|
|
75
80
|
// file" shape, verified case by case rather than by a directory
|
|
@@ -77,11 +82,23 @@ export function classifyByPath(repoRelativePath: string): Runtime | null {
|
|
|
77
82
|
// - locale-{de,es}/src/strings.ts: pure string-constant data (zero
|
|
78
83
|
// imports), re-exported by the already-client `web.ts` sibling in the
|
|
79
84
|
// same package but shadowed by the package's own `"runtime"` marker.
|
|
80
|
-
|
|
85
|
+
[/^packages\/locale-(?:de|es)\/src\/strings\.ts$/, "client"],
|
|
81
86
|
// - dev-server/src/env-schema.ts: a zod-only schema, no dev-server-
|
|
82
87
|
// internal imports — safe to carve out on its own.
|
|
83
|
-
|
|
88
|
+
[/^packages\/dev-server\/src\/env-schema\.ts$/, "client"],
|
|
89
|
+
];
|
|
84
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Classify a file by its path relative to the repo root. Returns null
|
|
93
|
+
* if no path-pattern matched (caller falls back to workspace / default).
|
|
94
|
+
*
|
|
95
|
+
* Pure function — accepts a repo-relative path string, no I/O.
|
|
96
|
+
*/
|
|
97
|
+
export function classifyByPath(repoRelativePath: string): Runtime | null {
|
|
98
|
+
const rel = repoRelativePath.replace(/\\/g, "/");
|
|
99
|
+
for (const [pattern, runtime] of PATH_RULES) {
|
|
100
|
+
if (pattern.test(rel)) return runtime;
|
|
101
|
+
}
|
|
85
102
|
return null;
|
|
86
103
|
}
|
|
87
104
|
|
|
@@ -166,7 +183,7 @@ export function isValueImport(decl: ImportDeclaration): boolean {
|
|
|
166
183
|
|
|
167
184
|
export type ValueModuleEdge = {
|
|
168
185
|
readonly spec: string;
|
|
169
|
-
readonly
|
|
186
|
+
readonly targetPath: string;
|
|
170
187
|
readonly line: number;
|
|
171
188
|
};
|
|
172
189
|
|
|
@@ -180,11 +197,11 @@ export function valueModuleEdges(sf: SourceFile): ValueModuleEdge[] {
|
|
|
180
197
|
const edges: ValueModuleEdge[] = [];
|
|
181
198
|
for (const decl of sf.getImportDeclarations()) {
|
|
182
199
|
if (!isValueImport(decl)) continue;
|
|
183
|
-
const
|
|
184
|
-
if (!
|
|
200
|
+
const targetPath = resolveImportTargetPath(decl);
|
|
201
|
+
if (!targetPath) continue;
|
|
185
202
|
edges.push({
|
|
186
203
|
spec: decl.getModuleSpecifierValue(),
|
|
187
|
-
|
|
204
|
+
targetPath,
|
|
188
205
|
line: decl.getStartLineNumber(),
|
|
189
206
|
});
|
|
190
207
|
}
|
|
@@ -192,14 +209,62 @@ export function valueModuleEdges(sf: SourceFile): ValueModuleEdge[] {
|
|
|
192
209
|
if (decl.isTypeOnly()) continue;
|
|
193
210
|
const named = decl.getNamedExports();
|
|
194
211
|
if (named.length > 0 && named.every((n) => n.isTypeOnly())) continue;
|
|
195
|
-
const target = decl.getModuleSpecifierSourceFile();
|
|
196
212
|
const spec = decl.getModuleSpecifierValue();
|
|
197
|
-
|
|
198
|
-
|
|
213
|
+
const targetPath = resolveImportTargetPath(decl);
|
|
214
|
+
if (!targetPath || spec === undefined) continue;
|
|
215
|
+
edges.push({ spec, targetPath, line: decl.getStartLineNumber() });
|
|
199
216
|
}
|
|
200
217
|
return edges;
|
|
201
218
|
}
|
|
202
219
|
|
|
220
|
+
const moduleResolutionCaches = new WeakMap<Project, ts.ModuleResolutionCache>();
|
|
221
|
+
const javaScriptExtensions: ReadonlySet<string> = new Set([
|
|
222
|
+
ts.Extension.Js,
|
|
223
|
+
ts.Extension.Jsx,
|
|
224
|
+
ts.Extension.Mjs,
|
|
225
|
+
ts.Extension.Cjs,
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Resolve an import or re-export to its target file path without touching the
|
|
230
|
+
* type checker. ts-morph's `getModuleSpecifierSourceFile()` goes through
|
|
231
|
+
* `getSymbol()`, which creates the full program and binds every file: on a
|
|
232
|
+
* multi-repo scan that doubled the RSS past the CI runner's memory. Plain
|
|
233
|
+
* `ts.resolveModuleName` yields the same real paths for workspace files. JS
|
|
234
|
+
* targets are dropped unless `allowJs` is on, because the program never
|
|
235
|
+
* contained them either.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveImportTargetPath(
|
|
238
|
+
decl: ImportDeclaration | ExportDeclaration,
|
|
239
|
+
): string | undefined {
|
|
240
|
+
const spec = decl.getModuleSpecifierValue();
|
|
241
|
+
if (spec === undefined) return undefined;
|
|
242
|
+
const project = decl.getProject();
|
|
243
|
+
const options = project.getCompilerOptions();
|
|
244
|
+
const host = project.getModuleResolutionHost();
|
|
245
|
+
let cache = moduleResolutionCaches.get(project);
|
|
246
|
+
if (!cache) {
|
|
247
|
+
cache = ts.createModuleResolutionCache(
|
|
248
|
+
host.getCurrentDirectory?.() ?? process.cwd(),
|
|
249
|
+
ts.sys.useCaseSensitiveFileNames
|
|
250
|
+
? (fileName) => fileName
|
|
251
|
+
: (fileName) => fileName.toLowerCase(),
|
|
252
|
+
options,
|
|
253
|
+
);
|
|
254
|
+
moduleResolutionCaches.set(project, cache);
|
|
255
|
+
}
|
|
256
|
+
const resolved = ts.resolveModuleName(
|
|
257
|
+
spec,
|
|
258
|
+
decl.getSourceFile().getFilePath(),
|
|
259
|
+
options,
|
|
260
|
+
host,
|
|
261
|
+
cache,
|
|
262
|
+
).resolvedModule;
|
|
263
|
+
if (!resolved) return undefined;
|
|
264
|
+
if (!options.allowJs && javaScriptExtensions.has(resolved.extension)) return undefined;
|
|
265
|
+
return resolved.resolvedFileName;
|
|
266
|
+
}
|
|
267
|
+
|
|
203
268
|
/**
|
|
204
269
|
* Map dist declaration files back to src. Project References make ts-morph
|
|
205
270
|
* resolve imports to `.d.ts` under `dist/`; classification and the reachable
|
|
@@ -237,10 +302,12 @@ export function computeClientReachablePaths(
|
|
|
237
302
|
if (fp.includes("/node_modules/") || fp.includes("/dist/")) continue;
|
|
238
303
|
if (reached.has(fp)) continue;
|
|
239
304
|
reached.add(fp);
|
|
240
|
-
|
|
241
|
-
|
|
305
|
+
const project = sf.getProject();
|
|
306
|
+
for (const edge of valueModuleEdges(sf)) {
|
|
307
|
+
const targetPath = toEffectivePath(edge.targetPath);
|
|
242
308
|
if (targetPath.includes("/node_modules/") || targetPath.includes("/dist/")) continue;
|
|
243
|
-
if (
|
|
309
|
+
if (reached.has(targetPath)) continue;
|
|
310
|
+
queue.push(project.getSourceFile(targetPath) ?? project.addSourceFileAtPath(targetPath));
|
|
244
311
|
}
|
|
245
312
|
}
|
|
246
313
|
return reached;
|
|
@@ -446,8 +513,7 @@ export function findRuntimeIsolationViolations(
|
|
|
446
513
|
const fileRt = classify(fp, repoRoot, workspaceCache, clientReachable, directiveOf);
|
|
447
514
|
stats[fileRt]++;
|
|
448
515
|
|
|
449
|
-
for (const { spec,
|
|
450
|
-
const targetPath = target.getFilePath();
|
|
516
|
+
for (const { spec, targetPath, line } of valueModuleEdges(sf)) {
|
|
451
517
|
if (targetPath.includes("/node_modules/")) continue;
|
|
452
518
|
if (!withinRoot(targetPath)) {
|
|
453
519
|
noteOutside(targetPath);
|