@ogygie/cli 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/dist/cli.js +137 -29
- package/package.json +2 -2
- package/templates/project/scripts/ogygie-impact.ts +70 -261
package/dist/cli.js
CHANGED
|
@@ -14679,7 +14679,21 @@ var FrameworkManifestSchema = external_exports.object({
|
|
|
14679
14679
|
external_exports.object({
|
|
14680
14680
|
name: external_exports.string().describe("Published npm name, e.g. @ogygie/core"),
|
|
14681
14681
|
path: external_exports.string().describe("Folder relative to the repo root"),
|
|
14682
|
-
intent: external_exports.string().min(20)
|
|
14682
|
+
intent: external_exports.string().min(20),
|
|
14683
|
+
/**
|
|
14684
|
+
* The spec describing this package, when one does. Named EXPLICITLY
|
|
14685
|
+
* because a published npm name (`@ogygie/mcp`) is not a spec id
|
|
14686
|
+
* (`ogygie-mcp`) — the other two natures of entry match by name, this
|
|
14687
|
+
* one cannot. Several packages may point at the same spec: a spec of
|
|
14688
|
+
* the framework often spans the core and the CLI.
|
|
14689
|
+
*/
|
|
14690
|
+
spec: external_exports.string().nullable(),
|
|
14691
|
+
/**
|
|
14692
|
+
* How far along, in the SAME words as the other natures. Required, and
|
|
14693
|
+
* deliberately without default: a lifecycle gate that guesses a status
|
|
14694
|
+
* confirms specs nobody delivered.
|
|
14695
|
+
*/
|
|
14696
|
+
status: external_exports.enum(["skeleton", "in-progress", "delivered"])
|
|
14683
14697
|
})
|
|
14684
14698
|
),
|
|
14685
14699
|
templates: external_exports.object({
|
|
@@ -14774,6 +14788,22 @@ var OgygieManifestSchema = external_exports.object({
|
|
|
14774
14788
|
}
|
|
14775
14789
|
packageNames.add(packageEntry.name);
|
|
14776
14790
|
});
|
|
14791
|
+
const specIdOf = (specPath) => (specPath.split("/").pop() ?? specPath).replace(/\.spec\.md$/, "");
|
|
14792
|
+
const ownedSpecIds = /* @__PURE__ */ new Set([
|
|
14793
|
+
...manifest.features.map((feature) => feature.name),
|
|
14794
|
+
...packageNames
|
|
14795
|
+
]);
|
|
14796
|
+
(manifest.framework?.packages ?? []).forEach((packageEntry, index) => {
|
|
14797
|
+
if (packageEntry.spec === null) return;
|
|
14798
|
+
const specId = specIdOf(packageEntry.spec);
|
|
14799
|
+
if (ownedSpecIds.has(specId)) {
|
|
14800
|
+
context.addIssue({
|
|
14801
|
+
code: "custom",
|
|
14802
|
+
path: ["framework", "packages", index, "spec"],
|
|
14803
|
+
message: `spec "${specId}" is already owned by a feature or a product package \u2014 one spec, one owner`
|
|
14804
|
+
});
|
|
14805
|
+
}
|
|
14806
|
+
});
|
|
14777
14807
|
const checkPackageDependencies = (dependsOn, path) => {
|
|
14778
14808
|
dependsOn.forEach((dependency, dependencyIndex) => {
|
|
14779
14809
|
if (!dependency.startsWith("package:")) return;
|
|
@@ -15115,9 +15145,57 @@ async function runStudio(deps) {
|
|
|
15115
15145
|
});
|
|
15116
15146
|
}
|
|
15117
15147
|
|
|
15148
|
+
// src/commands/mcp.ts
|
|
15149
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
15150
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
15151
|
+
import { join as join5 } from "node:path";
|
|
15152
|
+
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
15153
|
+
var PUBLISHED_MCP_SERVER = "@ogygie/mcp/server";
|
|
15154
|
+
async function loadMcpFrom(projectRoot) {
|
|
15155
|
+
const resolveFromProject = createRequire2(join5(projectRoot, "package.json"));
|
|
15156
|
+
const entryPoint = resolveFromProject.resolve(PUBLISHED_MCP_SERVER);
|
|
15157
|
+
return await import(pathToFileURL2(entryPoint).href);
|
|
15158
|
+
}
|
|
15159
|
+
function isModuleNotFound2(thrown) {
|
|
15160
|
+
const code = thrown?.code;
|
|
15161
|
+
return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED" || thrown instanceof Error && /Cannot find (package|module)/i.test(thrown.message);
|
|
15162
|
+
}
|
|
15163
|
+
async function runMcp(deps) {
|
|
15164
|
+
const importMcp = deps.importMcp ?? (() => loadMcpFrom(deps.projectRoot));
|
|
15165
|
+
let mcpServer;
|
|
15166
|
+
try {
|
|
15167
|
+
mcpServer = await importMcp();
|
|
15168
|
+
} catch (thrown) {
|
|
15169
|
+
const reason = thrown instanceof Error ? thrown.message : String(thrown);
|
|
15170
|
+
if (isModuleNotFound2(thrown)) {
|
|
15171
|
+
throwOgygieError({
|
|
15172
|
+
code: "MCP_NOT_INSTALLED",
|
|
15173
|
+
message: "The Ogygie MCP server is not installed in this project.",
|
|
15174
|
+
file: "package.json",
|
|
15175
|
+
cause: `${PUBLISHED_MCP_SERVER} cannot be resolved: ${reason}`,
|
|
15176
|
+
suggestedFix: `Install it at the framework's own version (lockstep D2): pnpm add -Dw @ogygie/mcp@${deps.cliVersion}`
|
|
15177
|
+
});
|
|
15178
|
+
}
|
|
15179
|
+
throwOgygieError({
|
|
15180
|
+
code: "MCP_LOAD_FAILED",
|
|
15181
|
+
message: "The Ogygie MCP server is installed but could not be loaded.",
|
|
15182
|
+
file: "package.json",
|
|
15183
|
+
cause: reason,
|
|
15184
|
+
suggestedFix: "Reinstalling will not help: this is a framework defect. Report it with this cause, and check that @ogygie/mcp matches @ogygie/cli (lockstep D2)."
|
|
15185
|
+
});
|
|
15186
|
+
}
|
|
15187
|
+
await mcpServer.startOgygieMcp({
|
|
15188
|
+
argv: deps.argv,
|
|
15189
|
+
cwd: deps.projectRoot,
|
|
15190
|
+
version: deps.cliVersion,
|
|
15191
|
+
token: process.env["OGYGIE_MCP_TOKEN"],
|
|
15192
|
+
readConfigFile: (absolutePath) => readFileSync4(absolutePath, "utf8")
|
|
15193
|
+
});
|
|
15194
|
+
}
|
|
15195
|
+
|
|
15118
15196
|
// src/commands/upgrade.ts
|
|
15119
|
-
import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as
|
|
15120
|
-
import { dirname as dirname2, join as
|
|
15197
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "node:fs";
|
|
15198
|
+
import { dirname as dirname2, join as join6 } from "node:path";
|
|
15121
15199
|
|
|
15122
15200
|
// src/diff.ts
|
|
15123
15201
|
function diffLines(oldText, newText) {
|
|
@@ -15185,11 +15263,11 @@ function compareVersions(a, b) {
|
|
|
15185
15263
|
|
|
15186
15264
|
// src/commands/upgrade.ts
|
|
15187
15265
|
function reportMissingGates(deps) {
|
|
15188
|
-
const packageJsonPath =
|
|
15266
|
+
const packageJsonPath = join6(deps.projectRoot, "package.json");
|
|
15189
15267
|
if (!existsSync4(packageJsonPath)) return;
|
|
15190
15268
|
let scripts;
|
|
15191
15269
|
try {
|
|
15192
|
-
const parsed = JSON.parse(
|
|
15270
|
+
const parsed = JSON.parse(readFileSync5(packageJsonPath, "utf8"));
|
|
15193
15271
|
const scriptsValue = typeof parsed === "object" && parsed !== null ? parsed.scripts : void 0;
|
|
15194
15272
|
if (typeof scriptsValue === "object" && scriptsValue !== null) {
|
|
15195
15273
|
scripts = scriptsValue;
|
|
@@ -15240,8 +15318,8 @@ async function upgradeProject(deps) {
|
|
|
15240
15318
|
for (const governed of governedFiles) {
|
|
15241
15319
|
const expected = installedChecksums[governed.targetPath];
|
|
15242
15320
|
if (expected === void 0) continue;
|
|
15243
|
-
const absolutePath =
|
|
15244
|
-
const actual = existsSync4(absolutePath) ? sha256Hex(
|
|
15321
|
+
const absolutePath = join6(deps.projectRoot, governed.targetPath);
|
|
15322
|
+
const actual = existsSync4(absolutePath) ? sha256Hex(readFileSync5(absolutePath, "utf8")) : "<absent>";
|
|
15245
15323
|
if (actual !== expected) {
|
|
15246
15324
|
throwOgygieError({
|
|
15247
15325
|
code: "OGYGIE_GOVERNED_FILE_MODIFIED",
|
|
@@ -15255,12 +15333,12 @@ async function upgradeProject(deps) {
|
|
|
15255
15333
|
const plannedWrites = [];
|
|
15256
15334
|
const diffBlocks = [];
|
|
15257
15335
|
for (const governed of governedFiles) {
|
|
15258
|
-
const content =
|
|
15259
|
-
|
|
15336
|
+
const content = readFileSync5(
|
|
15337
|
+
join6(deps.templatesDir, governed.templatePath),
|
|
15260
15338
|
"utf8"
|
|
15261
15339
|
);
|
|
15262
|
-
const absolutePath =
|
|
15263
|
-
const current = existsSync4(absolutePath) ?
|
|
15340
|
+
const absolutePath = join6(deps.projectRoot, governed.targetPath);
|
|
15341
|
+
const current = existsSync4(absolutePath) ? readFileSync5(absolutePath, "utf8") : "";
|
|
15264
15342
|
const diff = diffLines(current, content);
|
|
15265
15343
|
if (diff !== "") {
|
|
15266
15344
|
plannedWrites.push({ targetPath: governed.targetPath, content });
|
|
@@ -15280,7 +15358,7 @@ ${diff}`);
|
|
|
15280
15358
|
};
|
|
15281
15359
|
}
|
|
15282
15360
|
for (const write of plannedWrites) {
|
|
15283
|
-
const absolutePath =
|
|
15361
|
+
const absolutePath = join6(deps.projectRoot, write.targetPath);
|
|
15284
15362
|
mkdirSync2(dirname2(absolutePath), { recursive: true });
|
|
15285
15363
|
writeFileSync3(absolutePath, write.content);
|
|
15286
15364
|
}
|
|
@@ -15301,8 +15379,8 @@ ${diff}`);
|
|
|
15301
15379
|
}
|
|
15302
15380
|
|
|
15303
15381
|
// src/commands/verify-vocabulary.ts
|
|
15304
|
-
import { existsSync as existsSync5, readFileSync as
|
|
15305
|
-
import { join as
|
|
15382
|
+
import { existsSync as existsSync5, readFileSync as readFileSync6, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
|
|
15383
|
+
import { join as join7, relative, sep } from "node:path";
|
|
15306
15384
|
|
|
15307
15385
|
// ../ogygie-core/src/vocabulary.ts
|
|
15308
15386
|
var TOKEN_PATTERN = new RegExp("\\p{L}[\\p{L}\\p{N}]*(?:[-_$][\\p{L}\\p{N}]+)*", "gu");
|
|
@@ -15409,7 +15487,7 @@ function auditProductVocabulary(input) {
|
|
|
15409
15487
|
var CODE_EXTENSIONS = [".ts", ".tsx", ".mts", ".mjs", ".js", ".jsx"];
|
|
15410
15488
|
var IGNORED_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
|
|
15411
15489
|
function readManifest(projectRoot) {
|
|
15412
|
-
const manifestPath =
|
|
15490
|
+
const manifestPath = join7(projectRoot, MANIFEST_FILE_NAME);
|
|
15413
15491
|
if (!existsSync5(manifestPath)) {
|
|
15414
15492
|
throwOgygieError({
|
|
15415
15493
|
code: "OGYGIE_CLI_USAGE",
|
|
@@ -15420,7 +15498,7 @@ function readManifest(projectRoot) {
|
|
|
15420
15498
|
});
|
|
15421
15499
|
}
|
|
15422
15500
|
const parsed = OgygieManifestSchema.safeParse(
|
|
15423
|
-
JSON.parse(
|
|
15501
|
+
JSON.parse(readFileSync6(manifestPath, "utf8"))
|
|
15424
15502
|
);
|
|
15425
15503
|
if (!parsed.success) {
|
|
15426
15504
|
throwOgygieError({
|
|
@@ -15434,13 +15512,13 @@ function readManifest(projectRoot) {
|
|
|
15434
15512
|
return parsed.data;
|
|
15435
15513
|
}
|
|
15436
15514
|
function collectCodeFiles(projectRoot, folder) {
|
|
15437
|
-
const absoluteFolder =
|
|
15515
|
+
const absoluteFolder = join7(projectRoot, folder);
|
|
15438
15516
|
if (!existsSync5(absoluteFolder)) return [];
|
|
15439
15517
|
const files = [];
|
|
15440
15518
|
const walk = (directory) => {
|
|
15441
15519
|
for (const entry of readdirSync2(directory)) {
|
|
15442
15520
|
if (IGNORED_DIRS.has(entry)) continue;
|
|
15443
|
-
const entryPath =
|
|
15521
|
+
const entryPath = join7(directory, entry);
|
|
15444
15522
|
if (statSync2(entryPath).isDirectory()) {
|
|
15445
15523
|
walk(entryPath);
|
|
15446
15524
|
continue;
|
|
@@ -15448,7 +15526,7 @@ function collectCodeFiles(projectRoot, folder) {
|
|
|
15448
15526
|
if (!CODE_EXTENSIONS.some((extension) => entry.endsWith(extension))) continue;
|
|
15449
15527
|
files.push({
|
|
15450
15528
|
path: relative(projectRoot, entryPath).split(sep).join("/"),
|
|
15451
|
-
content:
|
|
15529
|
+
content: readFileSync6(entryPath, "utf8")
|
|
15452
15530
|
});
|
|
15453
15531
|
}
|
|
15454
15532
|
};
|
|
@@ -15476,9 +15554,9 @@ function verifyVocabulary(deps) {
|
|
|
15476
15554
|
const sourceFiles = productCodeFoldersOf(manifest).flatMap(
|
|
15477
15555
|
(folder) => collectCodeFiles(deps.projectRoot, folder)
|
|
15478
15556
|
);
|
|
15479
|
-
const governed = governedFiles.filter((file2) => existsSync5(
|
|
15557
|
+
const governed = governedFiles.filter((file2) => existsSync5(join7(deps.projectRoot, file2.targetPath))).map((file2) => ({
|
|
15480
15558
|
path: file2.targetPath,
|
|
15481
|
-
content:
|
|
15559
|
+
content: readFileSync6(join7(deps.projectRoot, file2.targetPath), "utf8")
|
|
15482
15560
|
}));
|
|
15483
15561
|
return auditProductVocabulary({
|
|
15484
15562
|
vocabulary: manifest.vocabulary,
|
|
@@ -15509,6 +15587,18 @@ var governedChecksumsByVersion = {
|
|
|
15509
15587
|
"CLAUDE.md": "785ee8d90e368806d8cf0b4d84a15f29d1dc961168bda86cf1a6c60a923c8c9b",
|
|
15510
15588
|
"tsconfig.base.json": "0f8919e9ed8bbc88e0308fa2fa91471dabb18d1df721bd78c04076597eba89a2"
|
|
15511
15589
|
},
|
|
15590
|
+
"0.10.0": {
|
|
15591
|
+
".github/workflows/ogygie-gates.yml": "48c40efe9fe66dd0f9c3b94287026d80eae9afd6782b5833fdf8846d8662babf",
|
|
15592
|
+
"AGENTS.md": "e44f635c0b203a0040098425f217c5a29c47e57280a3545b60d640aea0d59a70",
|
|
15593
|
+
"CLAUDE.md": "785ee8d90e368806d8cf0b4d84a15f29d1dc961168bda86cf1a6c60a923c8c9b",
|
|
15594
|
+
"scripts/lint-interdits.ts": "d859e132b7020942a5211f5419b10045170dee6afba3f50ca5642570a5c4a683",
|
|
15595
|
+
"scripts/ogygie-design-brief.ts": "aba1585717b30808dd0bc8930a37a1ad2d83ee6869f969dbd06ca595e37a4a89",
|
|
15596
|
+
"scripts/ogygie-impact.ts": "825a47a51e940b2d8473714a417b4a75bad4eede35e3f4b34258bf6e181e98f4",
|
|
15597
|
+
"scripts/verify-contracts.ts": "725cae269e78ecdaa1923553c3097d8d96277824b5bd79a9a40284c6246036b7",
|
|
15598
|
+
"scripts/verify-manifest.ts": "1adca9edee11e54ccaf0511012411516c52c68a32f686ca320c7e437afb22c7c",
|
|
15599
|
+
"scripts/verify-specs.ts": "ccf09c04155e5e88fa9acd4a5db33c2c79e85923b2d92e8e866fe9cbc4cdf342",
|
|
15600
|
+
"tsconfig.base.json": "0f8919e9ed8bbc88e0308fa2fa91471dabb18d1df721bd78c04076597eba89a2"
|
|
15601
|
+
},
|
|
15512
15602
|
"0.2.0": {
|
|
15513
15603
|
".github/workflows/ogygie-gates.yml": "48c40efe9fe66dd0f9c3b94287026d80eae9afd6782b5833fdf8846d8662babf",
|
|
15514
15604
|
"AGENTS.md": "ca73ab619d8c55fac11858d815bf5fbab6537ecebe7ae4123cbdeb3e3e36bf1a",
|
|
@@ -15586,27 +15676,39 @@ var governedChecksumsByVersion = {
|
|
|
15586
15676
|
"scripts/verify-manifest.ts": "1adca9edee11e54ccaf0511012411516c52c68a32f686ca320c7e437afb22c7c",
|
|
15587
15677
|
"scripts/verify-specs.ts": "ccf09c04155e5e88fa9acd4a5db33c2c79e85923b2d92e8e866fe9cbc4cdf342",
|
|
15588
15678
|
"tsconfig.base.json": "0f8919e9ed8bbc88e0308fa2fa91471dabb18d1df721bd78c04076597eba89a2"
|
|
15679
|
+
},
|
|
15680
|
+
"0.9.0": {
|
|
15681
|
+
".github/workflows/ogygie-gates.yml": "48c40efe9fe66dd0f9c3b94287026d80eae9afd6782b5833fdf8846d8662babf",
|
|
15682
|
+
"AGENTS.md": "e44f635c0b203a0040098425f217c5a29c47e57280a3545b60d640aea0d59a70",
|
|
15683
|
+
"CLAUDE.md": "785ee8d90e368806d8cf0b4d84a15f29d1dc961168bda86cf1a6c60a923c8c9b",
|
|
15684
|
+
"scripts/lint-interdits.ts": "d859e132b7020942a5211f5419b10045170dee6afba3f50ca5642570a5c4a683",
|
|
15685
|
+
"scripts/ogygie-design-brief.ts": "aba1585717b30808dd0bc8930a37a1ad2d83ee6869f969dbd06ca595e37a4a89",
|
|
15686
|
+
"scripts/ogygie-impact.ts": "0dd3ff8cdd57e6cff0dfec1c669fce53fde9ae43f0784ed114771c83d1f8b357",
|
|
15687
|
+
"scripts/verify-contracts.ts": "725cae269e78ecdaa1923553c3097d8d96277824b5bd79a9a40284c6246036b7",
|
|
15688
|
+
"scripts/verify-manifest.ts": "1adca9edee11e54ccaf0511012411516c52c68a32f686ca320c7e437afb22c7c",
|
|
15689
|
+
"scripts/verify-specs.ts": "ccf09c04155e5e88fa9acd4a5db33c2c79e85923b2d92e8e866fe9cbc4cdf342",
|
|
15690
|
+
"tsconfig.base.json": "0f8919e9ed8bbc88e0308fa2fa91471dabb18d1df721bd78c04076597eba89a2"
|
|
15589
15691
|
}
|
|
15590
15692
|
};
|
|
15591
15693
|
|
|
15592
15694
|
// src/paths.ts
|
|
15593
|
-
import { existsSync as existsSync6, readFileSync as
|
|
15594
|
-
import { dirname as dirname3, join as
|
|
15695
|
+
import { existsSync as existsSync6, readFileSync as readFileSync7 } from "node:fs";
|
|
15696
|
+
import { dirname as dirname3, join as join8 } from "node:path";
|
|
15595
15697
|
import { fileURLToPath } from "node:url";
|
|
15596
|
-
var PACKAGE_ROOT =
|
|
15698
|
+
var PACKAGE_ROOT = join8(dirname3(fileURLToPath(import.meta.url)), "..");
|
|
15597
15699
|
var PackageVersionSchema2 = external_exports.object({ version: external_exports.string() });
|
|
15598
15700
|
function cliVersion() {
|
|
15599
15701
|
return PackageVersionSchema2.parse(
|
|
15600
|
-
JSON.parse(
|
|
15702
|
+
JSON.parse(readFileSync7(join8(PACKAGE_ROOT, "package.json"), "utf8"))
|
|
15601
15703
|
).version;
|
|
15602
15704
|
}
|
|
15603
15705
|
function templatesDir() {
|
|
15604
|
-
const embedded =
|
|
15706
|
+
const embedded = join8(PACKAGE_ROOT, "templates");
|
|
15605
15707
|
if (existsSync6(embedded)) return embedded;
|
|
15606
15708
|
return repoTemplatesDir();
|
|
15607
15709
|
}
|
|
15608
15710
|
function repoTemplatesDir() {
|
|
15609
|
-
return
|
|
15711
|
+
return join8(PACKAGE_ROOT, "..", "..", "templates");
|
|
15610
15712
|
}
|
|
15611
15713
|
|
|
15612
15714
|
// src/cli.ts
|
|
@@ -15685,14 +15787,20 @@ ogygie verify-vocabulary \u2014 ${problems.length} probl\xE8me(s).`);
|
|
|
15685
15787
|
console.log("ogygie verify-vocabulary \u2014 OK, le vocabulaire d\xE9clar\xE9 est respect\xE9.");
|
|
15686
15788
|
} else if (command === "studio") {
|
|
15687
15789
|
await runStudio({ projectRoot: process.cwd(), cliVersion: cliVersion() });
|
|
15790
|
+
} else if (command === "mcp") {
|
|
15791
|
+
await runMcp({
|
|
15792
|
+
projectRoot: process.cwd(),
|
|
15793
|
+
cliVersion: cliVersion(),
|
|
15794
|
+
argv: process.argv.slice(3)
|
|
15795
|
+
});
|
|
15688
15796
|
} else {
|
|
15689
15797
|
printError(
|
|
15690
15798
|
ogygieError({
|
|
15691
15799
|
code: "OGYGIE_CLI_USAGE",
|
|
15692
15800
|
message: `Unknown command ${JSON.stringify(command ?? "")}.`,
|
|
15693
15801
|
file: null,
|
|
15694
|
-
cause: `expected one of: init <product-name>, upgrade, check, verify-vocabulary, studio (flags: --json, --yes)`,
|
|
15695
|
-
suggestedFix: "Run `ogygie init <product-name>`, `ogygie upgrade`, `ogygie check`, `ogygie verify-vocabulary` or `ogygie
|
|
15802
|
+
cause: `expected one of: init <product-name>, upgrade, check, verify-vocabulary, studio, mcp (flags: --json, --yes)`,
|
|
15803
|
+
suggestedFix: "Run `ogygie init <product-name>`, `ogygie upgrade`, `ogygie check`, `ogygie verify-vocabulary`, `ogygie studio` or `ogygie mcp`."
|
|
15696
15804
|
})
|
|
15697
15805
|
);
|
|
15698
15806
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygie/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Ogygie framework CLI: deterministic scaffolding (init), governed-file upgrades (upgrade) and lockstep verification (check) for Ogygie products.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"tsx": "^4.20.0",
|
|
23
23
|
"typescript": "^5.8.0",
|
|
24
24
|
"zod": "^4.0.0",
|
|
25
|
-
"@ogygie/core": "0.
|
|
25
|
+
"@ogygie/core": "0.10.0"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"typecheck": "tsc -p tsconfig.json",
|
|
@@ -1,255 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @intent Session tool `pnpm ogygie:impact <
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* fs + textual import analysis, no new dependency, findings as OgygieError.
|
|
2
|
+
* @intent Session tool `pnpm ogygie:impact <target>` (spec
|
|
3
|
+
* impact-analyzer-core): prints the blast radius of a change to delivered
|
|
4
|
+
* code — who depends on it, which symbols they use, its routes, the tables it
|
|
5
|
+
* owns and any outside reader of them, the tests to re-run, and WHAT THE
|
|
6
|
+
* ANALYSIS COULD NOT SEE (D12).
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* The judgement lives in `@ogygie/core/impact`, where it is tested against an
|
|
9
|
+
* in-memory repo and callable by other tools; this script is its collecting
|
|
10
|
+
* half — read the manifest, open the repo, print. Everything it used to
|
|
11
|
+
* decide by itself (which app carries which feature, what counts as an
|
|
12
|
+
* import) now comes from the map and from the core.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* - The owned-tables scan ignores *.test.ts files: populating ANOTHER feature's
|
|
16
|
-
* table as a fixture is the canonical bench pattern ("the socle feature owns
|
|
17
|
-
* that model" — see any service.test.ts); flagging it would make every run
|
|
18
|
-
* red and the tool useless. Production code referencing a foreign table
|
|
19
|
-
* remains a potential violation and IS flagged.
|
|
14
|
+
* Output contract: default prints the JSON report THEN the human summary;
|
|
15
|
+
* `--json` prints the JSON only. Exit 0 when findings are empty, 1 otherwise.
|
|
20
16
|
*/
|
|
21
|
-
import {
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
22
18
|
import { join } from "node:path";
|
|
23
|
-
import {
|
|
19
|
+
import { analyzeImpact, type ImpactReport } from "@ogygie/core/impact";
|
|
24
20
|
import { OgygieManifestSchema } from "@ogygie/core/manifest";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
21
|
+
import { createFilesystemRepoAdapter } from "@ogygie/core/repo-adapter";
|
|
22
|
+
import { OgygieFailure, ogygieError } from "@ogygie/core/errors";
|
|
27
23
|
|
|
28
24
|
const ROOT = new URL("..", import.meta.url).pathname;
|
|
29
25
|
const MANIFEST_PATH = "ogygie.manifest.json";
|
|
30
26
|
|
|
31
|
-
// ---------------------------------------------------------------- contract
|
|
32
|
-
/** In @ogygie/core once the pattern proves generic (spec §Contrats, D10 rule 3). */
|
|
33
|
-
export const ImpactReportSchema = z.object({
|
|
34
|
-
feature: z.string(),
|
|
35
|
-
dependents: z.array(
|
|
36
|
-
z.object({
|
|
37
|
-
name: z.string(),
|
|
38
|
-
importedSymbols: z.array(z.string()),
|
|
39
|
-
viaIndexOnly: z.boolean(),
|
|
40
|
-
}),
|
|
41
|
-
),
|
|
42
|
-
routes: z.array(
|
|
43
|
-
z.object({ method: z.string(), path: z.string(), description: z.string() }),
|
|
44
|
-
),
|
|
45
|
-
ownedTables: z.array(z.string()),
|
|
46
|
-
tableReadersOutsideOwner: z.array(
|
|
47
|
-
z.object({ table: z.string(), feature: z.string(), file: z.string() }),
|
|
48
|
-
),
|
|
49
|
-
findings: z.array(OgygieErrorSchema),
|
|
50
|
-
testCommand: z.string(),
|
|
51
|
-
});
|
|
52
|
-
export type ImpactReport = z.infer<typeof ImpactReportSchema>;
|
|
53
|
-
|
|
54
|
-
// ---------------------------------------------------------------- helpers
|
|
55
|
-
function walkTsFiles(dir: string): string[] {
|
|
56
|
-
const files: string[] = [];
|
|
57
|
-
for (const entry of readdirSync(dir)) {
|
|
58
|
-
const fullPath = join(dir, entry);
|
|
59
|
-
if (statSync(fullPath).isDirectory()) files.push(...walkTsFiles(fullPath));
|
|
60
|
-
else if (entry.endsWith(".ts") || entry.endsWith(".tsx")) files.push(fullPath);
|
|
61
|
-
}
|
|
62
|
-
return files;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Named/default/namespace symbols of one import clause ("type" stripped). */
|
|
66
|
-
function symbolsOfImportClause(clause: string): string[] {
|
|
67
|
-
const symbols: string[] = [];
|
|
68
|
-
const braces = /\{([^}]*)\}/.exec(clause);
|
|
69
|
-
if (braces?.[1] !== undefined) {
|
|
70
|
-
for (const rawEntry of braces[1].split(",")) {
|
|
71
|
-
const name = rawEntry.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
|
|
72
|
-
if (name !== undefined && name !== "") symbols.push(name);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
const outsideBraces = clause.replace(/\{[^}]*\}/, "").replace(/^type\s+/, "").trim();
|
|
76
|
-
if (/\*\s*as\s+/.test(outsideBraces)) symbols.push("*");
|
|
77
|
-
else if (outsideBraces.replace(/,/g, "").trim() !== "") symbols.push("default");
|
|
78
|
-
return symbols;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ---------------------------------------------------------------- report
|
|
82
|
-
export function buildImpactReport(featureName: string): ImpactReport {
|
|
83
|
-
const manifest = OgygieManifestSchema.parse(
|
|
84
|
-
JSON.parse(readFileSync(join(ROOT, MANIFEST_PATH), "utf8")),
|
|
85
|
-
);
|
|
86
|
-
const apiApp = manifest.apps.find((app) => app.type === "api");
|
|
87
|
-
if (apiApp === undefined) {
|
|
88
|
-
throw new Error("no app of type api in the manifest");
|
|
89
|
-
}
|
|
90
|
-
const target = manifest.features.find((feature) => feature.name === featureName);
|
|
91
|
-
if (target === undefined) {
|
|
92
|
-
const failure = ogygieError({
|
|
93
|
-
code: "IMPACT_UNKNOWN_FEATURE",
|
|
94
|
-
message: `Feature "${featureName}" is unknown to the manifest. Valid features: ${manifest.features.map((feature) => feature.name).join(", ")}.`,
|
|
95
|
-
file: MANIFEST_PATH,
|
|
96
|
-
cause: `no features[] entry has name="${featureName}"`,
|
|
97
|
-
suggestedFix: "Pick one of the valid feature names listed in the message.",
|
|
98
|
-
});
|
|
99
|
-
console.error(JSON.stringify(failure, null, 2));
|
|
100
|
-
console.error("\nogygie:impact — feature inconnue.");
|
|
101
|
-
process.exit(1);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const findings: OgygieError[] = [];
|
|
105
|
-
const targetDirName = target.folder.split("/").pop() ?? target.folder;
|
|
106
|
-
|
|
107
|
-
// (a)+(b) dependents: declared in the manifest AND/OR actually importing.
|
|
108
|
-
const importersByFeature = new Map<
|
|
109
|
-
string,
|
|
110
|
-
{ importedSymbols: Set<string>; viaIndexOnly: boolean }
|
|
111
|
-
>();
|
|
112
|
-
for (const feature of manifest.features) {
|
|
113
|
-
if (feature.name === target.name) continue;
|
|
114
|
-
const featureDir = join(ROOT, apiApp.path, feature.folder);
|
|
115
|
-
if (!existsSync(featureDir)) continue;
|
|
116
|
-
for (const filePath of walkTsFiles(featureDir)) {
|
|
117
|
-
const content = readFileSync(filePath, "utf8");
|
|
118
|
-
const relativeFile = filePath.slice(ROOT.length);
|
|
119
|
-
for (const match of content.matchAll(
|
|
120
|
-
/import\s+([^'"]+?)\s+from\s+"([^"]+)"/g,
|
|
121
|
-
)) {
|
|
122
|
-
const clause = match[1];
|
|
123
|
-
const source = match[2];
|
|
124
|
-
if (clause === undefined || source === undefined) continue;
|
|
125
|
-
const sibling = new RegExp(`^\\.\\./${targetDirName}/(.+)$`).exec(source);
|
|
126
|
-
if (sibling === null || sibling[1] === undefined) continue;
|
|
127
|
-
const entry = importersByFeature.get(feature.name) ?? {
|
|
128
|
-
importedSymbols: new Set<string>(),
|
|
129
|
-
viaIndexOnly: true,
|
|
130
|
-
};
|
|
131
|
-
for (const symbol of symbolsOfImportClause(clause)) {
|
|
132
|
-
entry.importedSymbols.add(symbol);
|
|
133
|
-
}
|
|
134
|
-
if (sibling[1] !== "index.js") {
|
|
135
|
-
entry.viaIndexOnly = false;
|
|
136
|
-
findings.push(
|
|
137
|
-
ogygieError({
|
|
138
|
-
code: "FEATURE_BOUNDARY_VIOLATION",
|
|
139
|
-
message: `Feature "${feature.name}" imports an internal file of "${target.name}" instead of its index.ts.`,
|
|
140
|
-
file: relativeFile,
|
|
141
|
-
cause: `import from "${source}" bypasses ${targetDirName}/index.ts`,
|
|
142
|
-
suggestedFix: `Export the needed symbols from ${targetDirName}/index.ts and import them from "../${targetDirName}/index.js".`,
|
|
143
|
-
}),
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
importersByFeature.set(feature.name, entry);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const declaredDependents = manifest.features.filter((feature) =>
|
|
152
|
-
feature.dependsOn.includes(target.name),
|
|
153
|
-
);
|
|
154
|
-
for (const declared of declaredDependents) {
|
|
155
|
-
if (!importersByFeature.has(declared.name)) {
|
|
156
|
-
findings.push(
|
|
157
|
-
ogygieError({
|
|
158
|
-
code: "MANIFEST_DEPENDENCY_STALE",
|
|
159
|
-
message: `Feature "${declared.name}" declares dependsOn "${target.name}" but never imports it.`,
|
|
160
|
-
file: `${MANIFEST_PATH}#features.${declared.name}`,
|
|
161
|
-
cause: `dependsOn contains "${target.name}", no import of ../${targetDirName}/ found in its code`,
|
|
162
|
-
suggestedFix:
|
|
163
|
-
"Remove the stale dependsOn entry, or make the dependency real through the feature's index.ts.",
|
|
164
|
-
}),
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
for (const importerName of importersByFeature.keys()) {
|
|
169
|
-
if (!declaredDependents.some((declared) => declared.name === importerName)) {
|
|
170
|
-
findings.push(
|
|
171
|
-
ogygieError({
|
|
172
|
-
code: "MANIFEST_DEPENDENCY_UNDECLARED",
|
|
173
|
-
message: `Feature "${importerName}" imports "${target.name}" but does not declare it in dependsOn.`,
|
|
174
|
-
file: `${MANIFEST_PATH}#features.${importerName}`,
|
|
175
|
-
cause: `imports of ../${targetDirName}/ found in its code, "${target.name}" absent from its dependsOn`,
|
|
176
|
-
suggestedFix: `Add "${target.name}" to the dependsOn of "${importerName}" — the manifest must never lie by omission.`,
|
|
177
|
-
}),
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const dependentNames = [
|
|
183
|
-
...new Set([
|
|
184
|
-
...declaredDependents.map((feature) => feature.name),
|
|
185
|
-
...importersByFeature.keys(),
|
|
186
|
-
]),
|
|
187
|
-
].sort();
|
|
188
|
-
const dependents = dependentNames.map((name) => {
|
|
189
|
-
const imports = importersByFeature.get(name);
|
|
190
|
-
return {
|
|
191
|
-
name,
|
|
192
|
-
importedSymbols: [...(imports?.importedSymbols ?? [])].sort(),
|
|
193
|
-
viaIndexOnly: imports?.viaIndexOnly ?? true,
|
|
194
|
-
};
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
// (d) owned tables referenced by another feature's PRODUCTION code.
|
|
198
|
-
const tableReadersOutsideOwner: ImpactReport["tableReadersOutsideOwner"] = [];
|
|
199
|
-
for (const feature of manifest.features) {
|
|
200
|
-
if (feature.name === target.name) continue;
|
|
201
|
-
const featureDir = join(ROOT, apiApp.path, feature.folder);
|
|
202
|
-
if (!existsSync(featureDir)) continue;
|
|
203
|
-
for (const filePath of walkTsFiles(featureDir)) {
|
|
204
|
-
if (filePath.endsWith(".test.ts") || filePath.endsWith(".test.tsx")) continue;
|
|
205
|
-
const content = readFileSync(filePath, "utf8");
|
|
206
|
-
const relativeFile = filePath.slice(ROOT.length);
|
|
207
|
-
for (const table of dbTableNamesOf(target)) {
|
|
208
|
-
if (!content.includes(`"${table}"`)) continue;
|
|
209
|
-
tableReadersOutsideOwner.push({
|
|
210
|
-
table,
|
|
211
|
-
feature: feature.name,
|
|
212
|
-
file: relativeFile,
|
|
213
|
-
});
|
|
214
|
-
findings.push(
|
|
215
|
-
ogygieError({
|
|
216
|
-
code: "TABLE_REFERENCED_OUTSIDE_OWNER",
|
|
217
|
-
message: `Table "${table}" is owned by "${target.name}" but referenced by production code of "${feature.name}".`,
|
|
218
|
-
file: relativeFile,
|
|
219
|
-
cause: `"${table}" found in a non-test file outside its owner feature`,
|
|
220
|
-
suggestedFix: `Go through the public contracts of ${targetDirName}/index.ts instead of touching the table — one table = one owner.`,
|
|
221
|
-
}),
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const dependentDirNames = dependentNames.map((name) => {
|
|
228
|
-
const feature = manifest.features.find((candidate) => candidate.name === name);
|
|
229
|
-
return feature?.folder.split("/").pop() ?? name;
|
|
230
|
-
});
|
|
231
|
-
const testCommand = ["pnpm test --", `features/${targetDirName}`, ...dependentDirNames.map((dirName) => `features/${dirName}`)].join(" ");
|
|
232
|
-
|
|
233
|
-
return ImpactReportSchema.parse({
|
|
234
|
-
feature: target.name,
|
|
235
|
-
dependents,
|
|
236
|
-
routes: target.routes.map((route) => ({
|
|
237
|
-
method: route.method,
|
|
238
|
-
path: route.path,
|
|
239
|
-
description: route.description,
|
|
240
|
-
})),
|
|
241
|
-
ownedTables: dbTableNamesOf(target),
|
|
242
|
-
tableReadersOutsideOwner,
|
|
243
|
-
findings,
|
|
244
|
-
testCommand,
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// ---------------------------------------------------------------- CLI
|
|
249
27
|
function printSummary(report: ImpactReport): void {
|
|
250
|
-
console.log(`\n── ogygie:impact ${report.
|
|
28
|
+
console.log(`\n── ogygie:impact ${report.target.name} (${report.target.kind}) ──`);
|
|
251
29
|
if (report.dependents.length === 0) {
|
|
252
|
-
console.log("Dépendants : aucun.");
|
|
30
|
+
console.log("Dépendants : aucun DANS CE PÉRIMÈTRE.");
|
|
253
31
|
} else {
|
|
254
32
|
console.log(`Dépendants (${report.dependents.length}) :`);
|
|
255
33
|
for (const dependent of report.dependents) {
|
|
@@ -258,7 +36,7 @@ function printSummary(report: ImpactReport): void {
|
|
|
258
36
|
? `importe { ${dependent.importedSymbols.join(", ")} }`
|
|
259
37
|
: "n'importe aucun symbole";
|
|
260
38
|
const boundary = dependent.viaIndexOnly ? "via index.ts uniquement" : "⚠ contourne index.ts";
|
|
261
|
-
console.log(` • ${dependent.name} — ${symbols} — ${boundary}`);
|
|
39
|
+
console.log(` • ${dependent.name} (${dependent.kind}) — ${symbols} — ${boundary}`);
|
|
262
40
|
}
|
|
263
41
|
}
|
|
264
42
|
const routes = report.routes.map((route) => `${route.method} ${route.path}`).join(" · ");
|
|
@@ -266,36 +44,67 @@ function printSummary(report: ImpactReport): void {
|
|
|
266
44
|
console.log(
|
|
267
45
|
`Tables possédées : ${report.ownedTables.length === 0 ? "aucune" : report.ownedTables.join(", ")}`,
|
|
268
46
|
);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
console.log(` ⚠ "${reader.table}" référencée par ${reader.feature} (${reader.file})`);
|
|
272
|
-
}
|
|
47
|
+
for (const reader of report.tableReadersOutsideOwner) {
|
|
48
|
+
console.log(` ⚠ "${reader.table}" référencée par ${reader.feature} (${reader.file})`);
|
|
273
49
|
}
|
|
274
50
|
console.log(
|
|
275
51
|
report.findings.length === 0
|
|
276
52
|
? "Findings : aucun."
|
|
277
53
|
: `Findings : ${report.findings.length} (détail dans le JSON ci-dessus).`,
|
|
278
54
|
);
|
|
279
|
-
console.log(`Tests à relancer : ${report.testCommand}
|
|
55
|
+
console.log(`Tests à relancer : ${report.testCommand}`);
|
|
56
|
+
// Always printed, ESPECIALLY when nothing was found: "no dependents" and
|
|
57
|
+
// "I did not look there" read the same without it (D12).
|
|
58
|
+
console.log(`\nPérimètre — dossiers scannés : ${report.scope.scannedFolders.length}`);
|
|
59
|
+
console.log(`Hors de portée de cette analyse :`);
|
|
60
|
+
for (const limit of report.scope.outOfReach) console.log(` · ${limit}`);
|
|
61
|
+
console.log("");
|
|
280
62
|
}
|
|
281
63
|
|
|
282
64
|
const cliArguments = process.argv.slice(2);
|
|
283
65
|
const jsonOnly = cliArguments.includes("--json");
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
66
|
+
const targetArgument = cliArguments.find((argument) => !argument.startsWith("--"));
|
|
67
|
+
|
|
68
|
+
function fail(error: unknown): never {
|
|
69
|
+
const payload =
|
|
70
|
+
error instanceof OgygieFailure
|
|
71
|
+
? error.error
|
|
72
|
+
: ogygieError({
|
|
73
|
+
code: "IMPACT_FAILED",
|
|
74
|
+
message: "The impact analysis could not run.",
|
|
75
|
+
file: MANIFEST_PATH,
|
|
76
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
77
|
+
suggestedFix: "Run `pnpm ogygie:verify-manifest` — the analysis reads the map first.",
|
|
78
|
+
});
|
|
79
|
+
console.error(JSON.stringify(payload, null, 2));
|
|
295
80
|
process.exit(1);
|
|
296
81
|
}
|
|
297
82
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
83
|
+
if (targetArgument === undefined) {
|
|
84
|
+
fail(
|
|
85
|
+
new OgygieFailure(
|
|
86
|
+
ogygieError({
|
|
87
|
+
code: "IMPACT_UNKNOWN_FEATURE",
|
|
88
|
+
message: "No target given. Usage: pnpm ogygie:impact <feature|package> [--json].",
|
|
89
|
+
file: null,
|
|
90
|
+
cause: "process.argv carries no non-flag argument",
|
|
91
|
+
suggestedFix: "Pass a name declared in ogygie.manifest.json.",
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const manifest = OgygieManifestSchema.parse(
|
|
99
|
+
JSON.parse(readFileSync(join(ROOT, MANIFEST_PATH), "utf8")),
|
|
100
|
+
);
|
|
101
|
+
const report = await analyzeImpact(
|
|
102
|
+
{ manifest, adapter: createFilesystemRepoAdapter(ROOT) },
|
|
103
|
+
targetArgument,
|
|
104
|
+
);
|
|
105
|
+
console.log(JSON.stringify(report, null, 2));
|
|
106
|
+
if (!jsonOnly) printSummary(report);
|
|
107
|
+
process.exit(report.findings.length === 0 ? 0 : 1);
|
|
108
|
+
} catch (thrown) {
|
|
109
|
+
fail(thrown);
|
|
110
|
+
}
|