@cosmicdrift/kumiko-guards 0.374.0 → 0.377.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.
@@ -2,10 +2,9 @@
2
2
  /**
3
3
  * Guard: `className` string-literal tokens under kumiko-framework's
4
4
  * `packages/bundled-features/src/**` must already be reachable through the
5
- * real Tailwind `@source` scan surface (`packages/renderer-web/src` +
6
- * `packages/renderer/src` + `samples/**\/src`, see
7
- * `packages/renderer-web/src/styles.css`). bundled-features itself is NOT
8
- * part of that scan surface (infra#654, follow-up to
5
+ * real Tailwind `@source` scan surface (`packages/renderer/src` +
6
+ * `samples/**\/src`, see `packages/renderer/src/web/styles.css`).
7
+ * bundled-features itself is NOT part of that scan surface (infra#654, follow-up to
9
8
  * kumiko-framework#2498/#2496): expanding `@source` there was verified to
10
9
  * work in the monorepo build but makes the standalone npm-consumer case
11
10
  * (no monorepo-relative scan path — #359) falsely green. A class used only
@@ -33,7 +32,7 @@
33
32
  * bun guards/guard-tailwind-scan-surface.ts --write-baseline # (re)write the baseline
34
33
  * bun guards/guard-tailwind-scan-surface.ts --no-baseline # skip the comparison
35
34
  */
36
- import { existsSync, readFileSync, realpathSync } from "node:fs";
35
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
37
36
  import * as path from "node:path";
38
37
  import { Glob } from "bun";
39
38
  import { type SourceFile, SyntaxKind } from "ts-morph";
@@ -64,7 +63,6 @@ const SCAN: ScanSpec = {
64
63
  kinds: ["framework"],
65
64
  frameworkWithin: [
66
65
  "packages/bundled-features/src/**/*.tsx",
67
- "packages/renderer-web/src/**",
68
66
  "packages/renderer/src/**",
69
67
  "samples/**/src/**",
70
68
  ],
@@ -75,7 +73,7 @@ const IGNORE_TAG = "kumiko-lint-ignore tailwind-scan-surface";
75
73
  // The allow-set mirrors what Tailwind's own scanner sees over the real
76
74
  // @source surface: every string/template-literal text in the file, not
77
75
  // just JSX className attributes — cn(...) calls, variant maps and shared
78
- // class constants in renderer-web/renderer/samples legitimately emit
76
+ // class constants in renderer/samples legitimately emit
79
77
  // classes too. No EXCLUDE filter here on purpose: the @source globs
80
78
  // themselves scan __tests__/*.test.tsx just the same, so excluding them
81
79
  // from the allow-set would manufacture false positives.
@@ -148,7 +146,7 @@ function scan(
148
146
  if (token.length === 0 || allowed.has(token)) continue;
149
147
  findings.push({ file, line, token });
150
148
  console.warn(
151
- ` [tailwind-scan-surface WARN] ${file}:${line} class "${token}" is outside the Tailwind scan surface (renderer-web/src, renderer/src, samples/**/src)`,
149
+ ` [tailwind-scan-surface WARN] ${file}:${line} class "${token}" is outside the Tailwind scan surface (renderer/src, samples/**/src)`,
152
150
  );
153
151
  }
154
152
  }
@@ -173,8 +171,8 @@ const tailwindScanSurfaceBaseline = (baselineRoot: string) =>
173
171
  // Second, independent rule: a consuming app repo (studio,
174
172
  // publicstatus, …) imports Framework AND Enterprise packages by npm name
175
173
  // (@cosmicdrift/* resp. @cosmicdriftgamestudio/*). Every one of them that
176
- // ships .tsx with `className` under its own scan surface (renderer-web +
177
- // renderer + samples/**/src) needs its own `@source` entry in the app's
174
+ // ships .tsx with `className` under its own scan surface (renderer/src +
175
+ // samples/**/src) needs its own `@source` entry in the app's
178
176
  // styles.css — nothing else scans it. Missed cases so far: kumiko-designer
179
177
  // in studio (studio#289), kumiko-ai-agent in publicstatus (publicstatus#442)
180
178
  // — both fixed by hand, both silent (no build/lint error) until noticed
@@ -244,11 +242,26 @@ function classNameExampleIn(pkgDir: string): string | undefined {
244
242
  }
