@cosmicdrift/kumiko-framework 0.334.0 → 0.335.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 (157) hide show
  1. package/dist/api/auth-routes.js +19 -4
  2. package/dist/api/client-ip.js +3 -6
  3. package/dist/api/extra-route.js +3 -2
  4. package/dist/api/pii-leak-guard.js +7 -4
  5. package/dist/api/redis-sse-broker.d.ts +2 -0
  6. package/dist/api/redis-sse-broker.js +6 -7
  7. package/dist/api/routes.js +16 -17
  8. package/dist/api/server.js +14 -7
  9. package/dist/api/sse-broker.d.ts +3 -0
  10. package/dist/api/sse-broker.js +14 -0
  11. package/dist/api/sse-route.d.ts +1 -1
  12. package/dist/api/sse-route.js +39 -6
  13. package/dist/bun-db/connection.js +0 -2
  14. package/dist/bun-db/index.d.ts +1 -1
  15. package/dist/bun-db/index.js +1 -1
  16. package/dist/bun-db/query.d.ts +1 -0
  17. package/dist/bun-db/query.js +15 -4
  18. package/dist/crypto/kek-source.js +40 -36
  19. package/dist/crypto/kms-adapter.js +2 -2
  20. package/dist/db/apply-entity-event.js +8 -2
  21. package/dist/db/blind-index-cleanup.js +11 -1
  22. package/dist/db/entity-table-meta.js +5 -3
  23. package/dist/db/event-store-executor-context.js +13 -19
  24. package/dist/db/event-store-executor-read.js +39 -36
  25. package/dist/db/event-store-executor-write.js +15 -17
  26. package/dist/db/migrate-generator.js +27 -28
  27. package/dist/db/pg-error.js +9 -5
  28. package/dist/db/queries/backfill-pii.js +15 -5
  29. package/dist/db/queries/shadow-swap.js +5 -4
  30. package/dist/db/table-builder.d.ts +1 -0
  31. package/dist/db/table-builder.js +7 -15
  32. package/dist/db/tenant-db.js +18 -2
  33. package/dist/derivatives/variant-key.js +3 -1
  34. package/dist/engine/boot-validator/access-declarations.js +12 -3
  35. package/dist/engine/boot-validator/entity-handler.js +13 -1
  36. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  37. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  38. package/dist/engine/boot-validator/index.js +6 -6
  39. package/dist/engine/boot-validator/nav.d.ts +2 -1
  40. package/dist/engine/boot-validator/nav.js +10 -20
  41. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  42. package/dist/engine/boot-validator/ownership.js +2 -1
  43. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  44. package/dist/engine/boot-validator/pii-retention.js +5 -2
  45. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  46. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  47. package/dist/engine/boot-validator/screens.d.ts +2 -2
  48. package/dist/engine/boot-validator/screens.js +103 -33
  49. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  50. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  51. package/dist/engine/boot-validator/zod-shape.js +17 -4
  52. package/dist/engine/build-app-schema.js +62 -19
  53. package/dist/engine/build-config-feature-schema.js +8 -1
  54. package/dist/engine/changeset-changes.js +24 -10
  55. package/dist/engine/feature-ast/extractors/events.js +11 -8
  56. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  57. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  58. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  59. package/dist/engine/feature-ast/parse.d.ts +1 -0
  60. package/dist/engine/feature-ast/parse.js +18 -3
  61. package/dist/engine/feature-ast/patch.js +80 -61
  62. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  63. package/dist/engine/feature-ast/patcher.js +2 -1
  64. package/dist/engine/feature-ast/render.js +1 -1
  65. package/dist/engine/index.d.ts +4 -3
  66. package/dist/engine/index.js +3 -2
  67. package/dist/engine/ownership.d.ts +1 -0
  68. package/dist/engine/ownership.js +15 -2
  69. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  70. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  71. package/dist/engine/pattern-library/shared-fields.js +17 -0
  72. package/dist/engine/registry-facade.js +3 -0
  73. package/dist/engine/role-assignment.d.ts +7 -1
  74. package/dist/engine/role-assignment.js +27 -7
  75. package/dist/engine/schema-builder.d.ts +1 -0
  76. package/dist/engine/schema-builder.js +9 -2
  77. package/dist/engine/screen-helpers.js +15 -4
  78. package/dist/engine/steps/_event-match.js +22 -3
  79. package/dist/engine/steps/_read-source.js +8 -0
  80. package/dist/engine/where-rule-lint.js +22 -4
  81. package/dist/env/index.js +21 -1
  82. package/dist/errors/reasons.d.ts +0 -1
  83. package/dist/errors/reasons.js +2 -5
  84. package/dist/event-store/event-store.js +6 -1
  85. package/dist/event-store/provenance-append.js +4 -1
  86. package/dist/files/file-routes.d.ts +0 -1
  87. package/dist/files/file-routes.js +22 -7
  88. package/dist/files/local-provider.js +13 -10
  89. package/dist/files/provider-resolver.js +20 -1
  90. package/dist/files/types.js +16 -1
  91. package/dist/i18n/request-locale.d.ts +3 -2
  92. package/dist/i18n/request-locale.js +16 -6
  93. package/dist/i18n/required-surface-keys.js +23 -35
  94. package/dist/i18n/settings-hub-keys.js +3 -0
  95. package/dist/jobs/index.d.ts +2 -2
  96. package/dist/jobs/index.js +1 -1
  97. package/dist/jobs/job-runner.d.ts +2 -0
  98. package/dist/jobs/job-runner.js +150 -59
  99. package/dist/migrations/index.d.ts +1 -1
  100. package/dist/migrations/index.js +1 -1
  101. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  102. package/dist/migrations/pending-rebuilds.js +23 -1
  103. package/dist/observability/escape-hatch-report.d.ts +1 -0
  104. package/dist/observability/escape-hatch-report.js +3 -0
  105. package/dist/observability/index.d.ts +1 -0
  106. package/dist/observability/index.js +1 -0
  107. package/dist/pipeline/dispatch-query.d.ts +1 -1
  108. package/dist/pipeline/dispatch-query.js +2 -2
  109. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  110. package/dist/pipeline/dispatch-shared.js +14 -9
  111. package/dist/pipeline/dispatch-stream.js +2 -5
  112. package/dist/pipeline/dispatch-write.js +22 -5
  113. package/dist/pipeline/dispatcher.js +1 -2
  114. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  115. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  116. package/dist/pipeline/event-dispatcher.js +26 -11
  117. package/dist/pipeline/index.d.ts +1 -1
  118. package/dist/pipeline/index.js +1 -1
  119. package/dist/pipeline/member-read-only-transaction.js +1 -14
  120. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  121. package/dist/pipeline/msp-rebuild.js +1 -0
  122. package/dist/pipeline/system-hooks.d.ts +1 -1
  123. package/dist/pipeline/system-hooks.js +9 -7
  124. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  125. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  126. package/dist/redis/pubsub-signal.d.ts +2 -0
  127. package/dist/redis/pubsub-signal.js +2 -2
  128. package/dist/schema-cli.js +4 -14
  129. package/dist/search/meilisearch-adapter.js +17 -6
  130. package/dist/search/purge-subject.js +8 -3
  131. package/dist/stack/drain-jobs.js +24 -12
  132. package/dist/stack/table-helpers.js +10 -8
  133. package/dist/testing/closed-connection-error.js +2 -3
  134. package/dist/testing/wait-for.js +1 -0
  135. package/dist/ui-types/index.d.ts +1 -1
  136. package/dist/ui-types/index.js +1 -1
  137. package/dist/upgrade-cli.d.ts +1 -0
  138. package/dist/upgrade-cli.js +36 -15
  139. package/package.json +4 -4
  140. package/src/changes.json +206 -0
  141. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  142. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  143. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  144. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  145. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  146. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  147. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  148. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  149. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  150. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  151. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  152. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  153. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  154. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  155. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  156. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  157. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -56,10 +56,10 @@ function readChangelogFile(filePath) {
56
56
  return [];
57
57
  }
