@cosmicdrift/kumiko-guards 0.334.0 → 0.335.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/_lib/framework-import.ts +29 -0
- package/src/_lib/git-env.ts +2 -10
- package/src/_lib/guard-kit.ts +8 -8
- package/src/_lib/sql-inventory.ts +5 -1
- package/src/changes.json +13 -0
- package/src/check-as-casts.ts +82 -14
- package/src/check-real-provider-isolation.ts +9 -1
- package/src/check-runtime-isolation.ts +9 -2
- package/src/cli.ts +4 -0
- package/src/guard-access-denied-test.ts +2 -2
- package/src/guard-admin-api.ts +1 -2
- package/src/guard-app-feature-structure.ts +8 -6
- package/src/guard-direct-fetch.ts +1 -1
- package/src/guard-error-reasons.ts +7 -3
- package/src/guard-escape-hatch-declared.ts +12 -11
- package/src/guard-i18n-keys.ts +0 -1
- package/src/guard-i18n-locale-terminology.ts +2 -2
- package/src/guard-lib-test-coverage.ts +11 -6
- package/src/guard-no-framed-extension-sections.ts +53 -20
- package/src/guard-open-to-all-reason.ts +2 -2
- package/src/guard-raw-sql.ts +9 -3
- package/src/guard-section-fields-raw.ts +81 -9
- package/src/guard-test-stack-drift.ts +11 -1
- package/src/guard-test-template-drift.ts +48 -2
- package/src/guard-upgrade-state.ts +7 -4
- package/src/run-guards.ts +2 -1
- package/src/run-repo-checks.ts +1 -1
- package/src/run-ui-guards.ts +1 -1
- package/src/runtime-isolation-classify.ts +147 -25
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// classification rules down so future edits trip a test, not a
|
|
12
12
|
// production drift.
|
|
13
13
|
|
|
14
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import type { ImportDeclaration, SourceFile } from "ts-morph";
|
|
17
17
|
|
|
@@ -96,11 +96,54 @@ export function classifyByPath(repoRelativePath: string): Runtime | null {
|
|
|
96
96
|
* their sources live under `packages/*\/src/`, not a repo-root `src/`. Kept
|
|
97
97
|
* independent of the framework's own regex (cross-package import would be a
|
|
98
98
|
* build-vs-lint layering violation) — if apps stop following this naming
|
|
99
|
-
* convention, this drifts and needs a matching update.
|
|
99
|
+
* convention, this drifts and needs a matching update. Entries the app
|
|
100
|
+
* declares itself (`readDeclaredClientEntries`) count in addition, so a
|
|
101
|
+
* `./src/admin/index.tsx` entry is classified as browser code too.
|
|
100
102
|
*/
|
|
101
|
-
export function isClientEntryPath(
|
|
103
|
+
export function isClientEntryPath(
|
|
104
|
+
repoRelativePath: string,
|
|
105
|
+
declaredEntries: ReadonlySet<string> = new Set(),
|
|
106
|
+
): boolean {
|
|
102
107
|
const rel = repoRelativePath.replace(/\\/g, "/");
|
|
103
|
-
return /^src\/client(-[a-z][a-z0-9-]*)?\.tsx?$/.test(rel);
|
|
108
|
+
return declaredEntries.has(rel) || /^src\/client(-[a-z][a-z0-9-]*)?\.tsx?$/.test(rel);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
112
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parses `kumiko.clientEntry` / `kumiko.clientEntries[].sourceFile` out of a
|
|
117
|
+
* package.json value into repo-relative paths. Parsed locally instead of
|
|
118
|
+
* importing the build's resolver (layering: lint must not depend on
|
|
119
|
+
* server-runtime); anything malformed is ignored — `kumiko build` owns
|
|
120
|
+
* rejecting it.
|
|
121
|
+
*/
|
|
122
|
+
export function parseDeclaredClientEntries(packageJson: unknown): ReadonlySet<string> {
|
|
123
|
+
const entries = new Set<string>();
|
|
124
|
+
const kumiko = isRecord(packageJson) ? packageJson["kumiko"] : undefined;
|
|
125
|
+
if (!isRecord(kumiko)) return entries;
|
|
126
|
+
const sources: unknown[] = [kumiko["clientEntry"]];
|
|
127
|
+
const multi = kumiko["clientEntries"];
|
|
128
|
+
if (Array.isArray(multi)) {
|
|
129
|
+
for (const entry of multi) sources.push(isRecord(entry) ? entry["sourceFile"] : undefined);
|
|
130
|
+
}
|
|
131
|
+
for (const source of sources) {
|
|
132
|
+
if (typeof source === "string") {
|
|
133
|
+
entries.add(path.posix.normalize(source.replace(/\\/g, "/")));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return entries;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function readDeclaredClientEntries(repoAbsPath: string): ReadonlySet<string> {
|
|
140
|
+
const file = path.join(repoAbsPath, "package.json");
|
|
141
|
+
if (!existsSync(file)) return new Set();
|
|
142
|
+
try {
|
|
143
|
+
return parseDeclaredClientEntries(JSON.parse(readFileSync(file, "utf8")));
|
|
144
|
+
} catch {
|
|
145
|
+
return new Set();
|
|
146
|
+
}
|
|
104
147
|
}
|
|
105
148
|
|
|
106
149
|
/**
|
|
@@ -122,6 +165,42 @@ export function isValueImport(decl: ImportDeclaration): boolean {
|
|
|
122
165
|
return true;
|
|
123
166
|
}
|
|
124
167
|
|
|
168
|
+
export type ValueModuleEdge = {
|
|
169
|
+
readonly spec: string;
|
|
170
|
+
readonly target: SourceFile;
|
|
171
|
+
readonly line: number;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Every runtime-weighted edge out of a file: value imports plus value
|
|
176
|
+
* re-exports (`export { x } from`, `export * from`) — barrels are how most
|
|
177
|
+
* client entries reach runtime code. Shared by the direct-edge check and the
|
|
178
|
+
* reachability walk so both see the same edge set.
|
|
179
|
+
*/
|
|
180
|
+
export function valueModuleEdges(sf: SourceFile): ValueModuleEdge[] {
|
|
181
|
+
const edges: ValueModuleEdge[] = [];
|
|
182
|
+
for (const decl of sf.getImportDeclarations()) {
|
|
183
|
+
if (!isValueImport(decl)) continue;
|
|
184
|
+
const target = decl.getModuleSpecifierSourceFile();
|
|
185
|
+
if (!target) continue;
|
|
186
|
+
edges.push({
|
|
187
|
+
spec: decl.getModuleSpecifierValue(),
|
|
188
|
+
target,
|
|
189
|
+
line: decl.getStartLineNumber(),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
for (const decl of sf.getExportDeclarations()) {
|
|
193
|
+
if (decl.isTypeOnly()) continue;
|
|
194
|
+
const named = decl.getNamedExports();
|
|
195
|
+
if (named.length > 0 && named.every((n) => n.isTypeOnly())) continue;
|
|
196
|
+
const target = decl.getModuleSpecifierSourceFile();
|
|
197
|
+
const spec = decl.getModuleSpecifierValue();
|
|
198
|
+
if (!target || spec === undefined) continue;
|
|
199
|
+
edges.push({ spec, target, line: decl.getStartLineNumber() });
|
|
200
|
+
}
|
|
201
|
+
return edges;
|
|
202
|
+
}
|
|
203
|
+
|
|
125
204
|
/**
|
|
126
205
|
* Map dist declaration files back to src. Project References make ts-morph
|
|
127
206
|
* resolve imports to `.d.ts` under `dist/`; classification and the reachable
|
|
@@ -159,10 +238,7 @@ export function computeClientReachablePaths(
|
|
|
159
238
|
if (fp.includes("/node_modules/") || fp.includes("/dist/")) continue;
|
|
160
239
|
if (reached.has(fp)) continue;
|
|
161
240
|
reached.add(fp);
|
|
162
|
-
for (const
|
|
163
|
-
if (!isValueImport(decl)) continue;
|
|
164
|
-
const target = decl.getModuleSpecifierSourceFile();
|
|
165
|
-
if (!target) continue;
|
|
241
|
+
for (const { target } of valueModuleEdges(sf)) {
|
|
166
242
|
const targetPath = toEffectivePath(target.getFilePath());
|
|
167
243
|
if (targetPath.includes("/node_modules/") || targetPath.includes("/dist/")) continue;
|
|
168
244
|
if (!reached.has(targetPath)) queue.push(target);
|
|
@@ -171,24 +247,69 @@ export function computeClientReachablePaths(
|
|
|
171
247
|
return reached;
|
|
172
248
|
}
|
|
173
249
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
250
|
+
const DIRECTIVE_HEAD_BYTES = 600;
|
|
251
|
+
const DIRECTIVE_HEAD_LINES = 8;
|
|
252
|
+
|
|
253
|
+
export type HeadReader = (filePath: string) => string | null;
|
|
254
|
+
|
|
255
|
+
// Reads only the file head, so classifying a large source file does not pull
|
|
256
|
+
// it fully into memory.
|
|
257
|
+
export function readFileHead(filePath: string): string | null {
|
|
258
|
+
let fd: number;
|
|
259
|
+
try {
|
|
260
|
+
fd = openSync(filePath, "r");
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
180
264
|
try {
|
|
181
|
-
|
|
265
|
+
const buffer = Buffer.alloc(DIRECTIVE_HEAD_BYTES);
|
|
266
|
+
const bytesRead = readSync(fd, buffer, 0, DIRECTIVE_HEAD_BYTES, 0);
|
|
267
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
182
268
|
} catch {
|
|
183
269
|
return null;
|
|
270
|
+
} finally {
|
|
271
|
+
closeSync(fd);
|
|
184
272
|
}
|
|
185
|
-
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Classify a file by its top-of-file `// @runtime <kind>` directive.
|
|
277
|
+
* Reads the first 600 bytes only (cap blast radius on huge files).
|
|
278
|
+
*/
|
|
279
|
+
export function classifyByDirective(
|
|
280
|
+
filePath: string,
|
|
281
|
+
readHead: HeadReader = readFileHead,
|
|
282
|
+
): Runtime | null {
|
|
283
|
+
const head = readHead(filePath);
|
|
284
|
+
if (head === null) return null;
|
|
285
|
+
for (const line of head.split("\n").slice(0, DIRECTIVE_HEAD_LINES)) {
|
|
186
286
|
const m = line.match(/\/\/\s*@runtime\s+(\w+)/);
|
|
187
287
|
if (m && ALL_RUNTIMES.has(m[1] ?? "")) return m[1] as Runtime;
|
|
188
288
|
}
|
|
189
289
|
return null;
|
|
190
290
|
}
|
|
191
291
|
|
|
292
|
+
export type DirectiveClassifier = (filePath: string) => Runtime | null;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Per-run memo of `classifyByDirective`: the guard classifies each file in
|
|
296
|
+
* several passes (client reachability, then violations), and each pass would
|
|
297
|
+
* otherwise re-read the same head. Created per run, not module-global, so
|
|
298
|
+
* tests stay isolated.
|
|
299
|
+
*/
|
|
300
|
+
export function createDirectiveClassifier(
|
|
301
|
+
readHead: HeadReader = readFileHead,
|
|
302
|
+
): DirectiveClassifier {
|
|
303
|
+
const cache = new Map<string, Runtime | null>();
|
|
304
|
+
return (filePath) => {
|
|
305
|
+
const cached = cache.get(filePath);
|
|
306
|
+
if (cached !== undefined) return cached;
|
|
307
|
+
const result = classifyByDirective(filePath, readHead);
|
|
308
|
+
cache.set(filePath, result);
|
|
309
|
+
return result;
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
192
313
|
/**
|
|
193
314
|
* Walk upward from `filePath` looking for the nearest package.json that
|
|
194
315
|
* carries a `kumiko.runtime` marker. Stops at `repoRoot`. Caches per
|
|
@@ -201,7 +322,8 @@ export function findWorkspaceRuntime(
|
|
|
201
322
|
cache: Map<string, Runtime | null>,
|
|
202
323
|
): Runtime | null {
|
|
203
324
|
let dir = path.dirname(filePath);
|
|
204
|
-
|
|
325
|
+
// Includes repoRoot itself: a single-package consumer repo keeps its marker there.
|
|
326
|
+
while (dir.startsWith(repoRoot)) {
|
|
205
327
|
const r = readWorkspaceRuntime(dir, cache);
|
|
206
328
|
if (r) return r;
|
|
207
329
|
// Stop at the first package.json — don't fall through to a parent
|
|
@@ -212,6 +334,7 @@ export function findWorkspaceRuntime(
|
|
|
212
334
|
} catch {
|
|
213
335
|
// No package.json here — keep climbing.
|
|
214
336
|
}
|
|
337
|
+
if (dir === repoRoot) break;
|
|
215
338
|
dir = path.dirname(dir);
|
|
216
339
|
}
|
|
217
340
|
return null;
|
|
@@ -250,12 +373,13 @@ export function classify(
|
|
|
250
373
|
repoRoot: string,
|
|
251
374
|
workspaceCache: Map<string, Runtime | null>,
|
|
252
375
|
clientReachable?: ReadonlySet<string>,
|
|
376
|
+
directiveOf: DirectiveClassifier = (file) => classifyByDirective(file),
|
|
253
377
|
): Runtime {
|
|
254
378
|
const effectivePath = toEffectivePath(filePath);
|
|
255
379
|
|
|
256
380
|
const rel = path.relative(repoRoot, effectivePath);
|
|
257
381
|
return (
|
|
258
|
-
|
|
382
|
+
directiveOf(effectivePath) ??
|
|
259
383
|
classifyByPath(rel) ??
|
|
260
384
|
findWorkspaceRuntime(effectivePath, repoRoot, workspaceCache) ??
|
|
261
385
|
(clientReachable?.has(effectivePath) ? "client" : undefined) ??
|
|
@@ -284,6 +408,7 @@ export function findRuntimeIsolationViolations(
|
|
|
284
408
|
repoRoot: string,
|
|
285
409
|
workspaceCache: Map<string, Runtime | null>,
|
|
286
410
|
clientReachable: ReadonlySet<string> = new Set(),
|
|
411
|
+
directiveOf: DirectiveClassifier = createDirectiveClassifier(),
|
|
287
412
|
): {
|
|
288
413
|
readonly violations: readonly Violation[];
|
|
289
414
|
readonly stats: Record<Runtime, number>;
|
|
@@ -315,13 +440,10 @@ export function findRuntimeIsolationViolations(
|
|
|
315
440
|
noteOutside(fp);
|
|
316
441
|
continue;
|
|
317
442
|
}
|
|
318
|
-
const fileRt = classify(fp, repoRoot, workspaceCache, clientReachable);
|
|
443
|
+
const fileRt = classify(fp, repoRoot, workspaceCache, clientReachable, directiveOf);
|
|
319
444
|
stats[fileRt]++;
|
|
320
445
|
|
|
321
|
-
for (const
|
|
322
|
-
if (!isValueImport(decl)) continue;
|
|
323
|
-
const target = decl.getModuleSpecifierSourceFile();
|
|
324
|
-
if (!target) continue;
|
|
446
|
+
for (const { spec, target, line } of valueModuleEdges(sf)) {
|
|
325
447
|
const targetPath = target.getFilePath();
|
|
326
448
|
if (targetPath.includes("/node_modules/")) continue;
|
|
327
449
|
if (!withinRoot(targetPath)) {
|
|
@@ -333,9 +455,9 @@ export function findRuntimeIsolationViolations(
|
|
|
333
455
|
if (!COMPAT[fileRt].has(targetRt)) {
|
|
334
456
|
violations.push({
|
|
335
457
|
file: fp,
|
|
336
|
-
line
|
|
458
|
+
line,
|
|
337
459
|
fileRuntime: fileRt,
|
|
338
|
-
importedSpec:
|
|
460
|
+
importedSpec: spec,
|
|
339
461
|
importedFile: targetPath,
|
|
340
462
|
importedRuntime: targetRt,
|
|
341
463
|
});
|