@ogygie/core 0.8.0 → 0.10.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 +3 -1
- package/src/decision-refs.test.ts +31 -0
- package/src/decision-refs.ts +33 -0
- package/src/impact.test.ts +251 -0
- package/src/impact.ts +366 -0
- package/src/manifest-owners.test.ts +166 -1
- package/src/manifest-owners.ts +104 -26
- package/src/manifest.schema.test.ts +72 -0
- package/src/manifest.schema.ts +35 -0
- package/src/repo-adapter.test.ts +192 -0
- package/src/repo-adapter.ts +307 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent READ-ONLY access to an Ogygie repo, and the only place the framework
|
|
3
|
+
* touches a repo's disk (DECISIONS.md D10).
|
|
4
|
+
*
|
|
5
|
+
* Two tools already need it — the studio, which projects specs into a local
|
|
6
|
+
* UI, and the MCP server, which serves them to a design space (spec
|
|
7
|
+
* ogygie-mcp). Two filesystem adapters would be a lockstep bug waiting to
|
|
8
|
+
* happen: the traversal guard fixed on one side and not the other. So the
|
|
9
|
+
* interface and its implementations live here, once.
|
|
10
|
+
*
|
|
11
|
+
* Writing is NEVER declared in `RepoAdapter`. A tool that writes COMPOSES —
|
|
12
|
+
* the studio's `SpecStorage` extends this interface with its `write`. That way
|
|
13
|
+
* the read-only promise of a consumer is checkable by what it consumes, not by
|
|
14
|
+
* a comment.
|
|
15
|
+
*
|
|
16
|
+
* No dependency, on purpose: the git ref is read straight out of `.git`
|
|
17
|
+
* (D10 excludes a git library for it), so a project installing the core to
|
|
18
|
+
* validate its manifest drags nothing along.
|
|
19
|
+
*/
|
|
20
|
+
import { readFile as readFileFromDisk, readdir, realpath, stat } from "node:fs/promises";
|
|
21
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { throwOgygieError } from "./error.schema.js";
|
|
24
|
+
|
|
25
|
+
export const RepoFileEntrySchema = z.object({
|
|
26
|
+
/** Path relative to the LISTED directory, "/" separated, never absolute. */
|
|
27
|
+
path: z.string(),
|
|
28
|
+
sizeBytes: z.number().int().nonnegative(),
|
|
29
|
+
});
|
|
30
|
+
export type RepoFileEntry = z.infer<typeof RepoFileEntrySchema>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a repo can be asked. Four methods, all reading — the shape a consumer
|
|
34
|
+
* declares when it promises to touch nothing.
|
|
35
|
+
*/
|
|
36
|
+
export interface RepoAdapter {
|
|
37
|
+
/** UTF-8 content of one file. Refuses a path escaping the repo. */
|
|
38
|
+
readFile(relativePath: string): Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* Files under a directory, RECURSIVELY, sorted by path. An absent directory
|
|
41
|
+
* lists EMPTY rather than failing: a feature declared in the manifest but
|
|
42
|
+
* not yet written is a legitimate state the map already explains.
|
|
43
|
+
*/
|
|
44
|
+
listDirectory(relativePath: string): Promise<RepoFileEntry[]>;
|
|
45
|
+
exists(relativePath: string): Promise<boolean>;
|
|
46
|
+
/** HEAD commit SHA, or null when the root is not a git clone. */
|
|
47
|
+
currentRef(): Promise<string | null>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Directories a repo reader never descends into. Not a blocklist of "bad"
|
|
52
|
+
* paths — a reader that returned forty thousand `node_modules` entries would
|
|
53
|
+
* simply be unusable, and nothing under a dot-directory is repo source. The
|
|
54
|
+
* traversal guard below is what protects, this only keeps listings useful.
|
|
55
|
+
*/
|
|
56
|
+
const NEVER_LISTED = new Set(["node_modules"]);
|
|
57
|
+
|
|
58
|
+
function isSkippedDirectory(name: string): boolean {
|
|
59
|
+
return name.startsWith(".") || NEVER_LISTED.has(name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function refusePathOutsideRepo(relativePath: string, cause: string): never {
|
|
63
|
+
return throwOgygieError(
|
|
64
|
+
{
|
|
65
|
+
code: "PATH_OUTSIDE_REPO",
|
|
66
|
+
message: "The requested path resolves outside the repo root.",
|
|
67
|
+
file: relativePath,
|
|
68
|
+
cause,
|
|
69
|
+
suggestedFix:
|
|
70
|
+
"Address a path INSIDE the repo, written relative to its root and without `..`.",
|
|
71
|
+
},
|
|
72
|
+
400,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Textual guard: absolute paths and `..` never reach the disk.
|
|
78
|
+
*
|
|
79
|
+
* The prefix test carries a separator on purpose — `/srv/repo-evil` starts
|
|
80
|
+
* with `/srv/repo` as a string, and a guard that forgets it lets a sibling
|
|
81
|
+
* directory through (the form this check had while it lived in the studio).
|
|
82
|
+
*/
|
|
83
|
+
function resolveInsideRoot(rootDir: string, relativePath: string): string {
|
|
84
|
+
if (isAbsolute(relativePath)) {
|
|
85
|
+
refusePathOutsideRepo(relativePath, `"${relativePath}" is an absolute path`);
|
|
86
|
+
}
|
|
87
|
+
const root = resolve(rootDir);
|
|
88
|
+
const absolute = resolve(root, relativePath);
|
|
89
|
+
if (absolute !== root && !absolute.startsWith(root + sep)) {
|
|
90
|
+
refusePathOutsideRepo(relativePath, `"${relativePath}" resolves to "${absolute}", outside "${root}"`);
|
|
91
|
+
}
|
|
92
|
+
return absolute;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Second guard, for what the first cannot see: a symlink INSIDE the repo
|
|
97
|
+
* pointing out of it resolves to a clean relative path and still reads someone
|
|
98
|
+
* else's file. Only checked when the target exists — when it does not, nothing
|
|
99
|
+
* is read anyway, and `exists` must be able to answer "no" without an error.
|
|
100
|
+
*/
|
|
101
|
+
async function assertRealPathInsideRoot(
|
|
102
|
+
rootDir: string,
|
|
103
|
+
absolute: string,
|
|
104
|
+
relativePath: string,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
let realRoot: string;
|
|
107
|
+
let realTarget: string;
|
|
108
|
+
try {
|
|
109
|
+
realRoot = await realpath(rootDir);
|
|
110
|
+
realTarget = await realpath(absolute);
|
|
111
|
+
} catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)) {
|
|
115
|
+
refusePathOutsideRepo(
|
|
116
|
+
relativePath,
|
|
117
|
+
`"${relativePath}" is a symbolic link leading to "${realTarget}", outside "${realRoot}"`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function listFilesRecursively(
|
|
123
|
+
absoluteDir: string,
|
|
124
|
+
prefix: string,
|
|
125
|
+
): Promise<RepoFileEntry[]> {
|
|
126
|
+
let entries;
|
|
127
|
+
try {
|
|
128
|
+
entries = await readdir(absoluteDir, { withFileTypes: true });
|
|
129
|
+
} catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
const files: RepoFileEntry[] = [];
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
135
|
+
if (entry.isDirectory()) {
|
|
136
|
+
if (isSkippedDirectory(entry.name)) continue;
|
|
137
|
+
files.push(...(await listFilesRecursively(join(absoluteDir, entry.name), relativePath)));
|
|
138
|
+
} else if (entry.isFile()) {
|
|
139
|
+
const stats = await stat(join(absoluteDir, entry.name));
|
|
140
|
+
files.push({ path: relativePath, sizeBytes: stats.size });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Where git really keeps its state for this root.
|
|
148
|
+
*
|
|
149
|
+
* `.git` is a directory in a plain clone and a FILE in a worktree, pointing at
|
|
150
|
+
* the real git dir; a reader that only knows the first form answers "not a git
|
|
151
|
+
* clone" for every worktree — silently, since a null ref is a legitimate
|
|
152
|
+
* answer elsewhere. Branch refs then live in the COMMON dir shared with the
|
|
153
|
+
* main clone, which `commondir` names.
|
|
154
|
+
*/
|
|
155
|
+
async function resolveGitDirs(rootDir: string): Promise<{ gitDir: string; commonDir: string } | null> {
|
|
156
|
+
const dotGit = join(rootDir, ".git");
|
|
157
|
+
let gitDir: string;
|
|
158
|
+
try {
|
|
159
|
+
const stats = await stat(dotGit);
|
|
160
|
+
if (stats.isDirectory()) {
|
|
161
|
+
gitDir = dotGit;
|
|
162
|
+
} else {
|
|
163
|
+
const pointer = (await readFileFromDisk(dotGit, "utf8")).trim();
|
|
164
|
+
const target = pointer.startsWith("gitdir:") ? pointer.slice("gitdir:".length).trim() : "";
|
|
165
|
+
if (target === "") return null;
|
|
166
|
+
gitDir = isAbsolute(target) ? target : resolve(rootDir, target);
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const commonPointer = (await readFileFromDisk(join(gitDir, "commondir"), "utf8")).trim();
|
|
173
|
+
return { gitDir, commonDir: isAbsolute(commonPointer) ? commonPointer : resolve(gitDir, commonPointer) };
|
|
174
|
+
} catch {
|
|
175
|
+
return { gitDir, commonDir: gitDir };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A ref name resolved to its SHA — loose file first, then `packed-refs`. */
|
|
180
|
+
async function readRefSha(
|
|
181
|
+
dirs: { gitDir: string; commonDir: string },
|
|
182
|
+
refName: string,
|
|
183
|
+
): Promise<string | null> {
|
|
184
|
+
for (const base of [dirs.gitDir, dirs.commonDir]) {
|
|
185
|
+
try {
|
|
186
|
+
const sha = (await readFileFromDisk(join(base, refName), "utf8")).trim();
|
|
187
|
+
if (sha !== "") return sha;
|
|
188
|
+
} catch {
|
|
189
|
+
// Not stored loose here — try the next location, then packed-refs.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const packed = await readFileFromDisk(join(dirs.commonDir, "packed-refs"), "utf8");
|
|
194
|
+
for (const line of packed.split("\n")) {
|
|
195
|
+
if (line.startsWith("#") || line.startsWith("^")) continue;
|
|
196
|
+
const [sha, name] = line.trim().split(" ");
|
|
197
|
+
if (name === refName && sha !== undefined && sha !== "") return sha;
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The repo as it sits on this disk, at this instant. Every path is relative to
|
|
207
|
+
* `rootDir` and guarded twice (textually, then through symlinks).
|
|
208
|
+
*/
|
|
209
|
+
export function createFilesystemRepoAdapter(rootDir: string): RepoAdapter {
|
|
210
|
+
return {
|
|
211
|
+
async readFile(relativePath: string): Promise<string> {
|
|
212
|
+
const absolute = resolveInsideRoot(rootDir, relativePath);
|
|
213
|
+
await assertRealPathInsideRoot(rootDir, absolute, relativePath);
|
|
214
|
+
try {
|
|
215
|
+
return await readFileFromDisk(absolute, "utf8");
|
|
216
|
+
} catch (thrown) {
|
|
217
|
+
return throwOgygieError({
|
|
218
|
+
code: "REPO_FILE_NOT_FOUND",
|
|
219
|
+
message: "The requested file cannot be read from the repo.",
|
|
220
|
+
file: relativePath,
|
|
221
|
+
cause: `reading "${absolute}" failed: ${thrown instanceof Error ? thrown.message : String(thrown)}`,
|
|
222
|
+
suggestedFix:
|
|
223
|
+
"Check the path against what the map declares — the manifest is the only chart of a repo.",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
async listDirectory(relativePath: string): Promise<RepoFileEntry[]> {
|
|
229
|
+
const absolute = resolveInsideRoot(rootDir, relativePath);
|
|
230
|
+
await assertRealPathInsideRoot(rootDir, absolute, relativePath);
|
|
231
|
+
return listFilesRecursively(absolute, "");
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async exists(relativePath: string): Promise<boolean> {
|
|
235
|
+
const absolute = resolveInsideRoot(rootDir, relativePath);
|
|
236
|
+
await assertRealPathInsideRoot(rootDir, absolute, relativePath);
|
|
237
|
+
try {
|
|
238
|
+
await stat(absolute);
|
|
239
|
+
return true;
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
async currentRef(): Promise<string | null> {
|
|
246
|
+
const dirs = await resolveGitDirs(rootDir);
|
|
247
|
+
if (dirs === null) return null;
|
|
248
|
+
let head: string;
|
|
249
|
+
try {
|
|
250
|
+
head = (await readFileFromDisk(join(dirs.gitDir, "HEAD"), "utf8")).trim();
|
|
251
|
+
} catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
// Detached HEAD holds the SHA itself; otherwise it names a ref.
|
|
255
|
+
if (!head.startsWith("ref:")) return head === "" ? null : head;
|
|
256
|
+
return readRefSha(dirs, head.slice("ref:".length).trim());
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Fixture repo for the tests (principle 7: no disk, no network). */
|
|
262
|
+
export interface InMemoryRepoAdapterOptions {
|
|
263
|
+
/** What `currentRef()` answers — null models a root that is not a clone. */
|
|
264
|
+
ref?: string | null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function createInMemoryRepoAdapter(
|
|
268
|
+
files: Record<string, string>,
|
|
269
|
+
options: InMemoryRepoAdapterOptions = {},
|
|
270
|
+
): RepoAdapter {
|
|
271
|
+
const contents = new Map(Object.entries(files));
|
|
272
|
+
const ref = options.ref === undefined ? "0".repeat(40) : options.ref;
|
|
273
|
+
const sizeOf = (content: string): number => Buffer.byteLength(content, "utf8");
|
|
274
|
+
return {
|
|
275
|
+
async readFile(relativePath: string): Promise<string> {
|
|
276
|
+
const content = contents.get(relativePath);
|
|
277
|
+
if (content === undefined) {
|
|
278
|
+
return throwOgygieError({
|
|
279
|
+
code: "REPO_FILE_NOT_FOUND",
|
|
280
|
+
message: "The requested file does not exist in the in-memory repo.",
|
|
281
|
+
file: relativePath,
|
|
282
|
+
cause: `no entry for "${relativePath}"`,
|
|
283
|
+
suggestedFix: "Arrange the file in the fixture passed to createInMemoryRepoAdapter.",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return content;
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
async listDirectory(relativePath: string): Promise<RepoFileEntry[]> {
|
|
290
|
+
const prefix = relativePath === "" || relativePath === "." ? "" : `${relativePath.replace(/\/$/, "")}/`;
|
|
291
|
+
return [...contents.entries()]
|
|
292
|
+
.filter(([path]) => path.startsWith(prefix))
|
|
293
|
+
.map(([path, content]) => ({ path: path.slice(prefix.length), sizeBytes: sizeOf(content) }))
|
|
294
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
async exists(relativePath: string): Promise<boolean> {
|
|
298
|
+
if (contents.has(relativePath)) return true;
|
|
299
|
+
const prefix = `${relativePath.replace(/\/$/, "")}/`;
|
|
300
|
+
return [...contents.keys()].some((path) => path.startsWith(prefix));
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
async currentRef(): Promise<string | null> {
|
|
304
|
+
return ref;
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|