@cosmicdrift/kumiko-framework 0.220.1 → 0.222.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 (110) hide show
  1. package/package.json +7 -3
  2. package/src/__tests__/store-table.integration.test.ts +2 -1
  3. package/src/__tests__/upgrade-cli.test.ts +81 -12
  4. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
  5. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
  7. package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
  8. package/src/api/api-constants.ts +10 -0
  9. package/src/api/auth-routes.ts +3 -0
  10. package/src/api/index.ts +1 -0
  11. package/src/api/pii-leak-guard.ts +4 -5
  12. package/src/api/server.ts +17 -1
  13. package/src/arg-parser.ts +1 -1
  14. package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
  15. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
  16. package/src/bun-db/index.ts +1 -0
  17. package/src/bun-db/query.ts +30 -14
  18. package/src/crypto/index.ts +1 -0
  19. package/src/crypto/is-self-pii-field.ts +8 -0
  20. package/src/crypto/subject-resolver.ts +4 -3
  21. package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
  22. package/src/db/__tests__/migrate-generator.test.ts +12 -0
  23. package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
  24. package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
  25. package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
  26. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
  27. package/src/db/blind-index-cleanup.ts +36 -19
  28. package/src/db/entity-table-meta.ts +6 -1
  29. package/src/db/event-store-executor-context.ts +2 -2
  30. package/src/db/event-store-executor-read.ts +7 -6
  31. package/src/db/event-store-executor-write.ts +103 -49
  32. package/src/db/index.ts +2 -0
  33. package/src/db/migrate-generator.ts +14 -0
  34. package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
  35. package/src/db/queries/backfill-pii.ts +13 -10
  36. package/src/db/queries/raw-sql.ts +14 -2
  37. package/src/db/queries/seed-context.ts +8 -4
  38. package/src/db/table-builder.ts +10 -3
  39. package/src/derivatives/__tests__/variant-key.test.ts +123 -1
  40. package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
  41. package/src/derivatives/derivatives-context.ts +4 -0
  42. package/src/derivatives/index.ts +9 -1
  43. package/src/derivatives/variant-key.ts +68 -0
  44. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
  45. package/src/engine/__tests__/boot-validator.test.ts +29 -3
  46. package/src/engine/__tests__/role-assignment.test.ts +41 -17
  47. package/src/engine/__tests__/schema-builder.test.ts +7 -7
  48. package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
  49. package/src/engine/boot-validator/entity-handler.ts +5 -0
  50. package/src/engine/boot-validator/pii-retention.ts +16 -4
  51. package/src/engine/boot-validator/screens.ts +9 -2
  52. package/src/engine/embedded-derived.ts +11 -10
  53. package/src/engine/extensions/storage-provider.ts +28 -0
  54. package/src/engine/extensions/user-data.ts +4 -0
  55. package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
  56. package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
  57. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
  58. package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
  59. package/src/engine/feature-ast/extractors/index.ts +2 -0
  60. package/src/engine/feature-ast/extractors/shared.ts +26 -1
  61. package/src/engine/feature-ast/parse.ts +10 -25
  62. package/src/engine/feature-ast/patch.ts +23 -16
  63. package/src/engine/feature-ast/render.ts +3 -3
  64. package/src/engine/field-helpers.ts +1 -1
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/pattern-library/mixed-schemas.ts +6 -0
  67. package/src/engine/role-assignment.ts +36 -18
  68. package/src/engine/schema-builder.ts +14 -2
  69. package/src/errors/__tests__/classes.test.ts +21 -2
  70. package/src/errors/__tests__/write-failures.test.ts +13 -3
  71. package/src/errors/classes.ts +14 -13
  72. package/src/errors/kumiko-error.ts +11 -11
  73. package/src/errors/write-error-info.ts +1 -1
  74. package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
  75. package/src/event-store/__tests__/perf.integration.test.ts +5 -1
  76. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
  77. package/src/event-store/event-store.ts +7 -0
  78. package/src/event-store/index.ts +1 -0
  79. package/src/files/__tests__/files.integration.test.ts +181 -2
  80. package/src/files/__tests__/local-provider.contract.test.ts +14 -0
  81. package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
  82. package/src/files/file-routes.ts +53 -6
  83. package/src/files/in-memory-provider.ts +4 -0
  84. package/src/files/local-provider.ts +22 -1
  85. package/src/i18n/__tests__/mail-registry.test.ts +13 -1
  86. package/src/i18n/__tests__/request-locale.test.ts +19 -0
  87. package/src/i18n/index.ts +7 -1
  88. package/src/i18n/mail-registry.ts +10 -0
  89. package/src/i18n/request-locale.ts +11 -2
  90. package/src/i18n/required-surface-keys.ts +3 -1
  91. package/src/jobs/job-runner.ts +22 -10
  92. package/src/lifecycle/signal-handlers.ts +2 -0
  93. package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
  94. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
  95. package/src/pipeline/__tests__/tenant-timezone-cache.test.ts +89 -0
  96. package/src/pipeline/dispatch-shared.ts +39 -2
  97. package/src/pipeline/dispatch-write.ts +48 -0
  98. package/src/pipeline/dispatcher.ts +5 -0
  99. package/src/pipeline/distributed-lock.ts +3 -0
  100. package/src/pipeline/tenant-timezone-cache.ts +92 -0
  101. package/src/schema-cli.ts +39 -48
  102. package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
  103. package/src/scripts/codemod/pii-personal-migration.ts +7 -7
  104. package/src/search/purge-subject.ts +4 -3
  105. package/src/search/reindex-entity.ts +2 -2
  106. package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
  107. package/src/stack/__tests__/request-helper.test.ts +2 -2
  108. package/src/testing/file-provider-contract.ts +19 -0
  109. package/src/ui-types/index.ts +1 -0
  110. package/src/upgrade-cli.ts +112 -15
