@cosmicdrift/kumiko-guards 0.336.1 → 0.338.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.336.1",
3
+ "version": "0.338.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.336.1",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.338.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.337.0",
4
+ "type": "fix",
5
+ "title": "i18n-keys guard decodes escaped literals, framed-sections guard skips test usages, renderer-boundaries guard blocks bare fetch and reports all JSX tags per line, pre-push shim no longer execs binaries from shared ancestor dirs",
6
+ "detail": "The i18n-keys guard reads string literals with `getLiteralText()` so escaped keys match their definitions. The no-framed-extension-sections guard ignores test files when collecting component usages. The renderer-boundaries guard now also rejects a bare `fetch(` call in `packages/renderer/src`, and reports every JSX tag on a line. The `.husky/pre-push` shim only resolves `kumiko-pre-push` from the repo's own `node_modules` or the parent-workspace root, never from arbitrary ancestor directories."
7
+ },
2
8
  {
3
9
  "version": "0.336.0",
4
10
  "type": "improvement",
@@ -187,8 +187,14 @@ export const check: RepoCheck = {
187
187
 
188
188
  const counts = countByKey(rootAbsPath, allViolations);
189
189
  const resolveLine = (key: string): number => {
190
- const relFile = key.split("::")[0];
191
- return allViolations.find((v) => path.relative(rootAbsPath, v.file) === relFile)?.line ?? 1;
190
+ const separator = key.indexOf("::");
191
+ const relFile = key.slice(0, separator);
192
+ const importedSpec = key.slice(separator + 2);
193
+ return (
194
+ allViolations.find(
195
+ (v) => path.relative(rootAbsPath, v.file) === relFile && v.importedSpec === importedSpec,
196
+ )?.line ?? 1
197
+ );
192
198
  };
193
199
  const violations: GuardViolation[] = ratchetFor(rootAbsPath).check(
194
200
  counts,
@@ -71,7 +71,7 @@ function collectUsedKeys(sf: SourceFile): UsedKey[] {
71
71
  !first?.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
72
72
  )
73
73
  continue;
74
- const literal = first.getText().slice(1, -1);
74
+ const literal = first.getLiteralText();
75
75
  if (!literal.includes(":")) continue;
76
76
  keys.push({
77
77
  key: literal,
@@ -90,7 +90,7 @@ function findEnclosingFeatureName(node: Node): string | null {
90
90
  if (call.getExpression().getText() === "defineFeature") {
91
91
  const first = call.getArguments()[0];
92
92
  if (first?.isKind(SyntaxKind.StringLiteral)) {
93
- return first.getText().slice(1, -1);
93
+ return first.getLiteralText();
94
94
  }
95
95
  }
96
96
  }
@@ -108,7 +108,7 @@ function extractKeysFromTranslationsObject(
108
108
  const nameNode = prop.getNameNode();
109
109
  let keyName: string;
110
110
  if (nameNode.isKind(SyntaxKind.StringLiteral)) {
111
- keyName = nameNode.getText().slice(1, -1);
111
+ keyName = nameNode.getLiteralText();
112
112
  } else if (nameNode.isKind(SyntaxKind.Identifier)) {
113
113
  keyName = nameNode.getText();
114
114
  } else continue;
@@ -119,8 +119,7 @@ function extractKeysFromTranslationsObject(
119
119
  for (const localeProp of initializer.getProperties()) {
120
120
  if (!localeProp.isKind(SyntaxKind.PropertyAssignment)) continue;
121
121
  const localeName = localeProp.getNameNode();
122
- if (localeName.isKind(SyntaxKind.StringLiteral))
123
- locales.add(localeName.getText().slice(1, -1));
122
+ if (localeName.isKind(SyntaxKind.StringLiteral)) locales.add(localeName.getLiteralText());
124
123
  else if (localeName.isKind(SyntaxKind.Identifier)) locales.add(localeName.getText());
125
124
  }
126
125
  out.push({ key: keyName, locales, line: prop.getStartLineNumber() });
@@ -148,7 +147,7 @@ function extractLocaleFirstKeys(
148
147
  if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
149
148
  const nameNode = prop.getNameNode();
150
149
  if (nameNode.isKind(SyntaxKind.StringLiteral)) {
151
- keySet.add(nameNode.getText().slice(1, -1));
150
+ keySet.add(nameNode.getLiteralText());
152
151
  }
153
152
  }
154
153
  }
@@ -160,7 +159,7 @@ function extractLocaleFirstKeys(
160
159
  const has = map.getProperties().some((prop) => {
161
160
  if (!prop.isKind(SyntaxKind.PropertyAssignment)) return false;
162
161
  const nameNode = prop.getNameNode();
163
- return nameNode.isKind(SyntaxKind.StringLiteral) && nameNode.getText().slice(1, -1) === key;
162
+ return nameNode.isKind(SyntaxKind.StringLiteral) && nameNode.getLiteralText() === key;
164
163
  });
165
164
  if (has) locales.add(locale);
166
165
  }
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import * as path from "node:path";
22
- import { type CallExpression, type Identifier, type SourceFile, SyntaxKind } from "ts-morph";
22
+ import { type CallExpression, type SourceFile, SyntaxKind } from "ts-morph";
23
23
  import {
24
24
  type AstGuard,
25
25
  type GuardViolation,
@@ -88,7 +88,7 @@ export function collectViolations(sourceFile: SourceFile): Violation[] {
88
88
  function getCalleeName(call: CallExpression): string | null {
89
89
  const expr = call.getExpression();
90
90
  if (expr.getKind() === SyntaxKind.Identifier) {
91
- return (expr as Identifier).getText();
91
+ return expr.getText();
92
92
  }
93
93
  if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
94
94
  return expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName();
@@ -337,6 +337,7 @@ function findComponentNameUsages(sf: SourceFile): { name: string; kind: MountKin
337
337
  function buildUsageKindsByName(files: readonly SourceFile[]): Map<string, Set<MountKind>> {
338
338
  const byName = new Map<string, Set<MountKind>>();
339
339
  for (const sf of files) {
340
+ if (EXCLUDE.test(sf.getFilePath())) continue;
340
341
  for (const { name, kind } of findComponentNameUsages(sf)) {
341
342
  const kinds = byName.get(name) ?? new Set<MountKind>();
342
343
  kinds.add(kind);
@@ -12,8 +12,8 @@
12
12
  * Checked:
13
13
  * - Imports: react-dom/*, jsdom, @cosmicdrift/kumiko-renderer-web, @cosmicdrift/kumiko-renderer-native
14
14
  * - Symbols: window., document., location., history., localStorage,
15
- * sessionStorage, navigator., EventSource, fetch
16
- * (bare — unqualified)
15
+ * sessionStorage, navigator., EventSource, fetch(
16
+ * (bare — unqualified), HTMLElement, createRoot
17
17
  *
18
18
  * __tests__ folders are excluded — tests mount in jsdom, that's expected.
19
19
  */
@@ -43,6 +43,7 @@ const FORBIDDEN_SYMBOLS = [
43
43
  /\bsessionStorage\b/,
44
44
  /\bnavigator\s*\./,
45
45
  /\bEventSource\b/,
46
+ /(?<![.\w])fetch\s*\(/,
46
47
  /\bHTMLElement\b/,
47
48
  /\bcreateRoot\b/,
48
49
  ];
@@ -56,7 +57,7 @@ const FORBIDDEN_SYMBOLS = [
56
57
  // Ignored: fragment `<>`, components `<Capitalized`, attribute values like
57
58
  // `<string>` in a type-annotation context (the regex only matches right
58
59
  // after whitespace/newline/>, not after an identifier character).
59
- const FORBIDDEN_JSX_TAG = /(^|\s|>|\()<([a-z][a-zA-Z0-9-]*)[\s/>]/;
60
+ const FORBIDDEN_JSX_TAG = /(^|\s|>|\()<([a-z][a-zA-Z0-9-]*)(?=[\s/>])/g;
60
61
 
61
62
  type Violation = {
62
63
  readonly file: string;
@@ -113,8 +114,7 @@ export function findViolations(file: string, root: string): Violation[] {
113
114
  });
114
115
  }
115
116
  }
116
- const jsxMatch = line.match(FORBIDDEN_JSX_TAG);
117
- if (jsxMatch !== null) {
117
+ for (const jsxMatch of line.matchAll(FORBIDDEN_JSX_TAG)) {
118
118
  violations.push({
119
119
  file: path.relative(root, file),
120
120
  line: i + 1,
@@ -126,9 +126,12 @@ export function resolveKumikoUpgradeBin(
126
126
  return Bun.which("kumiko-upgrade", { PATH: pathEnv }) ?? undefined;
127
127
  }
128
128
 
129
- async function runKumikoUpgrade(
129
+ const KUMIKO_UPGRADE_TIMEOUT_MS = 120_000;
130
+
131
+ export async function runKumikoUpgrade(
130
132
  version: string,
131
133
  cwd: string,
134
+ timeoutMs: number = KUMIKO_UPGRADE_TIMEOUT_MS,
132
135
  ): Promise<{ ok: true; json: UpgradeJson } | { ok: false; error: string }> {
133
136
  const binPath = resolveKumikoUpgradeBin(cwd);
134
137
  if (!binPath) {
@@ -142,14 +145,22 @@ async function runKumikoUpgrade(
142
145
  cmd: [binPath, "--from", version, "--json"],
143
146
  cwd,
144
147
  env: { ...process.env, INIT_CWD: cwd },
148
+ stdin: "ignore",
145
149
  stdout: "pipe",
146
150
  stderr: "pipe",
151
+ timeout: timeoutMs,
147
152
  });
148
153
  const [stdout, stderr, exitCode] = await Promise.all([
149
154
  new Response(proc.stdout).text(),
150
155
  new Response(proc.stderr).text(),
151
156
  proc.exited,
152
157
  ]);
158
+ if (proc.signalCode !== null) {
159
+ return {
160
+ ok: false,
161
+ error: `\`kumiko-upgrade --from ${version} --json\` timed out after ${timeoutMs} ms (killed by ${proc.signalCode})`,
162
+ };
163
+ }
153
164
  if (exitCode !== 0) {
154
165
  return {
155
166
  ok: false,
@@ -15,16 +15,15 @@ import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs
15
15
  import * as path from "node:path";
16
16
  import type { ImportDeclaration, SourceFile } from "ts-morph";
17
17
 
18
- export type Runtime = "runtime" | "client" | "prod" | "dev" | "tooling" | "test";
18
+ const RUNTIME_KINDS = ["runtime", "client", "prod", "dev", "tooling", "test"] as const;
19
19
 
20
- export const ALL_RUNTIMES: ReadonlySet<string> = new Set([
21
- "runtime",
22
- "client",
23
- "prod",
24
- "dev",
25
- "tooling",
26
- "test",
27
- ]);
20
+ export type Runtime = (typeof RUNTIME_KINDS)[number];
21
+
22
+ export const ALL_RUNTIMES: ReadonlySet<string> = new Set(RUNTIME_KINDS);
23
+
24
+ function isRuntime(value: unknown): value is Runtime {
25
+ return typeof value === "string" && ALL_RUNTIMES.has(value);
26
+ }
28
27
 
29
28
  export const COMPAT: Record<Runtime, ReadonlySet<Runtime>> = {
30
29
  runtime: new Set(["runtime", "client", "prod"]),
@@ -284,7 +283,8 @@ export function classifyByDirective(
284
283
  if (head === null) return null;
285
284
  for (const line of head.split("\n").slice(0, DIRECTIVE_HEAD_LINES)) {
286
285
  const m = line.match(/\/\/\s*@runtime\s+(\w+)/);
287
- if (m && ALL_RUNTIMES.has(m[1] ?? "")) return m[1] as Runtime;
286
+ const declared = m?.[1];
287
+ if (isRuntime(declared)) return declared;
288
288
  }
289
289
  return null;
290
290
  }
@@ -346,9 +346,11 @@ function readWorkspaceRuntime(dir: string, cache: Map<string, Runtime | null>):
346
346
  const pkgPath = path.join(dir, "package.json");
347
347
  let result: Runtime | null = null;
348
348
  try {
349
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
350
- const r = pkg.kumiko?.runtime;
351
- if (typeof r === "string" && ALL_RUNTIMES.has(r)) result = r as Runtime;
349
+ const pkg: unknown = JSON.parse(readFileSync(pkgPath, "utf8"));
350
+ const kumiko = typeof pkg === "object" && pkg !== null && "kumiko" in pkg ? pkg.kumiko : null;
351
+ const declared =
352
+ typeof kumiko === "object" && kumiko !== null && "runtime" in kumiko ? kumiko.runtime : null;
353
+ if (isRuntime(declared)) result = declared;
352
354
  } catch {
353
355
  // package.json missing or unreadable — unmarked
354
356
  }