@cosmicdrift/kumiko-guards 0.365.1 → 0.367.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.
|
|
3
|
+
"version": "0.367.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.
|
|
31
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.367.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(
|
|
14
|
-
* is a rebuildable implicit projection. The corresponding
|
|
15
|
-
* table is linked via the SHARED entity-def identifier, NOT via
|
|
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
|
-
* `
|
|
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 {
|
|
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 `
|
|
192
|
-
function entityTableOverrideId(
|
|
193
|
-
|
|
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
|
|
213
|
-
|
|
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:
|
|
216
|
-
overrideTableDid: entityTableOverrideId(
|
|
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";
|