@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
package/src/manifest-owners.ts
CHANGED
|
@@ -3,19 +3,42 @@
|
|
|
3
3
|
* the map owns this spec, and how far along is it?
|
|
4
4
|
*
|
|
5
5
|
* The rule itself does not change — a spec only claims `implemented` when the
|
|
6
|
-
* manifest confirms it. What changes is
|
|
7
|
-
*
|
|
8
|
-
* living
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* manifest confirms it. What changes is WHICH entries are able to confirm.
|
|
7
|
+
* The map declares THREE natures, and this function knows all three:
|
|
8
|
+
* a feature living under an app, a PRODUCT PACKAGE living outside every app
|
|
9
|
+
* (spec manifest-packages), and a FRAMEWORK PACKAGE — the published packages a
|
|
10
|
+
* framework repo describes in its `framework` block.
|
|
11
|
+
*
|
|
12
|
+
* Each nature was added the day a spec of that nature could not be confirmed,
|
|
13
|
+
* and the failure mode is always the same silence: the gate answers "no entry
|
|
14
|
+
* of that name", suggests fixes that cannot be applied, and the spec stays
|
|
15
|
+
* `validated` for ever while its code ships (FAILURES.md 2026-08-25 — three
|
|
16
|
+
* framework specs were in that state, one of them for a delivered gate).
|
|
17
|
+
* Hence the standing convention: a spec verifier knows EVERY nature of owner
|
|
18
|
+
* the manifest declares, and a nature added to the map is added here in the
|
|
19
|
+
* same lot.
|
|
20
|
+
*
|
|
21
|
+
* A framework package matches by its `spec` field, not by its name: a
|
|
22
|
+
* published npm name (`@ogygie/mcp`) is not a spec id (`ogygie-mcp`). Several
|
|
23
|
+
* may point at the same spec — a framework spec often spans the core and the
|
|
24
|
+
* CLI — and the LEAST advanced one answers: a spec is delivered when all its
|
|
25
|
+
* code is, not when its first half is.
|
|
12
26
|
*
|
|
13
27
|
* Lives in the core, not in the gate script, so the rule travels with the
|
|
14
28
|
* versioned package instead of with a copy each project keeps.
|
|
15
29
|
*/
|
|
16
30
|
import type { OgygieManifest } from "./manifest.schema.js";
|
|
17
31
|
|
|
18
|
-
|
|
32
|
+
/** Least advanced first — the order the "all its code" rule sorts on. */
|
|
33
|
+
const ADVANCEMENT = ["skeleton", "in-progress", "delivered"] as const;
|
|
34
|
+
|
|
35
|
+
/** `specs/ogygie-mcp.spec.md` -> `ogygie-mcp`. The manifest declares a PATH
|
|
36
|
+
* (as features do), the lifecycle rule reasons on ids. */
|
|
37
|
+
function specIdOf(specPath: string): string {
|
|
38
|
+
return (specPath.split("/").pop() ?? specPath).replace(/\.spec\.md$/, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SpecOwnerKind = "feature" | "package" | "framework-package";
|
|
19
42
|
|
|
20
43
|
export interface SpecOwner {
|
|
21
44
|
kind: SpecOwnerKind;
|
|
@@ -29,44 +52,99 @@ export interface SpecOwner {
|
|
|
29
52
|
folder: string | null;
|
|
30
53
|
}
|
|
31
54
|
|
|
55
|
+
/** One entry of the map, with everything a tool needs to go read its code. */
|
|
56
|
+
export interface MapEntry extends SpecOwner {
|
|
57
|
+
/** The spec this entry owns, or null when it owns none. */
|
|
58
|
+
specId: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
62
|
+
* EVERYTHING the map declares, each entry with its folder already resolved
|
|
63
|
+
* through the app that carries it.
|
|
64
|
+
*
|
|
65
|
+
* One resolution for the whole framework. The spec-lifecycle gate and the
|
|
66
|
+
* impact analyzer used to resolve folders on their own, and the analyzer got
|
|
67
|
+
* it wrong in the way that does not show: it picked ONE app —
|
|
68
|
+
* `apps.find(app => app.type === "api")` — and joined every feature's folder
|
|
69
|
+
* to that one, so a front feature was looked for under the api app and never
|
|
70
|
+
* found. It also refused to run at all when no api app existed. The map
|
|
71
|
+
* already answered per entry, through `feature.app`, which the schema REQUIRES
|
|
72
|
+
* a front or gateway feature to declare.
|
|
73
|
+
*
|
|
74
|
+
* A fourth nature of entry is therefore added HERE, once, and every tool sees
|
|
75
|
+
* it (FAILURES.md 2026-08-25: a verifier that ignores a category cannot report
|
|
76
|
+
* it).
|
|
36
77
|
*/
|
|
37
|
-
export function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (feature !== undefined) {
|
|
78
|
+
export function declaredEntries(manifest: OgygieManifest): MapEntry[] {
|
|
79
|
+
const entries: MapEntry[] = [];
|
|
80
|
+
|
|
81
|
+
for (const feature of manifest.features) {
|
|
82
|
+
// The app that CARRIES this feature — never a globally chosen one.
|
|
43
83
|
const owningApp =
|
|
44
84
|
feature.app === undefined
|
|
45
85
|
? manifest.apps.find((app) => app.type === "api")
|
|
46
86
|
: manifest.apps.find((app) => app.name === feature.app);
|
|
47
|
-
|
|
87
|
+
entries.push({
|
|
48
88
|
kind: "feature",
|
|
49
89
|
name: feature.name,
|
|
50
90
|
status: feature.status,
|
|
51
91
|
folder: owningApp === undefined ? null : `${owningApp.path}/${feature.folder}`,
|
|
52
|
-
|
|
92
|
+
specId: feature.name,
|
|
93
|
+
});
|
|
53
94
|
}
|
|
54
95
|
|
|
55
|
-
const productPackage
|
|
56
|
-
(
|
|
57
|
-
);
|
|
58
|
-
if (productPackage !== undefined) {
|
|
59
|
-
return {
|
|
96
|
+
for (const productPackage of manifest.packages ?? []) {
|
|
97
|
+
entries.push({
|
|
60
98
|
kind: "package",
|
|
61
99
|
name: productPackage.name,
|
|
62
100
|
status: productPackage.status,
|
|
63
101
|
folder: productPackage.path,
|
|
64
|
-
|
|
102
|
+
specId: productPackage.name,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const frameworkPackage of manifest.framework?.packages ?? []) {
|
|
107
|
+
entries.push({
|
|
108
|
+
kind: "framework-package",
|
|
109
|
+
name: frameworkPackage.name,
|
|
110
|
+
status: frameworkPackage.status,
|
|
111
|
+
folder: frameworkPackage.path,
|
|
112
|
+
// A published npm name is not a spec id: the link is DECLARED.
|
|
113
|
+
specId: frameworkPackage.spec === null ? null : specIdOf(frameworkPackage.spec),
|
|
114
|
+
});
|
|
65
115
|
}
|
|
66
116
|
|
|
67
|
-
return
|
|
117
|
+
return entries;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The entry owning `specId`, feature first. Names are unique across the whole
|
|
122
|
+
* map (the schema refuses a package named like a feature), so the order only
|
|
123
|
+
* decides which lookup runs first, never which answer is right.
|
|
124
|
+
*/
|
|
125
|
+
export function findSpecOwner(
|
|
126
|
+
manifest: OgygieManifest,
|
|
127
|
+
specId: string,
|
|
128
|
+
): SpecOwner | undefined {
|
|
129
|
+
const owning = declaredEntries(manifest).filter((entry) => entry.specId === specId);
|
|
130
|
+
if (owning.length === 0) return undefined;
|
|
131
|
+
// Feature first: names are unique across the map (the schema refuses a
|
|
132
|
+
// package named like a feature), so the order decides which lookup runs
|
|
133
|
+
// first, never which answer is right. Among several framework packages
|
|
134
|
+
// pointing at the SAME spec — a framework spec often spans the core and the
|
|
135
|
+
// CLI — the LEAST advanced answers: a spec is delivered when all its code is.
|
|
136
|
+
const byPriority = owning.slice().sort((left, right) => {
|
|
137
|
+
if (left.kind !== right.kind) {
|
|
138
|
+
const order: SpecOwnerKind[] = ["feature", "package", "framework-package"];
|
|
139
|
+
return order.indexOf(left.kind) - order.indexOf(right.kind);
|
|
140
|
+
}
|
|
141
|
+
return ADVANCEMENT.indexOf(left.status) - ADVANCEMENT.indexOf(right.status);
|
|
142
|
+
});
|
|
143
|
+
const owner = byPriority[0];
|
|
144
|
+
if (owner === undefined) return undefined;
|
|
145
|
+
return { kind: owner.kind, name: owner.name, status: owner.status, folder: owner.folder };
|
|
68
146
|
}
|
|
69
147
|
|
|
70
148
|
/** What a refusal should suggest — both natures, never one that cannot exist. */
|
|
71
149
|
export const SPEC_OWNER_FIX =
|
|
72
|
-
"Set the owning entry to delivered in the manifest — a feature under `features`, or a
|
|
150
|
+
"Set the owning entry to delivered in the manifest — a feature under `features`, a library under `packages`, or a published package under `framework.packages` (which names its spec explicitly) — or keep the spec in validated.";
|
|
@@ -220,6 +220,78 @@ describe("manifest — ogygieVersion et bloc framework (spec ogygie-standalone-r
|
|
|
220
220
|
name: "@ogygie/core",
|
|
221
221
|
path: "packages/ogygie-core",
|
|
222
222
|
intent: "Schémas, erreurs et outillage génériques du framework.",
|
|
223
|
+
spec: null,
|
|
224
|
+
status: "delivered",
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
templates: { project: "templates/project", feature: "templates/feature" },
|
|
228
|
+
governedFiles: "packages/ogygie-cli/src/governed-files.ts",
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
expect(OgygieManifestSchema.safeParse(manifest).success).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// A published package is an OWNER of specs like any other entry of the map.
|
|
235
|
+
// What follows guards the day that was discovered — a spec verifier that
|
|
236
|
+
// ignores a nature answers "no owner" and lets a delivered spec look unborn
|
|
237
|
+
// (FAILURES.md 2026-08-25).
|
|
238
|
+
it("MUTATION : un package du framework sans `status` est refusé (le cycle de vie ne devine pas)", () => {
|
|
239
|
+
const manifest = {
|
|
240
|
+
...manifestWithFeature({}),
|
|
241
|
+
framework: {
|
|
242
|
+
packages: [
|
|
243
|
+
{
|
|
244
|
+
name: "@ogygie/core",
|
|
245
|
+
path: "packages/ogygie-core",
|
|
246
|
+
intent: "Schémas, erreurs et outillage génériques du framework.",
|
|
247
|
+
spec: null,
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
templates: { project: "templates/project", feature: "templates/feature" },
|
|
251
|
+
governedFiles: "packages/ogygie-cli/src/governed-files.ts",
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
expect(OgygieManifestSchema.safeParse(manifest).success).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("MUTATION : un package du framework qui revendique la spec d'une FEATURE est refusé", () => {
|
|
258
|
+
const manifest = {
|
|
259
|
+
...manifestWithFeature({}),
|
|
260
|
+
framework: {
|
|
261
|
+
packages: [
|
|
262
|
+
{
|
|
263
|
+
name: "@ogygie/shell",
|
|
264
|
+
path: "packages/ogygie-shell",
|
|
265
|
+
intent: "Package revendiquant la spec déjà possédée par la feature web-shell.",
|
|
266
|
+
spec: "specs/web-shell.spec.md",
|
|
267
|
+
status: "delivered",
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
templates: { project: "templates/project", feature: "templates/feature" },
|
|
271
|
+
governedFiles: "packages/ogygie-cli/src/governed-files.ts",
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
expect(OgygieManifestSchema.safeParse(manifest).success).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("DEUX packages du framework peuvent porter la MÊME spec : une spec du framework enjambe souvent core et cli", () => {
|
|
278
|
+
const manifest = {
|
|
279
|
+
...manifestWithFeature({}),
|
|
280
|
+
framework: {
|
|
281
|
+
packages: [
|
|
282
|
+
{
|
|
283
|
+
name: "@ogygie/core",
|
|
284
|
+
path: "packages/ogygie-core",
|
|
285
|
+
intent: "Moitié jugement de la spec, sans aucune collecte disque.",
|
|
286
|
+
spec: "specs/outillage.spec.md",
|
|
287
|
+
status: "delivered",
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: "@ogygie/cli",
|
|
291
|
+
path: "packages/ogygie-cli",
|
|
292
|
+
intent: "Moitié collecte de la même spec, appelée par la CI gouvernée.",
|
|
293
|
+
spec: "specs/outillage.spec.md",
|
|
294
|
+
status: "in-progress",
|
|
223
295
|
},
|
|
224
296
|
],
|
|
225
297
|
templates: { project: "templates/project", feature: "templates/feature" },
|
package/src/manifest.schema.ts
CHANGED
|
@@ -145,6 +145,20 @@ export const FrameworkManifestSchema = z.object({
|
|
|
145
145
|
name: z.string().describe("Published npm name, e.g. @ogygie/core"),
|
|
146
146
|
path: z.string().describe("Folder relative to the repo root"),
|
|
147
147
|
intent: z.string().min(20),
|
|
148
|
+
/**
|
|
149
|
+
* The spec describing this package, when one does. Named EXPLICITLY
|
|
150
|
+
* because a published npm name (`@ogygie/mcp`) is not a spec id
|
|
151
|
+
* (`ogygie-mcp`) — the other two natures of entry match by name, this
|
|
152
|
+
* one cannot. Several packages may point at the same spec: a spec of
|
|
153
|
+
* the framework often spans the core and the CLI.
|
|
154
|
+
*/
|
|
155
|
+
spec: z.string().nullable(),
|
|
156
|
+
/**
|
|
157
|
+
* How far along, in the SAME words as the other natures. Required, and
|
|
158
|
+
* deliberately without default: a lifecycle gate that guesses a status
|
|
159
|
+
* confirms specs nobody delivered.
|
|
160
|
+
*/
|
|
161
|
+
status: z.enum(["skeleton", "in-progress", "delivered"]),
|
|
148
162
|
}),
|
|
149
163
|
),
|
|
150
164
|
templates: z.object({
|
|
@@ -310,6 +324,27 @@ export const OgygieManifestSchema = z.object({
|
|
|
310
324
|
packageNames.add(packageEntry.name);
|
|
311
325
|
});
|
|
312
326
|
|
|
327
|
+
// One spec, one owner, ACROSS the three natures. A framework package
|
|
328
|
+
// pointing at a spec a feature or a product package already owns would
|
|
329
|
+
// give the lifecycle gate two answers, and it only ever reads the first.
|
|
330
|
+
const specIdOf = (specPath: string): string =>
|
|
331
|
+
(specPath.split("/").pop() ?? specPath).replace(/\.spec\.md$/, "");
|
|
332
|
+
const ownedSpecIds = new Set<string>([
|
|
333
|
+
...manifest.features.map((feature) => feature.name),
|
|
334
|
+
...packageNames,
|
|
335
|
+
]);
|
|
336
|
+
(manifest.framework?.packages ?? []).forEach((packageEntry, index) => {
|
|
337
|
+
if (packageEntry.spec === null) return;
|
|
338
|
+
const specId = specIdOf(packageEntry.spec);
|
|
339
|
+
if (ownedSpecIds.has(specId)) {
|
|
340
|
+
context.addIssue({
|
|
341
|
+
code: "custom",
|
|
342
|
+
path: ["framework", "packages", index, "spec"],
|
|
343
|
+
message: `spec "${specId}" is already owned by a feature or a product package — one spec, one owner`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
313
348
|
// Every `package:<name>` must designate a DECLARED package, wherever it is
|
|
314
349
|
// written: a dangling dependency describes a map nobody can follow.
|
|
315
350
|
const checkPackageDependencies = (
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Proves the read-only repo access of DECISIONS.md D10, and guards the
|
|
3
|
+
* mutations that make such a module lie instead of fail: a traversal guard
|
|
4
|
+
* comparing prefixes without a separator, a symlink walking out of the repo, a
|
|
5
|
+
* `.git` that is a FILE (every worktree), a ref stored packed rather than
|
|
6
|
+
* loose. Each of those returns a plausible answer when it is wrong, which is
|
|
7
|
+
* why each has its test.
|
|
8
|
+
*/
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
14
|
+
import { OgygieFailure } from "./error.schema.js";
|
|
15
|
+
import {
|
|
16
|
+
createFilesystemRepoAdapter,
|
|
17
|
+
createInMemoryRepoAdapter,
|
|
18
|
+
type RepoAdapter,
|
|
19
|
+
} from "./repo-adapter.js";
|
|
20
|
+
|
|
21
|
+
/** The stable code carried by a thrown OgygieFailure. */
|
|
22
|
+
function codeOf(thrown: unknown): string {
|
|
23
|
+
return thrown instanceof OgygieFailure ? thrown.error.code : "<not an OgygieFailure>";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function expectRefusal(action: () => Promise<unknown>, expectedCode: string): Promise<void> {
|
|
27
|
+
await expect(action()).rejects.toSatisfy(
|
|
28
|
+
(thrown: unknown) => codeOf(thrown) === expectedCode,
|
|
29
|
+
`expected an OgygieError with code ${expectedCode}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("repo-adapter — lecture du disque", () => {
|
|
34
|
+
let rootDir: string;
|
|
35
|
+
let outsideDir: string;
|
|
36
|
+
let adapter: RepoAdapter;
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
const base = await mkdtemp(join(tmpdir(), "ogygie-repo-"));
|
|
40
|
+
rootDir = join(base, "repo");
|
|
41
|
+
outsideDir = join(base, "repo-evil");
|
|
42
|
+
await mkdir(join(rootDir, "src", "features", "orders"), { recursive: true });
|
|
43
|
+
await mkdir(outsideDir, { recursive: true });
|
|
44
|
+
await writeFile(join(rootDir, "AGENTS.md"), "règles", "utf8");
|
|
45
|
+
await writeFile(join(rootDir, "src", "features", "orders", "schemas.ts"), "export const a = 1;\n", "utf8");
|
|
46
|
+
await writeFile(join(rootDir, "src", "features", "orders", "service.ts"), "// service\n", "utf8");
|
|
47
|
+
await writeFile(join(outsideDir, "secrets.env"), "TOKEN=leaked", "utf8");
|
|
48
|
+
await mkdir(join(rootDir, "node_modules", "left-pad"), { recursive: true });
|
|
49
|
+
await writeFile(join(rootDir, "node_modules", "left-pad", "index.js"), "module.exports = 1;", "utf8");
|
|
50
|
+
await symlink(join(outsideDir, "secrets.env"), join(rootDir, "escape.env"));
|
|
51
|
+
adapter = createFilesystemRepoAdapter(rootDir);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
await rm(join(rootDir, ".."), { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ÉTANT DONNÉ un fichier du repo QUAND on le lit ALORS son contenu revient tel quel", async () => {
|
|
59
|
+
expect(await adapter.readFile("AGENTS.md")).toBe("règles");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("un listing est RÉCURSIF, trié, et porte la taille de chaque fichier", async () => {
|
|
63
|
+
const entries = await adapter.listDirectory("src/features/orders");
|
|
64
|
+
expect(entries.map((entry) => entry.path)).toEqual(["schemas.ts", "service.ts"]);
|
|
65
|
+
expect(entries[0]?.sizeBytes).toBe(20);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("un dossier ABSENT liste vide — une feature en squelette est un état légitime", async () => {
|
|
69
|
+
expect(await adapter.listDirectory("src/features/inexistante")).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("node_modules et les dossiers cachés ne sont jamais listés", async () => {
|
|
73
|
+
const paths = (await adapter.listDirectory(".")).map((entry) => entry.path);
|
|
74
|
+
expect(paths).toContain("AGENTS.md");
|
|
75
|
+
expect(paths.some((path) => path.startsWith("node_modules/"))).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("MUTATION : un chemin remontant vers un repo VOISIN est refusé, rien n'est lu", async () => {
|
|
79
|
+
// The neighbour is named "repo-evil" next to "repo" on purpose: a guard
|
|
80
|
+
// comparing prefixes without a separator lets it through, since
|
|
81
|
+
// "/…/repo-evil" does start with "/…/repo".
|
|
82
|
+
await expectRefusal(() => adapter.readFile("../repo-evil/secrets.env"), "PATH_OUTSIDE_REPO");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("MUTATION : un chemin ABSOLU est refusé", async () => {
|
|
86
|
+
await expectRefusal(() => adapter.readFile(join(outsideDir, "secrets.env")), "PATH_OUTSIDE_REPO");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("MUTATION : un lien symbolique sortant est refusé, bien que son chemin soit propre", async () => {
|
|
90
|
+
await expectRefusal(() => adapter.readFile("escape.env"), "PATH_OUTSIDE_REPO");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("un fichier absent échoue avec un code stable, jamais un contenu vide", async () => {
|
|
94
|
+
await expectRefusal(() => adapter.readFile("jamais-ecrit.md"), "REPO_FILE_NOT_FOUND");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("exists répond sans jamais lever pour un fichier absent", async () => {
|
|
98
|
+
expect(await adapter.exists("AGENTS.md")).toBe(true);
|
|
99
|
+
expect(await adapter.exists("jamais-ecrit.md")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("un dossier qui n'est PAS un clone git rend un ref null — jamais un identifiant inventé", async () => {
|
|
103
|
+
expect(await adapter.currentRef()).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("repo-adapter — le ref git, dans les trois formes que git donne à `.git`", () => {
|
|
108
|
+
let base: string;
|
|
109
|
+
|
|
110
|
+
const git = (cwd: string, ...args: string[]): string =>
|
|
111
|
+
execFileSync("git", args, {
|
|
112
|
+
cwd,
|
|
113
|
+
encoding: "utf8",
|
|
114
|
+
env: {
|
|
115
|
+
...process.env,
|
|
116
|
+
GIT_AUTHOR_NAME: "Fixture",
|
|
117
|
+
GIT_AUTHOR_EMAIL: "fixture@example.invalid",
|
|
118
|
+
GIT_COMMITTER_NAME: "Fixture",
|
|
119
|
+
GIT_COMMITTER_EMAIL: "fixture@example.invalid",
|
|
120
|
+
},
|
|
121
|
+
}).trim();
|
|
122
|
+
|
|
123
|
+
let clone: string;
|
|
124
|
+
let head: string;
|
|
125
|
+
|
|
126
|
+
beforeAll(async () => {
|
|
127
|
+
base = await mkdtemp(join(tmpdir(), "ogygie-git-"));
|
|
128
|
+
clone = join(base, "clone");
|
|
129
|
+
await mkdir(clone);
|
|
130
|
+
git(clone, "init", "--initial-branch=main");
|
|
131
|
+
await writeFile(join(clone, "AGENTS.md"), "règles", "utf8");
|
|
132
|
+
git(clone, "add", "AGENTS.md");
|
|
133
|
+
git(clone, "commit", "-m", "fixture");
|
|
134
|
+
head = git(clone, "rev-parse", "HEAD");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
afterAll(async () => {
|
|
138
|
+
await rm(base, { recursive: true, force: true });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("ÉTANT DONNÉ un clone ordinaire QUAND on demande le ref ALORS c'est le SHA de HEAD", async () => {
|
|
142
|
+
expect(await createFilesystemRepoAdapter(clone).currentRef()).toBe(head);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("ÉTANT DONNÉ un WORKTREE, dont `.git` est un FICHIER, ALORS le ref est trouvé quand même", async () => {
|
|
146
|
+
// The form this repo itself works in. A reader that only knows the
|
|
147
|
+
// directory form answers null here — and null is a legitimate answer
|
|
148
|
+
// elsewhere, so nothing would ever protest.
|
|
149
|
+
const worktree = join(base, "wt");
|
|
150
|
+
git(clone, "worktree", "add", "-b", "essai", worktree);
|
|
151
|
+
const worktreeHead = git(worktree, "rev-parse", "HEAD");
|
|
152
|
+
expect(await createFilesystemRepoAdapter(worktree).currentRef()).toBe(worktreeHead);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("ÉTANT DONNÉ des refs COMPACTÉES QUAND on demande le ref ALORS packed-refs répond", async () => {
|
|
156
|
+
git(clone, "pack-refs", "--all");
|
|
157
|
+
expect(await createFilesystemRepoAdapter(clone).currentRef()).toBe(head);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("ÉTANT DONNÉ une HEAD DÉTACHÉE ALORS le SHA qu'elle porte est rendu directement", async () => {
|
|
161
|
+
const detached = join(base, "detached");
|
|
162
|
+
git(clone, "worktree", "add", "--detach", detached, head);
|
|
163
|
+
expect(await createFilesystemRepoAdapter(detached).currentRef()).toBe(head);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("repo-adapter — fixture en mémoire", () => {
|
|
168
|
+
const adapter = createInMemoryRepoAdapter({
|
|
169
|
+
"AGENTS.md": "règles",
|
|
170
|
+
"specs/notes.spec.md": "---\nid: notes\n---\n",
|
|
171
|
+
"src/features/notes/schemas.ts": "export const a = 1;\n",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("lit, liste par préfixe et rend des tailles en OCTETS, pas en caractères", async () => {
|
|
175
|
+
expect(await adapter.readFile("AGENTS.md")).toBe("règles");
|
|
176
|
+
const entries = await adapter.listDirectory("specs");
|
|
177
|
+
expect(entries).toEqual([{ path: "notes.spec.md", sizeBytes: 18 }]);
|
|
178
|
+
// "règles" is 6 characters and 7 bytes: a size in characters would lie to
|
|
179
|
+
// every caller enforcing a byte budget.
|
|
180
|
+
const root = await adapter.listDirectory("");
|
|
181
|
+
expect(root.find((entry) => entry.path === "AGENTS.md")?.sizeBytes).toBe(7);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("un fichier absent échoue du MÊME code que sur disque", async () => {
|
|
185
|
+
await expectRefusal(() => adapter.readFile("absent.md"), "REPO_FILE_NOT_FOUND");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("le ref est fourni par la fixture, et peut modéliser un dossier sans git", async () => {
|
|
189
|
+
expect(await adapter.currentRef()).toBe("0".repeat(40));
|
|
190
|
+
expect(await createInMemoryRepoAdapter({}, { ref: null }).currentRef()).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
});
|