@cosmicdrift/kumiko-framework 0.333.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 (172) 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/eagerload.js +2 -1
  23. package/dist/db/entity-table-meta.js +5 -3
  24. package/dist/db/event-store-executor-context.js +13 -19
  25. package/dist/db/event-store-executor-read.js +39 -36
  26. package/dist/db/event-store-executor-write.js +15 -17
  27. package/dist/db/migrate-generator.js +27 -28
  28. package/dist/db/pg-error.js +9 -5
  29. package/dist/db/queries/backfill-pii.js +15 -5
  30. package/dist/db/queries/shadow-swap.js +5 -4
  31. package/dist/db/table-builder.d.ts +1 -0
  32. package/dist/db/table-builder.js +7 -15
  33. package/dist/db/tenant-db.js +18 -2
  34. package/dist/derivatives/variant-key.js +3 -1
  35. package/dist/engine/boot-validator/access-declarations.js +12 -3
  36. package/dist/engine/boot-validator/config-deps.js +13 -0
  37. package/dist/engine/boot-validator/entity-handler.js +13 -1
  38. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  39. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  40. package/dist/engine/boot-validator/index.js +6 -6
  41. package/dist/engine/boot-validator/nav.d.ts +2 -1
  42. package/dist/engine/boot-validator/nav.js +10 -20
  43. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  44. package/dist/engine/boot-validator/ownership.js +2 -1
  45. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  46. package/dist/engine/boot-validator/pii-retention.js +44 -4
  47. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  48. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  49. package/dist/engine/boot-validator/screens.d.ts +2 -2
  50. package/dist/engine/boot-validator/screens.js +138 -36
  51. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  52. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  53. package/dist/engine/boot-validator/zod-shape.js +17 -4
  54. package/dist/engine/build-app-schema.js +65 -19
  55. package/dist/engine/build-config-feature-schema.js +106 -22
  56. package/dist/engine/changeset-changes.js +24 -10
  57. package/dist/engine/config-helpers.d.ts +2 -2
  58. package/dist/engine/entity-handlers.js +7 -2
  59. package/dist/engine/feature-ast/extractors/events.js +11 -8
  60. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  61. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  62. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  63. package/dist/engine/feature-ast/parse.d.ts +1 -0
  64. package/dist/engine/feature-ast/parse.js +18 -3
  65. package/dist/engine/feature-ast/patch.js +80 -61
  66. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  67. package/dist/engine/feature-ast/patcher.js +2 -1
  68. package/dist/engine/feature-ast/render.js +1 -1
  69. package/dist/engine/feature-entity-handlers.js +22 -0
  70. package/dist/engine/field-access.d.ts +2 -0
  71. package/dist/engine/field-access.js +40 -0
  72. package/dist/engine/index.d.ts +5 -4
  73. package/dist/engine/index.js +4 -3
  74. package/dist/engine/ownership.d.ts +1 -0
  75. package/dist/engine/ownership.js +15 -2
  76. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  77. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  78. package/dist/engine/pattern-library/shared-fields.js +17 -0
  79. package/dist/engine/registry-facade.js +3 -0
  80. package/dist/engine/role-assignment.d.ts +7 -1
  81. package/dist/engine/role-assignment.js +27 -7
  82. package/dist/engine/schema-builder.d.ts +2 -0
  83. package/dist/engine/schema-builder.js +20 -3
  84. package/dist/engine/screen-helpers.js +15 -4
  85. package/dist/engine/steps/_event-match.js +22 -3
  86. package/dist/engine/steps/_read-source.js +8 -0
  87. package/dist/engine/types/index.d.ts +1 -1
  88. package/dist/engine/where-rule-lint.js +22 -4
  89. package/dist/env/index.js +21 -1
  90. package/dist/errors/reasons.d.ts +0 -1
  91. package/dist/errors/reasons.js +2 -5
  92. package/dist/event-store/event-store.js +6 -1
  93. package/dist/event-store/provenance-append.js +4 -1
  94. package/dist/files/file-ref-entity.d.ts +6 -0
  95. package/dist/files/file-ref-table.d.ts +6 -0
  96. package/dist/files/file-routes.d.ts +0 -1
  97. package/dist/files/file-routes.js +22 -7
  98. package/dist/files/local-provider.js +13 -10
  99. package/dist/files/provider-resolver.js +20 -1
  100. package/dist/files/types.js +16 -1
  101. package/dist/i18n/request-locale.d.ts +3 -2
  102. package/dist/i18n/request-locale.js +16 -6
  103. package/dist/i18n/required-surface-keys.js +28 -35
  104. package/dist/i18n/settings-hub-keys.js +21 -4
  105. package/dist/jobs/index.d.ts +2 -2
  106. package/dist/jobs/index.js +1 -1
  107. package/dist/jobs/job-runner.d.ts +2 -0
  108. package/dist/jobs/job-runner.js +150 -59
  109. package/dist/migrations/index.d.ts +1 -1
  110. package/dist/migrations/index.js +1 -1
  111. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  112. package/dist/migrations/pending-rebuilds.js +23 -1
  113. package/dist/observability/escape-hatch-report.d.ts +1 -0
  114. package/dist/observability/escape-hatch-report.js +3 -0
  115. package/dist/observability/index.d.ts +1 -0
  116. package/dist/observability/index.js +1 -0
  117. package/dist/pipeline/dispatch-batch.js +8 -3
  118. package/dist/pipeline/dispatch-query.d.ts +1 -1
  119. package/dist/pipeline/dispatch-query.js +2 -2
  120. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  121. package/dist/pipeline/dispatch-shared.js +14 -9
  122. package/dist/pipeline/dispatch-stream.js +2 -5
  123. package/dist/pipeline/dispatch-write.js +27 -7
  124. package/dist/pipeline/dispatcher-utils.js +4 -1
  125. package/dist/pipeline/dispatcher.js +1 -2
  126. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  127. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  128. package/dist/pipeline/event-dispatcher.js +26 -11
  129. package/dist/pipeline/index.d.ts +1 -1
  130. package/dist/pipeline/index.js +1 -1
  131. package/dist/pipeline/member-read-only-transaction.js +1 -14
  132. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  133. package/dist/pipeline/msp-rebuild.js +1 -0
  134. package/dist/pipeline/system-hooks.d.ts +1 -1
  135. package/dist/pipeline/system-hooks.js +9 -7
  136. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  137. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  138. package/dist/pipeline/write-result-masking.d.ts +6 -0
  139. package/dist/pipeline/write-result-masking.js +36 -0
  140. package/dist/redis/pubsub-signal.d.ts +2 -0
  141. package/dist/redis/pubsub-signal.js +2 -2
  142. package/dist/schema-cli.js +4 -14
  143. package/dist/search/meilisearch-adapter.js +17 -6
  144. package/dist/search/purge-subject.js +8 -3
  145. package/dist/stack/drain-jobs.js +24 -12
  146. package/dist/stack/table-helpers.js +10 -8
  147. package/dist/testing/closed-connection-error.js +2 -3
  148. package/dist/testing/shared-entities.d.ts +10 -0
  149. package/dist/testing/wait-for.js +1 -0
  150. package/dist/ui-types/index.d.ts +2 -2
  151. package/dist/ui-types/index.js +1 -1
  152. package/dist/upgrade-cli.d.ts +1 -0
  153. package/dist/upgrade-cli.js +36 -15
  154. package/package.json +4 -4
  155. package/src/changes.json +218 -0
  156. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  157. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  158. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  159. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  160. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  161. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  162. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  163. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  164. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  165. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  166. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  167. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  168. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  169. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  170. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  171. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  172. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -1,615 +0,0 @@