58
58
  }
59
- function collectChangelogs(featuresDir) {
59
+ export function findFeatureChangelogFiles(featuresDir) {
60
60
  if (!existsSync(featuresDir))
61
61
  return [];
62
- const entries = [];
62
+ const files = [];
63
63
  const features = readdirSync(featuresDir, { withFileTypes: true })
64
64
  .filter((d) => d.isDirectory())
65
65
  .map((d) => d.name);
@@ -71,9 +71,13 @@ function collectChangelogs(featuresDir) {
71
71
  const srcLayout = join(featuresDir, name, "src", "changes.json");
72
72
  const flatLayout = join(featuresDir, name, "changes.json");
73
73
  const changelogPath = existsSync(srcLayout) ? srcLayout : flatLayout;
74
- entries.push(...readChangelogFile(changelogPath));
74
+ if (existsSync(changelogPath))
75
+ files.push(changelogPath);
75
76
  }
76
- return entries;
77
+ return files;
78
+ }
79
+ function collectChangelogs(featuresDir) {
80
+ return findFeatureChangelogFiles(featuresDir).flatMap(readChangelogFile);
77
81
  }
78
82
  // bundled-features is excluded: findFeaturesDirs collects it per feature
79
83
  // (src/<feature>/changes.json), every other package has one src/changes.json.
@@ -99,7 +103,8 @@ export function findPackageChangelogFiles(cwd) {
99
103
  const scopeDir = join(dir, "node_modules/@cosmicdrift");
100
104
  if (existsSync(scopeDir)) {
101
105
  const pkgNames = readdirSync(scopeDir, { withFileTypes: true })
102
- .filter((d) => d.isDirectory() && d.name !== "kumiko-bundled-features")
106
+ // Dirent has lstat semantics: workspace-linked packages are symlinks.
107
+ .filter((d) => (d.isDirectory() || d.isSymbolicLink()) && d.name !== "kumiko-bundled-features")
103
108
  .map((d) => d.name);
104
109
  for (const name of pkgNames) {
105
110
  if (seenNames.has(name))
@@ -257,21 +262,21 @@ function writeUpgradeMarker(targetDir, marker) {
257
262
  mkdirSync(dir, { recursive: true });
258
263
  writeFileSync(join(dir, "upgrade-state.json"), `${JSON.stringify(marker, null, 2)}\n`, "utf-8");
259
264
  }
260
- // Missing, unreadable, or invalid marker means no baseline was ever recorded
261
- // — most commonly a bootstrap app that never ran `--apply` (fw#2299) — so
262
- // this reports "no marker" instead of throwing.
265
+ // A missing marker is a bootstrap app that never ran `--apply` (fw#2299); an
266
+ // existing but unreadable one must not silently fall back to the installed
267
+ // version, or a bare `--apply` would skip every codemod in between.
263
268
  function readMarkerVersion(targetDir) {
264
269
  const markerPath = join(targetDir, ".kumiko", "upgrade-state.json");
265
270
  if (!existsSync(markerPath))
266
- return null;
271
+ return { kind: "missing" };
267
272
  try {
268
273
  const parsed = JSON.parse(readFileSync(markerPath, "utf-8"));
269
274
  return typeof parsed.version === "string" && SEMVER_RE.test(parsed.version)
270
- ? parsed.version
271
- : null;
275
+ ? { kind: "ok", version: parsed.version }
276
+ : { kind: "invalid" };
272
277
  }
273
278
  catch {
274
- return null;
279
+ return { kind: "invalid" };
275
280
  }
276
281
  }
277
282
  /** Highest pending version strictly below the earliest open manual breaking
@@ -337,10 +342,12 @@ markerVersion) {
337
342
  : " ✓ No breaking changes pending.");
338
343
  if (!dryRun) {
339
344
  const markerVer = markerVersionForPending(pending, manualEntries, markerVersion);
345
+ const pendingManual = manualEntries.map((e) => ({ version: e.version, title: e.title }));
340
346
  writeUpgradeMarker(targetDir, {
341
347
  version: markerVer,
342
348
  appliedAt: Temporal.Now.instant().toString(),
343
349
  codemods: [],
350
+ ...(pendingManual.length > 0 && { pendingManual }),
344
351
  });
345
352
  out.log(` ✓ Applied 0 codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`);
346
353
  logManualMarkerOutcome(out, manualEntries, markerVer);
@@ -431,7 +438,14 @@ export async function runUpgradeCli(argv, cwd, out, options) {
431
438
  // when the marker says otherwise — a bare `--apply` would then always
432
439
  // report "Nothing new" and bootstrap the marker onto the installed
433
440
  // version, hiding breaking changes the marker never actually saw.
434
- const currentVersion = fromFlag ?? readMarkerVersion(targetDir) ?? installedVersion;
441
+ const marker = fromFlag === undefined ? readMarkerVersion(targetDir) : { kind: "missing" };
442
+ if (marker.kind === "invalid") {
443
+ out.err("");
444
+ out.err(" invalid .kumiko/upgrade-state.json, fix it or pass --from <version>");
445
+ out.err("");
446
+ return 1;
447
+ }
448
+ const currentVersion = fromFlag ?? (marker.kind === "ok" ? marker.version : undefined) ?? installedVersion;
435
449
  if (!currentVersion) {
436
450
  out.err("");
437
451
  out.err(" Could not detect Kumiko version.");
@@ -465,10 +479,17 @@ export async function runUpgradeCli(argv, cwd, out, options) {
465
479
  if (getFlag(args, "apply")) {
466
480
  // Marker must reflect what is actually installed under the target (or
467
481
  // cwd), not the filter baseline above — otherwise CI stays green forever.
468
- const markerVersion = (dirFlag ? readCurrentVersion(targetDir) : null) ?? installedVersion ?? currentVersion;
482
+ const markerVersion = dirFlag ? readCurrentVersion(targetDir) : installedVersion;
469
483
  const dryRun = getFlag(args, "dry-run");
484
+ if (markerVersion === null && !dryRun) {
485
+ out.err("");
486
+ out.err(` Could not detect the installed Kumiko version under ${targetDir}.`);
487
+ out.err(" Refusing to write an upgrade marker without it (--from is only a filter).");
488
+ out.err("");
489
+ return 1;
490
+ }
470
491
  out.log("");
471
- const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun, markerVersion);
492
+ const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun, markerVersion ?? currentVersion);
472
493
  out.log("");
473
494
  return code;
474
495
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.334.0",
3
+ "version": "0.335.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>",
@@ -206,8 +206,8 @@
206
206
  "./package.json": "./package.json"
207
207
  },
208
208
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.334.0",
210
- "@cosmicdrift/kumiko-types": "0.334.0",
209
+ "@cosmicdrift/kumiko-http": "0.335.0",
210
+ "@cosmicdrift/kumiko-types": "0.335.0",
211
211
  "bullmq": "^5.76.7",
212
212
  "bun-types": "^1.3.13",
213
213
  "hono": "^4.13.1",
@@ -223,7 +223,7 @@
223
223
  "zod": "^4.4.3"
224
224
  },
225
225
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.334.0",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.335.0",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },
package/src/changes.json CHANGED
@@ -1,4 +1,210 @@
1
1
  [
2
+ {
3
+ "version": "0.335.0",
4
+ "type": "fix",
5
+ "title": "Agent tool manifests no longer offer the caller-chosen id on create tools",
6
+ "detail": "Agent tool manifests no longer offer the caller-chosen `id` on create tools, since agent dispatch never runs as a system identity and the value was silently dropped. Combobox options now carry a value-based `data-testid`."
7
+ },
8
+ {
9
+ "version": "0.335.0",
10
+ "type": "improvement",
11
+ "title": "Stricter boot checks for list outputSchema envelopes, detail section fields, reference facet list handlers, metric navigate, dashboard screen panels, refEntity columns and checkbox columns",
12
+ "detail": "Stricter boot checks for screens and handlers; apps that carried a latent typo or misconfiguration now fail at boot instead of rendering an empty cell or doing nothing at runtime.\n- A plain (non-paged) query handler whose `outputSchema` declares the row shape instead of `{ rows, nextCursor }` now fails when a projectionList, relatedList, expandableRow or dashboard list panel uses it.\n- `projectionDetail` layout section `fields` are checked against the query's `outputSchema`.\n- A `reference` list facet requires the target's list query handler (`<feature>:query:<entity>:list`, or its central lookup override) to be registered.\n- `MetricSpec.navigate.screen`/`entity` are validated even without `tab`; `navigate.entity` needs `entityId`.\n- A dashboard `screen` panel must not embed an `actionForm`/`secretMint` with `redirect` or `cancelTarget`.\n- `refEntity` on entityList columns and dashboard list-panel columns must resolve to a registered entity.\n- `multiSelect` `display: \"checkboxes\"` rejects `columns` outside 1-4.\n- Personal-data detection for `openToAll` write handlers now also looks inside `z.map`, `z.set`, `z.promise` and `.catchall()` schemas.\n- Fixes false boot failures for cross-feature rowActions / `listScreenId` written as a fully-qualified screen QN, and for where-rules that use a bare outer column next to a qualified subquery.",
13
+ "migration": "Boot now fails for configurations that previously rendered empty or did nothing. Fix the named screen or field: wrap a list handler's outputSchema in { rows, nextCursor }, correct detail section field names, register the entity list handler (defineEntityListHandler) behind a reference facet, add entityId to a metric navigate.entity, drop redirect/cancelTarget on forms embedded in a dashboard, and use a registered entity for refEntity."
14
+ },
15
+ {
16
+ "version": "0.335.0",
17
+ "type": "fix",
18
+ "title": "Encrypted-at-rest text config keys derive writeOnly settings fields; boot validator allows writeOnly on configEdit only for those keys"
19
+ },
20
+ {
21
+ "version": "0.335.0",
22
+ "type": "fix",
23
+ "title": "migrate-db-raw codemod lists own-tenant calls with a spread after tenantId for manual review instead of rewriting them",
24
+ "detail": "The `migrate-db-raw` codemod no longer auto-rewrites own-tenant calls whose filter object has a spread after `tenantId`, since the spread can override it at runtime; such sites are listed for manual review."
25
+ },
26
+ {
27
+ "version": "0.335.0",
28
+ "type": "fix",
29
+ "title": "switch-tenant validates tenantId, fault logger tolerates non-string type, PII guard matches full ciphertext shape",
30
+ "detail": "`POST /api/auth/switch-tenant` answers 400 `invalid_tenant` for a missing or non-string `tenantId` instead of feeding it into the membership lookup. A non-string `type` in a request body no longer turns a 4xx into a 500 inside the fault logger. The PII ciphertext response guard now matches the full ciphertext shape, so user text containing the bare `kumiko-pii:v` marker no longer 500s reads. `kumiko upgrade --apply` records `pendingManual` in the marker when no codemod runs."
31
+ },
32
+ {
33
+ "version": "0.335.0",
34
+ "type": "fix",
35
+ "title": "File-storage boot gate reads the injected env, derivative erasure matches lowercase hashes only",
36
+ "detail": "The `FILE_STORAGE_PROVIDER` boot gate now reads the `env` passed to `validateBoot` (falling back to `process.env`), so an `envSource` handed to `runProdApp` with an empty `process.env` no longer fails the gate. `runDevApp` stops mutating `process.env` for an explicitly wired file provider. The derivative-key recognizer used by forget and tenant-destroy now matches the hash segment case-sensitively."
37
+ },
38
+ {
39
+ "version": "0.335.0",
40
+ "type": "breaking",
41
+ "title": "Boot rejects multiple principalStatus providers, updateMany rejects unknown columns again",
42
+ "detail": "Several fail-loud fixes. `buildServer` now also rejects more than one `principalStatus` provider when `auth.membershipQuery` is registered (previously every login 500ed at runtime). `updateMany` throws again on a key that is not a column, and the replay of historical update events skips fields removed from the entity instead. `lt`/`gt`/`lte`/`gte`/`like` on a jsonb column match nothing in the where builder, like the event-store read path.",
43
+ "migration": "`updateMany` now throws when `set` or `where` contains a key that is not a column of the table; remove such keys from your calls. Replay of historical update events still skips removed fields."
44
+ },
45
+ {
46
+ "version": "0.335.0",
47
+ "type": "improvement",
48
+ "title": "Boot validator rejects overlapping dateRange facet params, unknown subtitleHref/countField fields and whitespace in redirect.idFrom; clearer refEntity, nav and where-probe diagnostics",
49
+ "detail": "Boot validator follow-ups.\n- A `dateRange` facet param name used by more than one dateRange facet on a projectionList screen now fails boot (the facets overwrote each other's bound).\n- `header.subtitleHref` and a section's `countField` on a projectionDetail screen are checked against the query's `outputSchema`.\n- `redirect.idFrom` with leading or trailing whitespace now fails boot instead of silently navigating without an id.\n- A `refEntity` pointing at a feature that is not mounted now says so and names `r.requires(...)` instead of listing \"(none)\" entities.\n- The nav inversion warning no longer prints `requires roles []` for a leaf with an invalid `openToAll`, and the unreachable-screen warning points workspace apps at `r.workspace({ nav })` instead of a non-existent allowlist.\n- The where-rule boot probe renders `ctx.tableName` with the real table name, matching runtime.",
50
+ "migration": "Boot now fails for configurations that silently misbehaved. Give each dateRange facet on a screen its own from/to params, point header.subtitleHref and section countField at fields of the query's outputSchema, and remove stray whitespace from redirect.idFrom."
51
+ },
52
+ {
53
+ "version": "0.335.0",
54
+ "type": "fix",
55
+ "title": "Malformed waitForEvent matchExpr rows are skipped instead of blocking the wakeup projection",
56
+ "detail": "Review round 2 fixes for waitForEvent matching, read steps, env parsing and agent reasons.\n- A pending workflow row with a malformed or unsupported `matchExpr` is now skipped and logged instead of aborting the wakeup projection for every other run waiting on the same event. `evaluateEventMatch` reports a structurally malformed AST as a readable `Malformed EventMatch` error.\n- `read.findOne` and `read.findMany` reject a non-object `unsafeAllTenants` (for example `true`) with an `InternalError` instead of a raw `TypeError`.\n- `parseEnv` reports a refinement that throws as a `KumikoBootError`, and the ciphertext-only slot caveat for refinements is documented.\n- Removed the unused `AgentReasons.permissionDenied` (`agent.permission_denied`) reason and its error-docs pages; nothing emitted it."
57
+ },
58
+ {
59
+ "version": "0.335.0",
60
+ "type": "breaking",
61
+ "title": "AgentReasons.permissionDenied removed",
62
+ "migration": "Drop references to `AgentReasons.permissionDenied` / `\"agent.permission_denied\"`. The framework never emitted this reason."
63
+ },
64
+ {
65
+ "version": "0.335.0",
66
+ "type": "fix",
67
+ "title": "ExtraRouteRejection retryAfterSeconds must be a safe integer; token-request routes stop logging rate_limited",
68
+ "detail": "`ExtraRouteRejection` now rejects a `retryAfterSeconds` beyond the safe-integer range, so the `Retry-After` header is always valid delta-seconds. Password-reset style token-request routes no longer log `rate_limited` handler results as errors."
69
+ },
70
+ {
71
+ "version": "0.335.0",
72
+ "type": "fix",
73
+ "title": "Feature-AST patcher handles quoted keys, stream-handler escapeHatch and shorthand piiFields",
74
+ "detail": "The feature-AST patcher now replaces and unsets string-literal keys such as `\"description\": \"old\"` instead of appending a duplicate key or skipping the unset. `addStreamHandler` accepts `escapeHatch`. The defineEvent extractor accepts a shorthand `{ piiFields }` stance and reports a non-literal options argument with its own message."
75
+ },
76
+ {
77
+ "version": "0.335.0",
78
+ "type": "fix",
79
+ "title": "File byte routes survive undecryptable fileName; loadAggregate and appendProvenanceEvent reject empty type parts",
80
+ "detail": "`GET /files/:id` and the download-url route now fall back to the filename \"download\" when the stored fileName cannot be decrypted (malformed ciphertext, erased subject, annotation drift) instead of returning 500 or leaking ciphertext into headers. `loadAggregate` rejects an empty `aggregateType` instead of loading the unfiltered stream, and `appendProvenanceEvent` rejects event types with an empty owner or name."
81
+ },
82
+ {
83
+ "version": "0.335.0",
84
+ "type": "breaking",
85
+ "title": "FileRef type drops the unreachable isDeleted field",
86
+ "detail": "The exported `FileRef` type no longer carries `isDeleted`. Soft-deleted rows were already filtered out before the guard sees a file, so the field was never reachable. Custom file access guards that read `fileRef.isDeleted` must drop that check.",
87
+ "migration": "Remove reads of `fileRef.isDeleted` in custom file access guards; soft-deleted rows never reach the guard."
88
+ },
89
+ {
90
+ "version": "0.335.0",
91
+ "type": "breaking",
92
+ "title": "File content check covers csv alias, local list scoped to prefix, provider contract checked, locale tags canonicalized",
93
+ "detail": "File storage and locale hardening. `validateFileContent` now rejects binary bytes for an extension with a declared-MIME alias (a real `.xls` renamed to `.csv`), so custom upload routes using `validateFile` + `validateFileContent` are covered. `tenantExportPrefix` and `tenantStoragePrefixes` throw for a tenant id equal to the reserved `exports` segment. The local provider's `list()` only walks the prefix directory instead of the whole storage root. A file-provider plugin whose built provider lacks a required method now fails loudly at resolve time. `canonicalizeLocaleTag` normalizes region and script subtags (`de-AT`, `zh-Hant-TW`), so `X-Locale: DE-at` finds a `de-AT` mail registration.",
94
+ "migration": "A file-provider plugin whose built provider lacks a required method now throws at resolve time; make sure custom file providers implement the full provider interface."
95
+ },
96
+ {
97
+ "version": "0.335.0",
98
+ "type": "breaking",
99
+ "title": "Global-table tenant writes reject loudly, restore maps unique violations, managed unique-index changes recreate",
100
+ "detail": "Tenant-mode `updateMany`/`deleteMany` on a `tenancy: \"global\"` table now reject with an `AccessDeniedError` pointing at `db.global(table)` instead of silently matching zero rows. `restore` on a soft-deleted row whose unique value was re-used now returns a 409 `unique_violation` instead of a raw 500. The migration generator now recreates a managed projection when an existing index becomes unique, changes columns or widens its WHERE.",
101
+ "migration": "Tenant-mode `updateMany`/`deleteMany` on a `tenancy: \"global\"` table now throws instead of matching zero rows; switch those calls to `db.global(table)`."
102
+ },
103
+ {
104
+ "version": "0.335.0",
105
+ "type": "fix",
106
+ "title": "Guard edge cases, extension entityName fallback and dispatcher drain waiting for in-flight passes",
107
+ "detail": "Guards: the runtime-isolation guard now follows value re-exports (`export * from`, `export { x } from`) and reads the `kumiko.runtime` marker in the repo-root `package.json`. The upgrade-state guard prefers the repo-local `node_modules/.bin/kumiko-upgrade` over a PATH entry. The error-reasons guard no longer flags prose reasons passed to `withUnsafeRawGrant`. Remaining German guard messages are now English. Blank `entityName` overrides on extension sections fall back to the host entity. `EventDispatcher.drain()` now also waits for a pass still in its idle pre-check."
108
+ },
109
+ {
110
+ "version": "0.335.0",
111
+ "type": "breaking",
112
+ "title": "i18n key guard covers multiSelect option labels and writeForm section descriptions",
113
+ "detail": "The i18n key guard now requires translations for filterable `multiSelect` option labels and for the `description` of a `projectionDetail` writeForm section. Missing translations used to fall back silently; they now fail the guard.",
114
+ "migration": "The i18n required-surface-keys check now requires translations for filterable `multiSelect` option labels and `projectionDetail` writeForm section descriptions; add the missing keys to your locale files."
115
+ },
116
+ {
117
+ "version": "0.335.0",
118
+ "type": "fix",
119
+ "title": "perTenant fan-out without context.db reports the missing db instead of a missing tenant feature",
120
+ "detail": "A perTenant job on a job runner built without `context.db` now fails with a message that names the missing `context.db` when the tenant feature is mounted, instead of telling the operator to mount it. A readiness failure during `stop()` is now logged at debug level."
121
+ },
122
+ {
123
+ "version": "0.335.0",
124
+ "type": "fix",
125
+ "title": "Key Manager boot resolves slots in parallel, validates the previous-KEK version up front and keeps the fetch error as cause",
126
+ "detail": "The Key Manager boot resolution now resolves all KEK slots in parallel, rejects a previous KEK without `PLATFORM_KEK_PREVIOUS_VERSION` before any Key Manager call, keeps the original network error as `cause`, and names the slot in the partial-wiring error. The boot log line names the source actually taken."
127
+ },
128
+ {
129
+ "version": "0.335.0",
130
+ "type": "fix",
131
+ "title": "Boot column check skips loose/catchall output schemas; schema-walk error names the handler",
132
+ "detail": "The query output-schema column check now skips `z.looseObject()` and `.catchall()` row schemas, since they accept keys beyond the declared shape. The schema-size guard for write-handler input schemas now names the feature and handler in its boot error."
133
+ },
134
+ {
135
+ "version": "0.335.0",
136
+ "type": "fix",
137
+ "title": "Multi-stream projection rebuild honors the shutdown AbortSignal",
138
+ "detail": "`rebuildMultiStreamProjection` now accepts an `AbortSignal` and checks it per replayed event. The boot backfill's shutdown signal reaches it, so a SIGTERM during a long multi-stream replay rolls the transaction back and releases the consumer lock instead of hanging until the pod is killed."
139
+ },
140
+ {
141
+ "version": "0.335.0",
142
+ "type": "fix",
143
+ "title": "multiSelect filters handle empty and non-string values and NULL columns; forget sweeps tolerate missing columns and serial ids",
144
+ "detail": "multiSelect list filters no longer return every row for an empty array value, no longer fail with a 500 on boolean or numeric client values, and `ne` now keeps rows whose column is NULL. The forget sweeps (blind-index nulling, search purge) skip lookupable or searchable fields whose columns are missing from a migrated table instead of aborting after the key was erased, and the self-PII tenant probe ignores serial-id entities instead of throwing on a uuid subject id."
145
+ },
146
+ {
147
+ "version": "0.335.0",
148
+ "type": "fix",
149
+ "title": "App-schema JSON-safety rejects NaN/Infinity and cyclic defaults",
150
+ "detail": "The app-schema JSON-safety check now rejects `NaN`/`Infinity` defaults and guards against self-referencing values. The agent doc lint reports exposed handlers whose input schema cannot be converted to JSON Schema (they were silently left out of the agent manifest). Icon-only list and edit action buttons now also set `title`, so sighted mouse users get the hover tooltip."
151
+ },
152
+ {
153
+ "version": "0.335.0",
154
+ "type": "fix",
155
+ "title": "Executor create/restore validate before connection binding; clearer rebuild and backfill errors",
156
+ "detail": "The event-store executor's `create()` and `restore()` now run their validation (global-tenancy guard, `soft_delete_not_enabled`) before resolving the connection, so callers get the proper 4xx error instead of an internal \"no connection bound\" error. List `totalCount` always counts over the full WHERE, and a PII backfill failure for an unresolvable catalog event subject no longer suggests the entity-only retry options. The shadow-swap RLS and blind-index error messages now state the concrete action (full KMS wiring for the blind-index key)."
157
+ },
158
+ {
159
+ "version": "0.335.0",
160
+ "type": "fix",
161
+ "title": "afterCommit hook writes run in their own transaction, plus cache, nested-write, skip-poison and search-consumer fixes",
162
+ "detail": "Writes from an afterCommit hook now run as their own transaction with their own afterCommit flush, so the nested write's hooks fire and a failing projection rolls it back. The tenant timezone cache no longer stores a value read before a concurrent config write. Nested-write ownership checks complete a partial parent row from the stored row. `skipPoisonEvent` locks the consumer row before computing pending gaps. The search consumer fails an event for retry when its context has no database instead of removing the index entry."
163
+ },
164
+ {
165
+ "version": "0.335.0",
166
+ "type": "fix",
167
+ "title": "Qualified redirects resolve in their named feature, update forms skip untouched required fields, secretMint confirm can restart",
168
+ "detail": "A qualified redirect target (`feature:screen:id`) is now resolved inside the named feature, so a same-id screen in the redirecting feature no longer swallows the created record id. An entityEdit update form only presence-checks required fields the user changed, so a legacy row with an empty, newly required field can still be saved. The secretMint confirm step always offers a cancel that restarts at the mint form when no redirect or cancelTarget is set."
169
+ },
170
+ {
171
+ "version": "0.335.0",
172
+ "type": "breaking",
173
+ "title": "totalsMatch rejects a payload that carries only one side of the pair",
174
+ "detail": "A `totalsMatch` pair is now validated as a unit. A payload that carries only the embedded list or only its sibling money field is rejected with a validation error, because update payloads only carry `changes` and the sum could otherwise drift away from the total.",
175
+ "migration": "Send the embedded list and its sibling money field together in every create and update payload. A partial update that changes only `total` or only the list now fails validation."
176
+ },
177
+ {
178
+ "version": "0.335.0",
179
+ "type": "fix",
180
+ "title": "Transfer-graph boot check rejects a multiple reference only when its target is transferable",
181
+ "detail": "The transfer-graph boot check now rejects a `multiple` reference on a transferable entity only when the target entity is itself transferable. A multiple reference to a plain lookup entity boots again."
182
+ },
183
+ {
184
+ "version": "0.335.0",
185
+ "type": "fix",
186
+ "title": "Empty KUMIKO_TRUSTED_PROXY_HOPS is treated as unset, and dist no longer ships compiled codemods",
187
+ "detail": "`parseTrustedProxyHopsEnv` treats an empty `KUMIKO_TRUSTED_PROXY_HOPS` as unset, so a blank `.env` template line no longer crashes the dev boot while prod ignored it. The published package also no longer ships the unusable compiled `dist/scripts/` codemod copies."
188
+ },
189
+ {
190
+ "version": "0.335.0",
191
+ "type": "fix",
192
+ "title": "Upgrade marker needs an installed version, Meilisearch drop keeps sibling prefixes, drainJobs sees pre-run job failures",
193
+ "detail": "`kumiko upgrade --apply` now refuses to write a marker when no installed Kumiko version can be found under the target, instead of recording the `--from` filter value. It also finds `node_modules/@cosmicdrift/*` packages that are workspace symlinks. `MeilisearchAdapter.dropAllIndexes()` only drops tenant indexes of its own prefix and leaves indexes of a longer sibling prefix alone. `drainJobs()` now rejects when a perTenant fan-out or an unknown job fails before the job body runs."
194
+ },
195
+ {
196
+ "version": "0.335.0",
197
+ "type": "fix",
198
+ "title": "SSE stream closes when access is revoked",
199
+ "detail": "The live event stream (GET /sse) now closes as soon as the session behind it is revoked or the user's tenant roles change or the membership is removed. User-addressed frames such as in-app notifications are delivered only to the addressed user instead of the whole tenant."
200
+ },
201
+ {
202
+ "version": "0.335.0",
203
+ "type": "improvement",
204
+ "title": "Boot validator checks redirect targets of writeHandler record actions",
205
+ "detail": "An unknown `redirect` screen on a projectionDetail or entityEdit action or section action fails boot, like entityEdit `redirect`.",
206
+ "migration": "If a writeHandler record action already carries a `redirect` that does not resolve to a registered screen, fix or remove it."
207
+ },
2
208
  {
3
209
  "version": "0.334.0",
4
210
  "type": "improvement",
@@ -168,8 +168,20 @@ export function migrateCrossTenantSource(
168
168
  const rewrites: Rewrite[] = [];
169
169
  const manual: ManualSite[] = [];
170
170
 
171
+ // Captured up front: a forgotten node can no longer report its position.
172
+ const lineByAssignment = new Map(targets.map((a) => [a, a.getStartLineNumber()]));
173
+
171
174
  for (const assignment of targets) {
172
- if (assignment.wasForgotten()) continue;
175
+ // A forgotten node has no position or text left; a bare `continue` would
176
+ // drop a security-relevant crossTenant switch from the report entirely.
177
+ if (assignment.wasForgotten()) {
178
+ manual.push({
179
+ file: fileName,
180
+ line: lineByAssignment.get(assignment) ?? 0,
181
+ why: "node was invalidated by an earlier rewrite in this file — run the codemod on this file again",
182
+ });
183
+ continue;
184
+ }
173
185
  const line = assignment.getStartLineNumber();
174
186
  const init = assignment.getInitializer();
175
187
 
@@ -327,7 +339,7 @@ async function main(): Promise<void> {
327
339
  }
328
340
  }
329
341
 
330
- process.exit(0);
342
+ process.exit(allManual.length > 0 ? 1 : 0);
331
343
  }
332
344
 
333
345
  if (import.meta.main) {
@@ -89,14 +89,22 @@ function isValidOwnTenantIdValue(valueNode: Node): boolean {
89
89
 
90
90
  function objectHasValidOwnTenantId(obj: Node): boolean {
91
91
  if (!Node.isObjectLiteralExpression(obj)) return false;
92
- for (const prop of obj.getProperties()) {
93
- if (Node.isPropertyAssignment(prop) && prop.getName() === "tenantId") {
94
- const init = prop.getInitializer();
95
- return init !== undefined && isValidOwnTenantIdValue(init);
96
- }
97
- if (Node.isShorthandPropertyAssignment(prop) && prop.getName() === "tenantId") {
98
- return isConstTenantIdBinding(enclosingFunctionScope(prop), "tenantId");
99
- }
92
+ const props = obj.getProperties();
93
+ const tenantIdIndex = props.findIndex(
94
+ (prop) =>
95
+ (Node.isPropertyAssignment(prop) || Node.isShorthandPropertyAssignment(prop)) &&
96
+ prop.getName() === "tenantId",
97
+ );
98
+ if (tenantIdIndex === -1) return false;
99
+ // A later spread can override the literal tenantId at runtime.
100
+ if (props.slice(tenantIdIndex + 1).some((prop) => Node.isSpreadAssignment(prop))) return false;
101
+ const prop = props[tenantIdIndex];
102
+ if (Node.isPropertyAssignment(prop)) {
103
+ const init = prop.getInitializer();
104
+ return init !== undefined && isValidOwnTenantIdValue(init);
105
+ }
106
+ if (Node.isShorthandPropertyAssignment(prop)) {
107
+ return isConstTenantIdBinding(enclosingFunctionScope(prop), "tenantId");
100
108
  }
101
109
  return false;
102
110
  }
@@ -188,6 +188,12 @@ function collectFiles(paths: string[]): string[] {
188
188
  return files;
189
189
  }
190
190
 
191
+ // Remaining manual sites still fail boot validation, so upgrade-cli must not
192
+ // treat this run as a completed migration.
193
+ export function exitCodeForManualSites(manualCount: number): 0 | 1 {
194
+ return manualCount > 0 ? 1 : 0;
195
+ }
196
+
191
197
  async function main(): Promise<void> {
192
198
  let options: CliOptions;
193
199
  try {
@@ -241,7 +247,7 @@ async function main(): Promise<void> {
241
247
  }
242
248
  }
243
249
 
244
- process.exit(0);
250
+ process.exit(exitCodeForManualSites(allManual.length));
245
251
  }
246
252
 
247
253
  if (import.meta.main) {
@@ -473,7 +473,14 @@ function reportStanceHasPersonalStance(obj: ObjectLiteralExpression): boolean {
473
473
  function reportStanceEnclosingFieldName(call: CallExpression): string | undefined {
474
474
  let node = call.getParent();
475
475
  while (node) {
476
- if (Node.isPropertyAssignment(node)) return node.getName();
476
+ if (Node.isPropertyAssignment(node)) {
477
+ // getName() keeps the quotes of a string-literal key; computed keys have no static name.
478
+ const nameNode = node.getNameNode();
479
+ if (Node.isStringLiteral(nameNode) || Node.isNoSubstitutionTemplateLiteral(nameNode)) {
480
+ return nameNode.getLiteralText();
481
+ }
482
+ return Node.isIdentifier(nameNode) ? nameNode.getText() : undefined;
483
+ }
477
484
  node = node.getParent();
478
485
  }
479
486
  return undefined;
@@ -506,17 +513,28 @@ function reportStanceResolveEntity(call: CallExpression): string | null {
506
513
  return varDecl ? varDecl.getName() : null;
507
514
  }
508
515
 
509
- // A hint only counts at a segment boundary (index 0, an uppercase letter in
510
- // the original, or preceded by `_`) — otherwise a coincidental substring
511
- // like "text" inside "contextId" would false-positive.
516
+ function isSegmentStart(fieldOriginal: string, index: number): boolean {
517
+ return (
518
+ index === 0 || /[A-Z]/.test(fieldOriginal[index] ?? "") || fieldOriginal[index - 1] === "_"
519
+ );
520
+ }
521
+
522
+ // A hint only counts when it starts and ends on a segment boundary (index 0 /
523
+ // end of field, an uppercase letter in the original, or `_`) — otherwise a
524
+ // coincidental substring like "text" in "contextId" or "type" in "contentType"
525
+ // would false-positive.
512
526
  function hintOccursAtBoundary(fieldLower: string, fieldOriginal: string, hint: string): boolean {
513
527
  let searchFrom = 0;
514
528
  for (;;) {
515
529
  const index = fieldLower.indexOf(hint, searchFrom);
516
530
  if (index === -1) return false;
517
- const atBoundary =
518
- index === 0 || /[A-Z]/.test(fieldOriginal[index] ?? "") || fieldOriginal[index - 1] === "_";
519
- if (atBoundary) return true;
531
+ const end = index + hint.length;
532
+ if (
533
+ isSegmentStart(fieldOriginal, index) &&
534
+ (end === fieldOriginal.length || isSegmentStart(fieldOriginal, end))
535
+ ) {
536
+ return true;
537
+ }
520
538
  searchFrom = index + 1;
521
539
  }
522
540
  }
@@ -532,23 +550,26 @@ function findLongestBoundaryHint(fieldLower: string, fieldOriginal: string): str
532
550
 
533
551
  function segmentAlignedSuffixes(fieldOriginal: string): string[] {
534
552
  const fieldLower = fieldOriginal.toLowerCase();
535
- const suffixes: string[] = [];
553
+ const starts: number[] = [];
536
554
  for (let index = 0; index < fieldOriginal.length; index++) {
537
- const atBoundary =
538
- index === 0 || /[A-Z]/.test(fieldOriginal[index] ?? "") || fieldOriginal[index - 1] === "_";
539
- if (atBoundary) suffixes.push(fieldLower.slice(index));
555
+ if (isSegmentStart(fieldOriginal, index)) starts.push(index);
540
556
  }
541
- return suffixes;
557
+ // Hints are lowercase, so a hint's own segment boundary is unknowable. A
558
+ // single-segment tail ("Number", "Owner") is too generic to align with one;
559
+ // two or more segments ("UserId") are specific enough.
560
+ return starts.slice(0, -1).map((index) => fieldLower.slice(index));
542
561
  }
543
562
 
544
563
  // The hint sets only carry exact full names, so a suffix variant of one
545
564
  // (e.g. "...UserId" of "assigneeUserId") otherwise slips through undetected.
565
+ // The suffix must be the tail of the hint: plain `includes` matched "number"
566
+ // inside "phonenumber" for "orderNumber".
546
567
  function findShortestHintContainingSuffix(fieldOriginal: string): string | undefined {
547
568
  let best: string | undefined;
548
569
  for (const suffix of segmentAlignedSuffixes(fieldOriginal)) {
549
570
  if (suffix.length < 5) continue;
550
571
  for (const hint of ALL_PII_NAME_HINTS) {
551
- if (!hint.includes(suffix)) continue;
572
+ if (hint === suffix || !hint.endsWith(suffix)) continue;
552
573
  if (!best || hint.length < best.length) best = hint;
553
574
  }
554
575
  }
@@ -556,22 +577,37 @@ function findShortestHintContainingSuffix(fieldOriginal: string): string | undef
556
577
  }
557
578
 
558
579
  function classifyFieldStance(field: string): { stance: StanceClass; hint: string | undefined } {
559
- const fieldLower = field.toLowerCase();
580
+ const fieldLower = field.replaceAll("_", "").toLowerCase();
560
581
  if (PII_DIRECT_NAME_HINTS.has(fieldLower)) return { stance: "direct", hint: fieldLower };
561
582
  if (PII_USER_OWNED_NAME_HINTS.has(fieldLower)) return { stance: "user-owned", hint: fieldLower };
562
583
  if (PII_USER_REFERENCE_NAME_HINTS.has(fieldLower))
563
584
  return { stance: "user-reference", hint: fieldLower };
564
- const containmentHint = findLongestBoundaryHint(fieldLower, field);
585
+ const containmentHint = findLongestBoundaryHint(field.toLowerCase(), field);
565
586
  if (containmentHint) return { stance: "near-miss", hint: containmentHint };
566
587
  const suffixHint = findShortestHintContainingSuffix(field);
567
588
  if (suffixHint) return { stance: "near-miss", hint: suffixHint };
568
589
  return { stance: "unclassified", hint: undefined };
569
590
  }
570
591
 
571
- export function reportStanceForSource(source: string, filePath: string): StanceSite[] {
572
- const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
573
- const sourceFile = project.createSourceFile(filePath, source);
592
+ function createReportStanceProject(): Project {
593
+ return new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
594
+ }
595
+
596
+ export function reportStanceForSource(
597
+ source: string,
598
+ filePath: string,
599
+ project: Project = createReportStanceProject(),
600
+ ): StanceSite[] {
601
+ const sourceFile = project.createSourceFile(filePath, source, { overwrite: true });
602
+ try {
603
+ return collectStanceSites(sourceFile);
604
+ } finally {
605
+ // A shared project would otherwise keep every scanned file's AST alive.
606
+ project.removeSourceFile(sourceFile);
607
+ }
608
+ }
574
609
 
610
+ function collectStanceSites(sourceFile: SourceFile): StanceSite[] {
575
611
  const sites: StanceSite[] = [];
576
612
  for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
577
613
  const exprNode = call.getExpression();
@@ -634,8 +670,9 @@ function reportStance(rootDir: string): void {
634
670
  };
635
671
  let total = 0;
636
672
 
673
+ const project = createReportStanceProject();
637
674
  for (const file of findReportStanceFiles(rootDir)) {
638
- const sites = reportStanceForSource(readFileSync(file, "utf8"), file);
675
+ const sites = reportStanceForSource(readFileSync(file, "utf8"), file, project);
639
676
  if (sites.length === 0) continue;
640
677
 
641
678
  console.log(`\n${relative(rootDir, file)}`);