@cosmicdrift/kumiko-guards 0.365.1 → 0.366.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.365.1",
3
+ "version": "0.366.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,7 +28,7 @@
28
28
  "kumiko-pre-push": "./src/pre-push.sh"
29
29
  },
30
30
  "dependencies": {
31
- "@cosmicdrift/kumiko-repo-manifest": "0.365.1",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.366.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.366.0",
4
+ "type": "improvement",
5
+ "title": "kumiko-guards gains the api-object-form guard",
6
+ "detail": "The guard fails on a positional r.writeHandler, r.queryHandler, r.entity, r.requires or r.hook call and on registerEntityCrud, and names the object form to use. It runs with the default kumiko-guards checks. The direct-entity-writes guard now also reads entity registrations written in the object form, including a backingTable override."
7
+ },
2
8
  {
3
9
  "version": "0.365.0",
4
10
  "type": "fix",
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: the registrar methods `r.writeHandler`, `r.queryHandler`, `r.entity`,
4
+ * `r.requires` and `r.hook` only take the object form; `registerEntityCrud(r, …)`
5
+ * is replaced by `r.crud(…)`.
6
+ *
7
+ * The positional-shape and receiver rules mirror the object-form codemod in
8
+ * kumiko-framework (scripts/codemod/object-form.ts). This package does not
9
+ * depend on the framework, so the two copies are held together by the parity
10
+ * test in __tests__/guard-api-object-form.test.ts.
11
+ *
12
+ * Usage:
13
+ * bun guards/guard-api-object-form.ts
14
+ */
15
+ import {
16
+ type CallExpression,
17
+ Node,
18
+ type ParameterDeclaration,
19
+ type SourceFile,
20
+ SyntaxKind,
21
+ } from "ts-morph";
22
+ import {
23
+ type AstGuard,
24
+ type GuardViolation,
25
+ relativeToCwd,
26
+ runStandalone,
27
+ type ScanSpec,
28
+ } from "./_lib/guard-kit";
29
+
30
+ const SCAN: ScanSpec = {
31
+ scope: "source+tests",
32
+ extensions: ["ts", "tsx"],
33
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
34
+ };
35
+
36
+ const REGISTRAR_METHODS = ["writeHandler", "queryHandler", "entity", "requires", "hook"] as const;
37
+ type RegistrarMethod = (typeof REGISTRAR_METHODS)[number];
38
+
39
+ const CRUD_FUNCTION_NAME = "registerEntityCrud";
40
+ const REGISTRAR_TYPE_MARKER = "FeatureRegistrar";
41
+ const FEATURE_FACTORY_NAME = "defineFeature";
42
+ const REGISTRAR_RECEIVER_NAMES: ReadonlySet<string> = new Set(["r", "registrar"]);
43
+ const EXCLUDED_PATH = /(\/__tests__\/fixtures\/|\.d\.ts$)/;
44
+ const UPGRADE_HINT = "run `bun kumiko-upgrade --apply` (codemod object-form)";
45
+
46
+ type ReceiverResolution = "registrar" | "other" | "unresolved";
47
+
48
+ function isRegistrarMethod(name: string): name is RegistrarMethod {
49
+ return REGISTRAR_METHODS.some((method) => method === name);
50
+ }
51
+
52
+ function isFeatureFactoryCallee(callee: Node): boolean {
53
+ if (Node.isIdentifier(callee)) return callee.getText() === FEATURE_FACTORY_NAME;
54
+ if (Node.isPropertyAccessExpression(callee)) return callee.getName() === FEATURE_FACTORY_NAME;
55
+ return false;
56
+ }
57
+
58
+ function isFeatureCallbackParameter(param: ParameterDeclaration): boolean {
59
+ const fn = param.getParent();
60
+ if (!Node.isArrowFunction(fn) && !Node.isFunctionExpression(fn)) return false;
61
+ if (fn.getParameters()[0] !== param) return false;
62
+ const call = fn.getParentIfKind(SyntaxKind.CallExpression);
63
+ return (
64
+ call !== undefined &&
65
+ call.getArguments()[1] === fn &&
66
+ isFeatureFactoryCallee(call.getExpression())
67
+ );
68
+ }
69
+
70
+ function resolveParameter(param: ParameterDeclaration): ReceiverResolution {
71
+ if (isFeatureCallbackParameter(param)) return "registrar";
72
+ const typeNode = param.getTypeNode();
73
+ if (typeNode === undefined) return "unresolved";
74
+ return typeNode.getText().includes(REGISTRAR_TYPE_MARKER) ? "registrar" : "other";
75
+ }
76
+
77
+ function declaredVariableIn(scope: Node, name: string): ReceiverResolution | undefined {
78
+ if (!Node.isBlock(scope) && !Node.isSourceFile(scope) && !Node.isModuleBlock(scope)) {
79
+ return undefined;
80
+ }
81
+ for (const statement of scope.getStatements()) {
82
+ if (!Node.isVariableStatement(statement)) continue;
83
+ for (const declaration of statement.getDeclarations()) {
84
+ if (declaration.getNameNode().getText() !== name) continue;
85
+ const typeText = declaration.getTypeNode()?.getText();
86
+ if (typeText === undefined) return "unresolved";
87
+ return typeText.includes(REGISTRAR_TYPE_MARKER) ? "registrar" : "other";
88
+ }
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ // Nearest binding of the receiver's name decides; no type checker involved.
94
+ function resolveReceiver(receiver: Node): ReceiverResolution {
95
+ if (!Node.isIdentifier(receiver)) return "other";
96
+ const name = receiver.getText();
97
+ for (
98
+ let scope: Node | undefined = receiver.getParent();
99
+ scope !== undefined;
100
+ scope = scope.getParent()
101
+ ) {
102
+ if (Node.isParametered(scope)) {
103
+ const param = scope.getParameters().find((p) => p.getNameNode().getText() === name);
104
+ if (param !== undefined) return resolveParameter(param);
105
+ }
106
+ const variable = declaredVariableIn(scope, name);
107
+ if (variable !== undefined) return variable;
108
+ }
109
+ return "unresolved";
110
+ }
111
+
112
+ function isRegistrarOrLookalike(receiver: Node): boolean {
113
+ const resolution = resolveReceiver(receiver);
114
+ if (resolution === "registrar") return true;
115
+ return (
116
+ resolution === "unresolved" &&
117
+ Node.isIdentifier(receiver) &&
118
+ REGISTRAR_RECEIVER_NAMES.has(receiver.getText())
119
+ );
120
+ }
121
+
122
+ function isPositionalShape(method: RegistrarMethod, args: readonly Node[]): boolean {
123
+ if (method === "requires") return args.some((arg) => !Node.isObjectLiteralExpression(arg));
124
+ if (args.some((arg) => Node.isSpreadElement(arg))) return true;
125
+ return args.length >= (method === "hook" ? 3 : 2);
126
+ }
127
+
128
+ function crudLocalNames(sf: SourceFile): Set<string> {
129
+ const names = new Set<string>([CRUD_FUNCTION_NAME]);
130
+ for (const specifier of sf.getDescendantsOfKind(SyntaxKind.ImportSpecifier)) {
131
+ if (specifier.getName() !== CRUD_FUNCTION_NAME) continue;
132
+ names.add(specifier.getAliasNode()?.getText() ?? CRUD_FUNCTION_NAME);
133
+ }
134
+ return names;
135
+ }
136
+
137
+ function positionalCallMethod(call: CallExpression): RegistrarMethod | undefined {
138
+ const callee = call.getExpression();
139
+ if (!Node.isPropertyAccessExpression(callee)) return undefined;
140
+ const method = callee.getName();
141
+ if (!isRegistrarMethod(method)) return undefined;
142
+ if (!isPositionalShape(method, call.getArguments())) return undefined;
143
+ return isRegistrarOrLookalike(callee.getExpression()) ? method : undefined;
144
+ }
145
+
146
+ export type ObjectFormFinding = { file: string; line: number; message: string };
147
+
148
+ export function findPositionalRegistrarCalls(files: readonly SourceFile[]): ObjectFormFinding[] {
149
+ const out: ObjectFormFinding[] = [];
150
+ for (const sf of files) {
151
+ if (EXCLUDED_PATH.test(sf.getFilePath())) continue;
152
+ const file = relativeToCwd(sf.getFilePath());
153
+ const crudNames = crudLocalNames(sf);
154
+
155
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
156
+ const method = positionalCallMethod(call);
157
+ if (method === undefined) continue;
158
+ out.push({
159
+ file,
160
+ line: call.getStartLineNumber(),
161
+ message: `positional r.${method}(…) removed in 0.366 — ${UPGRADE_HINT}`,
162
+ });
163
+ }
164
+
165
+ for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
166
+ if (!crudNames.has(id.getText())) continue;
167
+ const parent = id.getParent();
168
+ const isCall = Node.isCallExpression(parent) && parent.getExpression() === id;
169
+ const isImportOrExport =
170
+ Node.isImportSpecifier(parent) ||
171
+ Node.isExportSpecifier(parent) ||
172
+ id.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) !== undefined;
173
+ if (!isCall && !isImportOrExport) continue;
174
+ if (Node.isImportSpecifier(parent) && parent.getAliasNode() === id) continue;
175
+ out.push({
176
+ file,
177
+ line: id.getStartLineNumber(),
178
+ message: `registerEntityCrud removed in 0.366 — use r.crud(…); ${UPGRADE_HINT}`,
179
+ });
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+
185
+ export const guard: AstGuard = {
186
+ name: "API-Object-Form Guard",
187
+ scan: SCAN,
188
+ hint: "Run `bun kumiko-upgrade --apply` (codemod object-form) or rewrite the call to the object form by hand; registerEntityCrud(r, …) becomes r.crud(…).",
189
+ run(files) {
190
+ const violations: GuardViolation[] = findPositionalRegistrarCalls(files);
191
+ return { violations };
192
+ },
193
+ };
194
+
195
+ if (import.meta.main) {
196
+ runStandalone(guard);
197
+ }
@@ -10,13 +10,13 @@
10
10
  * 1. **Collect ES tables.** Two sources:
11
11
  * a) All `createEventStoreExecutor(<tableExpr>, <entity>, ...)` calls;
12
12
  * `<tableExpr>` is the projection table (identifier).
13
- * b) All `r.entity(<name>, <entityDef>)` registrations: every `r.entity`
14
- * is a rebuildable implicit projection. The corresponding Drizzle
15
- * table is linked via the SHARED entity-def identifier, NOT via a
16
- * name string — `r.entity("user-session", ent)` and
13
+ * b) All `r.entity({ name, ...<entityDef> })` registrations: every
14
+ * `r.entity` is a rebuildable implicit projection. The corresponding
15
+ * Drizzle table is linked via the SHARED entity-def identifier, NOT via
16
+ * a name string — `r.entity({ name: "user-session", ...ent })` and
17
17
  * `buildEntityTable("user_session", ent)` disagree on the name
