@cosmicdrift/kumiko-repo-manifest 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +57 -0
  2. package/README.md +14 -0
  3. package/package.json +39 -0
  4. package/src/index.ts +226 -0
package/LICENSE ADDED
@@ -0,0 +1,57 @@
1
+ Business Source License 1.1
2
+
3
+ Parameters
4
+
5
+ Licensor: Marc Frost
6
+
7
+ Licensed Work: @cosmicdrift/kumiko-repo-manifest
8
+ The Licensed Work is © 2026 Marc Frost.
9
+
10
+ Additional Use Grant:
11
+ You may use the Licensed Work in production for any purpose, including
12
+ commercially, EXCEPT for the Restricted Use.
13
+
14
+ "Restricted Use" is defined as using the Licensed Work to provide a platform
15
+ or service to third parties that allows them to host, deploy, or run their
16
+ own applications built with the Licensed Work. This includes, but is not
17
+ limited to: managed hosting services, software-as-a-service (SaaS) platforms,
18
+ platform-as-a-service (PaaS), developer platforms, or any multi-tenant
19
+ managed offering of the Licensed Work.
20
+
21
+ This restriction does not apply to the Licensor, any entity controlled by,
22
+ controlling, or under common control with the Licensor ("Affiliates"), or
23
+ contractors acting on their behalf. The Licensor remains free to use the
24
+ Licensed Work for any purpose, including for the operation of kumiko.rocks.
25
+
26
+ Change Date: 2030-05-05
27
+ Change License: Apache License, Version 2.0
28
+
29
+
30
+ Terms
31
+
32
+ The Licensor hereby grants you the right to copy, modify, create derivative works,
33
+ redistribute, and make non-production use of the Licensed Work. The Licensor may
34
+ make an Additional Use Grant, above, permitting limited production use.
35
+
36
+ Effective on the Change Date, or the fourth anniversary of the first publicly
37
+ available distribution of the Licensed Work under this License, whichever comes
38
+ first, this License will convert to the Change License.
39
+
40
+ This Business Source License governs use of the Licensed Work in all cases, except
41
+ as to any use that is explicitly granted in the Additional Use Grant above or
42
+ under the Change License after the Change Date.
43
+
44
+ If your use of the Licensed Work does not comply with the requirements of this
45
+ License, you must cease use of the Licensed Work immediately.
46
+
47
+ All copies of the Licensed Work, and all derivative works thereof, must include
48
+ this License.
49
+
50
+ This License does not grant you any right, title, or interest in any trademark,
51
+ logo, or branding of the Licensor, except as required to comply with this License.
52
+
53
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
54
+ “AS IS” BASIS. LICENSOR DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
55
+ WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
56
+ TITLE, AND NON-INFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY DAMAGES
57
+ ARISING OUT OF OR RELATED TO THIS LICENSE OR THE USE OF THE LICENSED WORK.
package/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # @cosmicdrift/kumiko-repo-manifest
2
+
3
+ Schema and loader for a repo's `kumiko.json` manifest: repo `kind`
4
+ (`framework`/`library`/`app`), source roots, test globs, UI roots and
5
+ excludes. Backs guard and tooling scope resolution across Kumiko repos.
6
+
7
+ Without a `kumiko.json`, `loadRepoManifest(root)` derives a manifest from a
8
+ `packages/*/src` or `src/` layout instead of failing.
9
+
10
+ ```ts
11
+ import { loadRepoManifest } from "@cosmicdrift/kumiko-repo-manifest";
12
+
13
+ const { manifest, source } = loadRepoManifest(process.cwd());
14
+ ```
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@cosmicdrift/kumiko-repo-manifest",
3
+ "version": "0.1.0",
4
+ "description": "kumiko.json repo manifest schema and loader.",
5
+ "license": "BUSL-1.1",
6
+ "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/CosmicDriftGameStudio/kumiko-framework.git",
10
+ "directory": "packages/repo-manifest"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/CosmicDriftGameStudio/kumiko-framework/issues"
14
+ },
15
+ "homepage": "https://kumiko.rocks",
16
+ "type": "module",
17
+ "kumiko": {
18
+ "runtime": "dev"
19
+ },
20
+ "exports": {
21
+ ".": {
22
+ "types": "./src/index.ts",
23
+ "default": "./src/index.ts"
24
+ }
25
+ },
26
+ "dependencies": {
27
+ "zod": "^4.4.3"
28
+ },
29
+ "publishConfig": {
30
+ "registry": "https://registry.npmjs.org",
31
+ "access": "public"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "!src/**/__tests__/**",
36
+ "README.md",
37
+ "LICENSE"
38
+ ]
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,226 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { z } from "zod";
4
+
5
+ export const REPO_MANIFEST_FILE = "kumiko.json";
6
+
7
+ export const repoKindSchema = z.enum(["framework", "library", "app"]);
8
+ export type RepoKind = z.infer<typeof repoKindSchema>;
9
+
10
+ const DRIVE_LETTER_RE = /^[A-Za-z]:/;
11
+
12
+ function isBlank(value: string): boolean {
13
+ return value.trim().length === 0;
14
+ }
15
+
16
+ function patternViolation(value: string): string | undefined {
17
+ if (isBlank(value)) return `must not be empty: "${value}"`;
18
+ if (value.includes("\0")) return `must not contain a NUL character: "${value}"`;
19
+ if (value.startsWith("/") || DRIVE_LETTER_RE.test(value)) {
20
+ return `must be relative to the repo root: "${value}"`;
21
+ }
22
+ if (value.includes("\\")) return `must use forward slashes, not backslashes: "${value}"`;
23
+ if (value.startsWith("!")) return `must not start with "!": "${value}"`;
24
+ if (value.split("/").some((segment) => segment === "..")) {
25
+ return `must not traverse outside the repo root: "${value}"`;
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ const pattern = z.string().superRefine((value, ctx) => {
31
+ const violation = patternViolation(value);
32
+ if (violation !== undefined) {
33
+ ctx.addIssue({ code: "custom", message: violation });
34
+ }
35
+ });
36
+
37
+ export const repoManifestSchema = z.strictObject({
38
+ kind: repoKindSchema,
39
+ sourceRoots: z.array(pattern).min(1),
40
+ testGlobs: z.array(pattern).min(1),
41
+ uiRoots: z.array(pattern).optional(),
42
+ excludes: z.array(pattern).optional(),
43
+ });
44
+ export type RepoManifest = z.infer<typeof repoManifestSchema>;
45
+
46
+ export type RepoManifestSource = "file" | "derived";
47
+
48
+ export type LoadedRepoManifest = {
49
+ readonly manifest: RepoManifest;
50
+ readonly source: RepoManifestSource;
51
+ readonly manifestPath: string;
52
+ };
53
+
54
+ export class RepoManifestError extends Error {
55
+ readonly manifestPath: string;
56
+
57
+ constructor(manifestPath: string, message: string) {
58
+ super(`${manifestPath}: ${message}`);
59
+ this.name = "RepoManifestError";
60
+ this.manifestPath = manifestPath;
61
+ }
62
+ }
63
+
64
+ export type LoadRepoManifestOptions = {
65
+ readonly warn?: (message: string) => void;
66
+ };
67
+
68
+ function isDirectory(path: string): boolean {
69
+ return existsSync(path) && statSync(path).isDirectory();
70
+ }
71
+
72
+ function isWithinRoot(candidateReal: string, rootReal: string): boolean {
73
+ return candidateReal === rootReal || candidateReal.startsWith(rootReal + sep);
74
+ }
75
+
76
+ // Static prefix (pre-glob segments) is what a symlink escape must hide behind — the glob tail never resolves to a real path.
77
+ const GLOB_METACHARACTER_RE = /[*?[\]{}]/;
78
+
79
+ function staticPatternBase(value: string): string {
80
+ const staticSegments: string[] = [];
81
+ for (const segment of value.split("/")) {
82
+ if (GLOB_METACHARACTER_RE.test(segment)) break;
83
+ staticSegments.push(segment);
84
+ }
85
+ return staticSegments.join("/");
86
+ }
87
+
88
+ function assertPatternsResolveWithinRoot(
89
+ manifestPath: string,
90
+ rootAbs: string,
91
+ rootReal: string,
92
+ manifest: RepoManifest,
93
+ ): void {
94
+ const patterns = [
95
+ ...manifest.sourceRoots,
96
+ ...manifest.testGlobs,
97
+ ...(manifest.uiRoots ?? []),
98
+ ...(manifest.excludes ?? []),
99
+ ];
100
+ for (const value of patterns) {
101
+ const base = staticPatternBase(value);
102
+ if (base.length === 0) continue;
103
+ // Walk segment by segment — a symlink can escape before the full base path exists on disk (e.g. "linked/src" whose target has no "src").
104
+ let relSoFar = "";
105
+ for (const segment of base.split("/")) {
106
+ relSoFar = relSoFar.length === 0 ? segment : `${relSoFar}/${segment}`;
107
+ const segmentAbs = join(rootAbs, relSoFar);
108
+ if (!existsSync(segmentAbs)) break;
109
+ const segmentReal = realpathSync(segmentAbs);
110
+ if (!isWithinRoot(segmentReal, rootReal)) {
111
+ throw new RepoManifestError(manifestPath, `"${value}" resolves outside the repo root`);
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ function loadManifestFile(
118
+ manifestPath: string,
119
+ rootAbs: string,
120
+ rootReal: string,
121
+ ): LoadedRepoManifest {
122
+ const manifestReal = realpathSync(manifestPath);
123
+ if (!isWithinRoot(manifestReal, rootReal)) {
124
+ throw new RepoManifestError(manifestPath, "resolves outside the repo root");
125
+ }
126
+
127
+ const raw = readFileSync(manifestPath, "utf8");
128
+ let json: unknown;
129
+ try {
130
+ json = JSON.parse(raw);
131
+ } catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ throw new RepoManifestError(manifestPath, `invalid JSON — ${message}`);
134
+ }
135
+
136
+ const result = repoManifestSchema.safeParse(json);
137
+ if (!result.success) {
138
+ const issues = result.error.issues
139
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
140
+ .join("; ");
141
+ throw new RepoManifestError(manifestPath, `invalid repo manifest — ${issues}`);
142
+ }
143
+
144
+ assertPatternsResolveWithinRoot(manifestPath, rootAbs, rootReal, result.data);
145
+
146
+ return { manifest: result.data, source: "file", manifestPath };
147
+ }
148
+
149
+ function hasPackagesSrcLayout(rootAbs: string): boolean {
150
+ const packagesDir = join(rootAbs, "packages");
151
+ if (!isDirectory(packagesDir)) return false;
152
+ return readdirSync(packagesDir, { withFileTypes: true }).some(
153
+ (entry) => entry.isDirectory() && isDirectory(join(packagesDir, entry.name, "src")),
154
+ );
155
+ }
156
+
157
+ type DerivedLayout = { readonly layout: string; readonly manifest: RepoManifest };
158
+
159
+ function derivedLayoutFor(rootAbs: string): DerivedLayout | undefined {
160
+ if (hasPackagesSrcLayout(rootAbs)) {
161
+ return {
162
+ layout: "packages/*/src",
163
+ manifest: {
164
+ kind: "library",
165
+ sourceRoots: ["packages/*/src"],
166
+ testGlobs: ["packages/*/src/**/*.{test,integration}.{ts,tsx}"],
167
+ },
168
+ };
169
+ }
170
+ if (isDirectory(join(rootAbs, "src"))) {
171
+ return {
172
+ layout: "src",
173
+ manifest: {
174
+ kind: "app",
175
+ sourceRoots: ["src"],
176
+ testGlobs: ["src/**/*.{test,integration}.{ts,tsx}"],
177
+ },
178
+ };
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ function deriveManifest(
184
+ manifestPath: string,
185
+ rootAbs: string,
186
+ warn: (message: string) => void,
187
+ ): LoadedRepoManifest {
188
+ const derived = derivedLayoutFor(rootAbs);
189
+
190
+ if (!derived) {
191
+ throw new RepoManifestError(
192
+ manifestPath,
193
+ "not found and no packages/*/src or src/ layout to derive one from",
194
+ );
195
+ }
196
+
197
+ warn(
198
+ `${manifestPath} not found — derived "${derived.layout}" layout (kind "${derived.manifest.kind}"); ` +
199
+ "add a kumiko.json to declare the repo layout explicitly",
200
+ );
201
+
202
+ return { manifest: derived.manifest, source: "derived", manifestPath };
203
+ }
204
+
205
+ function defaultWarn(message: string): void {
206
+ // biome-ignore lint/suspicious/noConsole: no logger is wired when the manifest loads
207
+ console.warn(message);
208
+ }
209
+
210
+ export function loadRepoManifest(
211
+ root: string,
212
+ options: LoadRepoManifestOptions = {},
213
+ ): LoadedRepoManifest {
214
+ const rootAbs = resolve(root);
215
+ const manifestPath = join(rootAbs, REPO_MANIFEST_FILE);
216
+
217
+ if (!isDirectory(rootAbs)) {
218
+ throw new RepoManifestError(manifestPath, "repo root does not exist or is not a directory");
219
+ }
220
+
221
+ if (existsSync(manifestPath)) {
222
+ return loadManifestFile(manifestPath, rootAbs, realpathSync(rootAbs));
223
+ }
224
+
225
+ return deriveManifest(manifestPath, rootAbs, options.warn ?? defaultWarn);
226
+ }