245
243
 
246
244
  // Resolves `@import "spec";` specifiers in a CSS file to the imported file's
247
- // absolute path — a bare/package specifier (`@cosmicdrift/kumiko-renderer-
245
+ // absolute path — a bare/package specifier (`@cosmicdrift/kumiko-renderer/
248
246
  // web/styles.css`) resolves through the SAME node_modules hop search as a
249
247
  // package dependency (it IS one); a relative specifier (`./x.css`) resolves
250
248
  // against the importing file's own directory. Unresolvable imports (e.g.
251
249
  // Tailwind's own `"tailwindcss"`) are dropped, not thrown on.
250
+ // A subpath like "@scope/name/web/styles.css" only exists as a file through
251
+ // the package's `exports` map ("./web/styles.css" -> "./src/web/styles.css").
252
+ function resolveThroughPackageExports(appRoot: string, spec: string): string | undefined {
253
+ const [scope, name, ...rest] = spec.split("/");
254
+ if (scope === undefined || name === undefined || rest.length === 0) return undefined;
255
+ const pkgDir = resolvePackageDir(appRoot, `${scope}/${name}`);
256
+ if (pkgDir === undefined) return undefined;
257
+ const exportsField = readJsonSafe(path.join(pkgDir, "package.json"))?.["exports"];
258
+ if (typeof exportsField !== "object" || exportsField === null) return undefined;
259
+ const target = Object.entries(exportsField).find(([key]) => key === `./${rest.join("/")}`)?.[1];
260
+ if (typeof target !== "string") return undefined;
261
+ const abs = path.join(pkgDir, target);
262
+ return existsSync(abs) ? abs : undefined;
263
+ }
264
+
252
265
  function resolveCssImports(cssAbsPath: string, css: string, appRoot: string): string[] {
253
266
  const specifiers = [...css.matchAll(/@import\s+["']([^"']+)["']/g)].map((m) => m[1]);
254
267
  const resolved: string[] = [];
@@ -262,7 +275,8 @@ function resolveCssImports(cssAbsPath: string, css: string, appRoot: string): st
262
275
  // Package-style specifier including its subpath (e.g. "@scope/name/
263
276
  // styles.css") — resolvePackageDir's node_modules hop search works
264
277
  // unchanged since it only checks existence, file or directory alike.
265
- const candidate = resolvePackageDir(appRoot, spec);
278
+ const candidate =
279
+ resolvePackageDir(appRoot, spec) ?? resolveThroughPackageExports(appRoot, spec);
266
280
  if (candidate !== undefined) resolved.push(candidate);
267
281
  }
268
282
  return resolved;
@@ -297,8 +311,22 @@ function realpathOrSelf(p: string): string {
297
311
  }
298
312
  }
299
313
 
314
+ // The deprecated renderer-web stylesheet only `@import`s the renderer's, so the
315
+ // chain has to be followed past the first hop.
316
+ function resolveCssImportsTransitively(cssAbsPath: string, css: string, appRoot: string): string[] {
317
+ const seen = new Set<string>();
318
+ const queue = resolveCssImports(cssAbsPath, css, appRoot);
319
+ for (let imported = queue.shift(); imported !== undefined; imported = queue.shift()) {
320
+ if (seen.has(imported)) continue;
321
+ seen.add(imported);
322
+ if (!existsSync(imported) || statSync(imported).isDirectory()) continue;
323
+ queue.push(...resolveCssImports(imported, readFileSync(imported, "utf-8"), appRoot));
324
+ }
325
+ return [...seen];
326
+ }
327
+
300
328
  // A package is covered if an `@source` entry — in the app's own styles.css
301
- // OR in a CSS file it `@import`s (e.g. renderer-web's, which ships its own
329
+ // OR in a CSS file it `@import`s (e.g. the renderer's web stylesheet, which ships its own
302
330
  // @source lines for the framework's scan surface) — resolves, in THIS
303
331
  // install layout, to a directory on the same branch as the package's own
304
332
  // directory (either one nested inside the other: the entry could scan the
