@cosmicdrift/kumiko-guards 0.326.0 → 0.327.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/bun-lock-packages.ts +96 -0
- package/src/changes.json +7 -0
- package/src/check-runtime-isolation.ts +4 -3
- package/src/check-single-runtime-instance.ts +134 -0
- package/src/guard-feature-integration-tests.ts +1 -1
- package/src/run-repo-checks.ts +2 -0
- package/src/runtime-isolation-classify.ts +15 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.327.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.327.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type LockPackage = {
|
|
5
|
+
/** Key inside the lockfile's `packages` object; nested copies carry their parent path. */
|
|
6
|
+
readonly lockKey: string;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly version: string;
|
|
9
|
+
/** 1-based line of the key in bun.lock, 1 when it cannot be located. */
|
|
10
|
+
readonly line: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type LockfileRead =
|
|
14
|
+
| { readonly ok: true; readonly packages: readonly LockPackage[] }
|
|
15
|
+
| { readonly ok: false; readonly reason: string };
|
|
16
|
+
|
|
17
|
+
type ParsedBunLock = { readonly packages?: Record<string, unknown> };
|
|
18
|
+
|
|
19
|
+
function isParsedBunLock(value: unknown): value is ParsedBunLock {
|
|
20
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
21
|
+
const packages = (value as Record<string, unknown>)["packages"];
|
|
22
|
+
return (
|
|
23
|
+
packages === undefined ||
|
|
24
|
+
(packages !== null && typeof packages === "object" && !Array.isArray(packages))
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// bun.lock allows trailing commas (JSONC-lite) but no comments in practice —
|
|
29
|
+
// stripping them is enough to make it JSON.parse-able.
|
|
30
|
+
function stripTrailingCommas(jsonc: string): string {
|
|
31
|
+
return jsonc.replace(/,(\s*[}\]])/g, "$1");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseNameAndVersion(
|
|
35
|
+
resolvedNameAtVersion: string,
|
|
36
|
+
): { readonly name: string; readonly version: string } | null {
|
|
37
|
+
const match = resolvedNameAtVersion.match(/^(@[^/]+\/[^@]+|[^@]+)@(.+)$/);
|
|
38
|
+
const [, name, version] = match ?? [];
|
|
39
|
+
return name !== undefined && version !== undefined ? { name, version } : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// packages are visited in file order, so each lookup resumes where the last one ended
|
|
43
|
+
// (a per-entry slice+split of the whole file would be quadratic on real lockfiles).
|
|
44
|
+
function createLineLocator(raw: string): (lockKey: string) => number {
|
|
45
|
+
let offset = Math.max(raw.indexOf('"packages"'), 0);
|
|
46
|
+
let line = 1;
|
|
47
|
+
for (let i = 0; i < offset; i++) if (raw.charCodeAt(i) === 10) line++;
|
|
48
|
+
return (lockKey) => {
|
|
49
|
+
const at = raw.indexOf(`${JSON.stringify(lockKey)}:`, offset);
|
|
50
|
+
if (at < 0) return 1;
|
|
51
|
+
for (let i = offset; i < at; i++) if (raw.charCodeAt(i) === 10) line++;
|
|
52
|
+
offset = at;
|
|
53
|
+
return line;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every entry of `root`'s bun.lock `packages` graph as `{ lockKey, name, version }`.
|
|
59
|
+
* Name and version come from the entry's own resolved `name@version` value, not
|
|
60
|
+
* the key (keys are hoisting-conflict paths).
|
|
61
|
+
*
|
|
62
|
+
* `undefined` when there is no bun.lock. A lockfile that cannot be parsed, or
|
|
63
|
+
* holds an entry not readable as `name@version`, fails closed (`ok: false`)
|
|
64
|
+
* instead of silently shrinking the package list. A lockfile without a
|
|
65
|
+
* `packages` key yields an empty list.
|
|
66
|
+
*/
|
|
67
|
+
export function readLockfilePackages(root: string): LockfileRead | undefined {
|
|
68
|
+
let raw: string;
|
|
69
|
+
try {
|
|
70
|
+
raw = readFileSync(join(root, "bun.lock"), "utf-8");
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let parsed: unknown;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(stripTrailingCommas(raw));
|
|
78
|
+
} catch {
|
|
79
|
+
return { ok: false, reason: "not valid JSON" };
|
|
80
|
+
}
|
|
81
|
+
if (!isParsedBunLock(parsed)) return { ok: false, reason: "unexpected top-level shape" };
|
|
82
|
+
|
|
83
|
+
const lineOf = createLineLocator(raw);
|
|
84
|
+
const packages: LockPackage[] = [];
|
|
85
|
+
for (const [lockKey, entry] of Object.entries(parsed.packages ?? {})) {
|
|
86
|
+
if (!Array.isArray(entry) || typeof entry[0] !== "string") {
|
|
87
|
+
return { ok: false, reason: `entry "${lockKey}" is not a resolved-package tuple` };
|
|
88
|
+
}
|
|
89
|
+
const nameAndVersion = parseNameAndVersion(entry[0]);
|
|
90
|
+
if (!nameAndVersion) {
|
|
91
|
+
return { ok: false, reason: `entry "${lockKey}" is not readable as name@version` };
|
|
92
|
+
}
|
|
93
|
+
packages.push({ lockKey, ...nameAndVersion, line: lineOf(lockKey) });
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, packages };
|
|
96
|
+
}
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.327.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "New repo check flags nested Kumiko package copies that drift from the top-level install",
|
|
6
|
+
"detail": "The new Single-Runtime-Instance check reads bun.lock and reports every Kumiko-scoped package resolved at more than one version unless it is marked dev/tooling/test; copies nested under a dev/tooling/test-marked package are ignored. The runtime-isolation guard also recognizes the \"prod\" kumiko.runtime marker.",
|
|
7
|
+
"migration": "A repo whose bun.lock resolves a runtime/client/prod-marked Kumiko package at more than one version now fails `kumiko-guards checks`; align the dependency ranges or pin one version via root overrides. dev/tooling/test-marked packages (kumiko-cli, kumiko-guards, kumiko-repo-manifest) are exempt, as are copies nested under such a package (e.g. the kumiko-guards > kumiko-cli subtree)."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.324.0",
|
|
4
11
|
"type": "fix",
|
|
@@ -23,10 +23,11 @@
|
|
|
23
23
|
// 5. Default → runtime
|
|
24
24
|
//
|
|
25
25
|
// Compat matrix: which runtime context may import which.
|
|
26
|
-
// runtime → runtime, client
|
|
26
|
+
// runtime → runtime, client, prod
|
|
27
27
|
// client → client
|
|
28
|
-
//
|
|
29
|
-
//
|
|
28
|
+
// prod → runtime, client, prod
|
|
29
|
+
// dev → runtime, client, prod, dev, tooling
|
|
30
|
+
// tooling → runtime, client, prod, dev, tooling, test
|
|
30
31
|
// test → everything
|
|
31
32
|
//
|
|
32
33
|
// Single-repo only (unlike infra/guards' check-runtime-isolation.ts, which
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Single-Runtime-Instance Guard (kumiko-studio#229).
|
|
4
|
+
*
|
|
5
|
+
* A Kumiko runtime library resolved at two versions in one bun.lock (a nested
|
|
6
|
+
* copy under a feature package next to the top-level install) loads twice in
|
|
7
|
+
* one process: two registries, two module singletons. This flags every
|
|
8
|
+
* Kumiko-scoped package with more than one distinct resolved version unless
|
|
9
|
+
* its `kumiko.runtime` marker says dev/tooling/test (those never share a
|
|
10
|
+
* process with the app runtime). A package with no marker, or one not
|
|
11
|
+
* installed, is treated as runtime — fail closed.
|
|
12
|
+
*
|
|
13
|
+
* Copies nested under a dev/tooling/test-marked ancestor (e.g. kumiko-guards >
|
|
14
|
+
* kumiko-cli > kumiko-framework) belong to the dev-tool subtree, not the app
|
|
15
|
+
* process: they are ignored, and drift is judged on the remaining entries. An
|
|
16
|
+
* ancestor without marker does not protect its subtree.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { type LockPackage, readLockfilePackages } from "./_lib/bun-lock-packages";
|
|
21
|
+
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
22
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
23
|
+
import { ALL_RUNTIMES } from "./runtime-isolation-classify";
|
|
24
|
+
|
|
25
|
+
const KUMIKO_PACKAGE_SCOPES: readonly string[] = ["@cosmicdrift/", "@cosmicdriftgamestudio/"];
|
|
26
|
+
const WORKSPACE_VERSION_PREFIX = "workspace:";
|
|
27
|
+
const EXEMPT_MARKERS: ReadonlySet<string> = new Set(["dev", "tooling", "test"]);
|
|
28
|
+
|
|
29
|
+
function isKumikoPackage(name: string): boolean {
|
|
30
|
+
return KUMIKO_PACKAGE_SCOPES.some((scope) => name.startsWith(scope));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Node-style lookup: the parent workspace hoists installs above the repo root.
|
|
34
|
+
function readRuntimeMarker(rootAbsPath: string, packageName: string): string | undefined {
|
|
35
|
+
let dir = rootAbsPath;
|
|
36
|
+
for (;;) {
|
|
37
|
+
const pkgPath = join(dir, "node_modules", packageName, "package.json");
|
|
38
|
+
if (existsSync(pkgPath)) {
|
|
39
|
+
try {
|
|
40
|
+
const marker = (
|
|
41
|
+
JSON.parse(readFileSync(pkgPath, "utf8")) as { kumiko?: { runtime?: unknown } }
|
|
42
|
+
).kumiko?.runtime; // @cast-boundary package-json
|
|
43
|
+
return typeof marker === "string" && ALL_RUNTIMES.has(marker) ? marker : undefined;
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const parent = dirname(dir);
|
|
49
|
+
if (parent === dir) return undefined;
|
|
50
|
+
dir = parent;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A copy lives in the dev-tool subtree when any ancestor segment of its lockKey is dev/tooling/test-marked.
|
|
55
|
+
function lockKeyAncestors(lockKey: string): string[] {
|
|
56
|
+
const names: string[] = [];
|
|
57
|
+
const segments = lockKey.split("/");
|
|
58
|
+
for (let i = 0; i < segments.length; i++) {
|
|
59
|
+
const segment = segments[i] ?? "";
|
|
60
|
+
if (segment.startsWith("@") && i + 1 < segments.length) {
|
|
61
|
+
names.push(`${segment}/${segments[i + 1]}`);
|
|
62
|
+
i++;
|
|
63
|
+
} else {
|
|
64
|
+
names.push(segment);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return names.slice(0, -1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function groupByName(packages: readonly LockPackage[]): Map<string, LockPackage[]> {
|
|
71
|
+
const byName = new Map<string, LockPackage[]>();
|
|
72
|
+
for (const pkg of packages) {
|
|
73
|
+
if (pkg.version.startsWith(WORKSPACE_VERSION_PREFIX) || !isKumikoPackage(pkg.name)) continue;
|
|
74
|
+
const group = byName.get(pkg.name) ?? [];
|
|
75
|
+
group.push(pkg);
|
|
76
|
+
byName.set(pkg.name, group);
|
|
77
|
+
}
|
|
78
|
+
return byName;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const check: RepoCheck = {
|
|
82
|
+
name: "Single-Runtime-Instance Guard",
|
|
83
|
+
hint:
|
|
84
|
+
"A runtime/client/prod Kumiko package resolves at more than one version in bun.lock, so two " +
|
|
85
|
+
"instances load in one process. Align the dependency ranges so one version satisfies all " +
|
|
86
|
+
'dependents, or pin it via root `overrides`. Packages marked kumiko.runtime "dev", "tooling" ' +
|
|
87
|
+
'or "test", and copies nested under such a package, are exempt.',
|
|
88
|
+
run(roots: readonly RepoRoot[]) {
|
|
89
|
+
const violations: { file: string; line: number; message: string }[] = [];
|
|
90
|
+
let matchedFiles = 0;
|
|
91
|
+
for (const root of roots) {
|
|
92
|
+
const markerCache = new Map<string, string | undefined>();
|
|
93
|
+
const markerOf = (name: string): string | undefined => {
|
|
94
|
+
if (!markerCache.has(name)) markerCache.set(name, readRuntimeMarker(root.absPath, name));
|
|
95
|
+
return markerCache.get(name);
|
|
96
|
+
};
|
|
97
|
+
const isUnderExemptAncestor = (lockKey: string): boolean =>
|
|
98
|
+
lockKeyAncestors(lockKey).some((ancestor) => EXEMPT_MARKERS.has(markerOf(ancestor) ?? ""));
|
|
99
|
+
const lock = readLockfilePackages(root.absPath);
|
|
100
|
+
if (lock === undefined) continue;
|
|
101
|
+
matchedFiles++;
|
|
102
|
+
if (!lock.ok) {
|
|
103
|
+
violations.push({
|
|
104
|
+
file: "bun.lock",
|
|
105
|
+
line: 1,
|
|
106
|
+
message: `bun.lock could not be parsed (${root.name}): ${lock.reason}`,
|
|
107
|
+
});
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
for (const [name, allEntries] of groupByName(lock.packages)) {
|
|
111
|
+
const marker = markerOf(name);
|
|
112
|
+
if (marker !== undefined && EXEMPT_MARKERS.has(marker)) continue;
|
|
113
|
+
const entries = allEntries.filter((e) => !isUnderExemptAncestor(e.lockKey));
|
|
114
|
+
if (new Set(entries.map((e) => e.version)).size < 2) continue;
|
|
115
|
+
const nested = entries.filter((e) => e.lockKey !== name);
|
|
116
|
+
const firstNestedLine = Math.min(
|
|
117
|
+
...(nested.length > 0 ? nested : entries).map((e) => e.line),
|
|
118
|
+
);
|
|
119
|
+
const versions = entries.map((e) => `${e.version} (${e.lockKey})`).join(", ");
|
|
120
|
+
violations.push({
|
|
121
|
+
file: "bun.lock",
|
|
122
|
+
line: firstNestedLine,
|
|
123
|
+
message: `${name} [${marker ?? "no kumiko.runtime marker"}] resolves at ${new Set(entries.map((e) => e.version)).size} versions in ${root.name}: ${versions}`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { violations, matchedFiles, notApplicable: matchedFiles === 0 };
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (import.meta.main) {
|
|
132
|
+
const failed = reportResults(await runRepoChecks([check], resolveRepoRoots()));
|
|
133
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
134
|
+
}
|
|
@@ -120,7 +120,7 @@ function collectImportedFeatureIds(project: Project, roots: readonly RepoRoot[])
|
|
|
120
120
|
* introduced by this change. Backfilling is its own scope per feature, not
|
|
121
121
|
* a sweep.
|
|
122
122
|
*/
|
|
123
|
-
const ALLOWLIST: ReadonlySet<string> = new Set(
|
|
123
|
+
const ALLOWLIST: ReadonlySet<string> = new Set<string>();
|
|
124
124
|
|
|
125
125
|
export function computeOrphans(
|
|
126
126
|
features: ReadonlyMap<string, string>,
|
package/src/run-repo-checks.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { check as realProviderIsolation } from "./check-real-provider-isolation"
|
|
|
5
5
|
// per-guard subprocess/project.
|
|
6
6
|
import { check as runtimeIsolation } from "./check-runtime-isolation";
|
|
7
7
|
import { check as secretLiterals } from "./check-secret-literals";
|
|
8
|
+
import { check as singleRuntimeInstance } from "./check-single-runtime-instance";
|
|
8
9
|
import { check as featureIntegrationTests } from "./guard-feature-integration-tests";
|
|
9
10
|
import { check as noDirectProcessEnv } from "./guard-no-direct-process-env";
|
|
10
11
|
import { check as primitivesDiscipline } from "./guard-primitives-discipline";
|
|
@@ -26,6 +27,7 @@ export const REPO_CHECKS = [
|
|
|
26
27
|
runtimeIsolation,
|
|
27
28
|
upgradeState,
|
|
28
29
|
realProviderIsolation,
|
|
30
|
+
singleRuntimeInstance,
|
|
29
31
|
];
|
|
30
32
|
|
|
31
33
|
// No flags today — the array stays so an unknown flag still fails loud
|
|
@@ -15,22 +15,24 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import type { ImportDeclaration, SourceFile } from "ts-morph";
|
|
17
17
|
|
|
18
|
-
export type Runtime = "runtime" | "client" | "dev" | "tooling" | "test";
|
|
18
|
+
export type Runtime = "runtime" | "client" | "prod" | "dev" | "tooling" | "test";
|
|
19
19
|
|
|
20
20
|
export const ALL_RUNTIMES: ReadonlySet<string> = new Set([
|
|
21
21
|
"runtime",
|
|
22
22
|
"client",
|
|
23
|
+
"prod",
|
|
23
24
|
"dev",
|
|
24
25
|
"tooling",
|
|
25
26
|
"test",
|
|
26
27
|
]);
|
|
27
28
|
|
|
28
29
|
export const COMPAT: Record<Runtime, ReadonlySet<Runtime>> = {
|
|
29
|
-
runtime: new Set(["runtime", "client"]),
|
|
30
|
+
runtime: new Set(["runtime", "client", "prod"]),
|
|
30
31
|
client: new Set(["client"]),
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
prod: new Set(["runtime", "client", "prod"]),
|
|
33
|
+
dev: new Set(["runtime", "client", "prod", "dev", "tooling"]),
|
|
34
|
+
tooling: new Set(["runtime", "client", "prod", "dev", "tooling", "test"]),
|
|
35
|
+
test: new Set(["runtime", "client", "prod", "dev", "tooling", "test"]),
|
|
34
36
|
};
|
|
35
37
|
|
|
36
38
|
/**
|
|
@@ -296,7 +298,14 @@ export function findRuntimeIsolationViolations(
|
|
|
296
298
|
seenOutside.add(fp);
|
|
297
299
|
outsideRoot.push(fp);
|
|
298
300
|
};
|
|
299
|
-
const stats: Record<Runtime, number> = {
|
|
301
|
+
const stats: Record<Runtime, number> = {
|
|
302
|
+
runtime: 0,
|
|
303
|
+
client: 0,
|
|
304
|
+
prod: 0,
|
|
305
|
+
dev: 0,
|
|
306
|
+
tooling: 0,
|
|
307
|
+
test: 0,
|
|
308
|
+
};
|
|
300
309
|
const withinRoot = (fp: string) => fp === repoRoot || fp.startsWith(`${repoRoot}/`);
|
|
301
310
|
|
|
302
311
|
for (const sf of sourceFiles) {
|