18
18
  * (dash vs. underscore) but share the `ent` symbol. Plus an explicit
19
- * `{ table }` override. Tables that are NOT statically resolvable
19
+ * `backingTable` override. Tables that are NOT statically resolvable
20
20
  * are skipped (a miss is preferable to a false block).
21
21
  *
22
22
  * 2. **Find direct writes.** Two forms:
@@ -44,7 +44,14 @@
44
44
  */
45
45
 
46
46
  import * as path from "node:path";
47
- import { type CallExpression, type Identifier, Node, type SourceFile, SyntaxKind } from "ts-morph";
47
+ import {
48
+ type CallExpression,
49
+ type Identifier,
50
+ Node,
51
+ type ObjectLiteralExpression,
52
+ type SourceFile,
53
+ SyntaxKind,
54
+ } from "ts-morph";
48
55
  import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
49
56
 
50
57
  const ROOT = process.cwd();
@@ -188,16 +195,27 @@ function unwrappedIdentifierDeclId(node: Node | undefined): TableId | undefined
188
195
  return identifier ? declIdOf(identifier) : undefined;
189
196
  }
190
197
 
191
- // The `{ table }` override of `r.entity(name, def, { table })`.
192
- function entityTableOverrideId(opts: Node | undefined): TableId | undefined {
193
- if (opts?.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
194
- const tableProp = opts.asKindOrThrow(SyntaxKind.ObjectLiteralExpression).getProperty("table");
198
+ // The `backingTable` override of `r.entity({ name, ...def, backingTable })`.
199
+ function entityTableOverrideId(definition: ObjectLiteralExpression): TableId | undefined {
200
+ const tableProp = definition.getProperty("backingTable");
195
201
  if (tableProp?.getKind() !== SyntaxKind.PropertyAssignment) return undefined;
196
202
  return unwrappedIdentifierDeclId(
197
203
  tableProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer(),
198
204
  );
199
205
  }
200
206
 
207
+ // The entity definition spread into `r.entity({ name, ...def })`.
208
+ function spreadEntityDefinitionId(definition: ObjectLiteralExpression): TableId | undefined {
209
+ for (const property of definition.getProperties()) {
210
+ if (property.getKind() !== SyntaxKind.SpreadAssignment) continue;
211
+ const spreadId = unwrappedIdentifierDeclId(
212
+ property.asKindOrThrow(SyntaxKind.SpreadAssignment).getExpression(),
213
+ );
214
+ if (spreadId) return spreadId;
215
+ }
216
+ return undefined;
217
+ }
218
+
201
219
  type EntityRegistration = {
202
220
  readonly entityDid: TableId | undefined;
203
221
  readonly overrideTableDid: TableId | undefined;
@@ -209,11 +227,14 @@ function entityRegistrationOf(call: CallExpression): EntityRegistration | undefi
209
227
  if (expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName() !== "entity") {
210
228
  return undefined;
211
229
  }
212
- const args = call.getArguments();
213
- if (!args[0] || args[0].getKind() !== SyntaxKind.StringLiteral) return undefined;
230
+ const definition = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
231
+ const nameProp = definition?.getProperty("name");
232
+ if (!definition || nameProp?.getKind() !== SyntaxKind.PropertyAssignment) return undefined;
233
+ const nameValue = nameProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
234
+ if (nameValue?.getKind() !== SyntaxKind.StringLiteral) return undefined;
214
235
  return {
215
- entityDid: unwrappedIdentifierDeclId(args[1]),
216
- overrideTableDid: entityTableOverrideId(args[2]),
236
+ entityDid: spreadEntityDefinitionId(definition),
237
+ overrideTableDid: entityTableOverrideId(definition),
217
238
  };
218
239
  }
219
240
 
@@ -230,7 +251,7 @@ function rebuildableEntityTableDeclId(
230
251
  return declIdOf(nameNode.asKindOrThrow(SyntaxKind.Identifier));
231
252
  }
232
253
 
233
- // Tables registered via `r.entity(name, entityDef)` as a rebuildable implicit
254
+ // Tables registered via `r.entity({ name, ...entityDef })` as a rebuildable implicit
234
255
  // projection, linked entity→table via the shared entity-def symbol (see header), not by name string.
235
256
  export function collectEntityProjectionTables(files: readonly SourceFile[]): Set<TableId> {
236
257
  const rebuildableEntities = new Set<TableId>();
package/src/run-guards.ts CHANGED
@@ -24,6 +24,7 @@ import { guard as complexity } from "./check-complexity";
24
24
  import { guard as predicateExtraction } from "./check-predicates";
25
25
  import { guard as accessDeniedTest } from "./guard-access-denied-test";
26
26
  import { guard as adminApi } from "./guard-admin-api";
27
+ import { guard as apiObjectForm } from "./guard-api-object-form";
27
28
  import { guard as appFeatureStructure } from "./guard-app-feature-structure";
28
29
  import { guard as brokerSubscribe } from "./guard-broker-subscribe";
29
30
  import { guard as crossFeatureImports } from "./guard-cross-feature-imports";
@@ -61,6 +62,7 @@ import { guard as writeHandlerQns } from "./guard-write-handler-qns";
61
62
  export const GUARDS = [
62
63
  accessDeniedTest,
63
64
  adminApi,
65
+ apiObjectForm,
64
66
  directEntityWrites,
65
67
  directFetch,
66
68
  escapeHatchDeclared,
@@ -44,7 +44,7 @@ export function classifyByPath(repoRelativePath: string): Runtime | null {
44
44
  const rel = repoRelativePath.replace(/\\/g, "/");
45
45
  if (/\/(__tests__|testing)\//.test(rel)) return "test";
46
46
  if (/\/testing\.tsx?$/.test(rel)) return "test";
47
- if (/\.(test|integration|e2e)\.[tj]sx?$/.test(rel)) return "test";
47
+ if (/\.(test|integration|e2e|spec)\.[tj]sx?$/.test(rel)) return "test";
48
48
  if (/(?:^|\/)scripts\//.test(rel)) return "tooling";
49
49
  if (/(?:^|\/)bin\//.test(rel)) return "tooling";
50
50
  if (/\/drizzle\/[^/]+\.ts$/.test(rel)) return "tooling";