@@ -314,7 +342,7 @@ function isCoveredByImportChain(
314
342
  pkgDir: string,
315
343
  ): boolean {
316
344
  const realPkgDir = realpathOrSelf(pkgDir);
317
- for (const imported of resolveCssImports(stylesAbs, css, appRoot)) {
345
+ for (const imported of resolveCssImportsTransitively(stylesAbs, css, appRoot)) {
318
346
  for (const staticDir of sourceStaticDirsFromCss(imported)) {
319
347
  const realStaticDir = realpathOrSelf(staticDir);
320
348
  if (
@@ -329,6 +357,12 @@ function isCoveredByImportChain(
329
357
  return false;
330
358
  }
331
359
 
360
+ function isRendererWebStylesheetImport(css: string): boolean {
361
+ return (
362
+ css.includes("kumiko-renderer/web/styles.css") || css.includes("kumiko-renderer-web/styles.css")
363
+ );
364
+ }
365
+
332
366
  export function scanSourceCoverage(roots: readonly RepoRoot[]): SourceCoverageFinding[] {
333
367
  const findings: SourceCoverageFinding[] = [];
334
368
  for (const root of roots) {
@@ -336,14 +370,14 @@ export function scanSourceCoverage(roots: readonly RepoRoot[]): SourceCoverageFi
336
370
  const stylesAbs = path.join(root.absPath, APP_STYLES_REL);
337
371
  if (!existsSync(stylesAbs)) continue;
338
372
  const css = readFileSync(stylesAbs, "utf-8");
339
- // Only apps on the renderer-web @source convention are in scope — an
373
+ // Only apps on the renderer web @source convention are in scope — an
340
374
  // app without that @import never had a scan surface to extend.
341
- if (!css.includes("kumiko-renderer-web/styles.css")) continue;
375
+ if (!isRendererWebStylesheetImport(css)) continue;
342
376
  const pkg = readJsonSafe(path.join(root.absPath, "package.json"));
343
377
  if (!pkg) continue;
344
378
  const file = path.relative(ROOT, stylesAbs);
345
379
  for (const name of dependencyNames(pkg)) {
346
- if (css.includes(name)) continue; // already @source'd (or @import'd, e.g. renderer-web itself)
380
+ if (css.includes(name)) continue; // already @source'd (or @import'd, e.g. the renderer's web stylesheet itself)
347
381
  const dir = resolvePackageDir(root.absPath, name);
348
382
  if (dir === undefined) continue;
349
383
  const example = classNameExampleIn(dir);
@@ -531,7 +565,7 @@ function checkBaseline(findings: readonly Finding[], baselineRoot: string): Guar
531
565
  const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
532
566
  return tailwindScanSurfaceBaseline(baselineRoot).check(
533
567
  baselineCounts(findings),
534
- "Class outside the @source scan surface (renderer-web/src, renderer/src, samples/**/src) — reuse a class already emitted there, or move the styling into renderer-web.",
568
+ "Class outside the @source scan surface (renderer/src, samples/**/src) — reuse a class already emitted there, or move the styling into renderer/src/web.",
535
569
  {
536
570
  formatDriftRemediation:
537
571
  "Run `bun guards/guard-tailwind-scan-surface.ts --write-baseline` once.",
@@ -569,8 +603,8 @@ export function createGuard(options: { readonly baselineRoot?: string } = {}): A
569
603
  name: "Tailwind-Scan-Surface Guard",
570
604
  scan: SCAN,
571
605
  hint:
572
- "Tailwind class in bundled-features outside the @source scan surface (renderer-web/src, renderer/src, samples/**/src) " +
573
- `— reuse a class already emitted there, or move the styling into renderer-web. Justified exception: // ${IGNORE_TAG} <reason>. ` +
606
+ "Tailwind class in bundled-features outside the @source scan surface (renderer/src, samples/**/src) " +
607
+ `— reuse a class already emitted there, or move the styling into renderer/src/web. Justified exception: // ${IGNORE_TAG} <reason>. ` +
574
608
  "Package without @source coverage in an app: add an @source entry for both install layouts. " +
575
609
  "@source with a node_modules/<pkg>/<segment> path: check whether <pkg> even publishes that segment (package.json files).",
576
610
  run: (files, roots = resolveRepoRoots()) => analyse(files, true, roots, baselineRoot),
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: reports `ctx.unsafeAppendEvent` (also `ctx["unsafeAppendEvent"]` and destructuring) outside `migrations/`, `upcasters/` and tests.
4
+ *
5
+ * `appendEvent` is the typed default, `tryAppendEvent` the optimistic variant. The untyped
6
+ * `unsafeAppendEvent` skips payload typing and is meant for migrations and upcasters, where the
7
+ * event type is not knowable at compile time.
8
+ *
9
+ * Existing call sites are frozen per file in `.kumiko-unsafe-append-event-baseline.json`; a count
10
+ * over the baseline fails, at or under passes. Without the file (consumer repos) the guard only
11
+ * warns.
12
+ *
13
+ * Usage:
14
+ * bun packages/guards/src/guard-unsafe-append-event.ts # compare to baseline
15
+ * bun packages/guards/src/guard-unsafe-append-event.ts --write-baseline # freeze current
16
+ */
17
+
18
+ import * as path from "node:path";
19
+
20
+ import { Node, type SourceFile, SyntaxKind } from "ts-morph";
21
+ import {
22
+ ALL_REPO_KINDS,
23
+ type AstGuard,
24
+ baselineRatchet,
25
+ buildSharedProject,
26
+ filesForGuard,
27
+ isLocalFinding,
28
+ relFromRepoRoot,
29
+ runStandalone,
30
+ type ScanSpec,
31
+ } from "./_lib/guard-kit";
32
+ import { resolveRepoRoots } from "./_lib/roots";
33
+
34
+ const SCAN: ScanSpec = {
35
+ kinds: ALL_REPO_KINDS,
36
+ scope: "source",
37
+ extensions: ["ts"],
38
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
39
+ };
40
+
41
+ const SYMBOL = "unsafeAppendEvent";
42
+ const ALLOWED_PATH = /(^|\/)(migrations|upcasters|__tests__)\/|\.test\.ts$|\.d\.ts$/;
43
+
44
+ function unsafeAppendEventLines(sf: SourceFile): number[] {
45
+ const propertyAccesses = sf
46
+ .getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
47
+ .filter((access) => access.getNameNode().getText() === SYMBOL);
48
+ const elementAccesses = sf
49
+ .getDescendantsOfKind(SyntaxKind.ElementAccessExpression)
50
+ .filter((access) => {
51
+ const argument = access.getArgumentExpression();
52
+ return (
53
+ argument !== undefined &&
54
+ (Node.isStringLiteral(argument) || Node.isNoSubstitutionTemplateLiteral(argument)) &&
55
+ argument.getLiteralText() === SYMBOL
56
+ );
57
+ });
58
+ const destructurings = sf
59
+ .getDescendantsOfKind(SyntaxKind.BindingElement)
60
+ .filter(
61
+ (element) => (element.getPropertyNameNode() ?? element.getNameNode()).getText() === SYMBOL,
62
+ );
63
+ return [...propertyAccesses, ...elementAccesses, ...destructurings].map((node) =>
64
+ node.getStartLineNumber(),
65
+ );
66
+ }
67
+
68
+ type Finding = { readonly file: string; readonly line: number };
69
+
70
+ function scan(files: readonly SourceFile[], roots = resolveRepoRoots()): Finding[] {
71
+ const findings: Finding[] = [];
72
+ for (const sf of files) {
73
+ const file = relFromRepoRoot(sf.getFilePath(), roots);
74
+ if (ALLOWED_PATH.test(file)) continue;
75
+ for (const line of unsafeAppendEventLines(sf)) findings.push({ file, line });
76
+ }
77
+ return findings;
78
+ }
79
+
80
+ function countsPerFile(findings: readonly Finding[]): Record<string, number> {
81
+ const counts: Record<string, number> = {};
82
+ for (const f of findings.filter(isLocalFinding)) counts[f.file] = (counts[f.file] ?? 0) + 1;
83
+ return counts;
84
+ }
85
+
86
+ const GUARD_NAME = "Unsafe-Append-Event Guard";
87
+ const DEFAULT_BASELINE_FILE = path.join(process.cwd(), ".kumiko-unsafe-append-event-baseline.json");
88
+
89
+ const REMEDIATION =
90
+ "unsafeAppendEvent drops payload typing. Use ctx.appendEvent (default) or ctx.tryAppendEvent (conflict as result); the untyped variant belongs in migrations/ and upcasters/.";
91
+
92
+ const REWRITE_COMMAND = `kumiko-guards guards --write-baseline --guard="${GUARD_NAME}"`;
93
+
94
+ export function createUnsafeAppendEventGuard(baselineFile: string): AstGuard {
95
+ const baseline = baselineRatchet({
96
+ file: baselineFile,
97
+ formatVersion: 1,
98
+ unit: "unsafeAppendEvent call(s)",
99
+ });
100
+ return {
101
+ name: GUARD_NAME,
102
+ scan: SCAN,
103
+ hint: `${REMEDIATION} After a deliberate change: \`${REWRITE_COMMAND}\`.`,
104
+ run(files, roots = resolveRepoRoots()) {
105
+ const findings = scan(files, roots);
106
+ const violations = baseline.check(countsPerFile(findings), REMEDIATION, {
107
+ formatDriftRemediation: `Run \`${REWRITE_COMMAND}\` once.`,
108
+ resolveLine: (file) => findings.find((f) => f.file === file)?.line ?? 1,
109
+ });
110
+ return { violations };
111
+ },
112
+ writeBaseline: (files, roots) => baseline.write(countsPerFile(scan(files, roots))),
113
+ };
114
+ }
115
+
116
+ export const guard = createUnsafeAppendEventGuard(DEFAULT_BASELINE_FILE);
117
+
118
+ if (import.meta.main) {
119
+ if (process.argv.slice(2).includes("--write-baseline")) {
120
+ const project = buildSharedProject([guard]);
121
+ guard.writeBaseline?.(filesForGuard(project, guard));
122
+ process.exit(0);
123
+ }
124
+ runStandalone(guard);
125
+ }
package/src/pre-push.sh CHANGED
@@ -24,13 +24,6 @@
24
24
  # Extension point: a tracked, executable scripts/pre-push-extra.sh runs
25
25
  # before the main check in every branch above, with KUMIKO_PUSH_REPO_ROOT
26
26
  # and KUMIKO_PUSH_PARENT_DIR available inline (never exported).
27
- #
28
- # Override: set PRE_PUSH_SKIP=1 to bypass (use sparingly).
29
-
30
- if [ "${PRE_PUSH_SKIP:-0}" = "1" ]; then
31
- echo "[pre-push] PRE_PUSH_SKIP=1 — hook skipped"
32
- exit 0
33
- fi
34
27
 
35
28
  # Git exports GIT_DIR & co. to hooks; nothing this hook starts may inherit them,
36
29
  # or a test's fixture git calls write into this repo.
@@ -142,7 +135,9 @@ fi
142
135
  # canonical sibling path instead of this worktree — checking the wrong
143
136
  # code. Run the worktree-local check only when scripts/check-wt.sh is
144
137
  # tracked and executable (untracked/symlink stubs must not hijack push).
145
- if [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ] \
138
+ # A check-wt.sh that wraps this hook sets KUMIKO_CHECK_WT=1 so it is not re-entered.
139
+ if [ "${KUMIKO_CHECK_WT:-0}" != "1" ] \
140
+ && [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ] \
146
141
  && git -C "$REPO_ROOT" ls-files --error-unmatch scripts/check-wt.sh >/dev/null 2>&1 \
147
142
  && [ -x "$REPO_ROOT/scripts/check-wt.sh" ]; then
148
143
  echo "[pre-push] worktree detected — worktree check (see scripts/check-wt.sh)…"
@@ -178,7 +173,7 @@ if [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ]
178
173
  echo "[pre-push] worktree without scripts/check-wt.sh — running this worktree's package.json scripts (typecheck, lint, test, test:dom) and guards (guards, checks, comment-lang)…"
179
174
  if ! has_package_script test; then
180
175
  echo "[pre-push] FATAL: worktree without a package.json \"test\" script — nothing to run, refusing push." >&2
181
- echo "Add a \"test\" script to package.json, or set PRE_PUSH_SKIP=1 to bypass (use sparingly)." >&2
176
+ echo "Add a \"test\" script to package.json." >&2
182
177
  exit 1
183
178
  fi
184
179
  FAILED=""
@@ -218,7 +213,7 @@ else
218
213
  # "Script not found" — check it up front with a clear, actionable message.
219
214
  if ! has_package_script test; then
220
215
  echo "[pre-push] FATAL: standalone clone without a package.json \"test\" script — nothing to run, refusing push." >&2
221
- echo "Add a \"test\" script to package.json, or set PRE_PUSH_SKIP=1 to bypass (use sparingly)." >&2
216
+ echo "Add a \"test\" script to package.json." >&2
222
217
  exit 1
223
218
  fi
224
219
  cd "$REPO_ROOT" && bun run test
@@ -0,0 +1,13 @@
1
+ export { listAppPorts } from "@cosmicdrift/kumiko-framework/config";
2
+ export {
3
+ findPortCollisions,
4
+ type PortClaim,
5
+ type PortCollision,
6
+ type ScannedApp,
7
+ scanWorkspaceApps,
8
+ } from "./port-registry.ts";
9
+ export {
10
+ parseStaticAppConfig,
11
+ readStaticAppConfig,
12
+ type StaticAppConfig,
13
+ } from "./static-app-config.ts";
@@ -0,0 +1,45 @@
1
+ // Port registry across the apps of a workspace: every app declares its ports
2
+ // in kumiko.config.ts, and two apps (or two roles of one app) must never claim
3
+ // the same port, otherwise their local dev and E2E runs block each other.
4
+ import { existsSync, readdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import {
7
+ APP_CONFIG_FILE,
8
+ type AppPortRole,
9
+ listAppPorts,
10
+ } from "@cosmicdrift/kumiko-framework/config";
11
+ import { readStaticAppConfig, type StaticAppConfig } from "./static-app-config.ts";
12
+
13
+ export type ScannedApp = { readonly app: string; readonly config: StaticAppConfig };
14
+
15
+ export type PortClaim = { readonly app: string; readonly role: AppPortRole };
16
+
17
+ export type PortCollision = { readonly port: number; readonly claims: readonly PortClaim[] };
18
+
19
+ /** Direct children of `root` with a kumiko.config.ts; worktrees and dot-dirs are skipped. */
20
+ export function scanWorkspaceApps(root: string): readonly ScannedApp[] {
21
+ return readdirSync(root, { withFileTypes: true })
22
+ .filter(
23
+ (entry) =>
24
+ entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules",
25
+ )
26
+ .filter((entry) => existsSync(join(root, entry.name, APP_CONFIG_FILE)))
27
+ .map((entry) => ({ app: entry.name, config: readStaticAppConfig(join(root, entry.name)) }))
28
+ .sort((a, b) => a.app.localeCompare(b.app));
29
+ }
30
+
31
+ export function findPortCollisions(apps: readonly ScannedApp[]): readonly PortCollision[] {
32
+ const claimsByPort = new Map<number, PortClaim[]>();
33
+ for (const { app, config } of apps) {
34
+ if (config.kind !== "defined") continue;
35
+ for (const { role, port } of listAppPorts(config.ports)) {
36
+ const claims = claimsByPort.get(port) ?? [];
37
+ claims.push({ app, role });
38
+ claimsByPort.set(port, claims);
39
+ }
40
+ }
41
+ return [...claimsByPort.entries()]
42
+ .filter(([, claims]) => claims.length > 1)
43
+ .map(([port, claims]) => ({ port, claims }))
44
+ .sort((a, b) => a.port - b.port);
45
+ }
@@ -0,0 +1,121 @@
1
+ // Reads the ports out of kumiko.config.ts without importing it: importing
2
+ // would execute every feature module of the app, and the port registry scans
3
+ // sibling apps whose dependencies may not even be installed.
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import {
7
+ APP_CONFIG_FILE,
8
+ type AppInfraPorts,
9
+ type AppPorts,
10
+ findAppPortProblems,
11
+ } from "@cosmicdrift/kumiko-framework/config";
12
+ import { type Node, type ObjectLiteralExpression, Project, SyntaxKind } from "ts-morph";
13
+
14
+ export type StaticAppConfig =
15
+ | { readonly kind: "missing" }
16
+ /** A config without defineAppConfig: the app has not adopted the standard yet. */
17
+ | { readonly kind: "legacy" }
18
+ | { readonly kind: "invalid"; readonly message: string }
19
+ | { readonly kind: "defined"; readonly ports: AppPorts };
20
+
21
+ const INFRA_KEYS = [
22
+ "postgres",
23
+ "redis",
24
+ "meilisearch",
25
+ "s3",
26
+ ] as const satisfies readonly (keyof AppInfraPorts)[];
27
+
28
+ type InfraKey = (typeof INFRA_KEYS)[number];
29
+
30
+ function isInfraKey(name: string): name is InfraKey {
31
+ return INFRA_KEYS.some((key) => key === name);
32
+ }
33
+
34
+ class StaticConfigError extends Error {}
35
+
36
+ function propertyName(node: Node): string | undefined {
37
+ if (node.isKind(SyntaxKind.PropertyAssignment)) return node.getName().replace(/^["']|["']$/g, "");
38
+ return undefined;
39
+ }
40
+
41
+ function objectProperty(object: ObjectLiteralExpression, name: string): Node | undefined {
42
+ for (const property of object.getProperties()) {
43
+ if (
44
+ property.isKind(SyntaxKind.SpreadAssignment) ||
45
+ property.isKind(SyntaxKind.ShorthandPropertyAssignment)
46
+ ) {
47
+ throw new StaticConfigError(
48
+ `${name}: spread and shorthand properties cannot be read statically`,
49
+ );
50
+ }
51
+ if (propertyName(property) === name && property.isKind(SyntaxKind.PropertyAssignment)) {
52
+ return property.getInitializerOrThrow();
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function numericLiteral(node: Node | undefined, path: string): number {
59
+ if (node === undefined) throw new StaticConfigError(`${path} is missing`);
60
+ if (!node.isKind(SyntaxKind.NumericLiteral)) {
61
+ throw new StaticConfigError(
62
+ `${path} must be a number literal so tooling can read it, got "${node.getText()}"`,
63
+ );
64
+ }
65
+ return node.getLiteralValue();
66
+ }
67
+
68
+ function objectLiteral(node: Node | undefined, path: string): ObjectLiteralExpression {
69
+ if (node === undefined) throw new StaticConfigError(`${path} is missing`);
70
+ if (!node.isKind(SyntaxKind.ObjectLiteralExpression)) {
71
+ throw new StaticConfigError(`${path} must be an object literal, got "${node.getText()}"`);
72
+ }
73
+ return node;
74
+ }
75
+
76
+ function readInfraPorts(node: Node | undefined): AppInfraPorts | undefined {
77
+ if (node === undefined) return undefined;
78
+ const object = objectLiteral(node, "ports.infra");
79
+ const infra: Partial<Record<InfraKey, number>> = {};
80
+ for (const property of object.getProperties()) {
81
+ const name = propertyName(property);
82
+ if (name === undefined || !isInfraKey(name)) {
83
+ throw new StaticConfigError(
84
+ `ports.infra: unknown entry "${property.getText()}", expected ${INFRA_KEYS.join(", ")}`,
85
+ );
86
+ }
87
+ infra[name] = numericLiteral(objectProperty(object, name), `ports.infra.${name}`);
88
+ }
89
+ return infra;
90
+ }
91
+
92
+ export function parseStaticAppConfig(source: string): StaticAppConfig {
93
+ const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
94
+ const file = project.createSourceFile(APP_CONFIG_FILE, source);
95
+ const call = file
96
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
97
+ .find((candidate) => candidate.getExpression().getText() === "defineAppConfig");
98
+ if (call === undefined) return { kind: "legacy" };
99
+ try {
100
+ const config = objectLiteral(call.getArguments()[0], "defineAppConfig argument");
101
+ const ports = objectLiteral(objectProperty(config, "ports"), "ports");
102
+ const infra = readInfraPorts(objectProperty(ports, "infra"));
103
+ const parsed: AppPorts = {
104
+ dev: numericLiteral(objectProperty(ports, "dev"), "ports.dev"),
105
+ e2e: numericLiteral(objectProperty(ports, "e2e"), "ports.e2e"),
106
+ ...(infra === undefined ? {} : { infra }),
107
+ };
108
+ const problems = findAppPortProblems(parsed);
109
+ if (problems.length > 0) return { kind: "invalid", message: problems.join("; ") };
110
+ return { kind: "defined", ports: parsed };
111
+ } catch (error) {
112
+ if (error instanceof StaticConfigError) return { kind: "invalid", message: error.message };
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ export function readStaticAppConfig(appRoot: string): StaticAppConfig {
118
+ const path = join(appRoot, APP_CONFIG_FILE);
119
+ if (!existsSync(path)) return { kind: "missing" };
120
+ return parseStaticAppConfig(readFileSync(path, "utf-8"));
121
+ }