@@ -8,47 +8,55 @@
8
8
  //
9
9
  // Usage: bun scripts/codemod/crypto-shredding-testing-move.ts <targetDir> [--dry-run]
10
10
 
11
- import { resolve } from "node:path";
12
- import { Glob } from "bun";
13
- import { Project } from "ts-morph";
11
+ import { type Dirent, readdirSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import { Project, type SourceFile } from "ts-morph";
14
14
 
15
15
  const OLD_SPECIFIER = "@cosmicdrift/kumiko-framework/crypto";
16
16
  const NEW_SPECIFIER = "@cosmicdrift/kumiko-framework/testing";
17
17
  const MOVED_NAMES = new Set(["resetPiiSubjectKmsForTests", "resetBlindIndexKeyForTests"]);
18
18
 
19
+ const EXCLUDE_DIRS = new Set(["node_modules", "dist", "build"]);
20
+
19
21
  function findTargetFiles(rootDir: string): string[] {
20
- const glob = new Glob("**/*.{ts,tsx}");
21
- const EXCLUDE = ["/node_modules/", "/dist/", "/build/"];
22
+ // Walk the tree and skip excluded dirs while descending — filtering the
23
+ // absolute path after a full Glob scan silently no-ops when the repo root
24
+ // itself contains "/build/" or "/node_modules/" (fw#2289).
22
25
  const files: string[] = [];
23
- for (const file of glob.scanSync({ cwd: rootDir, dot: false })) {
24
- const abs = resolve(rootDir, file);
25
- if (EXCLUDE.some((p) => abs.includes(p))) continue;
26
- files.push(abs);
27
- }
26
+ const walk = (dir: string): void => {
27
+ let entries: Dirent[];
28
+ try {
29
+ entries = readdirSync(dir, { withFileTypes: true });
30
+ } catch {
31
+ // skip: unreadable directory during walk — treat as empty.
32
+ return;
33
+ }
34
+ for (const ent of entries) {
35
+ if (ent.isDirectory()) {
36
+ if (EXCLUDE_DIRS.has(ent.name) || ent.name.startsWith(".")) continue;
37
+ walk(join(dir, ent.name));
38
+ continue;
39
+ }
40
+ if (ent.isFile() && /\.(ts|tsx)$/.test(ent.name)) {
41
+ files.push(join(dir, ent.name));
42
+ }
43
+ }
44
+ };
45
+ walk(rootDir);
28
46
  return files.sort();
29
47
  }
30
48
 
31
- async function main(): Promise<void> {
32
- const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
33
- const dryRun = process.argv.includes("--dry-run");
34
- const rootDir = resolve(positional[0] ?? process.cwd());
35
-
36
- const files = findTargetFiles(rootDir);
37
- const project = new Project({
38
- skipAddingFilesFromTsConfig: true,
39
- skipFileDependencyResolution: true,
40
- });
41
-
42
- let touchedFiles = 0;
43
- let movedNames = 0;
44
-
45
- for (const file of files) {
46
- const sourceFile = project.addSourceFileAtPath(file);
47
- const oldImport = sourceFile
48
- .getImportDeclarations()
49
- .find((d) => d.getModuleSpecifierValue() === OLD_SPECIFIER);
50
- if (!oldImport) continue;
49
+ /** Move matching value imports from crypto → testing. Returns count of names moved. */
50
+ function migrateFile(sourceFile: SourceFile): number {
51
+ // Value imports only — a pre-existing `import type { … } from testing`
52
+ // must not receive runtime helpers (they would be erased).
53
+ const oldImports = sourceFile
54
+ .getImportDeclarations()
55
+ .filter((d) => d.getModuleSpecifierValue() === OLD_SPECIFIER && !d.isTypeOnly());
56
+ if (oldImports.length === 0) return 0;
51
57
 
58
+ let fileMoved = 0;
59
+ for (const oldImport of oldImports) {
52
60
  const movedHere = oldImport.getNamedImports().filter((spec) => MOVED_NAMES.has(spec.getName()));
53
61
  if (movedHere.length === 0) continue;
54
62
 
@@ -56,7 +64,7 @@ async function main(): Promise<void> {
56
64
 
57
65
  const existingNewImport = sourceFile
58
66
  .getImportDeclarations()
59
- .find((d) => d.getModuleSpecifierValue() === NEW_SPECIFIER);
67
+ .find((d) => d.getModuleSpecifierValue() === NEW_SPECIFIER && !d.isTypeOnly());
60
68
  if (existingNewImport) {
61
69
  const already = new Set(existingNewImport.getNamedImports().map((s) => s.getName()));
62
70
  for (const name of names) {
@@ -73,8 +81,32 @@ async function main(): Promise<void> {
73
81
  !!oldImport.getNamespaceImport();
74
82
  if (!remaining) oldImport.remove();
75
83
 
84
+ fileMoved += names.length;
85
+ }
86
+ return fileMoved;
87
+ }
88
+
89
+ async function main(): Promise<void> {
90
+ const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
91
+ const dryRun = process.argv.includes("--dry-run");
92
+ const rootDir = resolve(positional[0] ?? process.cwd());
93
+
94
+ const files = findTargetFiles(rootDir);
95
+ const project = new Project({
96
+ skipAddingFilesFromTsConfig: true,
97
+ skipFileDependencyResolution: true,
98
+ });
99
+
100
+ let touchedFiles = 0;
101
+ let movedNames = 0;
102
+
103
+ for (const file of files) {
104
+ const sourceFile = project.addSourceFileAtPath(file);
105
+ const fileMoved = migrateFile(sourceFile);
106
+ if (fileMoved === 0) continue;
107
+
76
108
  touchedFiles++;
77
- movedNames += names.length;
109
+ movedNames += fileMoved;
78
110
  if (!dryRun) sourceFile.saveSync();
79
111
  }
80
112
 
@@ -77,7 +77,7 @@ const OVERRIDES_ARG_INDEX_1 = new Set(["createEmbeddedField", "createEmbeddedLis
77
77
 
78
78
  const FIELD_FACTORY_RE = /^create\w*Field$/;
79
79
 
80
- type ReportEntry = { readonly file: string; readonly line: number; readonly reason: string };
80
+ type ReportEntry = { readonly file: string; readonly line: number; readonly note: string };
81
81
  type FindBucket = "exact" | "fuzzy" | "none" | "secret" | "ref" | "personal-false" | "no-find";
82
82
 
83
83
  const reports: ReportEntry[] = [];
@@ -92,11 +92,11 @@ const counts: Record<FindBucket, number> = {
92
92
  "no-find": 0,
93
93
  };
94
94
 
95
- function report(node: Node, reason: string): void {
95
+ function report(node: Node, note: string): void {
96
96
  reports.push({
97
97
  file: node.getSourceFile().getFilePath(),
98
98
  line: node.getStartLineNumber(),
99
- reason,
99
+ note,
100
100
  });
101
101
  }
102
102
 
@@ -374,8 +374,8 @@ function processObjectLiteral(obj: ObjectLiteralExpression, factoryName: string)
374
374
  newLookupableSites.push({
375
375
  file: obj.getSourceFile().getFilePath(),
376
376
  line: subject.prop.getStartLineNumber(),
377
- // find: "fuzzy" newly adds lookupable (was searchable-only) needs a _bidx column migration
378
- reason: "fuzzy_search_needs_bidx_migration",
377
+ // Guard keys on property name `reason`; this is console copy, not an error code.
378
+ note: "needs a `_bidx` column migration",
379
379
  });
380
380
  }
381
381
 
@@ -469,11 +469,11 @@ async function main(): Promise<void> {
469
469
  }
470
470
  console.log(`\nNewly gains lookupable (needs a _bidx migration): ${newLookupableSites.length}`);
471
471
  for (const r of newLookupableSites) {
472
- console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.reason}`);
472
+ console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.note}`);
473
473
  }
474
474
  console.log(`\nReported (not transformed): ${reports.length}`);
475
475
  for (const r of reports) {
476
- console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.reason}`);
476
+ console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.note}`);
477
477
  }
478
478
  }
479
479
 
@@ -9,11 +9,12 @@
9
9
  // that still carry the subject key in encrypted columns.
10
10
 
11
11
  import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
12
+ import { isSelfPiiField } from "../crypto/is-self-pii-field";
12
13
  import type { SubjectId } from "../crypto/kms-adapter";
13
14
  import { collectSearchableSubjectFields } from "../crypto/subject-resolver";
14
15
  import type { DbRunner } from "../db/connection";
15
16
  import { resolveTableName } from "../db/entity-table-meta";
16
- import { executeRawQuery } from "../db/queries/raw-sql";
17
+ import { executeRawQueryRead } from "../db/queries/raw-sql";
17
18
  import type { FeatureDefinition } from "../engine/types";
18
19
  import type { EntityDefinition } from "../engine/types/fields";
19
20
  import type { EntityId, TenantId } from "../engine/types/identifiers";
@@ -46,7 +47,7 @@ function ownershipPredicates(
46
47
  params.push(subject.userId);
47
48
  parts.push(`${quoteIdent(col)} = $${n}`);
48
49
  }
49
- } else if ("pii" in field && field.pii === true && selfIdN === undefined) {
50
+ } else if (isSelfPiiField(field) && selfIdN === undefined) {
50
51
  selfIdN = nextParam();
51
52
  params.push(subject.userId);
52
53
  parts.push(`${quoteIdent("id")} = $${selfIdN}`);
@@ -78,7 +79,7 @@ async function collectMatchingRowsForEntity(
78
79
  let offset = 0;
79
80
  for (;;) {
80
81
  const offsetN = params.length + 1;
81
- const page = await executeRawQuery<MatchedRow>(
82
+ const page = await executeRawQueryRead<MatchedRow>(
82
83
  db,
83
84
  `SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE ${whereSql}
84
85
  ORDER BY ${quoteIdent("id")} ASC
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { DbRunner } from "../db/connection";
9
9
  import { resolveTableName } from "../db/entity-table-meta";
10
- import { executeRawQuery } from "../db/queries/raw-sql";
10
+ import { executeRawQueryRead } from "../db/queries/raw-sql";
11
11
  import type { Registry, TenantId } from "../engine/types";
12
12
  import {
13
13
  buildSearchDocument,
@@ -112,7 +112,7 @@ export async function reindexEntity(
112
112
  // across both types needs a text cast that breaks integer ordering.
113
113
  // This is a one-time backfill over existing rows, not a live hot path;
114
114
  // switch to keyset if it ever needs to run against a churning table.
115
- const rows = await executeRawQuery<Record<string, unknown>>(
115
+ const rows = await executeRawQueryRead<Record<string, unknown>>(
116
116
  db,
117
117
  `SELECT * FROM ${quoteIdent(tableName)}
118
118
  WHERE ${quoteIdent("tenant_id")} = $1 ${deletedFilter}
@@ -10,7 +10,7 @@ import { NotFoundError, UnprocessableError, writeFailure } from "../../errors";
10
10
  import { setupTestStack, type TestStack } from "../test-stack";
11
11
  import { TestUsers } from "../test-users";
12
12
 
13
- let stack: TestStack;
13
+ let stack: TestStack | undefined;
14
14
 
15
15
  const pingFeature = defineFeature("reqhelp", (r) => {
16
16
  r.writeHandler(
@@ -49,12 +49,12 @@ beforeAll(async () => {
49
49
  });
50
50
 
51
51
  afterAll(async () => {
52
- await stack.cleanup();
52
+ await stack?.cleanup();
53
53
  });
54
54
 
55
55
  describe("createRequestHelper via setupTestStack.http", () => {
56
56
  test("writeOk posts to /api/write and returns handler data", async () => {
57
- const data = await stack.http.writeOk<{ note: string; userId: string }>(
57
+ const data = await stack!.http.writeOk<{ note: string; userId: string }>(
58
58
  "reqhelp:write:echo",
59
59
  { note: "hello" },
60
60
  TestUsers.admin,
@@ -63,7 +63,7 @@ describe("createRequestHelper via setupTestStack.http", () => {
63
63
  });
64
64
 
65
65
  test("queryOk posts to /api/query and returns handler data", async () => {
66
- const data = await stack.http.queryOk<{ id: string; ok: boolean }>(
66
+ const data = await stack!.http.queryOk<{ id: string; ok: boolean }>(
67
67
  "reqhelp:query:lookup",
68
68
  { id: "abc" },
69
69
  TestUsers.admin,
@@ -72,14 +72,14 @@ describe("createRequestHelper via setupTestStack.http", () => {
72
72
  });
73
73
 
74
74
  test("writeErr returns structured WriteErrorInfo with httpStatus", async () => {
75
- const err = await stack.http.writeErr("reqhelp:write:boom", {}, TestUsers.admin);
75
+ const err = await stack!.http.writeErr("reqhelp:write:boom", {}, TestUsers.admin);
76
76
  expect(err.code).toBe("unprocessable");
77
- expect(err.httpStatus).toBeGreaterThanOrEqual(400);
77
+ expect(err.httpStatus).toBe(422);
78
78
  expect(err.i18nKey).toBe("errors.unprocessable");
79
79
  });
80
80
 
81
81
  test("queryErr returns structured WriteErrorInfo for not-found", async () => {
82
- const err = await stack.http.queryErr(
82
+ const err = await stack!.http.queryErr(
83
83
  "reqhelp:query:lookup",
84
84
  { id: "missing" },
85
85
  TestUsers.admin,
@@ -89,13 +89,13 @@ describe("createRequestHelper via setupTestStack.http", () => {
89
89
  });
90
90
 
91
91
  test("writeOk throws when the write fails (so suites cannot ignore failures)", async () => {
92
- await expect(stack.http.writeOk("reqhelp:write:boom", {}, TestUsers.admin)).rejects.toThrow(
92
+ await expect(stack!.http.writeOk("reqhelp:write:boom", {}, TestUsers.admin)).rejects.toThrow(
93
93
  /reqhelp:write:boom/,
94
94
  );
95
95
  });
96
96
 
97
97
  test("batch posts commands and returns per-command results", async () => {
98
- const res = await stack.http.batch(
98
+ const res = await stack!.http.batch(
99
99
  [
100
100
  { type: "reqhelp:write:echo", payload: { note: "a" } },
101
101
  { type: "reqhelp:write:echo", payload: { note: "b" } },
@@ -112,7 +112,7 @@ describe("createRequestHelper via setupTestStack.http", () => {
112
112
  });
113
113
 
114
114
  test("writeWithHeaders forwards extra headers alongside auth", async () => {
115
- const res = await stack.http.writeWithHeaders(
115
+ const res = await stack!.http.writeWithHeaders(
116
116
  "reqhelp:write:echo",
117
117
  { note: "hdr" },
118
118
  TestUsers.admin,
@@ -122,5 +122,19 @@ describe("createRequestHelper via setupTestStack.http", () => {
122
122
  const body = (await res.json()) as { isSuccess?: boolean; data?: { note?: string } };
123
123
  expect(body.isSuccess).toBe(true);
124
124
  expect(body.data?.note).toBe("hdr");
125
+ expect(res.headers.get("X-Correlation-ID")).toBe("corr-42");
126
+ });
127
+
128
+ test("queryWithHeaders forwards extra headers alongside auth", async () => {
129
+ const res = await stack!.http.queryWithHeaders(
130
+ "reqhelp:query:lookup",
131
+ { id: "abc" },
132
+ TestUsers.admin,
133
+ { "X-Correlation-ID": "corr-query" },
134
+ );
135
+ expect(res.ok).toBe(true);
136
+ expect(res.headers.get("X-Correlation-ID")).toBe("corr-query");
137
+ const body = (await res.json()) as { data?: { id?: string; ok?: boolean } };
138
+ expect(body.data).toEqual({ id: "abc", ok: true });
125
139
  });
126
140
  });
@@ -103,7 +103,7 @@ describe("createRequestHelper", () => {
103
103
  ),
104
104
  );
105
105
  const http = createRequestHelper(app, jwtStub().jwt);
106
- expect(http.writeOk("todo.create", {}, user)).rejects.toThrow(
106
+ await expect(http.writeOk("todo.create", {}, user)).rejects.toThrow(
107
107
  'Expected write "todo.create" to succeed but got error: internal_error (DbError: connection lost)',
108
108
  );
109
109
  });
@@ -119,7 +119,7 @@ describe("createRequestHelper", () => {
119
119
 
120
120
  const succeeding = appRecording(() => Response.json({ isSuccess: true, data: {} }));
121
121
  const http2 = createRequestHelper(succeeding.app, jwtStub().jwt);
122
- expect(http2.queryErr("q.type", {}, user)).rejects.toThrow(
122
+ await expect(http2.queryErr("q.type", {}, user)).rejects.toThrow(
123
123
  'Expected query "q.type" to fail but it succeeded',
124
124
  );
125
125
  });
@@ -90,6 +90,25 @@ export function describeFileProviderContract(
90
90
  await expect(it.next()).rejects.toThrow();
91
91
  });
92
92
 
93
+ test("list returns only keys under the given prefix", async () => {
94
+ const group = `contract/list-${crypto.randomUUID()}`;
95
+ const keyA = `${group}/a.txt`;
96
+ const keyB = `${group}/b.txt`;
97
+ const outsideKey = `contract/list-${crypto.randomUUID()}/c.txt`;
98
+ writtenKeys.push(keyA, keyB, outsideKey);
99
+ await provider.write(keyA, bytes("a"));
100
+ await provider.write(keyB, bytes("b"));
101
+ await provider.write(outsideKey, bytes("c"));
102
+
103
+ const listed = await provider.list(`${group}/`);
104
+ expect(new Set(listed)).toEqual(new Set([keyA, keyB]));
105
+ });
106
+
107
+ test("list on a prefix with no matches returns an empty array", async () => {
108
+ const listed = await provider.list(`contract/list-missing-${crypto.randomUUID()}/`);
109
+ expect(listed).toEqual([]);
110
+ });
111
+
93
112
  test("getSignedUrl, when implemented, returns a URL string", async () => {
94
113
  // skip: getSignedUrl is optional on the contract — feature-detected
95
114
  if (!provider.getSignedUrl) return;
@@ -77,6 +77,7 @@ export type {
77
77
  EntityEditScreenDefinition,
78
78
  EntityListScreenDefinition,
79
79
  FieldCondition,
80
+ FieldIconKey,
80
81
  FieldRenderer,
81
82
  FormWidth,
82
83
  ListColumnSpec,
@@ -4,6 +4,7 @@ import {
4
4
  readdirSync,
5
5
  readFileSync,
6
6
  realpathSync,
7
+ statSync,
7
8
  writeFileSync,
8
9
  } from "node:fs";
9
10
  import { isAbsolute, join, relative, resolve } from "node:path";
@@ -170,7 +171,23 @@ export function findFeaturesDirs(cwd: string): string[] {
170
171
  // node_modules after a plain npm/bun install (fw#2301).
171
172
  export function findCodemodScriptsRoot(repoRoot: string): string | null {
172
173
  const local = join(repoRoot, "packages/framework/src");
173
- if (existsSync(join(local, CODEMOD_SUBDIR))) return local;
174
+ if (existsSync(join(local, CODEMOD_SUBDIR))) {
175
+ // If package.json exists, require it to be kumiko-framework so a generic
176
+ // monorepo `packages/framework` cannot shadow the installed package.
177
+ // Missing package.json (test fixtures / incomplete trees) keeps prior behavior.
178
+ const pkgPath = join(repoRoot, "packages/framework/package.json");
179
+ if (existsSync(pkgPath)) {
180
+ try {
181
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string };
182
+ if (pkg.name === "@cosmicdrift/kumiko-framework") return local;
183
+ // wrong name → fall through
184
+ } catch {
185
+ // unreadable → fall through
186
+ }
187
+ } else {
188
+ return local;
189
+ }
190
+ }
174
191
 
175
192
  let dir = repoRoot;
176
193
  for (let i = 0; i < 10; i++) {
@@ -187,7 +204,10 @@ export function findCodemodScriptsRoot(repoRoot: string): string | null {
187
204
  // Resolves a changes.json `codemod` field to an absolute script path,
188
205
  // refusing anything that would escape scripts/codemod/ (path traversal,
189
206
  // absolute paths, symlinks pointing outward) or that isn't a real .ts file.
190
- export function resolveCodemodScript(
207
+ // First arg is the consumer repo root (same as findCodemodScriptsRoot) —
208
+ // not the scripts root — so out-of-tree callers keep compiling against the
209
+ // public upgrade-cli subpath without silently resolving to null (fw#2341).
210
+ function resolveCodemodScriptAt(
191
211
  codemodScriptsRoot: string,
192
212
  codemodField: string | undefined,
193
213
  ): string | null {
@@ -213,6 +233,15 @@ export function resolveCodemodScript(
213
233
  return resolved;
214
234
  }
215
235
 
236
+ export function resolveCodemodScript(
237
+ repoRoot: string,
238
+ codemodField: string | undefined,
239
+ ): string | null {
240
+ const scriptsRoot = findCodemodScriptsRoot(repoRoot);
241
+ if (!scriptsRoot) return null;
242
+ return resolveCodemodScriptAt(scriptsRoot, codemodField);
243
+ }
244
+
216
245
  type CodemodRunResult = { readonly ok: boolean; readonly output: string };
217
246
 
218
247
  // Array-form argv only — never a shell string. The script itself decides
@@ -242,10 +271,15 @@ type UpgradeMarkerCodemod = {
242
271
  readonly codemod: string;
243
272
  readonly title: string;
244
273
  };
274
+ type UpgradeMarkerManual = {
275
+ readonly version: string;
276
+ readonly title: string;
277
+ };
245
278
  type UpgradeMarker = {
246
279
  readonly version: string;
247
280
  readonly appliedAt: string;
248
281
  readonly codemods: readonly UpgradeMarkerCodemod[];
282
+ readonly pendingManual?: readonly UpgradeMarkerManual[];
249
283
  };
250
284
 
251
285
  function writeUpgradeMarker(targetDir: string, marker: UpgradeMarker): void {
@@ -254,24 +288,64 @@ function writeUpgradeMarker(targetDir: string, marker: UpgradeMarker): void {
254
288
  writeFileSync(join(dir, "upgrade-state.json"), `${JSON.stringify(marker, null, 2)}\n`, "utf-8");
255
289
  }
256
290
 
291
+ /** Highest pending version strictly below the earliest open manual breaking
292
+ * entry (no codemod). Falls back to highest non-breaking pending when every
293
+ * pending version is at/after that manual — never advances onto the manual. */
294
+ /** Highest pending version strictly below the earliest open manual breaking
295
+ * entry (no codemod). Falls back to highest non-breaking pending when every
296
+ * pending version is at/after that manual. If only manuals remain, returns
297
+ * `fallbackInstalled` so the marker stamp moves without claiming manuals done. */
298
+ function markerVersionForPending(
299
+ pending: readonly ChangelogEntry[],
300
+ manualEntries: readonly ChangelogEntry[],
301
+ fallbackInstalled: string,
302
+ ): string {
303
+ const earliestManual = [...manualEntries].sort((a, b) =>
304
+ compareVersions(a.version, b.version),
305
+ )[0];
306
+ const eligible = pending.filter(
307
+ (e) => earliestManual === undefined || compareVersions(e.version, earliestManual.version) < 0,
308
+ );
309
+ const headEligible = eligible[0];
310
+ if (headEligible !== undefined) {
311
+ return eligible.reduce(
312
+ (max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
313
+ headEligible.version,
314
+ );
315
+ }
316
+ const nonBreaking = pending.filter((e) => e.type !== "breaking");
317
+ const headNonBreaking = nonBreaking[0];
318
+ if (headNonBreaking !== undefined) {
319
+ return nonBreaking.reduce(
320
+ (max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
321
+ headNonBreaking.version,
322
+ );
323
+ }
324
+ return fallbackInstalled;
325
+ }
326
+
257
327
  // Runs every pending breaking entry's codemod, oldest version first (so a
258
328
  // later codemod can assume an earlier one already ran). Stops on the first
259
329
  // failure — no partial marker. Writes the marker whenever dryRun is false —
260
330
  // even with zero pending entries, so an already-current app still gets a
261
331
  // bootstrap marker recording its installed version (fw#2299).
332
+ // kumiko-lint-ignore complexity-budget sequential codemod runner with zero-pending bootstrap marker
262
333
  async function applyCodemods(
263
334
  out: UpgradeCliOut,
264
335
  pending: readonly ChangelogEntry[],
265
336
  repoRoot: string,
266
337
  targetDir: string,
267
338
  dryRun: boolean,
268
- currentVersion: string,
339
+ // Installed version for the zero-pending bootstrap marker — never a
340
+ // `--from` filter override (that would permanently skip real pending
341
+ // codemods once the CI guard compares against the fake marker).
342
+ markerVersion: string,
269
343
  ): Promise<number> {
270
344
  if (pending.length === 0) {
271
345
  out.log(" ✓ Nothing new since your version.");
272
346
  if (!dryRun) {
273
347
  writeUpgradeMarker(targetDir, {
274
- version: currentVersion,
348
+ version: markerVersion,
275
349
  appliedAt: Temporal.Now.instant().toString(),
276
350
  codemods: [],
277
351
  });
@@ -284,7 +358,7 @@ async function applyCodemods(
284
358
  const codemodEntries = breaking
285
359
  .filter(hasCodemod)
286
360
  .sort((a, b) => compareVersions(a.version, b.version));
287
- const manualEntries = breaking.filter((e) => !e.codemod);
361
+ const manualEntries = breaking.filter((e) => !hasCodemod(e));
288
362
 
289
363
  for (const e of manualEntries) {
290
364
  out.log(` ⚠ ${e.version} · ${e.title} — no codemod, manual migration required`);
@@ -296,15 +370,28 @@ async function applyCodemods(
296
370
  ? " No automatable codemods among the pending breaking changes."
297
371
  : " ✓ No breaking changes pending.",
298
372
  );
373
+ if (!dryRun) {
374
+ const markerVer = markerVersionForPending(pending, manualEntries, markerVersion);
375
+ writeUpgradeMarker(targetDir, {
376
+ version: markerVer,
377
+ appliedAt: Temporal.Now.instant().toString(),
378
+ codemods: [],
379
+ });
380
+ out.log(` ✓ Applied 0 codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`);
381
+ }
299
382
  return 0;
300
383
  }
301
384
 
302
385
  const codemodScriptsRoot = findCodemodScriptsRoot(repoRoot);
386
+ if (codemodScriptsRoot === null) {
387
+ out.err(
388
+ ` ✗ could not locate @cosmicdrift/kumiko-framework/src/scripts/codemod — is the framework installed? searched from ${repoRoot} upward`,
389
+ );
390
+ return 1;
391
+ }
303
392
  const ran: UpgradeMarkerCodemod[] = [];
304
393
  for (const e of codemodEntries) {
305
- const scriptPath = codemodScriptsRoot
306
- ? resolveCodemodScript(codemodScriptsRoot, e.codemod)
307
- : null;
394
+ const scriptPath = resolveCodemodScript(repoRoot, e.codemod);
308
395
  if (!scriptPath) {
309
396
  out.err(` ✗ ${e.version} · ${e.title} — invalid codemod path "${e.codemod}"`);
310
397
  return 1;
@@ -325,20 +412,20 @@ async function applyCodemods(
325
412
  return 0;
326
413
  }
327
414
 
328
- const firstPending = pending[0];
329
- if (!firstPending) return 0;
330
- const latestVersion = pending.reduce(
331
- (max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
332
- firstPending.version,
333
- );
415
+ const latestVersion = markerVersionForPending(pending, manualEntries, markerVersion);
416
+ const pendingManual = manualEntries.map((e) => ({ version: e.version, title: e.title }));
334
417
  writeUpgradeMarker(targetDir, {
335
418
  version: latestVersion,
336
419
  appliedAt: Temporal.Now.instant().toString(),
337
420
  codemods: ran,
421
+ ...(pendingManual.length > 0 && { pendingManual }),
338
422
  });
339
423
  out.log(
340
424
  ` ✓ Applied ${ran.length} codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`,
341
425
  );
426
+ if (pendingManual.length > 0) {
427
+ out.log(` ⚠ ${pendingManual.length} breaking change(s) still need manual migration.`);
428
+ }
342
429
  return 0;
343
430
  }
344
431
 
@@ -398,9 +485,19 @@ export async function runUpgradeCli(
398
485
  if (getFlag(args, "apply")) {
399
486
  const dirFlag = getStringFlag(args, "dir");
400
487
  const targetDir = dirFlag ? resolve(dirFlag) : cwd;
488
+ if (!existsSync(targetDir) || !statSync(targetDir).isDirectory()) {
489
+ out.err("");
490
+ out.err(` --dir path is not a directory: ${targetDir}`);
491
+ out.err("");
492
+ return 1;
493
+ }
494
+ // Marker must reflect what is actually installed under the target (or
495
+ // cwd), not a `--from` filter override — otherwise CI stays green forever.
496
+ const markerVersion =
497
+ (dirFlag ? readCurrentVersion(targetDir) : null) ?? installedVersion ?? currentVersion;
401
498
  const dryRun = getFlag(args, "dry-run");
402
499
  out.log("");
403
- const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun, currentVersion);
500
+ const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun, markerVersion);
404
501
  out.log("");
405
502
  return code;
406
503
  }