@cosmicdrift/kumiko-framework 0.378.0 → 0.379.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.
Files changed (94) hide show
  1. package/AGENTS.md +4 -1
  2. package/agents/examples/compose-stacks.md +1 -2
  3. package/agents/examples/currencies-global.md +2 -3
  4. package/agents/examples/embedded.md +23 -37
  5. package/agents/examples/event-sourcing.md +6 -5
  6. package/agents/examples/legal-pages.md +5 -5
  7. package/agents/examples/reference-data.md +26 -21
  8. package/agents/examples/relations.md +2 -1
  9. package/agents/features.md +4 -4
  10. package/dist/api/entity-read-access.d.ts +2 -0
  11. package/dist/api/entity-read-access.js +27 -0
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/server.js +10 -3
  15. package/dist/api/sse-route.d.ts +5 -0
  16. package/dist/api/sse-route.js +59 -9
  17. package/dist/db/entity-table-from-registry.d.ts +2 -1
  18. package/dist/db/entity-table-meta.d.ts +0 -2
  19. package/dist/db/entity-table-meta.js +1 -3
  20. package/dist/db/event-store-executor-write.js +21 -16
  21. package/dist/db/executor-managed-tables.d.ts +6 -0
  22. package/dist/db/executor-managed-tables.js +40 -0
  23. package/dist/db/index.d.ts +4 -2
  24. package/dist/db/index.js +4 -2
  25. package/dist/db/money.d.ts +1 -1
  26. package/dist/db/queries/backfill-pii.js +22 -12
  27. package/dist/db/queries/database-now.d.ts +5 -0
  28. package/dist/db/queries/database-now.js +12 -0
  29. package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
  30. package/dist/db/queries/event-store-orphan-rows.js +32 -0
  31. package/dist/db/query.js +28 -0
  32. package/dist/db/reference-data.d.ts +5 -4
  33. package/dist/db/reference-data.js +23 -21
  34. package/dist/db/table-builder.js +2 -0
  35. package/dist/db/tenant-db-runner.d.ts +2 -0
  36. package/dist/db/tenant-db-runner.js +7 -0
  37. package/dist/db/tenant-db.d.ts +3 -0
  38. package/dist/db/tenant-db.js +50 -18
  39. package/dist/engine/boot-validator/entity-handler.js +9 -4
  40. package/dist/engine/boot-validator/query-refs.js +8 -0
  41. package/dist/engine/boot-validator/screens.js +34 -9
  42. package/dist/engine/feature-ui-extensions.js +4 -0
  43. package/dist/engine/registry-state.js +2 -0
  44. package/dist/engine/registry-validate.js +4 -1
  45. package/dist/engine/schema-builder.js +18 -4
  46. package/dist/engine/screen-helpers.d.ts +1 -0
  47. package/dist/engine/screen-helpers.js +3 -0
  48. package/dist/engine/soft-delete-cleanup.js +53 -24
  49. package/dist/engine/steps/unsafe-projection-delete.js +2 -1
  50. package/dist/entrypoint/index.d.ts +1 -3
  51. package/dist/errors/reasons.d.ts +1 -0
  52. package/dist/errors/reasons.js +4 -1
  53. package/dist/errors/serialize.d.ts +1 -0
  54. package/dist/errors/serialize.js +9 -5
  55. package/dist/errors/write-error-info.js +3 -2
  56. package/dist/errors/zod-bridge.js +7 -0
  57. package/dist/event-store/index.d.ts +1 -0
  58. package/dist/event-store/index.js +1 -0
  59. package/dist/files/action-form-upload.d.ts +16 -0
  60. package/dist/files/action-form-upload.js +45 -0
  61. package/dist/files/bind-pending-file-refs.d.ts +14 -0
  62. package/dist/files/bind-pending-file-refs.js +44 -4
  63. package/dist/files/create-file-ref.d.ts +3 -0
  64. package/dist/files/create-file-ref.js +41 -13
  65. package/dist/files/file-routes.js +29 -2
  66. package/dist/files/index.d.ts +2 -1
  67. package/dist/files/index.js +1 -0
  68. package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
  69. package/dist/files/purge-expired-file-ref-bytes.js +11 -5
  70. package/dist/internal/testing.d.ts +2 -0
  71. package/dist/internal/testing.js +15 -1
  72. package/dist/migrations/pii-event-backfill.js +14 -7
  73. package/dist/observability/escape-hatch-report.d.ts +1 -1
  74. package/dist/observability/escape-hatch-report.js +14 -3
  75. package/dist/pipeline/cascade-handler.js +79 -10
  76. package/dist/pipeline/dispatch-shared.js +1 -9
  77. package/dist/pipeline/msp-rebuild.js +5 -3
  78. package/dist/pipeline/projection-rebuild.js +4 -2
  79. package/dist/pipeline/projections-runner.js +2 -1
  80. package/dist/rate-limit/index.d.ts +1 -1
  81. package/dist/rate-limit/index.js +1 -1
  82. package/dist/rate-limit/middleware.d.ts +4 -0
  83. package/dist/rate-limit/middleware.js +5 -2
  84. package/dist/ui-types/index.d.ts +2 -2
  85. package/dist/ui-types/index.js +2 -2
  86. package/dist/ui-types/list-row-meta.d.ts +3 -0
  87. package/dist/ui-types/list-row-meta.js +12 -1
  88. package/kumiko-es-tables.json +6 -0
  89. package/package.json +4 -3
  90. package/src/changes.json +110 -0
  91. package/src/scripts/codemod/README.md +20 -0
  92. package/src/scripts/codemod/codemod-support.ts +1 -1
  93. package/src/scripts/codemod/feature-layout.ts +416 -0
  94. package/src/scripts/codemod/package-merge.ts +0 -11