1
- #!/usr/bin/env bun
2
- // Migrates create*Field(...) calls from the old flag-based PII API (pii,
3
- // userOwned, tenantOwned, subjectRef, allowPlaintext, lookupable,
4
- // searchable, sensitive) to the author-facing personal/find API
5
- // (kumiko-framework#2250). Idempotent — re-running is a no-op on already
6
- // migrated fields (skipped once a `personal` property is present).
7
- //
8
- // Mapping table (exact):
9
- // pii: true -> personal: "self"
10
- // userOwned: { ownerField: "x" } -> personal: { of: "x" }
11
- // tenantOwned: true -> personal: "tenant"
12
- // subjectRef: true -> personal: "ref"
13
- // allowPlaintext: "R" -> personal: false, reason: "<R, normalized to snake_case>"
14
- // lookupable: true (alone) -> find: "exact"
15
- // lookupable: true + searchable: true -> find: "fuzzy"
16
- // searchable: true (alone) -> find: "fuzzy"
17
- // sensitive: true -> find: "secret"
18
- // none of the above -> find: "none"
19
- // `find` only applies to text/longText fields, and only when the subject
20
- // is neither "ref" nor `personal: false` (see PersonalAnnotations in
21
- // packages/types/src/fields.ts). Never guesses: anything outside this
22
- // table is reported (file:line) instead of transformed.
23
- //
24
- // Usage: bun scripts/codemod/pii-personal-migration.ts <targetDir> [--dry-run]
25
- import { readFileSync } from "node:fs";
26
- import { relative, resolve } from "node:path";
27
- import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "@cosmicdrift/kumiko-framework/engine";
28
- import { Glob } from "bun";
29
- import { Node, Project, SyntaxKind, } from "ts-morph";
30
- const SUBJECT_FLAG_NAMES = [
31
- "pii",
32
- "userOwned",
33
- "tenantOwned",
34
- "subjectRef",
35
- "allowPlaintext",
36
- ];
37
- // `create*Field` factories that accept `find` (text/longText) — see
38
- // packages/framework/src/engine/factories.ts.
39
- const TEXT_FIND_FACTORIES = new Set(["createTextField"]);
40
- const LONGTEXT_FIND_FACTORIES = new Set(["createLongTextField"]);
41
- // Accept `personal` but never `find` (PersonalAnnotationsNoFind).
42
- const NO_FIND_FACTORIES = new Set([
43
- "createSelectField",
44
- "createMultiSelectField",
45
- "createNumberField",
46
- "createBigIntField",
47
- "createDecimalField",
48
- "createEmbeddedField",
49
- "createEmbeddedListField",
50
- "createJsonbField",
51
- "createDateField",
52
- "createTimestampField",
53
- "createTzField",
54
- "createLocatedTimestampField",
55
- ]);
56
- // No personal-annotation support at all — a subject flag here is a bug.
57
- const NO_PERSONAL_FACTORIES = new Set([
58
- "createBooleanField",
59
- "createMoneyField",
60
- "createFileField",
61
- "createImageField",
62
- "createFilesField",
63
- "createImagesField",
64
- "createDerivedField",
65
- ]);
66
- // Overrides live in argument position 1, not 0.
67
- const OVERRIDES_ARG_INDEX_1 = new Set(["createEmbeddedField", "createEmbeddedListField"]);
68
- const FIELD_FACTORY_RE = /^create\w*Field$/;
69
- const reports = [];
70
- const newLookupableSites = [];
71
- const counts = {
72
- exact: 0,
73
- fuzzy: 0,
74
- none: 0,
75
- secret: 0,
76
- ref: 0,
77
- "personal-false": 0,
78
- "no-find": 0,
79
- };
80
- function report(node, note) {
81
- reports.push({
82
- file: node.getSourceFile().getFilePath(),
83
- line: node.getStartLineNumber(),
84
- note,
85
- });
86
- }
87
- function isTrueLiteral(node) {
88
- return !!node && node.getKind() === SyntaxKind.TrueKeyword;
89
- }
90
- function findProp(obj, name) {
91
- const p = obj.getProperty(name);
92
- return p && Node.isPropertyAssignment(p) ? p : undefined;
93
- }
94
- // Reason-string convention (infra/guards/guard-error-reasons.ts):
95
- // ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$ — lowercase snake_case, optional
96
- // dot-namespacing. Old allowPlaintext values are free-form kebab-case.
97
- function normalizeReasonSlug(raw) {
98
- const slug = raw
99
- .trim()
100
- .toLowerCase()
101
- .replace(/[^a-z0-9.]+/g, "_")
102
- .replace(/_+/g, "_")
103
- .replace(/^[._]+|[._]+$/g, "");
104
- return /^[0-9]/.test(slug) ? `_${slug}` : slug;
105
- }
106
- function collectFieldCalls(sourceFile) {
107
- const calls = [];
108
- for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
109
- const exprNode = call.getExpression();
110
- if (!Node.isIdentifier(exprNode))
111
- continue;
112
- const name = exprNode.getText();
113
- // `createTenantConfig`/`createUserConfig`/etc. never match — they
114
- // don't end in "Field", so they're out of scope by construction.
115
- if (!FIELD_FACTORY_RE.test(name))
116
- continue;
117
- const argIndex = OVERRIDES_ARG_INDEX_1.has(name) ? 1 : 0;
118
- const objArg = call.getArguments()[argIndex];
119
- if (objArg && Node.isObjectLiteralExpression(objArg)) {
120
- calls.push({ name, objArg });
121
- }
122
- }
123
- return calls;
124
- }
125
- function reportRawSubjectLiterals(sourceFile, handled) {
126
- for (const objLit of sourceFile.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
127
- if (handled.has(objLit))
128
- continue;
129
- const found = [...SUBJECT_FLAG_NAMES, "piiEncrypted"].filter((n) => objLit.getProperty(n));
130
- if (found.length > 0) {
131
- report(objLit, `subject flag(s) [${found.join(", ")}] on an object literal that is not a create*Field(...) call`);
132
- }
133
- }
134
- }
135
- // kumiko-lint-ignore complexity-budget migration codemod, one-time script — splitting would add risk without benefit
136
- function processObjectLiteral(obj, factoryName) {
137
- // skip: field already carries `personal` — already migrated, idempotent no-op
138
- if (obj.getProperty("personal"))
139
- return;
140
- const piiEncryptedProp = obj.getProperty("piiEncrypted");
141
- if (piiEncryptedProp) {
142
- report(piiEncryptedProp, "piiEncrypted: true on an entity field (removed from the type system) — needs a human decision, not a mechanical mapping");
143
- // skip: reported above — piiEncrypted needs a human decision, not a mechanical mapping
144
- return;
145
- }
146
- const subjectProps = SUBJECT_FLAG_NAMES.map((n) => ({ name: n, prop: findProp(obj, n) })).filter((x) => !!x.prop);
147
- const lookupableProp = findProp(obj, "lookupable");
148
- const searchableProp = findProp(obj, "searchable");
149
- const sensitiveProp = findProp(obj, "sensitive");
150
- const anonymizeProp = obj.getProperty("anonymize");
151
- if (subjectProps.length === 0) {
152
- // `searchable`/`sensitive` alone remain valid, unrelated FieldDef
153
- // properties — left untouched. `lookupable` alone can never compile
154
- // (always an excess property without a `personal` to attach `find`
155
- // to). `anonymize` alone has no PersonalAnnotations arm to live on.
156
- if (lookupableProp) {
157
- report(lookupableProp, "lookupable without any subject annotation — no `personal` to attach `find` to");
158
- }
159
- if (anonymizeProp) {
160
- report(anonymizeProp, "anonymize without any subject annotation — no PersonalAnnotations arm accepts anonymize alone");
161
- }
162
- // skip: no subject flag on this field — stray lookupable/anonymize already reported above if present
163
- return;
164
- }
165
- if (subjectProps.length > 1) {
166
- const first = subjectProps[0];
167
- if (first) {
168
- report(first.prop, `multiple subject annotations on one field (${subjectProps.map((s) => s.name).join(", ")}) — needs a human decision on which subject is correct`);
169
- }
170
- // skip: reported above — multiple subject annotations need a human call on which one wins
171
- return;
172
- }
173
- const subject = subjectProps[0];
174
- if (!subject) {
175
- // skip: length===0 and length>1 already returned — empty [0] is unreachable
176
- return;
177
- }
178
- if (NO_PERSONAL_FACTORIES.has(factoryName)) {
179
- report(subject.prop, `${factoryName} has no personal-annotation support — subject flag "${subject.name}" cannot be expressed here`);
180
- // skip: reported above — this factory has no personal-annotation support to migrate into
181
- return;
182
- }
183
- const isTextFind = TEXT_FIND_FACTORIES.has(factoryName);
184
- const isLongTextFind = LONGTEXT_FIND_FACTORIES.has(factoryName);
185
- const isNoFind = NO_FIND_FACTORIES.has(factoryName);
186
- if (!isTextFind && !isLongTextFind && !isNoFind) {
187
- report(subject.prop, `unrecognized field factory "${factoryName}" — cannot verify its PersonalAnnotations shape`);
188
- // skip: reported above — unknown factory shape, cannot verify safely
189
- return;
190
- }
191
- let personalInit;
192
- let reasonInit;
193
- if (subject.name === "pii") {
194
- if (!isTrueLiteral(subject.prop.getInitializer())) {
195
- report(subject.prop, "pii is set to a non-`true` value — cannot infer intent");
196
- // skip: reported above — non-`true` pii value, can't infer intent
197
- return;
198
- }
199
- personalInit = `"self"`;
200
- }
201
- else if (subject.name === "tenantOwned") {
202
- if (!isTrueLiteral(subject.prop.getInitializer())) {
203
- report(subject.prop, "tenantOwned is set to a non-`true` value — cannot infer intent");
204
- // skip: reported above — non-`true` tenantOwned value, can't infer intent
205
- return;
206
- }
207
- personalInit = `"tenant"`;
208
- }
209
- else if (subject.name === "subjectRef") {
210
- if (!isTrueLiteral(subject.prop.getInitializer())) {
211
- report(subject.prop, "subjectRef is set to a non-`true` value — cannot infer intent");
212
- // skip: reported above — non-`true` subjectRef value, can't infer intent
213
- return;
214
- }
215
- personalInit = `"ref"`;
216
- }
217
- else if (subject.name === "allowPlaintext") {
218
- const init = subject.prop.getInitializer();
219
- if (!init) {
220
- report(subject.prop, "allowPlaintext has no value");
221
- // skip: reported above — allowPlaintext has no value to migrate
222
- return;
223
- }
224
- personalInit = "false";
225
- reasonInit = Node.isStringLiteral(init)
226
- ? `"${normalizeReasonSlug(init.getLiteralText())}"`
227
- : init.getText();
228
- }
229
- else {
230
- const init = subject.prop.getInitializer();
231
- if (!init || !Node.isObjectLiteralExpression(init)) {
232
- report(subject.prop, "userOwned value is not an object literal — cannot extract ownerField");
233
- // skip: reported above — userOwned value isn't an object literal, can't extract ownerField
234
- return;
235
- }
236
- const ownerFieldProp = findProp(init, "ownerField");
237
- const ownerFieldInit = ownerFieldProp?.getInitializer();
238
- if (!ownerFieldInit) {
239
- report(subject.prop, 'userOwned is missing an "ownerField" property');
240
- // skip: reported above — userOwned is missing its ownerField property
241
- return;
242
- }
243
- personalInit = `{ of: ${ownerFieldInit.getText()} }`;
244
- }
245
- const subjectIsRefOrPlaintext = subject.name === "subjectRef" || subject.name === "allowPlaintext";
246
- if (subjectIsRefOrPlaintext) {
247
- // These PersonalAnnotations arms carry no `find` field at all.
248
- if (lookupableProp) {
249
- report(lookupableProp, `lookupable combined with personal:${subject.name === "subjectRef" ? '"ref"' : "false"} — findability doesn't apply to this subject, table has no mapping`);
250
- // skip: reported above — lookupable doesn't apply to this subject, no mapping to migrate
251
- return;
252
- }
253
- applyTransform(obj, [subject.prop], {
254
- personal: personalInit,
255
- reason: reasonInit,
256
- find: undefined,
257
- });
258
- counts[subject.name === "subjectRef" ? "ref" : "personal-false"]++;
259
- // skip: already transformed above — nothing left to check for this subject
260
- return;
261
- }
262
- if (isNoFind) {
263
- const stray = lookupableProp ?? searchableProp ?? sensitiveProp;
264
- if (stray) {
265
- report(stray, `${factoryName} has no \`find\` in its PersonalAnnotations — findability flag present on a non-text field`);
266
- // skip: reported above — findability flag on a non-text field, no valid find to attach
267
- return;
268
- }
269
- applyTransform(obj, [subject.prop], {
270
- personal: personalInit,
271
- reason: undefined,
272
- find: undefined,
273
- });
274
- counts["no-find"]++;
275
- // skip: already transformed above — no-find factory has nothing further to check
276
- return;
277
- }
278
- const hasLookupable = !!lookupableProp;
279
- const hasSearchable = !!searchableProp;
280
- const hasSensitive = !!sensitiveProp;
281
- if (sensitiveProp && (hasLookupable || hasSearchable)) {
282
- report(sensitiveProp, 'sensitive combined with lookupable/searchable — two find values ("secret" vs "exact"/"fuzzy"), needs a human decision');
283
- // skip: reported above — sensitive plus lookupable/searchable is an ambiguous find value
284
- return;
285
- }
286
- let find;
287
- if (hasSensitive)
288
- find = "secret";
289
- else if (hasLookupable && hasSearchable)
290
- find = "fuzzy";
291
- else if (hasLookupable)
292
- find = "exact";
293
- else if (hasSearchable)
294
- find = "fuzzy";
295
- else
296
- find = "none";
297
- if (isLongTextFind && (find === "exact" || find === "fuzzy")) {
298
- const findSite = lookupableProp ?? searchableProp;
299
- if (findSite) {
300
- report(findSite, 'lookupable/searchable on createLongTextField — only "none"/"secret" are valid find values on longText');
301
- }
302
- // skip: reported above — exact/fuzzy find isn't valid on createLongTextField
303
- return;
304
- }
305
- // searchable-alone -> "fuzzy" makes expandPersonalAnnotations add
306
- // `lookupable: true`, which the field didn't carry before — that's a
307
- // new `_bidx` column (DDL migration), tracked separately for the report.
308
- if (hasSearchable && !hasLookupable) {
309
- newLookupableSites.push({
310
- file: obj.getSourceFile().getFilePath(),
311
- line: subject.prop.getStartLineNumber(),
312
- // Guard keys on property name `reason`; this is console copy, not an error code.
313
- note: "needs a `_bidx` column migration",
314
- });
315
- }
316
- const removedProps = [subject.prop, lookupableProp, searchableProp, sensitiveProp].filter((p) => !!p);
317
- applyTransform(obj, removedProps, { personal: personalInit, reason: undefined, find });
318
- counts[find]++;
319
- }
320
- function applyTransform(obj, removedProps, next) {
321
- const properties = obj.getProperties();
322
- // Call sites always pass at least subject.prop; without an anchor there is nowhere to insert.
323
- const anchor = removedProps[0];
324
- if (!anchor) {
325
- // skip: no removed prop to anchor inserts on — nothing to transform
326
- return;
327
- }
328
- const anchorIndex = properties.indexOf(anchor);
329
- const removedSet = new Set(removedProps);
330
- let insertIndex = 0;
331
- for (let i = 0; i < anchorIndex; i++) {
332
- if (!removedSet.has(properties[i]))
333
- insertIndex++;
334
- }
335
- for (const p of removedProps)
336
- p.remove();
337
- const newProps = [
338
- { name: "personal", initializer: next.personal },
339
- ];
340
- if (next.find)
341
- newProps.push({ name: "find", initializer: `"${next.find}"` });
342
- if (next.reason !== undefined)
343
- newProps.push({ name: "reason", initializer: next.reason });
344
- obj.insertPropertyAssignments(insertIndex, newProps);
345
- }
346
- function findTargetFiles(rootDir) {
347
- const glob = new Glob("**/*.{ts,tsx}");
348
- // engine/factories.ts constructs raw ResolvedPiiFlags literals as the
349
- // *implementation* of expandPersonalAnnotations (personal -> flags) —
350
- // not an authored override, so it's not a migration target.
351
- const EXCLUDE = ["/node_modules/", "/dist/", "/build/", "/engine/factories.ts"];
352
- const files = [];
353
- for (const file of glob.scanSync({ cwd: rootDir, dot: false })) {
354
- const abs = resolve(rootDir, file);
355
- if (EXCLUDE.some((p) => abs.includes(p)))
356
- continue;
357
- files.push(abs);
358
- }
359
- return files.sort();
360
- }
361
- const ALL_PII_NAME_HINTS = [
362
- ...PII_DIRECT_NAME_HINTS,
363
- ...PII_USER_OWNED_NAME_HINTS,
364
- ...PII_USER_REFERENCE_NAME_HINTS,
365
- ];
366
- const REPORT_STANCE_CALLEES = new Set(["createTextField", "createLongTextField"]);
367
- // Mirrors guard-text-field-stance.ts's hasPersonalStance exactly.
368
- function reportStanceHasPersonalStance(obj) {
369
- const prop = obj.getProperty("personal");
370
- if (!prop || !Node.isPropertyAssignment(prop))
371
- return false;
372
- const init = prop.getInitializer();
373
- return (init !== undefined &&
374
- init.getKind() !== SyntaxKind.UndefinedKeyword &&
375
- !(Node.isIdentifier(init) && init.getText() === "undefined") &&
376
- init.getKind() !== SyntaxKind.NullKeyword);
377
- }
378
- function reportStanceEnclosingFieldName(call) {
379
- let node = call.getParent();
380
- while (node) {
381
- if (Node.isPropertyAssignment(node))
382
- return node.getName();
383
- node = node.getParent();
384
- }
385
- return undefined;
386
- }
387
- function reportStanceResolveEntity(call) {
388
- let node = call.getParent();
389
- let entityCall;
390
- while (node) {
391
- if (Node.isCallExpression(node)) {
392
- const expr = node.getExpression();
393
- if (Node.isIdentifier(expr) && expr.getText() === "createEntity") {
394
- entityCall = node;
395
- break;
396
- }
397
- }
398
- node = node.getParent();
399
- }
400
- if (!entityCall)
401
- return null;
402
- const firstArg = entityCall.getArguments()[0];
403
- if (firstArg && Node.isObjectLiteralExpression(firstArg)) {
404
- const tableProp = firstArg.getProperty("table");
405
- if (tableProp && Node.isPropertyAssignment(tableProp)) {
406
- const init = tableProp.getInitializer();
407
- if (init && Node.isStringLiteral(init))
408
- return init.getLiteralText();
409
- }
410
- }
411
- const varDecl = entityCall.getParentIfKind(SyntaxKind.VariableDeclaration);
412
- return varDecl ? varDecl.getName() : null;
413
- }
414
- // A hint only counts at a segment boundary (index 0, an uppercase letter in
415
- // the original, or preceded by `_`) — otherwise a coincidental substring
416
- // like "text" inside "contextId" would false-positive.
417
- function hintOccursAtBoundary(fieldLower, fieldOriginal, hint) {
418
- let searchFrom = 0;
419
- for (;;) {
420
- const index = fieldLower.indexOf(hint, searchFrom);
421
- if (index === -1)
422
- return false;
423
- const atBoundary = index === 0 || /[A-Z]/.test(fieldOriginal[index] ?? "") || fieldOriginal[index - 1] === "_";
424
- if (atBoundary)
425
- return true;
426
- searchFrom = index + 1;
427
- }
428
- }
429
- function findLongestBoundaryHint(fieldLower, fieldOriginal) {
430
- let best;
431
- for (const hint of ALL_PII_NAME_HINTS) {
432
- if (best && hint.length <= best.length)
433
- continue;
434
- if (hintOccursAtBoundary(fieldLower, fieldOriginal, hint))
435
- best = hint;
436
- }
437
- return best;
438
- }
439
- function segmentAlignedSuffixes(fieldOriginal) {
440
- const fieldLower = fieldOriginal.toLowerCase();
441
- const suffixes = [];
442
- for (let index = 0; index < fieldOriginal.length; index++) {
443
- const atBoundary = index === 0 || /[A-Z]/.test(fieldOriginal[index] ?? "") || fieldOriginal[index - 1] === "_";
444
- if (atBoundary)
445
- suffixes.push(fieldLower.slice(index));
446
- }
447
- return suffixes;
448
- }
449
- // The hint sets only carry exact full names, so a suffix variant of one
450
- // (e.g. "...UserId" of "assigneeUserId") otherwise slips through undetected.
451
- function findShortestHintContainingSuffix(fieldOriginal) {
452
- let best;
453
- for (const suffix of segmentAlignedSuffixes(fieldOriginal)) {
454
- if (suffix.length < 5)
455
- continue;
456
- for (const hint of ALL_PII_NAME_HINTS) {
457
- if (!hint.includes(suffix))
458
- continue;
459
- if (!best || hint.length < best.length)
460
- best = hint;
461
- }
462
- }
463
- return best;
464
- }
465
- function classifyFieldStance(field) {
466
- const fieldLower = field.toLowerCase();
467
- if (PII_DIRECT_NAME_HINTS.has(fieldLower))
468
- return { stance: "direct", hint: fieldLower };
469
- if (PII_USER_OWNED_NAME_HINTS.has(fieldLower))
470
- return { stance: "user-owned", hint: fieldLower };
471
- if (PII_USER_REFERENCE_NAME_HINTS.has(fieldLower))
472
- return { stance: "user-reference", hint: fieldLower };
473
- const containmentHint = findLongestBoundaryHint(fieldLower, field);
474
- if (containmentHint)
475
- return { stance: "near-miss", hint: containmentHint };
476
- const suffixHint = findShortestHintContainingSuffix(field);
477
- if (suffixHint)
478
- return { stance: "near-miss", hint: suffixHint };
479
- return { stance: "unclassified", hint: undefined };
480
- }
481
- export function reportStanceForSource(source, filePath) {
482
- const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
483
- const sourceFile = project.createSourceFile(filePath, source);
484
- const sites = [];
485
- for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
486
- const exprNode = call.getExpression();
487
- if (!Node.isIdentifier(exprNode))
488
- continue;
489
- const callee = exprNode.getText();
490
- if (!REPORT_STANCE_CALLEES.has(callee))
491
- continue;
492
- const args = call.getArguments();
493
- if (args.length > 0) {
494
- const options = args[0];
495
- if (!options || !Node.isObjectLiteralExpression(options))
496
- continue;
497
- if (options.getProperties().some((p) => p.isKind(SyntaxKind.SpreadAssignment)))
498
- continue;
499
- if (reportStanceHasPersonalStance(options))
500
- continue;
501
- }
502
- const enclosingField = reportStanceEnclosingFieldName(call);
503
- // A call with no enclosing field (e.g. a bare createTextField() at top
504
- // level) has no real name to classify — `createTextField(...)` would
505
- // otherwise false-positive as a near-miss on its own "Text".
506
- const { stance, hint } = enclosingField
507
- ? classifyFieldStance(enclosingField)
508
- : { stance: "unclassified", hint: undefined };
509
- sites.push({
510
- line: call.getStartLineNumber(),
511
- field: enclosingField ?? `${callee}(...)`,
512
- entity: reportStanceResolveEntity(call),
513
- callee,
514
- stance,
515
- hint,
516
- });
517
- }
518
- return sites;
519
- }
520
- function findReportStanceFiles(rootDir) {
521
- const glob = new Glob("**/*.{ts,tsx}");
522
- const EXCLUDE = ["/node_modules/", "/dist/", "/build/"];
523
- const files = [];
524
- for (const file of glob.scanSync({ cwd: rootDir, dot: false })) {
525
- if (file.endsWith(".d.ts"))
526
- continue;
527
- const abs = resolve(rootDir, file);
528
- if (EXCLUDE.some((p) => abs.includes(p)))
529
- continue;
530
- files.push(abs);
531
- }
532
- return files.sort();
533
- }
534
- function reportStance(rootDir) {
535
- console.log(`Scanning every .ts/.tsx under ${rootDir} except node_modules/dist/build/*.d.ts — guard-text-field-stance scans packages/*/src/** only, so counts can differ outside that scope.`);
536
- const totals = {
537
- direct: 0,
538
- "user-owned": 0,
539
- "user-reference": 0,
540
- "near-miss": 0,
541
- unclassified: 0,
542
- };
543
- let total = 0;
544
- for (const file of findReportStanceFiles(rootDir)) {
545
- const sites = reportStanceForSource(readFileSync(file, "utf8"), file);
546
- if (sites.length === 0)
547
- continue;
548
- console.log(`\n${relative(rootDir, file)}`);
549
- for (const site of sites) {
550
- totals[site.stance]++;
551
- total++;
552
- const entity = site.entity ?? "<unresolved>";
553
- const hintSuffix = site.hint ? ` (hint: ${site.hint})` : "";
554
- console.log(` ${site.line} ${site.field} entity=${entity} ${site.callee} ${site.stance}${hintSuffix}`);
555
- }
556
- }
557
- console.log("\nBy stance:");
558
- for (const [stance, count] of Object.entries(totals)) {
559
- if (count > 0)
560
- console.log(` ${stance}: ${count}`);
561
- }
562
- console.log(`Total: ${total}`);
563
- if (totals["near-miss"] > 0) {
564
- console.log("\nHint sets in entity-handler.ts are exact name matches — near-miss field names slip past the boot heuristic.");
565
- }
566
- }
567
- async function main() {
568
- const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
569
- const rootDir = resolve(positional[0] ?? process.cwd());
570
- if (process.argv.includes("--report-stance")) {
571
- reportStance(rootDir);
572
- // skip: report mode never rewrites, so the transform path below must not run
573
- return;
574
- }
575
- const dryRun = process.argv.includes("--dry-run");
576
- const files = findTargetFiles(rootDir);
577
- const project = new Project({
578
- skipAddingFilesFromTsConfig: true,
579
- skipFileDependencyResolution: true,
580
- });
581
- let touchedFiles = 0;
582
- for (const file of files) {
583
- const sourceFile = project.addSourceFileAtPath(file);
584
- const calls = collectFieldCalls(sourceFile);
585
- const handled = new Set(calls.map((c) => c.objArg));
586
- reportRawSubjectLiterals(sourceFile, handled);
587
- const countsBefore = { ...counts };
588
- for (const { name, objArg } of calls)
589
- processObjectLiteral(objArg, name);
590
- const changed = Object.keys(counts).some((k) => counts[k] !== countsBefore[k]);
591
- if (changed) {
592
- touchedFiles++;
593
- if (!dryRun)
594
- sourceFile.saveSync();
595
- }
596
- }
597
- console.log(`\nScanned ${files.length} files under ${rootDir}${dryRun ? " (dry-run)" : ""}.`);
598
- console.log(`Touched ${touchedFiles} files.\n`);
599
- console.log("Transformed, by find/personal bucket:");
600
- for (const [bucket, n] of Object.entries(counts)) {
601
- if (n > 0)
602
- console.log(` ${bucket}: ${n}`);
603
- }
604
- console.log(`\nNewly gains lookupable (needs a _bidx migration): ${newLookupableSites.length}`);
605
- for (const r of newLookupableSites) {
606
- console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.note}`);
607
- }
608
- console.log(`\nReported (not transformed): ${reports.length}`);
609
- for (const r of reports) {
610
- console.log(` ${relative(rootDir, r.file)}:${r.line} — ${r.note}`);
611
- }
612
- }
613
- if (import.meta.main) {
614
- await main();
615
- }
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env bun
2
- export {};