@ogygie/core 0.7.0 → 0.9.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 -1
- package/src/decision-refs.test.ts +31 -0
- package/src/decision-refs.ts +33 -0
- package/src/manifest-owners.test.ts +67 -0
- package/src/manifest-owners.ts +46 -8
- package/src/manifest.schema.test.ts +72 -0
- package/src/manifest.schema.ts +60 -1
- package/src/repo-adapter.test.ts +192 -0
- package/src/repo-adapter.ts +307 -0
- package/src/shared-tables.test.ts +23 -9
- package/src/shared-tables.ts +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygie/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Ogygie framework core: manifest/spec/error schemas, generic tenancy and gate tooling. Ships TypeScript sources — Ogygie products run tsx/vitest by construction (D1).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./web-lint": "./src/web-lint.ts",
|
|
21
21
|
"./bundle-budget": "./src/bundle-budget.ts",
|
|
22
22
|
"./spec": "./src/spec.schema.ts",
|
|
23
|
+
"./repo-adapter": "./src/repo-adapter.ts",
|
|
23
24
|
"./decision-refs": "./src/decision-refs.ts",
|
|
24
25
|
"./design-brief": "./src/design-brief.ts",
|
|
25
26
|
"./module-purity": "./src/module-purity.ts",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it } from "vitest";
|
|
9
9
|
import {
|
|
10
|
+
extractDecisionSection,
|
|
10
11
|
findDecisionReferences,
|
|
11
12
|
findDuplicateDecisionIds,
|
|
12
13
|
findUnknownDecisionReferences,
|
|
@@ -112,3 +113,33 @@ describe("gate SPEC_UNKNOWN_DECISION_REFERENCE", () => {
|
|
|
112
113
|
]);
|
|
113
114
|
});
|
|
114
115
|
});
|
|
116
|
+
|
|
117
|
+
describe("extraction d'UNE décision (spec ogygie-mcp)", () => {
|
|
118
|
+
it("ÉTANT DONNÉ une décision déclarée QUAND on l'extrait ALORS on reçoit sa section, titre compris", () => {
|
|
119
|
+
expect(extractDecisionSection(DECISIONS, "D11")).toBe(
|
|
120
|
+
[
|
|
121
|
+
"## D11 — 2026-07-30 — Anglais maximal dans le code",
|
|
122
|
+
"- **Décision** : les identifiants sont en anglais (voir aussi D99, jamais prise).",
|
|
123
|
+
].join("\n"),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("la DERNIÈRE décision du fichier va jusqu'à la fin, sans ligne vide traînante", () => {
|
|
128
|
+
expect(extractDecisionSection(`${DECISIONS}\n\n`, "D26")).toBe(
|
|
129
|
+
[
|
|
130
|
+
"## D26 — 2026-08 — Nomenclature partagée : codes normalisés",
|
|
131
|
+
"- **Décision** : le catalogue global s'appuie sur des codes normalisés.",
|
|
132
|
+
].join("\n"),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("MUTATION : une décision seulement CITÉE dans le corps d'une autre ne s'extrait pas", () => {
|
|
137
|
+
// D99 is mentioned inside D11. Cutting on a citation instead of a heading
|
|
138
|
+
// would serve half of D11 as if it were a decision of its own.
|
|
139
|
+
expect(extractDecisionSection(DECISIONS, "D99")).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("MUTATION : un préfixe ne fait pas une décision — D1 n'est pas D11", () => {
|
|
143
|
+
expect(extractDecisionSection(DECISIONS, "D1")).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/decision-refs.ts
CHANGED
|
@@ -69,6 +69,39 @@ export function findDuplicateDecisionIds(
|
|
|
69
69
|
return duplicated;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The section of ONE decision — its declaring heading and everything up to the
|
|
74
|
+
* next one — or null when no decisions file declares it.
|
|
75
|
+
*
|
|
76
|
+
* Serving a whole decisions file to answer a question about one decision is
|
|
77
|
+
* how a reader's context fills with rules nobody asked about (spec ogygie-mcp,
|
|
78
|
+
* `ogygie_read_decisions`). The cut is made on the DECLARING heading, the very
|
|
79
|
+
* one `parseKnownDecisionIds` counts, so what can be cited and what can be
|
|
80
|
+
* served never drift apart.
|
|
81
|
+
*/
|
|
82
|
+
export function extractDecisionSection(
|
|
83
|
+
decisionsMarkdown: string,
|
|
84
|
+
decisionId: string,
|
|
85
|
+
): string | null {
|
|
86
|
+
const lines = decisionsMarkdown.split("\n");
|
|
87
|
+
const trimTrailingBlanks = (section: string[]): string => {
|
|
88
|
+
while (section.length > 0 && (section[section.length - 1] ?? "").trim() === "") section.pop();
|
|
89
|
+
return section.join("\n");
|
|
90
|
+
};
|
|
91
|
+
let start = -1;
|
|
92
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
93
|
+
const heading = DECISION_HEADING.exec(lines[index] ?? "");
|
|
94
|
+
if (heading === null) continue;
|
|
95
|
+
if (start === -1) {
|
|
96
|
+
if (`D${heading[1]}` === decisionId) start = index;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// The next declaration ends the one being cut.
|
|
100
|
+
return trimTrailingBlanks(lines.slice(start, index));
|
|
101
|
+
}
|
|
102
|
+
return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
|
|
103
|
+
}
|
|
104
|
+
|
|
72
105
|
/** Every decision citation found in a text (frontmatter included), in order. */
|
|
73
106
|
export function findDecisionReferences(text: string): DecisionReference[] {
|
|
74
107
|
const references: DecisionReference[] = [];
|
|
@@ -77,3 +77,70 @@ describe("findSpecOwner — feature ou package, la carte confirme les deux", ()
|
|
|
77
77
|
expect(findSpecOwner(manifestWith({}), "gadget-engine")).toBeUndefined();
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The nature added the day a framework spec could not be confirmed. The
|
|
83
|
+
* mutation these tests guard is the one that was there for months: the lookup
|
|
84
|
+
* IGNORING a nature the map declares, and answering "no owner" instead of
|
|
85
|
+
* "this owner, not delivered yet".
|
|
86
|
+
*/
|
|
87
|
+
const frameworkWith = (packages: unknown[]) => ({
|
|
88
|
+
framework: {
|
|
89
|
+
packages,
|
|
90
|
+
templates: { project: "templates/project", feature: "templates/feature" },
|
|
91
|
+
governedFiles: "packages/ogygie-cli/src/governed-files.ts",
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const mcpPackage = {
|
|
96
|
+
name: "@acme/mcp",
|
|
97
|
+
path: "packages/acme-mcp",
|
|
98
|
+
intent: "Serveur du protocole, publié à part parce qu'il porte sa propre dépendance.",
|
|
99
|
+
spec: "specs/acme-mcp.spec.md",
|
|
100
|
+
status: "delivered",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
describe("findSpecOwner — le package d'un framework confirme aussi", () => {
|
|
104
|
+
it("ÉTANT DONNÉ une spec décrite par un package du framework ALORS la carte le confirme", () => {
|
|
105
|
+
const owner = findSpecOwner(manifestWith(frameworkWith([mcpPackage])), "acme-mcp");
|
|
106
|
+
expect(owner).toEqual({
|
|
107
|
+
kind: "framework-package",
|
|
108
|
+
name: "@acme/mcp",
|
|
109
|
+
status: "delivered",
|
|
110
|
+
folder: "packages/acme-mcp",
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("l'appariement se fait par le champ `spec`, JAMAIS par le nom npm publié", () => {
|
|
115
|
+
// The mutation this kills: matching `candidate.name === specId`, which
|
|
116
|
+
// silently finds nothing — "@acme/mcp" is not "acme-mcp".
|
|
117
|
+
expect(findSpecOwner(manifestWith(frameworkWith([mcpPackage])), "@acme/mcp")).toBeUndefined();
|
|
118
|
+
expect(findSpecOwner(manifestWith(frameworkWith([mcpPackage])), "acme-mcp")?.name).toBe(
|
|
119
|
+
"@acme/mcp",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("une spec portée par PLUSIEURS packages vaut le MOINS avancé d'entre eux", () => {
|
|
124
|
+
const owner = findSpecOwner(
|
|
125
|
+
manifestWith(
|
|
126
|
+
frameworkWith([
|
|
127
|
+
{ ...mcpPackage, name: "@acme/core", path: "packages/acme-core" },
|
|
128
|
+
{ ...mcpPackage, name: "@acme/cli", path: "packages/acme-cli", status: "in-progress" },
|
|
129
|
+
]),
|
|
130
|
+
),
|
|
131
|
+
"acme-mcp",
|
|
132
|
+
);
|
|
133
|
+
expect(owner?.status).toBe("in-progress");
|
|
134
|
+
expect(owner?.name).toBe("@acme/cli");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// The "a feature and a framework package claim the same spec" case is not
|
|
138
|
+
// tested here: the manifest schema refuses it upstream (see
|
|
139
|
+
// manifest.schema.test.ts), so it cannot reach this lookup.
|
|
140
|
+
it("un package du framework SANS spec ne possède rien", () => {
|
|
141
|
+
expect(
|
|
142
|
+
findSpecOwner(manifestWith(frameworkWith([{ ...mcpPackage, spec: null }])), "acme-mcp"),
|
|
143
|
+
).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
});
|
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;
|
|
@@ -64,9 +87,24 @@ export function findSpecOwner(
|
|
|
64
87
|
};
|
|
65
88
|
}
|
|
66
89
|
|
|
90
|
+
const frameworkPackages = (manifest.framework?.packages ?? []).filter(
|
|
91
|
+
(candidate) => candidate.spec !== null && specIdOf(candidate.spec) === specId,
|
|
92
|
+
);
|
|
93
|
+
const leastAdvanced = frameworkPackages
|
|
94
|
+
.slice()
|
|
95
|
+
.sort((a, b) => ADVANCEMENT.indexOf(a.status) - ADVANCEMENT.indexOf(b.status))[0];
|
|
96
|
+
if (leastAdvanced !== undefined) {
|
|
97
|
+
return {
|
|
98
|
+
kind: "framework-package",
|
|
99
|
+
name: leastAdvanced.name,
|
|
100
|
+
status: leastAdvanced.status,
|
|
101
|
+
folder: leastAdvanced.path,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
67
105
|
return undefined;
|
|
68
106
|
}
|
|
69
107
|
|
|
70
108
|
/** What a refusal should suggest — both natures, never one that cannot exist. */
|
|
71
109
|
export const SPEC_OWNER_FIX =
|
|
72
|
-
"Set the owning entry to delivered in the manifest — a feature under `features`, or a
|
|
110
|
+
"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
|
@@ -64,9 +64,33 @@ export const HttpRouteSchema = z.object({
|
|
|
64
64
|
* `shared` is a literal `true`: a table is either shared (and says so) or
|
|
65
65
|
* tenant-scoped (and stays a plain string) — no `shared: false` third state.
|
|
66
66
|
*/
|
|
67
|
+
/**
|
|
68
|
+
* WHICH hole in the tenancy rail, because the two do not carry the same risk
|
|
69
|
+
* (D8):
|
|
70
|
+
* - `reference` — universal knowledge loaded by an extraction script, READ
|
|
71
|
+
* ONLY for the API. What an ABSENT key means, which is also what
|
|
72
|
+
* `shared: true` historically meant — read it through `sharedKindOf()`
|
|
73
|
+
* rather than defaulting in the schema, so the manifest a project wrote is
|
|
74
|
+
* the manifest an agent reads.
|
|
75
|
+
* - `ownerless` — data the API itself produces, owned by nobody because it is
|
|
76
|
+
* born before any account. Its feature WRITES it, and since no tenant guards
|
|
77
|
+
* it, an unguessable secret must: its rows are read by token, never by an
|
|
78
|
+
* enumerable id. No gate can check that, so the feature's spec must say
|
|
79
|
+
* where the secret lives.
|
|
80
|
+
*
|
|
81
|
+
* A security fact belongs on the map, not in a comment — hence a declared key
|
|
82
|
+
* rather than a convention.
|
|
83
|
+
*/
|
|
84
|
+
export const SharedTableKindSchema = z.enum(["reference", "ownerless"]);
|
|
85
|
+
export type SharedTableKind = z.infer<typeof SharedTableKindSchema>;
|
|
86
|
+
|
|
67
87
|
export const DbTableDeclarationSchema = z.union([
|
|
68
88
|
z.string(),
|
|
69
|
-
z.object({
|
|
89
|
+
z.object({
|
|
90
|
+
name: z.string(),
|
|
91
|
+
shared: z.literal(true),
|
|
92
|
+
sharedKind: SharedTableKindSchema.optional(),
|
|
93
|
+
}),
|
|
70
94
|
]);
|
|
71
95
|
export type DbTableDeclaration = z.infer<typeof DbTableDeclarationSchema>;
|
|
72
96
|
|
|
@@ -121,6 +145,20 @@ export const FrameworkManifestSchema = z.object({
|
|
|
121
145
|
name: z.string().describe("Published npm name, e.g. @ogygie/core"),
|
|
122
146
|
path: z.string().describe("Folder relative to the repo root"),
|
|
123
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"]),
|
|
124
162
|
}),
|
|
125
163
|
),
|
|
126
164
|
templates: z.object({
|
|
@@ -286,6 +324,27 @@ export const OgygieManifestSchema = z.object({
|
|
|
286
324
|
packageNames.add(packageEntry.name);
|
|
287
325
|
});
|
|
288
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
|
+
|
|
289
348
|
// Every `package:<name>` must designate a DECLARED package, wherever it is
|
|
290
349
|
// written: a dangling dependency describes a map nobody can follow.
|
|
291
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
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -7,15 +7,7 @@
|
|
|
7
7
|
import { describe, expect, it } from "vitest";
|
|
8
8
|
import type { FeatureManifest } from "./manifest.schema.js";
|
|
9
9
|
import { FeatureManifestSchema } from "./manifest.schema.js";
|
|
10
|
-
import {
|
|
11
|
-
dbTableNamesOf,
|
|
12
|
-
findTenantColumnsInSharedTables,
|
|
13
|
-
mayUseRawDb,
|
|
14
|
-
ownsOnlySharedTables,
|
|
15
|
-
sharedTableNamesOf,
|
|
16
|
-
splitDbSchemaByTable,
|
|
17
|
-
tenantTableNamesOf,
|
|
18
|
-
} from "./shared-tables.js";
|
|
10
|
+
import { dbTableNamesOf, findTenantColumnsInSharedTables, mayUseRawDb, ownsOnlySharedTables, sharedKindOf, sharedTableNamesOf, splitDbSchemaByTable, tenantTableNamesOf } from "./shared-tables.js";
|
|
19
11
|
|
|
20
12
|
const SOCLE = new Set(["accounts"]);
|
|
21
13
|
|
|
@@ -134,3 +126,25 @@ export const labels = pgTable("labels", {
|
|
|
134
126
|
).toEqual([{ table: "labels", tenantColumn: "org_id" }]);
|
|
135
127
|
});
|
|
136
128
|
});
|
|
129
|
+
|
|
130
|
+
describe("sharedKindOf — quelle brèche dans le rail tenant (D8)", () => {
|
|
131
|
+
it("une table tenant n'a AUCUNE nature : ce n'est pas une brèche", () => {
|
|
132
|
+
expect(sharedKindOf("orders")).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("une table partagée sans `sharedKind` vaut `reference` — le sens historique", () => {
|
|
136
|
+
expect(sharedKindOf({ name: "currency_rates", shared: true })).toBe("reference");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("une table `ownerless` est rendue telle quelle : sa feature l'écrit", () => {
|
|
140
|
+
expect(sharedKindOf({ name: "public_drafts", shared: true, sharedKind: "ownerless" })).toBe(
|
|
141
|
+
"ownerless",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("une table `reference` explicite reste `reference`", () => {
|
|
146
|
+
expect(sharedKindOf({ name: "postal_codes", shared: true, sharedKind: "reference" })).toBe(
|
|
147
|
+
"reference",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
});
|
package/src/shared-tables.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* they return. The module is GENERIC infrastructure (D10 amended): it speaks
|
|
20
20
|
* "tenant", never a word of the product's domain.
|
|
21
21
|
*/
|
|
22
|
-
import type { DbTableDeclaration, FeatureManifest } from "./manifest.schema.js";
|
|
22
|
+
import type { SharedTableKind, DbTableDeclaration, FeatureManifest } from "./manifest.schema.js";
|
|
23
23
|
|
|
24
24
|
/** Name of a table however it was declared (bare string or object form). */
|
|
25
25
|
export function dbTableNameOf(declaration: DbTableDeclaration): string {
|
|
@@ -111,3 +111,14 @@ export function findTenantColumnsInSharedTables(
|
|
|
111
111
|
}
|
|
112
112
|
return findings;
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The nature of a shared table: what the declaration says, or `reference` when
|
|
117
|
+
* it says nothing — the historical meaning of `shared: true`, kept so older
|
|
118
|
+
* manifests keep meaning what they meant (AGENTS.md §Multi-tenant rule 4).
|
|
119
|
+
* A tenant-scoped table has no nature: it is not a hole in the rail.
|
|
120
|
+
*/
|
|
121
|
+
export function sharedKindOf(declaration: DbTableDeclaration): SharedTableKind | null {
|
|
122
|
+
if (typeof declaration === "string") return null;
|
|
123
|
+
return declaration.sharedKind ?? "reference";
|
|
124
|
+
}
|