@@ -0,0 +1,6 @@
1
+ [
2
+ {
3
+ "module": "files/file-ref-table",
4
+ "export": "fileRefsTable"
5
+ }
6
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.378.0",
3
+ "version": "0.379.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -199,7 +199,7 @@
199
199
  "./package.json": "./package.json"
200
200
  },
201
201
  "dependencies": {
202
- "@cosmicdrift/kumiko-types": "0.378.0",
202
+ "@cosmicdrift/kumiko-types": "0.379.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-testing": "0.378.0",
218
+ "@cosmicdrift/kumiko-testing": "0.379.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -394,6 +394,7 @@
394
394
  },
395
395
  "files": [
396
396
  "dist",
397
+ "kumiko-es-tables.json",
397
398
  "presets",
398
399
  "src/changes.json",
399
400
  "src/scripts/codemod",
package/src/changes.json CHANGED
@@ -1,4 +1,114 @@
1
1
  [
2
+ {
3
+ "version": "0.379.0",
4
+ "type": "improvement",
5
+ "title": "PII backfill encrypts jsonb object and array fields",
6
+ "detail": "runPiiEventBackfill and backfillEventPiiEncryption now retro-encrypt plaintext jsonb objects and arrays of personal fields and personal jsonb scalars, and createPiiBackfillStateTable bootstraps under an advisory lock so concurrent first boots no longer skip the ALTER. Completed backfill states rerun once because the transform version changed."
7
+ },
8
+ {
9
+ "version": "0.379.0",
10
+ "type": "improvement",
11
+ "title": "applyEntityEvent is exported from the db barrel, extension sources accept auto-verbs",
12
+ "detail": "applyEntityEvent can be used by extendEntityProjection apply handlers to project stored entity events of a foreign aggregate type. validateProjectionApplyKeys accepts the entity auto-verbs for an extension source that is not a registered entity."
13
+ },
14
+ {
15
+ "version": "0.379.0",
16
+ "type": "breaking",
17
+ "title": "SSE entity signals follow the read access of the connection",
18
+ "detail": "A signed-in connection receives an entity signal only when a query reading that entity lets one of its roles in (SseRouteOptions.entityReadAccess, built by collectEntityReadAccess). Entities without a reading query no longer go on the channel. When the reading access depends on a row or owner filter, the signal goes out without id and version, so a client refetches its query instead of learning which row changed.",
19
+ "migration": "Apps that rely on a signal for an entity no query exposes to the user must add a query for that entity with a matching access rule. Custom buildServer callers that build SseRouteOptions themselves must pass entityReadAccess."
20
+ },
21
+ {
22
+ "version": "0.379.0",
23
+ "type": "fix",
24
+ "title": "Cause details only with NODE_ENV development or test",
25
+ "detail": "internal_error responses and write error infos carry the cause snapshot (name, message, stack) only when NODE_ENV is explicitly development or test. A missing or misspelled NODE_ENV no longer exposes stack traces, paths or upstream URLs. kumiko-dev starts the server with NODE_ENV=development when none is set, so local dev keeps the details; a dev script that runs the server directly (bun --watch bin/server.ts) must set NODE_ENV=development itself to see them."
26
+ },
27
+ {
28
+ "version": "0.379.0",
29
+ "type": "breaking",
30
+ "title": "Global IP rate-limit middleware default matches runProdApp",
31
+ "detail": "globalIpRateLimit defaults to 600 requests per 60 seconds (was 1000). DEFAULT_GLOBAL_RATE_LIMIT is exported from the api barrel and shared with server-runtime. authEndpointRateLimit keeps its default of 5 per 60 seconds.",
32
+ "migration": "Apps that use globalIpRateLimit directly and relied on 1000 pass limit explicitly. runProdApp users see no change."
33
+ },
34
+ {
35
+ "version": "0.379.0",
36
+ "type": "improvement",
37
+ "title": "Boot warning when /metrics is off in production",
38
+ "detail": "buildServer logs a warning in production when no metrics option is set, which happens when PROMETHEUS_METRICS_TOKEN is unset."
39
+ },
40
+ {
41
+ "version": "0.379.0",
42
+ "type": "improvement",
43
+ "title": "Package ships its event-store table catalog",
44
+ "detail": "kumiko-es-tables.json is included in the published files; no runtime change."
45
+ },
46
+ {
47
+ "version": "0.379.0",
48
+ "type": "breaking",
49
+ "title": "Direct writes on event-fed tables throw at runtime",
50
+ "detail": "buildEntityTable and r.projection mark their tables. insertOne, updateMany, deleteMany, the upsert and counter helpers, ctx.db write methods and db.global() writes throw AccessDeniedError on a marked table unless the runner carries the framework write capability. The executor, projection apply, rebuild, unsafeProjectionDelete, tenant-destroy hooks and deleteRowsWithoutReplayableStream hold it; ctx.db.unsafeRaw() never does. Retention deletes only rows whose stream is gone or archived. The cascade delete hook, the soft-delete purge jobs and seedReferenceData now write through the executor, so their effect survives a rebuild; cascade children are written as the system user, and softDelete children are soft-deleted instead of hard-deleted. The direct-entity-writes guard has no exceptions left.",
51
+ "migration": "Write event-fed rows through the executor (r.crud, ctx.write, createEventStoreExecutor). Projection apply functions must use the db they receive. Test seeds and teardown use seedRow, seedRows, updateRows, deleteRows and clearTables from kumiko-testing instead of hand-written wipe helpers. Do not pass ctx.db.unsafeRaw() to code that deletes event-fed rows; pass ctx.db inside a tenant-destroy hook. Retention code calls deleteRowsWithoutReplayableStream after pruning or archiving the events. seedReferenceData takes the registry instead of a table map. Affects kumiko-enterprise, kumiko-studio and solon."
52
+ },
53
+ {
54
+ "version": "0.379.0",
55
+ "type": "fix",
56
+ "title": "Tests pin the money units of form derivations",
57
+ "detail": "form-derivations tests now cover realistic money values (1234,56 EUR plus 234,57 EUR, 0,10 plus 0,20) and minor-unit list cells; no behaviour change.",
58
+ "migration": "None."
59
+ },
60
+ {
61
+ "version": "0.379.0",
62
+ "type": "fix",
63
+ "title": "A slow SSE client no longer buffers without bound",
64
+ "detail": "The SSE route tracks the writes a client has not consumed. A client whose queued frames make no progress for SSE_STALLED_CLIENT_TIMEOUT_MS (one heartbeat interval), or that queues SSE_MAX_PENDING_WRITES_PER_CLIENT frames, is aborted and unregistered; it reconnects and resyncs. A large broadcast batch does not drop a client that keeps reading, and one failing client no longer stops the broadcast to the others. SseRouteOptions.now makes the clock injectable."
65
+ },
66
+ {
67
+ "version": "0.379.0",
68
+ "type": "improvement",
69
+ "title": "Deprecations name their removal release",
70
+ "detail": "The three @deprecated APIs without a target release (the minor-units money accessor and the landingPath props of the invite and signup screens) now say they are removed in 0.390.0."
71
+ },
72
+ {
73
+ "version": "0.379.0",
74
+ "type": "breaking",
75
+ "title": "buildEntityTableMeta and observabilityOptions removed",
76
+ "detail": "buildEntityTableMeta was a deprecated alias of deriveEntityTableMeta. The deprecated observabilityOptions option of the entrypoint and the prod and worker runners is gone as well, it had no effect since the HTTP route label no longer carries a query string.",
77
+ "migration": "Rename buildEntityTableMeta to deriveEntityTableMeta (same signature). Delete observabilityOptions from the entrypoint and runner options, nothing replaces it."
78
+ },
79
+ {
80
+ "version": "0.379.0",
81
+ "type": "improvement",
82
+ "title": "readDatabaseNow(db) in framework/db",
83
+ "detail": "Typed read of the Postgres clock for a TenantDb without unsafeRaw. Inside a transaction now() is the transaction start, so repeated reads agree."
84
+ },
85
+ {
86
+ "version": "0.379.0",
87
+ "type": "fix",
88
+ "title": "SaveContext.event no longer reaches JSON responses",
89
+ "detail": "The event echoed on the SaveContext of create, update and restore is non-enumerable, so a handler returning the save result no longer leaks event data (PII) in its response; hooks and projections still read it by property."
90
+ },
91
+ {
92
+ "version": "0.379.0",
93
+ "type": "fix",
94
+ "title": "Dashboard navigate params may bind a record",
95
+ "detail": "The boot validator accepts navigate params on a dashboard row action that has a record binding (record.param) but no filter; it still rejects params when the dashboard reads no filter, time range or record binding."
96
+ },
97
+ {
98
+ "version": "0.379.0",
99
+ "type": "breaking",
100
+ "title": "Re-export shim packages removed",
101
+ "detail": "The shims headless, dispatcher-live, renderer-web, http and repo-manifest are gone. Their code lives in kumiko-renderer (/headless, /live, /web), kumiko-framework (/http) and kumiko-guards (/repo-manifest).",
102
+ "migration": "Run bun kumiko-upgrade --apply, or bun kumiko codemod package-merge . --write. It rewrites the imports and package.json dependencies of all five shims to their target packages.",
103
+ "codemod": "scripts/codemod/package-merge.ts"
104
+ },
105
+ {
106
+ "version": "0.379.0",
107
+ "type": "breaking",
108
+ "title": "TenantDb.databaseNow",
109
+ "detail": "TenantDb has a databaseNow() method returning Postgres now(); custom TenantDb implementations must add it.",
110
+ "migration": "Add databaseNow(): Promise<Date> to hand-built TenantDb objects and mocks, returning the Postgres now()."
111
+ },
2
112
  {
3
113
  "version": "0.378.0",
4
114
  "type": "improvement",
@@ -165,3 +165,23 @@ bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/package-merge
165
165
 
166
166
  Runs via `bun kumiko-upgrade --apply`. Without a mode flag it writes. `--check` exits 1 while any
167
167
  site is left. Run your package manager's install afterwards to refresh the lockfile.
168
+
169
+ ## feature-layout.ts
170
+
171
+ Applies the feature layout rule "the runtime of a file follows from its path": everything
172
+ under `src/features/<feature>/web/` is client code, the rest of the feature is server or
173
+ isomorphic. Every file that carries a runtime-client marker outside `web/` moves to
174
+ `<feature>/web/` (sub-structure kept), the relative imports, re-exports, `import()` calls and
175
+ `mock.module` strings that point at it or out of it are rewritten, `package.json` export
176
+ targets follow, and the marker is dropped. A marker the path already implies (client in
177
+ `web/`, test in `__tests__/`) is only removed. Idempotent.
178
+
179
+ ```bash
180
+ bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/feature-layout.ts <dir> [--check | --dry-run | --write] [--no-format]
181
+ ```
182
+
183
+ Features are found as `src/features/<feature>/` and `bundled-features/src/<feature>/` below
184
+ `<dir>`; `--feature-dir <dir>` names a directory that is itself one feature and
185
+ `--features-root <dir>` one whose children are features. A target that already exists in
186
+ `web/` and a marker no path rule covers (for example `dev` outside `dev/`) are reported as
187
+ `manual` and left alone. `--check` exits 1 while anything is left.
@@ -180,7 +180,7 @@ function findBiomeBinary(startDir: string): string | undefined {
180
180
 
181
181
  const FORMAT_BATCH_SIZE = 50;
182
182
 
183
- async function formatWithBiome(
183
+ export async function formatWithBiome(
184
184
  codemodName: string,
185
185
  files: readonly string[],
186
186
  cwd: string,
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env bun
2
+ // feature-layout — the runtime of a feature file follows from its path: <feature>/web/** is client
3
+ // code, everything else is server or isomorphic. This moves every file marked as runtime client of a
4
+ // feature that lies outside web/ into <feature>/web/ (sub-structure kept), rewrites each relative
5
+ // import / re-export / import() / mock.module that points at it or out of it, rewrites package.json
6
+ // export targets, and drops the marker. A marker the path already implies (client in web/, test in
7
+ // __tests__/, dev in dev/, tooling in scripts/) is only removed. A target that already exists, or a
8
+ // marker no path rule covers, is reported as manual and left alone.
9
+ //
10
+ // Features are found as src/features/<feature>/ and bundled-features/src/<feature>/ below the target
11
+ // directories; --feature-dir <dir> names a directory that is itself one feature, --features-root <dir>
12
+ // a directory whose children are features.
13
+ //
14
+ // Usage:
15
+ // bun scripts/codemod/feature-layout.ts <targetDir...> [--feature-dir <dir>] [--features-root <dir>]
16
+ // [--check | --dry-run | --write] [--no-format]
17
+ // Without a mode flag it writes (kumiko-upgrade --apply calls it that way). --check exits 1 while
18
+ // any move, marker or manual site is left; --dry-run only reports.
19
+
20
+ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
21
+ import { dirname, join, relative, resolve } from "node:path";
22
+ import { Node, Project } from "ts-morph";
23
+ import {
24
+ applyEdits,
25
+ type CodemodSite,
26
+ formatWithBiome,
27
+ moduleSpecifierLiterals,
28
+ SCRIPT_EXTENSION,
29
+ type TextEdit,
30
+ } from "./codemod-support.ts";
31
+ import { collectSourceFiles } from "./collect-source-files.ts";
32
+
33
+ const MARKER_HEAD_LINES = 8;
34
+ const MARKER_LINE = /^\s*\/\/\s*@runtime(?:\s+(\w+))?\s*$/;
35
+ const WEB_DIR = "web";
36
+ const IMPLICIT_FEATURE_PATTERNS: readonly RegExp[] = [
37
+ /^(.*\/src\/features\/[^/]+)\/(.+)$/,
38
+ /^(.*\/bundled-features\/src\/[^/]+)\/(.+)$/,
39
+ ];
40
+ const TEST_PATH = /(?:^|\/)__tests__\/|\.(?:test|integration|e2e|spec)\.[tj]sx?$/;
41
+
42
+ export interface FeatureLayoutOptions {
43
+ readonly featureDirs?: readonly string[];
44
+ readonly featuresRoots?: readonly string[];
45
+ }
46
+
47
+ export interface FeatureLayoutMove {
48
+ readonly from: string;
49
+ readonly to: string;
50
+ }
51
+
52
+ export interface FeatureLayoutResult {
53
+ // New content per final path (moved files under their new path).
54
+ readonly writes: ReadonlyMap<string, string>;
55
+ // Old paths that no longer exist after the moves.
56
+ readonly removals: readonly string[];
57
+ readonly moves: readonly FeatureLayoutMove[];
58
+ readonly markersRemoved: readonly string[];
59
+ readonly importRewrites: readonly CodemodSite[];
60
+ readonly manual: readonly CodemodSite[];
61
+ }
62
+
63
+ interface Marker {
64
+ readonly line: number;
65
+ readonly kind: string | undefined;
66
+ }
67
+
68
+ function findMarker(source: string): Marker | undefined {
69
+ const lines = source.split("\n", MARKER_HEAD_LINES);
70
+ for (const [index, text] of lines.entries()) {
71
+ const match = MARKER_LINE.exec(text);
72
+ if (match) return { line: index, kind: match[1] };
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ function removeMarkerLine(source: string, line: number): string {
78
+ const lines = source.split("\n");
79
+ lines.splice(line, 1);
80
+ if (line === 0 && lines[0] === "") lines.shift();
81
+ return lines.join("\n");
82
+ }
83
+
84
+ function splitFeaturePath(
85
+ file: string,
86
+ options: FeatureLayoutOptions,
87
+ ): { readonly featureDir: string; readonly rel: string } | undefined {
88
+ for (const dir of options.featureDirs ?? []) {
89
+ if (file.startsWith(`${dir}/`)) return { featureDir: dir, rel: file.slice(dir.length + 1) };
90
+ }
91
+ for (const root of options.featuresRoots ?? []) {
92
+ if (!file.startsWith(`${root}/`)) continue;
93
+ const [feature, ...rest] = file.slice(root.length + 1).split("/");
94
+ if (feature !== undefined && rest.length > 0) {
95
+ return { featureDir: `${root}/${feature}`, rel: rest.join("/") };
96
+ }
97
+ }
98
+ for (const pattern of IMPLICIT_FEATURE_PATTERNS) {
99
+ const match = pattern.exec(file);
100
+ if (match?.[1] !== undefined && match[2] !== undefined) {
101
+ return { featureDir: match[1], rel: match[2] };
102
+ }
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ function pathImpliedKind(rel: string): string | undefined {
108
+ if (TEST_PATH.test(rel)) return "test";
109
+ if (rel === WEB_DIR || rel.startsWith(`${WEB_DIR}/`) || /^web\.tsx?$/.test(rel)) return "client";
110
+ if (rel.startsWith("dev/")) return "dev";
111
+ if (rel.startsWith("scripts/")) return "tooling";
112
+ return undefined;
113
+ }
114
+
115
+ function stripScriptExtension(file: string): { readonly base: string; readonly extension: string } {
116
+ const extension = SCRIPT_EXTENSION.exec(file)?.[0] ?? "";
117
+ return { base: file.slice(0, file.length - extension.length), extension };
118
+ }
119
+
120
+ function movedTargetOf(
121
+ absoluteSpecifier: string,
122
+ moved: ReadonlyMap<string, string>,
123
+ ): string | undefined {
124
+ const { base, extension } = stripScriptExtension(absoluteSpecifier);
125
+ const candidates =
126
+ extension === ".js" || extension === ".ts" || extension === ".tsx" || extension === ""
127
+ ? [absoluteSpecifier, `${base}.ts`, `${base}.tsx`]
128
+ : [absoluteSpecifier];
129
+ for (const candidate of candidates) {
130
+ if (moved.has(candidate)) return candidate;
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ function relativeSpecifierFrom(fromDir: string, target: string): string {
136
+ const path = relative(fromDir, target).split("\\").join("/");
137
+ return path.startsWith(".") ? path : `./${path}`;
138
+ }
139
+
140
+ function rewrittenSpecifier(
141
+ specifier: string,
142
+ oldFile: string,
143
+ newFile: string,
144
+ moved: ReadonlyMap<string, string>,
145
+ ): string | undefined {
146
+ if (!specifier.startsWith(".")) return undefined;
147
+ const absolute = resolve(dirname(oldFile), specifier);
148
+ const oldTarget = movedTargetOf(absolute, moved);
149
+ if (oldTarget === undefined && oldFile === newFile) return undefined;
150
+ const newTarget = oldTarget === undefined ? absolute : moved.get(oldTarget);
151
+ if (newTarget === undefined) return undefined;
152
+ let next: string;
153
+ if (oldTarget === undefined) {
154
+ next = relativeSpecifierFrom(dirname(newFile), newTarget);
155
+ if (specifier.endsWith("/")) next = `${next}/`;
156
+ } else {
157
+ const { extension } = stripScriptExtension(absolute);
158
+ const { base } = stripScriptExtension(newTarget);
159
+ const isDirectoryImport = extension === "" && base.endsWith("/index");
160
+ const target = isDirectoryImport ? base.slice(0, -"/index".length) : `${base}${extension}`;
161
+ next = relativeSpecifierFrom(dirname(newFile), target);
162
+ }
163
+ return next === specifier ? undefined : next;
164
+ }
165
+
166
+ function rewriteSpecifiers(
167
+ source: string,
168
+ oldFile: string,
169
+ newFile: string,
170
+ moved: ReadonlyMap<string, string>,
171
+ sites: CodemodSite[],
172
+ ): string {
173
+ const project = new Project({
174
+ useInMemoryFileSystem: true,
175
+ skipAddingFilesFromTsConfig: true,
176
+ skipFileDependencyResolution: true,
177
+ });
178
+ const sourceFile = project.createSourceFile(oldFile, source);
179
+ const edits: TextEdit[] = [];
180
+ for (const literal of moduleSpecifierLiterals(sourceFile)) {
181
+ if (!Node.isStringLiteral(literal)) continue;
182
+ const current = literal.getLiteralValue();
183
+ const next = rewrittenSpecifier(current, oldFile, newFile, moved);
184
+ if (next === undefined) continue;
185
+ sites.push({
186
+ file: oldFile,
187
+ line: literal.getStartLineNumber(),
188
+ description: `${current} -> ${next}`,
189
+ });
190
+ edits.push({ start: literal.getStart() + 1, end: literal.getEnd() - 1, text: next });
191
+ }
192
+ return applyEdits(source, edits);
193
+ }
194
+
195
+ // package.json export targets: "./src/<rel>.ts" and the publishConfig mirror "./dist/<rel>.js|.d.ts".
196
+ function rewritePackageJson(
197
+ source: string,
198
+ packageJsonPath: string,
199
+ moved: ReadonlyMap<string, string>,
200
+ sites: CodemodSite[],
201
+ ): string {
202
+ const packageDir = dirname(packageJsonPath);
203
+ return source.replace(/"(\.\/(?:src|dist)\/[^"]+)"/g, (whole, target: string) => {
204
+ const isDist = target.startsWith("./dist/");
205
+ const sourceTarget = isDist
206
+ ? target.replace("./dist/", "./src/").replace(/(?:\.d\.ts|\.js)$/, "")
207
+ : target;
208
+ const { base } = stripScriptExtension(resolve(packageDir, sourceTarget));
209
+ const candidates = isDist ? [`${base}.ts`, `${base}.tsx`] : [resolve(packageDir, sourceTarget)];
210
+ const oldFile = candidates.find((candidate) => moved.has(candidate));
211
+ const newFile = oldFile === undefined ? undefined : moved.get(oldFile);
212
+ if (newFile === undefined) return whole;
213
+ const newRel = relative(packageDir, newFile).split("\\").join("/");
214
+ const next = isDist
215
+ ? target.replace(/^\.\/dist\/.*?(\.d\.ts|\.js)$/, (_all, ext: string) => {
216
+ return `./dist/${newRel.slice("src/".length).replace(/\.tsx?$/, "")}${ext}`;
217
+ })
218
+ : `./${newRel}`;
219
+ sites.push({
220
+ file: packageJsonPath,
221
+ line: source.slice(0, source.indexOf(whole)).split("\n").length,
222
+ description: `${target} -> ${next}`,
223
+ });
224
+ return `"${next}"`;
225
+ });
226
+ }
227
+
228
+ type MarkerAction =
229
+ | { readonly action: "strip" }
230
+ | { readonly action: "move"; readonly target: string }
231
+ | { readonly action: "manual"; readonly description: string };
232
+
233
+ function actionForMarker(
234
+ marker: Marker,
235
+ location: { readonly featureDir: string; readonly rel: string },
236
+ isTaken: (target: string) => boolean,
237
+ ): MarkerAction {
238
+ if (marker.kind === undefined) {
239
+ return { action: "manual", description: "bare @runtime marker without a kind" };
240
+ }
241
+ const implied = pathImpliedKind(location.rel);
242
+ if (marker.kind === implied || (marker.kind === "client" && implied !== undefined)) {
243
+ return { action: "strip" };
244
+ }
245
+ if (marker.kind !== "client") {
246
+ return { action: "manual", description: `@runtime ${marker.kind} has no path rule here` };
247
+ }
248
+ const target = join(location.featureDir, WEB_DIR, location.rel);
249
+ return isTaken(target)
250
+ ? { action: "manual", description: `cannot move to ${WEB_DIR}/${location.rel}: target exists` }
251
+ : { action: "move", target };
252
+ }
253
+
254
+ function buildWrites(
255
+ files: ReadonlyMap<string, string>,
256
+ stripped: ReadonlyMap<string, string>,
257
+ moved: ReadonlyMap<string, string>,
258
+ importRewrites: CodemodSite[],
259
+ ): Map<string, string> {
260
+ const writes = new Map<string, string>();
261
+ const basenames = [...moved.keys()].map((file) => stripScriptExtension(baseName(file)).base);
262
+ for (const [file, original] of files) {
263
+ const content = stripped.get(file) ?? original;
264
+ const newFile = moved.get(file) ?? file;
265
+ if (file.endsWith("package.json")) {
266
+ const next =
267
+ moved.size > 0 ? rewritePackageJson(content, file, moved, importRewrites) : content;
268
+ if (next !== original) writes.set(file, next);
269
+ continue;
270
+ }
271
+ const mentionsMoved = moved.has(file) || basenames.some((name) => content.includes(name));
272
+ const next = mentionsMoved
273
+ ? rewriteSpecifiers(content, file, newFile, moved, importRewrites)
274
+ : content;
275
+ if (next !== original || newFile !== file) writes.set(newFile, next);
276
+ }
277
+ return writes;
278
+ }
279
+
280
+ export function applyFeatureLayout(
281
+ files: ReadonlyMap<string, string>,
282
+ options: FeatureLayoutOptions = {},
283
+ ): FeatureLayoutResult {
284
+ const manual: CodemodSite[] = [];
285
+ const markersRemoved: string[] = [];
286
+ const stripped = new Map<string, string>();
287
+ const moved = new Map<string, string>();
288
+ const claimedTargets = new Set<string>();
289
+ const isTaken = (target: string): boolean => files.has(target) || claimedTargets.has(target);
290
+
291
+ for (const [file, source] of files) {
292
+ const location = splitFeaturePath(file, options);
293
+ const marker = location === undefined ? undefined : findMarker(source);
294
+ if (location === undefined || marker === undefined) continue;
295
+ const decision = actionForMarker(marker, location, isTaken);
296
+ if (decision.action === "manual") {
297
+ manual.push({ file, line: marker.line + 1, description: decision.description });
298
+ continue;
299
+ }
300
+ if (decision.action === "move") {
301
+ claimedTargets.add(decision.target);
302
+ moved.set(file, decision.target);
303
+ }
304
+ stripped.set(file, removeMarkerLine(source, marker.line));
305
+ markersRemoved.push(file);
306
+ }
307
+
308
+ const importRewrites: CodemodSite[] = [];
309
+ const writes = buildWrites(files, stripped, moved, importRewrites);
310
+
311
+ return {
312
+ writes,
313
+ removals: [...moved.keys()],
314
+ moves: [...moved].map(([from, to]) => ({ from, to })),
315
+ markersRemoved,
316
+ importRewrites,
317
+ manual,
318
+ };
319
+ }
320
+
321
+ function baseName(file: string): string {
322
+ return file.slice(file.lastIndexOf("/") + 1);
323
+ }
324
+
325
+ type Mode = "write" | "dry-run" | "check";
326
+
327
+ interface CliOptions extends FeatureLayoutOptions {
328
+ readonly mode: Mode;
329
+ readonly format: boolean;
330
+ readonly paths: readonly string[];
331
+ }
332
+
333
+ function parseArgs(argv: readonly string[]): CliOptions {
334
+ let mode: Mode = "write";
335
+ let format = true;
336
+ const paths: string[] = [];
337
+ const featureDirs: string[] = [];
338
+ const featuresRoots: string[] = [];
339
+ for (let i = 0; i < argv.length; i++) {
340
+ const arg = argv[i] ?? "";
341
+ if (arg === "--check") mode = "check";
342
+ else if (arg === "--dry-run") mode = "dry-run";
343
+ else if (arg === "--write") mode = "write";
344
+ else if (arg === "--no-format") format = false;
345
+ else if (arg === "--feature-dir" || arg === "--features-root") {
346
+ const value = argv[++i];
347
+ if (value === undefined) throw new Error(`${arg} needs a directory`);
348
+ (arg === "--feature-dir" ? featureDirs : featuresRoots).push(resolve(value));
349
+ } else if (arg.startsWith("--")) throw new Error(`unknown flag: ${arg}`);
350
+ else paths.push(arg);
351
+ }
352
+ if (paths.length === 0) throw new Error("at least one target directory is required");
353
+ return { mode, format, paths, featureDirs, featuresRoots };
354
+ }
355
+
356
+ function readTargetFiles(paths: readonly string[]): Map<string, string> {
357
+ const files = new Map<string, string>();
358
+ const names = collectSourceFiles(paths, {
359
+ isMigratableFile: (name) => /\.(ts|tsx)$/.test(name) && !name.endsWith(".d.ts"),
360
+ });
361
+ const packageJsons = collectSourceFiles(paths, {
362
+ isMigratableFile: (name) => name === "package.json",
363
+ });
364
+ for (const file of [...names, ...packageJsons]) {
365
+ if (file.includes("/__tests__/fixtures/")) continue;
366
+ if (statSync(file).isFile()) files.set(file, readFileSync(file, "utf8"));
367
+ }
368
+ return files;
369
+ }
370
+
371
+ function display(file: string): string {
372
+ const fromCwd = relative(process.cwd(), file);
373
+ return fromCwd.startsWith("..") ? file : fromCwd;
374
+ }
375
+
376
+ function writeResult(result: FeatureLayoutResult): void {
377
+ for (const old of result.removals) rmSync(old);
378
+ for (const [file, content] of result.writes) {
379
+ mkdirSync(dirname(file), { recursive: true });
380
+ writeFileSync(file, content);
381
+ }
382
+ }
383
+
384
+ async function main(argv: readonly string[]): Promise<void> {
385
+ let options: CliOptions;
386
+ try {
387
+ options = parseArgs(argv);
388
+ } catch (err) {
389
+ console.error(`[feature-layout] ${err instanceof Error ? err.message : String(err)}`);
390
+ process.exit(2);
391
+ }
392
+ const files = readTargetFiles(options.paths);
393
+ const result = applyFeatureLayout(files, options);
394
+ for (const move of result.moves) {
395
+ console.log(`${display(move.from)} moved -> ${display(move.to)}`);
396
+ }
397
+ for (const file of result.markersRemoved) console.log(`${display(file)} marker removed`);
398
+ for (const site of result.manual) {
399
+ console.log(`${display(site.file)}:${site.line} manual ${site.description}`);
400
+ }
401
+ console.log(
402
+ `\n[feature-layout] ${options.mode === "write" ? "moved" : "movable"}: ${result.moves.length} file(s); markers: ${result.markersRemoved.length}; import sites: ${result.importRewrites.length}; manual: ${result.manual.length}`,
403
+ );
404
+ if (options.mode === "write") {
405
+ writeResult(result);
406
+ if (options.format) {
407
+ const changed = [...result.writes.keys()].filter((file) => /\.(ts|tsx)$/.test(file));
408
+ if (changed.length > 0)
409
+ await formatWithBiome("feature-layout", changed, resolve(options.paths[0] ?? "."));
410
+ }
411
+ }
412
+ const pending = result.markersRemoved.length + result.manual.length;
413
+ process.exit(options.mode === "check" && pending > 0 ? 1 : 0);
414
+ }
415
+
416
+ if (import.meta.main) await main(process.argv.slice(2));
@@ -42,17 +42,6 @@ export const PACKAGE_MERGE_MAPPINGS: readonly PackageMergeMapping[] = [
42
42
  const DEPENDENCY_BLOCKS = ["dependencies", "devDependencies", "peerDependencies"] as const;
43
43
 
44
44
  const SKIPPED_PATH_SUFFIXES: readonly string[] = [
45
- "packages/http/src/index.ts",
46
- "packages/repo-manifest/src/index.ts",
47
- "packages/repo-manifest/src/app-ports/index.ts",
48
- "packages/headless/src/index.ts",
49
- "packages/headless/src/apex/index.ts",
50
- "packages/headless/src/dispatcher/index.ts",
51
- "packages/headless/src/locale-routing/index.ts",
52
- "packages/dispatcher-live/src/index.ts",
53
- "packages/renderer-web/src/index.ts",
54
- "packages/renderer-web/src/theme-plugin.ts",
55
- "packages/renderer-web/src/styles.css",
56
45
  "packages/framework/src/scripts/codemod/package-merge.ts",
57
46
  "packages/framework/src/__tests__/package-merge-codemod.test.ts",
58
47
  ];