@cosmicdrift/kumiko-guards 0.363.0 → 0.365.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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Direct dependencies declared by one repo: its root package.json and every workspace member.
3
+ */
4
+ import { join } from "node:path";
5
+ import { readPackageJson, workspaceMemberDirs } from "./installed-packages";
6
+
7
+ const DEP_FIELDS = [
8
+ "dependencies",
9
+ "devDependencies",
10
+ "optionalDependencies",
11
+ "peerDependencies",
12
+ ] as const;
13
+
14
+ export type DirectDependency = {
15
+ readonly name: string;
16
+ readonly version: string;
17
+ };
18
+
19
+ function isFirstParty(name: string, spec: string): boolean {
20
+ return (
21
+ name.startsWith("@cosmicdrift/") ||
22
+ name.startsWith("@cosmicdriftgamestudio/") ||
23
+ name.startsWith("@app/") ||
24
+ spec.startsWith("workspace:")
25
+ );
26
+ }
27
+
28
+ function stringEntries(section: unknown): [string, string][] {
29
+ if (typeof section !== "object" || section === null) return [];
30
+ return Object.entries(section).filter(
31
+ (entry): entry is [string, string] => typeof entry[1] === "string",
32
+ );
33
+ }
34
+
35
+ /** Every declared (name, spec) pair across the repo, duplicates kept so each differing range stays visible. */
36
+ export function collectDeclaredDependencies(repoRoot: string): DirectDependency[] {
37
+ const deps: DirectDependency[] = [];
38
+ for (const dir of [repoRoot, ...workspaceMemberDirs(repoRoot)]) {
39
+ const pkg = readPackageJson(join(dir, "package.json"));
40
+ if (!pkg) continue;
41
+ for (const field of DEP_FIELDS) {
42
+ for (const [name, version] of stringEntries(pkg[field])) {
43
+ if (!isFirstParty(name, version)) deps.push({ name, version });
44
+ }
45
+ }
46
+ }
47
+ return deps;
48
+ }
49
+
50
+ /** Unique direct dependencies by name (first declaration wins), sorted. */
51
+ export function collectDirectDependencies(repoRoot: string): DirectDependency[] {
52
+ const byName = new Map<string, DirectDependency>();
53
+ for (const dep of collectDeclaredDependencies(repoRoot)) {
54
+ if (!byName.has(dep.name)) byName.set(dep.name, dep);
55
+ }
56
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
57
+ }
58
+
59
+ /** Every declared spec per name, so an install satisfying any one of several differing ranges counts as direct. */
60
+ export function collectDeclaredSpecsByName(
61
+ repoRoot: string,
62
+ ): ReadonlyMap<string, readonly string[]> {
63
+ const byName = new Map<string, string[]>();
64
+ for (const dep of collectDeclaredDependencies(repoRoot)) {
65
+ const specs = byName.get(dep.name);
66
+ if (specs) specs.push(dep.version);
67
+ else byName.set(dep.name, [dep.version]);
68
+ }
69
+ return byName;
70
+ }
71
+
72
+ /** Pins from the root package.json `overrides` / `resolutions` (string values only). */
73
+ export function collectRootOverrides(repoRoot: string): ReadonlyMap<string, string> {
74
+ const pkg = readPackageJson(join(repoRoot, "package.json"));
75
+ const overrides = new Map<string, string>();
76
+ for (const field of ["overrides", "resolutions"] as const) {
77
+ for (const [name, value] of stringEntries(pkg?.[field])) overrides.set(name, value);
78
+ }
79
+ return overrides;
80
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Installed third-party packages of one repo, found the way Bun resolves them:
3
+ * node_modules of the repo root, of its workspace members and of every
4
+ * ancestor directory (a hoisted parent workspace or a `.wt/` worktree has no
5
+ * node_modules of its own).
6
+ */
7
+ import { type Dirent, existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+
10
+ export type InstalledPackage = {
11
+ readonly name: string;
12
+ readonly version: string;
13
+ readonly license: string | null;
14
+ readonly path: string;
15
+ };
16
+
17
+ const FIRST_PARTY_SCOPES = ["@cosmicdrift/", "@cosmicdriftgamestudio/", "@app/"];
18
+
19
+ function isRecord(value: unknown): value is Record<string, unknown> {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ }
22
+
23
+ export function readPackageJson(file: string): Record<string, unknown> | undefined {
24
+ try {
25
+ const parsed: unknown = JSON.parse(readFileSync(file, "utf-8"));
26
+ return isRecord(parsed) ? parsed : undefined;
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ /** Workspace member directories declared by the repo's package.json `workspaces`. */
33
+ export function workspaceMemberDirs(repoRoot: string): string[] {
34
+ const workspaces = readPackageJson(join(repoRoot, "package.json"))?.["workspaces"];
35
+ const patterns = Array.isArray(workspaces)
36
+ ? workspaces
37
+ : isRecord(workspaces) && Array.isArray(workspaces["packages"])
38
+ ? workspaces["packages"]
39
+ : [];
40
+ const dirs = new Set<string>();
41
+ for (const pattern of patterns) {
42
+ if (typeof pattern !== "string" || pattern.startsWith("!")) continue;
43
+ for (const match of new Bun.Glob(pattern).scanSync({ cwd: repoRoot, onlyFiles: false })) {
44
+ const dir = join(repoRoot, match);
45
+ if (existsSync(join(dir, "package.json"))) dirs.add(dir);
46
+ }
47
+ }
48
+ return [...dirs];
49
+ }
50
+
51
+ /** `node_modules` directories Bun would load from for this repo, nearest first. */
52
+ export function nodeModulesDirs(repoRoot: string): string[] {
53
+ const dirs: string[] = [];
54
+ const add = (dir: string): void => {
55
+ const nodeModules = join(dir, "node_modules");
56
+ if (!dirs.includes(nodeModules) && existsSync(nodeModules)) dirs.push(nodeModules);
57
+ };
58
+ add(repoRoot);
59
+ for (const member of workspaceMemberDirs(repoRoot)) add(member);
60
+ for (let dir = dirname(repoRoot); ; dir = dirname(dir)) {
61
+ add(dir);
62
+ if (dirname(dir) === dir) break;
63
+ }
64
+ return dirs;
65
+ }
66
+
67
+ /** Directories whose node_modules a scan of `repoRoot` reads — also where their exception files live. */
68
+ export function installationRoots(repoRoot: string): string[] {
69
+ return [repoRoot, ...nodeModulesDirs(repoRoot).map((dir) => dirname(dir))];
70
+ }
71
+
72
+ export function collectInstalledPackages(repoRoot: string): InstalledPackage[] {
73
+ const seen = new Map<string, InstalledPackage>();
74
+ const visited = new Set<string>();
75
+ for (const dir of nodeModulesDirs(repoRoot)) walkNodeModules(dir, seen, visited);
76
+ return [...seen.values()].sort((a, b) =>
77
+ `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`),
78
+ );
79
+ }
80
+
81
+ function readUnvisitedDir(dir: string, visited: Set<string>): Dirent[] {
82
+ try {
83
+ const real = realpathSync(dir);
84
+ if (visited.has(real)) return [];
85
+ visited.add(real);
86
+ return readdirSync(dir, { withFileTypes: true });
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
92
+ function walkNodeModules(
93
+ dir: string,
94
+ seen: Map<string, InstalledPackage>,
95
+ visited: Set<string>,
96
+ ): void {
97
+ const entries = readUnvisitedDir(dir, visited);
98
+ for (const entry of entries) {
99
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
100
+ if (entry.name.startsWith(".")) continue;
101
+ const full = join(dir, entry.name);
102
+ if (entry.name.startsWith("@")) {
103
+ walkNodeModules(full, seen, visited);
104
+ continue;
105
+ }
106
+ const info = readPackageInfo(join(full, "package.json"));
107
+ if (info && !seen.has(`${info.name}@${info.version}`)) {
108
+ seen.set(`${info.name}@${info.version}`, info);
109
+ }
110
+ walkNodeModules(join(full, "node_modules"), seen, visited);
111
+ }
112
+ }
113
+
114
+ function readPackageInfo(file: string): InstalledPackage | undefined {
115
+ const pkg = readPackageJson(file);
116
+ if (!pkg) return undefined;
117
+ const { name, version } = pkg;
118
+ if (typeof name !== "string" || typeof version !== "string") return undefined;
119
+ // Workspace members and the repo's own scopes are first-party, not third-party licences.
120
+ if (pkg["private"] === true || FIRST_PARTY_SCOPES.some((scope) => name.startsWith(scope))) {
121
+ return undefined;
122
+ }
123
+ return {
124
+ name,
125
+ version,
126
+ license: normalizeLicense(pkg["license"] ?? pkg["licenses"]),
127
+ path: file,
128
+ };
129
+ }
130
+
131
+ function licenseTypeOf(entry: unknown): string | undefined {
132
+ if (typeof entry === "string") return entry.trim() || undefined;
133
+ if (isRecord(entry) && typeof entry["type"] === "string")
134
+ return entry["type"].trim() || undefined;
135
+ return undefined;
136
+ }
137
+
138
+ /** SPDX string from package.json `license` (string | {type}) or legacy `licenses` (array). */
139
+ export function normalizeLicense(raw: unknown): string | null {
140
+ if (Array.isArray(raw)) {
141
+ const parts = raw.map(licenseTypeOf).filter((part): part is string => part !== undefined);
142
+ if (parts.length === 0) return null;
143
+ return parts.length === 1 ? (parts[0] ?? null) : `(${parts.join(" OR ")})`;
144
+ }
145
+ return licenseTypeOf(raw) ?? null;
146
+ }
@@ -0,0 +1,168 @@
1
+ // Registry lookups go through `bun info` (honors the project's registry/auth config) instead of raw HTTP.
2
+
3
+ export type LatestRelease = {
4
+ readonly version: string;
5
+ readonly publishedAt: string;
6
+ };
7
+
8
+ export type DeprecationLookup =
9
+ | { readonly kind: "deprecated"; readonly message: string }
10
+ | { readonly kind: "current" }
11
+ | { readonly kind: "unknown"; readonly reason: string };
12
+
13
+ export type RegistryClient = {
14
+ /** Deprecation status of one exact version; `unknown` when the registry could not answer. */
15
+ deprecation(name: string, version: string): Promise<DeprecationLookup>;
16
+ /** Latest dist-tag with its publish date; `null` when unknown. */
17
+ latestRelease(name: string): Promise<LatestRelease | null>;
18
+ };
19
+
20
+ const BUN_INFO_TIMEOUT_MS = 30_000;
21
+
22
+ export type BunInfoResult =
23
+ | { readonly ok: true; readonly exitCode: number; readonly value: unknown }
24
+ | { readonly ok: false; readonly reason: string };
25
+
26
+ async function bunInfoJson(cwd: string, args: readonly string[]): Promise<BunInfoResult> {
27
+ try {
28
+ const proc = Bun.spawn(["bun", "info", ...args, "--json"], {
29
+ cwd,
30
+ stdout: "pipe",
31
+ stderr: "pipe",
32
+ timeout: BUN_INFO_TIMEOUT_MS,
33
+ });
34
+ const [out, err, exitCode] = await Promise.all([
35
+ new Response(proc.stdout).text(),
36
+ new Response(proc.stderr).text(),
37
+ proc.exited,
38
+ ]);
39
+ if (out.trim() === "") {
40
+ const firstErrorLine = err.trim().split("\n")[0];
41
+ return { ok: false, reason: firstErrorLine || `bun info exited ${exitCode} without output` };
42
+ }
43
+ return { ok: true, exitCode, value: JSON.parse(out) };
44
+ } catch (error) {
45
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
46
+ }
47
+ }
48
+
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+
53
+ // `bun info <pkg>@<v> deprecated` answers a version without the field with exit 1 and {"error":"Property not found"}.
54
+ function isPropertyNotFound(value: unknown): boolean {
55
+ return isRecord(value) && value["error"] === "Property not found";
56
+ }
57
+
58
+ export function interpretDeprecationLookup(result: BunInfoResult): DeprecationLookup {
59
+ if (!result.ok) return { kind: "unknown", reason: result.reason };
60
+ if (isPropertyNotFound(result.value)) return { kind: "current" };
61
+ if (result.exitCode === 0 && typeof result.value === "string") {
62
+ return result.value === ""
63
+ ? { kind: "current" }
64
+ : { kind: "deprecated", message: result.value };
65
+ }
66
+ return { kind: "unknown", reason: `unexpected bun info answer (exit ${result.exitCode})` };
67
+ }
68
+
69
+ /** `cwd` must contain a package.json — `bun info` refuses to run without one. */
70
+ export function createBunInfoClient(cwd: string): RegistryClient {
71
+ return {
72
+ async deprecation(name, version) {
73
+ return interpretDeprecationLookup(
74
+ await bunInfoJson(cwd, [`${name}@${version}`, "deprecated"]),
75
+ );
76
+ },
77
+ async latestRelease(name) {
78
+ const version = await bunInfoJson(cwd, [name, "dist-tags.latest"]);
79
+ if (!version.ok || version.exitCode !== 0 || typeof version.value !== "string") return null;
80
+ const time = await bunInfoJson(cwd, [name, "time"]);
81
+ const publishedAt = time.ok && isRecord(time.value) ? time.value[version.value] : undefined;
82
+ return typeof publishedAt === "string" ? { version: version.value, publishedAt } : null;
83
+ },
84
+ };
85
+ }
86
+
87
+ /** Memoizes per call key, so a package listed twice costs one lookup. */
88
+ export function memoizeRegistryClient(client: RegistryClient): RegistryClient {
89
+ const deprecation = new Map<string, Promise<DeprecationLookup>>();
90
+ const latest = new Map<string, Promise<LatestRelease | null>>();
91
+ return {
92
+ deprecation(name, version) {
93
+ const key = `${name}@${version}`;
94
+ const cached = deprecation.get(key);
95
+ if (cached) return cached;
96
+ const pending = client.deprecation(name, version);
97
+ deprecation.set(key, pending);
98
+ return pending;
99
+ },
100
+ latestRelease(name) {
101
+ const cached = latest.get(name);
102
+ if (cached) return cached;
103
+ const pending = client.latestRelease(name);
104
+ latest.set(name, pending);
105
+ return pending;
106
+ },
107
+ };
108
+ }
109
+
110
+ export function parseMajor(version: string): number | null {
111
+ const match = version.match(/^(\d+)/);
112
+ return match?.[1] === undefined ? null : Number.parseInt(match[1], 10);
113
+ }
114
+
115
+ export type MajorLagWarning = {
116
+ readonly name: string;
117
+ readonly current: string;
118
+ readonly latest: string;
119
+ readonly latestPublished: string;
120
+ };
121
+
122
+ export function findMajorLagWarning(
123
+ name: string,
124
+ currentVersion: string,
125
+ release: LatestRelease,
126
+ lagMonths: number,
127
+ nowMs: number,
128
+ ): MajorLagWarning | null {
129
+ const currentMajor = parseMajor(currentVersion);
130
+ const latestMajor = parseMajor(release.version);
131
+ if (currentMajor === null || latestMajor === null || latestMajor <= currentMajor) return null;
132
+
133
+ let published: Temporal.Instant;
134
+ try {
135
+ published = Temporal.Instant.from(release.publishedAt);
136
+ } catch {
137
+ return null;
138
+ }
139
+ const cutoff = Temporal.Instant.fromEpochMilliseconds(nowMs)
140
+ .toZonedDateTimeISO("UTC")
141
+ .subtract({ months: lagMonths });
142
+ if (Temporal.Instant.compare(published, cutoff.toInstant()) > 0) return null;
143
+
144
+ return {
145
+ name,
146
+ current: currentVersion,
147
+ latest: release.version,
148
+ latestPublished: release.publishedAt,
149
+ };
150
+ }
151
+
152
+ export async function mapWithConcurrency<T, R>(
153
+ items: readonly T[],
154
+ concurrency: number,
155
+ fn: (item: T) => Promise<R>,
156
+ ): Promise<R[]> {
157
+ const results: R[] = new Array(items.length);
158
+ let index = 0;
159
+ async function worker(): Promise<void> {
160
+ while (index < items.length) {
161
+ const i = index++;
162
+ const item = items[i];
163
+ if (item !== undefined) results[i] = await fn(item);
164
+ }
165
+ }
166
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
167
+ return results;
168
+ }
@@ -1,11 +1,13 @@
1
1
  import { basename } from "node:path";
2
2
  import { findLocalRepo, localRepoDirName } from "./roots";
3
3
 
4
- // A worktree's folder name proves nothing (`kumiko-*` siblings share prefixes), so it is judged by its canonical checkout's directory name.
4
+ // A worktree's folder name proves nothing (`kumiko-*` siblings share prefixes), so a path that
5
+ // resolves to a repo is judged by its canonical checkout's directory name; the folder name only
6
+ // counts for paths that are not a repo (yet).
5
7
  function pushRootMatchesCliScope(pushRepoRoot: string, cliScope: string): boolean {
6
- if (basename(pushRepoRoot) === cliScope) return true;
7
8
  const local = findLocalRepo(pushRepoRoot);
8
- return local !== undefined && (local.name === cliScope || localRepoDirName(local) === cliScope);
9
+ if (local === undefined) return basename(pushRepoRoot) === cliScope;
10
+ return local.name === cliScope || localRepoDirName(local) === cliScope;
9
11
  }
10
12
 
11
13
  // A push root left over from an earlier hook run or a debug session must not redirect a guard at the wrong repo, so it only counts when KUMIKO_CLI_SCOPE names it.