@cosmicdrift/kumiko-guards 0.369.0 → 0.370.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.369.0",
3
+ "version": "0.370.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.369.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.370.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.370.0",
4
+ "type": "improvement",
5
+ "title": "import-extensions guard reports relative .js and .jsx specifiers",
6
+ "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."
7
+ },
8
+ {
9
+ "version": "0.370.0",
10
+ "type": "improvement",
11
+ "title": "Runtime-isolation guard resolves imports through the TypeScript resolver",
12
+ "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."
13
+ },
2
14
  {
3
15
  "version": "0.366.0",
4
16
  "type": "improvement",
@@ -0,0 +1,80 @@
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
+
28
+ interface Finding {
29
+ readonly line: number;
30
+ readonly specifier: string;
31
+ }
32
+
33
+ function relativeJsSpecifier(node: Node | undefined): string | undefined {
34
+ const literal = node?.asKind(SyntaxKind.StringLiteral);
35
+ const value = literal?.getLiteralValue();
36
+ return value !== undefined && RELATIVE_JS_SPECIFIER.test(value) ? value : undefined;
37
+ }
38
+
39
+ function findJsSpecifiers(sf: SourceFile): Finding[] {
40
+ const findings: Finding[] = [];
41
+ const add = (node: Node | undefined): void => {
42
+ const specifier = relativeJsSpecifier(node);
43
+ if (node !== undefined && specifier !== undefined) {
44
+ findings.push({ line: node.getStartLineNumber(), specifier });
45
+ }
46
+ };
47
+ for (const declaration of [...sf.getImportDeclarations(), ...sf.getExportDeclarations()]) {
48
+ add(declaration.getModuleSpecifier());
49
+ }
50
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
51
+ if (call.getExpression().getKind() === SyntaxKind.ImportKeyword) add(call.getArguments()[0]);
52
+ }
53
+ return findings;
54
+ }
55
+
56
+ export const guard: AstGuard = {
57
+ name: "Import-Extensions Guard",
58
+ scan: SCAN,
59
+ hint:
60
+ "Relative imports name the source file: `./foo.js` -> `./foo.ts`, `./Comp.jsx` -> `./Comp.tsx`. " +
61
+ "Run the import-extensions codemod: `bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/import-extensions.ts . --write`. " +
62
+ "The tsconfig needs `allowImportingTsExtensions` (and `rewriteRelativeImportExtensions` when tsc emits).",
63
+ run(files) {
64
+ const violations: Array<{ file: string; line: number; message: string }> = [];
65
+ for (const sf of files) {
66
+ const file = sf.getFilePath();
67
+ if (EXCLUDE.test(file)) continue;
68
+ for (const { line, specifier } of findJsSpecifiers(sf)) {
69
+ violations.push({
70
+ file,
71
+ line,
72
+ message: `relative import "${specifier}" ends in .js/.jsx; use the .ts/.tsx source (codemod: import-extensions)`,
73
+ });
74
+ }
75
+ }
76
+ return { violations };
77
+ },
78
+ };
79
+
80
+ if (import.meta.main) runStandalone(guard);
@@ -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
- isCoveredByPendingChangeset(
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;
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 type { ImportDeclaration, SourceFile } from "ts-morph";
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
- * Classify a file by its path relative to the repo root. Returns null
39
- * if no path-pattern matched (caller falls back to workspace / default).
40
- *
41
- * Pure function — accepts a repo-relative path string, no I/O.
42
- */
43
- export function classifyByPath(repoRelativePath: string): Runtime | null {
44
- const rel = repoRelativePath.replace(/\\/g, "/");
45
- if (/\/(__tests__|testing)\//.test(rel)) return "test";
46
- if (/\/testing\.tsx?$/.test(rel)) return "test";
47
- if (/\.(test|integration|e2e|spec)\.[tj]sx?$/.test(rel)) return "test";
48
- if (/(?:^|\/)scripts\//.test(rel)) return "tooling";
49
- if (/(?:^|\/)bin\//.test(rel)) return "tooling";
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
- if (/(?:^|\/)ui-types\//.test(rel)) return "client";
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
- if (/(?:^|\/)web\//.test(rel) || /\/web\.tsx?$/.test(rel)) return "client";
72
- if (/^packages\/framework\/src\/(?:time|utils|engine\/types|errors)\//.test(rel)) return "client";
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
- if (/^packages\/locale-(?:de|es)\/src\/strings\.ts$/.test(rel)) return "client";
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
- if (/^packages\/dev-server\/src\/env-schema\.ts$/.test(rel)) return "client";
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 target: SourceFile;
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 target = decl.getModuleSpecifierSourceFile();
184
- if (!target) continue;
200
+ const targetPath = resolveImportTargetPath(decl);
201
+ if (!targetPath) continue;
185
202
  edges.push({
186
203
  spec: decl.getModuleSpecifierValue(),
187
- target,
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
- if (!target || spec === undefined) continue;
198
- edges.push({ spec, target, line: decl.getStartLineNumber() });
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
- for (const { target } of valueModuleEdges(sf)) {
241
- const targetPath = toEffectivePath(target.getFilePath());
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 (!reached.has(targetPath)) queue.push(target);
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, target, line } of valueModuleEdges(sf)) {
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);