@ogygie/mcp 0.9.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/mcp.js +343 -24
- package/dist/server.js +343 -24
- package/package.json +8 -8
package/dist/mcp.js
CHANGED
|
@@ -618,6 +618,7 @@ var ListSpecsInput = { repo: RepoIdSchema, status: SpecStatusSchema.optional() }
|
|
|
618
618
|
var ReadSpecInput = { repo: RepoIdSchema, id: SpecIdSchema };
|
|
619
619
|
var ReadDecisionsInput = { repo: RepoIdSchema, decision: DecisionIdSchema.optional() };
|
|
620
620
|
var ReadGovernanceInput = { repo: RepoIdSchema, file: GovernanceFileSchema };
|
|
621
|
+
var ImpactInput = { repo: RepoIdSchema, feature: FeatureNameSchema2 };
|
|
621
622
|
var ReadFeatureInput = {
|
|
622
623
|
repo: RepoIdSchema,
|
|
623
624
|
feature: FeatureNameSchema2,
|
|
@@ -805,46 +806,353 @@ function extractDecisionSection(decisionsMarkdown, decisionId) {
|
|
|
805
806
|
return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
|
|
806
807
|
}
|
|
807
808
|
|
|
809
|
+
// ../ogygie-core/src/impact.ts
|
|
810
|
+
import { z as z6 } from "zod";
|
|
811
|
+
|
|
808
812
|
// ../ogygie-core/src/manifest-owners.ts
|
|
809
813
|
var ADVANCEMENT = ["skeleton", "in-progress", "delivered"];
|
|
810
814
|
function specIdOf(specPath) {
|
|
811
815
|
return (specPath.split("/").pop() ?? specPath).replace(/\.spec\.md$/, "");
|
|
812
816
|
}
|
|
813
|
-
function
|
|
814
|
-
const
|
|
815
|
-
|
|
817
|
+
function declaredEntries(manifest) {
|
|
818
|
+
const entries = [];
|
|
819
|
+
for (const feature of manifest.features) {
|
|
816
820
|
const owningApp = feature.app === void 0 ? manifest.apps.find((app) => app.type === "api") : manifest.apps.find((app) => app.name === feature.app);
|
|
817
|
-
|
|
821
|
+
entries.push({
|
|
818
822
|
kind: "feature",
|
|
819
823
|
name: feature.name,
|
|
820
824
|
status: feature.status,
|
|
821
|
-
folder: owningApp === void 0 ? null : `${owningApp.path}/${feature.folder}
|
|
822
|
-
|
|
825
|
+
folder: owningApp === void 0 ? null : `${owningApp.path}/${feature.folder}`,
|
|
826
|
+
specId: feature.name
|
|
827
|
+
});
|
|
823
828
|
}
|
|
824
|
-
const productPackage
|
|
825
|
-
(
|
|
826
|
-
);
|
|
827
|
-
if (productPackage !== void 0) {
|
|
828
|
-
return {
|
|
829
|
+
for (const productPackage of manifest.packages ?? []) {
|
|
830
|
+
entries.push({
|
|
829
831
|
kind: "package",
|
|
830
832
|
name: productPackage.name,
|
|
831
833
|
status: productPackage.status,
|
|
832
|
-
folder: productPackage.path
|
|
833
|
-
|
|
834
|
+
folder: productPackage.path,
|
|
835
|
+
specId: productPackage.name
|
|
836
|
+
});
|
|
834
837
|
}
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
);
|
|
838
|
-
const leastAdvanced = frameworkPackages.slice().sort((a, b) => ADVANCEMENT.indexOf(a.status) - ADVANCEMENT.indexOf(b.status))[0];
|
|
839
|
-
if (leastAdvanced !== void 0) {
|
|
840
|
-
return {
|
|
838
|
+
for (const frameworkPackage of manifest.framework?.packages ?? []) {
|
|
839
|
+
entries.push({
|
|
841
840
|
kind: "framework-package",
|
|
842
|
-
name:
|
|
843
|
-
status:
|
|
844
|
-
folder:
|
|
845
|
-
|
|
841
|
+
name: frameworkPackage.name,
|
|
842
|
+
status: frameworkPackage.status,
|
|
843
|
+
folder: frameworkPackage.path,
|
|
844
|
+
// A published npm name is not a spec id: the link is DECLARED.
|
|
845
|
+
specId: frameworkPackage.spec === null ? null : specIdOf(frameworkPackage.spec)
|
|
846
|
+
});
|
|
846
847
|
}
|
|
847
|
-
return
|
|
848
|
+
return entries;
|
|
849
|
+
}
|
|
850
|
+
function findSpecOwner(manifest, specId) {
|
|
851
|
+
const owning = declaredEntries(manifest).filter((entry) => entry.specId === specId);
|
|
852
|
+
if (owning.length === 0) return void 0;
|
|
853
|
+
const byPriority = owning.slice().sort((left, right) => {
|
|
854
|
+
if (left.kind !== right.kind) {
|
|
855
|
+
const order = ["feature", "package", "framework-package"];
|
|
856
|
+
return order.indexOf(left.kind) - order.indexOf(right.kind);
|
|
857
|
+
}
|
|
858
|
+
return ADVANCEMENT.indexOf(left.status) - ADVANCEMENT.indexOf(right.status);
|
|
859
|
+
});
|
|
860
|
+
const owner = byPriority[0];
|
|
861
|
+
if (owner === void 0) return void 0;
|
|
862
|
+
return { kind: owner.kind, name: owner.name, status: owner.status, folder: owner.folder };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ../ogygie-core/src/module-purity.ts
|
|
866
|
+
var IMPORT_STATEMENT = /\bimport\b(?![.(])([\s\S]*?)from\s*["']([^"']+)["']|\bimport\s+["']([^"']+)["']/g;
|
|
867
|
+
var DYNAMIC_LOAD = /\b(?:import\s*\(|require\s*\()\s*["']([^"']+)["']/g;
|
|
868
|
+
var REEXPORT_STATEMENT = /\bexport\s+(type\s+)?(\*(?:\s+as\s+[A-Za-z_$][\w$]*)?|\{[^}]*\})\s*from\s*["']([^"']+)["']/g;
|
|
869
|
+
function parseModuleImports(source) {
|
|
870
|
+
const code = stripComments(source);
|
|
871
|
+
const imports = [];
|
|
872
|
+
for (const match of code.matchAll(IMPORT_STATEMENT)) {
|
|
873
|
+
const [statement, clause, fromSpecifier, bareSpecifier] = match;
|
|
874
|
+
const specifier = fromSpecifier ?? bareSpecifier;
|
|
875
|
+
if (specifier === void 0) continue;
|
|
876
|
+
imports.push({
|
|
877
|
+
specifier,
|
|
878
|
+
typeOnly: clause !== void 0 && isTypeOnlyClause(clause),
|
|
879
|
+
statement: normalizeWhitespace(statement)
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
for (const match of code.matchAll(REEXPORT_STATEMENT)) {
|
|
883
|
+
const [statement, typeKeyword, clause, specifier] = match;
|
|
884
|
+
if (specifier === void 0 || clause === void 0) continue;
|
|
885
|
+
imports.push({
|
|
886
|
+
specifier,
|
|
887
|
+
typeOnly: typeKeyword !== void 0 || isTypeOnlyClause(clause),
|
|
888
|
+
statement: normalizeWhitespace(statement)
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
for (const match of code.matchAll(DYNAMIC_LOAD)) {
|
|
892
|
+
const [statement, specifier] = match;
|
|
893
|
+
if (specifier === void 0) continue;
|
|
894
|
+
imports.push({
|
|
895
|
+
specifier,
|
|
896
|
+
typeOnly: false,
|
|
897
|
+
statement: normalizeWhitespace(statement)
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
return imports;
|
|
901
|
+
}
|
|
902
|
+
function isTypeOnlyClause(clause) {
|
|
903
|
+
const trimmed = clause.trim();
|
|
904
|
+
if (trimmed.startsWith("type ")) return true;
|
|
905
|
+
const named = /^\{([\s\S]*)\}$/.exec(trimmed);
|
|
906
|
+
if (named === null || named[1] === void 0) return false;
|
|
907
|
+
const bindings = named[1].split(",").map((binding) => binding.trim()).filter((binding) => binding.length > 0);
|
|
908
|
+
return bindings.length > 0 && bindings.every((binding) => binding.startsWith("type "));
|
|
909
|
+
}
|
|
910
|
+
function stripComments(source) {
|
|
911
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
912
|
+
}
|
|
913
|
+
function normalizeWhitespace(statement) {
|
|
914
|
+
return statement.replace(/\s+/g, " ").trim();
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ../ogygie-core/src/shared-tables.ts
|
|
918
|
+
function dbTableNameOf(declaration) {
|
|
919
|
+
return typeof declaration === "string" ? declaration : declaration.name;
|
|
920
|
+
}
|
|
921
|
+
function dbTableNamesOf(feature) {
|
|
922
|
+
return feature.dbTables.map(dbTableNameOf);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// ../ogygie-core/src/impact.ts
|
|
926
|
+
var MapEntryKindSchema = z6.enum(["feature", "package", "framework-package"]);
|
|
927
|
+
var ImpactScopeSchema = z6.object({
|
|
928
|
+
scannedFolders: z6.array(z6.string()),
|
|
929
|
+
coveredKinds: z6.array(MapEntryKindSchema),
|
|
930
|
+
outOfReach: z6.array(z6.string())
|
|
931
|
+
});
|
|
932
|
+
var ImpactReportSchema = z6.object({
|
|
933
|
+
target: z6.object({
|
|
934
|
+
name: z6.string(),
|
|
935
|
+
kind: MapEntryKindSchema,
|
|
936
|
+
folder: z6.string()
|
|
937
|
+
}),
|
|
938
|
+
dependents: z6.array(
|
|
939
|
+
z6.object({
|
|
940
|
+
name: z6.string(),
|
|
941
|
+
kind: MapEntryKindSchema,
|
|
942
|
+
importedSymbols: z6.array(z6.string()),
|
|
943
|
+
viaIndexOnly: z6.boolean()
|
|
944
|
+
})
|
|
945
|
+
),
|
|
946
|
+
routes: z6.array(
|
|
947
|
+
z6.object({ method: z6.string(), path: z6.string(), description: z6.string() })
|
|
948
|
+
),
|
|
949
|
+
ownedTables: z6.array(z6.string()),
|
|
950
|
+
tableReadersOutsideOwner: z6.array(
|
|
951
|
+
z6.object({ table: z6.string(), feature: z6.string(), file: z6.string() })
|
|
952
|
+
),
|
|
953
|
+
findings: z6.array(OgygieErrorSchema),
|
|
954
|
+
testCommand: z6.string(),
|
|
955
|
+
scope: ImpactScopeSchema
|
|
956
|
+
});
|
|
957
|
+
var SOURCE_EXTENSIONS = [".ts", ".tsx"];
|
|
958
|
+
function isSource(path) {
|
|
959
|
+
return SOURCE_EXTENSIONS.some((extension) => path.endsWith(extension));
|
|
960
|
+
}
|
|
961
|
+
function isTest(path) {
|
|
962
|
+
return /\.test\.tsx?$/.test(path);
|
|
963
|
+
}
|
|
964
|
+
function symbolsOfImportClause(clause) {
|
|
965
|
+
const symbols = [];
|
|
966
|
+
const braces = /\{([^}]*)\}/.exec(clause);
|
|
967
|
+
if (braces?.[1] !== void 0) {
|
|
968
|
+
for (const rawEntry of braces[1].split(",")) {
|
|
969
|
+
const name = rawEntry.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
|
|
970
|
+
if (name !== void 0 && name !== "") symbols.push(name);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const outsideBraces = clause.replace(/\{[^}]*\}/, "").replace(/^type\s+/, "").trim();
|
|
974
|
+
if (/\*\s*as\s+/.test(outsideBraces)) symbols.push("*");
|
|
975
|
+
else if (outsideBraces.replace(/,/g, "").trim() !== "") symbols.push("default");
|
|
976
|
+
return symbols;
|
|
977
|
+
}
|
|
978
|
+
function specifiersMatching(target, source) {
|
|
979
|
+
const folder = target.folder;
|
|
980
|
+
if (folder === null) return false;
|
|
981
|
+
const directory = folder.split("/").pop() ?? folder;
|
|
982
|
+
if (new RegExp(`(^|/)\\.\\./${directory}/`).test(source)) return true;
|
|
983
|
+
if (source.startsWith(`../${directory}/`) || source === `../${directory}`) return true;
|
|
984
|
+
if (source === target.name || source.startsWith(`${target.name}/`)) return true;
|
|
985
|
+
const segments = source.split("/");
|
|
986
|
+
return segments.includes(directory) && !source.startsWith(".");
|
|
987
|
+
}
|
|
988
|
+
function piercesTheIndex(specifier) {
|
|
989
|
+
if (!specifier.startsWith(".")) return false;
|
|
990
|
+
return !/(^|\/)index(\.js)?$/.test(specifier);
|
|
991
|
+
}
|
|
992
|
+
async function analyzeImpact(deps, targetName) {
|
|
993
|
+
const entries = declaredEntries(deps.manifest);
|
|
994
|
+
const target = entries.find((entry) => entry.name === targetName);
|
|
995
|
+
if (target === void 0) {
|
|
996
|
+
return throwOgygieError({
|
|
997
|
+
code: "IMPACT_UNKNOWN_FEATURE",
|
|
998
|
+
message: `"${targetName}" is unknown to the manifest.`,
|
|
999
|
+
file: "ogygie.manifest.json",
|
|
1000
|
+
cause: `no entry of the map is named "${targetName}"`,
|
|
1001
|
+
suggestedFix: `Pick one of: ${entries.map((entry) => entry.name).join(", ") || "<the map declares nothing>"}.`
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
return buildReport(deps, entries, target);
|
|
1005
|
+
}
|
|
1006
|
+
async function buildReport(deps, entries, target) {
|
|
1007
|
+
const findings = [];
|
|
1008
|
+
const scannedFolders = [];
|
|
1009
|
+
const outOfReach = [];
|
|
1010
|
+
if (target.folder === null) {
|
|
1011
|
+
outOfReach.push(
|
|
1012
|
+
`the target "${target.name}" has no folder the map can resolve \u2014 its app is not declared`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
const dependents = /* @__PURE__ */ new Map();
|
|
1016
|
+
const tableReadersOutsideOwner = [];
|
|
1017
|
+
const ownedTables = target.kind === "feature" ? tablesOf(deps.manifest, target.name) : [];
|
|
1018
|
+
for (const entry of entries) {
|
|
1019
|
+
if (entry.name === target.name) continue;
|
|
1020
|
+
if (entry.folder === null) {
|
|
1021
|
+
outOfReach.push(
|
|
1022
|
+
`"${entry.name}" (${entry.kind}) \u2014 the map declares no resolvable folder for it`
|
|
1023
|
+
);
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
const files = (await deps.adapter.listDirectory(entry.folder)).filter(
|
|
1027
|
+
(file) => isSource(file.path)
|
|
1028
|
+
);
|
|
1029
|
+
if (files.length > 0) scannedFolders.push(entry.folder);
|
|
1030
|
+
for (const file of files) {
|
|
1031
|
+
const filePath = `${entry.folder}/${file.path}`;
|
|
1032
|
+
const content = await deps.adapter.readFile(filePath);
|
|
1033
|
+
for (const imported of parseModuleImports(content)) {
|
|
1034
|
+
if (!specifiersMatching(target, imported.specifier)) continue;
|
|
1035
|
+
const accumulator = dependents.get(entry.name) ?? {
|
|
1036
|
+
kind: entry.kind,
|
|
1037
|
+
importedSymbols: /* @__PURE__ */ new Set(),
|
|
1038
|
+
viaIndexOnly: true
|
|
1039
|
+
};
|
|
1040
|
+
for (const symbol of symbolsOfImportClause(clauseOf(imported.statement))) {
|
|
1041
|
+
accumulator.importedSymbols.add(symbol);
|
|
1042
|
+
}
|
|
1043
|
+
if (piercesTheIndex(imported.specifier)) {
|
|
1044
|
+
accumulator.viaIndexOnly = false;
|
|
1045
|
+
findings.push(
|
|
1046
|
+
ogygieError({
|
|
1047
|
+
code: "FEATURE_BOUNDARY_VIOLATION",
|
|
1048
|
+
message: `"${entry.name}" imports an internal file of "${target.name}" instead of its index.ts.`,
|
|
1049
|
+
file: filePath,
|
|
1050
|
+
cause: `import from "${imported.specifier}" bypasses the public index of ${target.name}`,
|
|
1051
|
+
suggestedFix: `Export the needed symbols from the index of ${target.name} and import them from there.`
|
|
1052
|
+
})
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
dependents.set(entry.name, accumulator);
|
|
1056
|
+
}
|
|
1057
|
+
if (isTest(file.path)) continue;
|
|
1058
|
+
for (const table of ownedTables) {
|
|
1059
|
+
if (!content.includes(`"${table}"`)) continue;
|
|
1060
|
+
tableReadersOutsideOwner.push({ table, feature: entry.name, file: filePath });
|
|
1061
|
+
findings.push(
|
|
1062
|
+
ogygieError({
|
|
1063
|
+
code: "TABLE_REFERENCED_OUTSIDE_OWNER",
|
|
1064
|
+
message: `Table "${table}" is owned by "${target.name}" but referenced by production code of "${entry.name}".`,
|
|
1065
|
+
file: filePath,
|
|
1066
|
+
cause: `"${table}" found in a non-test file outside its owner`,
|
|
1067
|
+
suggestedFix: `Go through the public contracts of ${target.name} instead of touching the table \u2014 one table, one owner.`
|
|
1068
|
+
})
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
findings.push(...manifestDependencyFindings(deps.manifest, target, dependents));
|
|
1074
|
+
const feature = deps.manifest.features.find((candidate) => candidate.name === target.name);
|
|
1075
|
+
const report = {
|
|
1076
|
+
target: { name: target.name, kind: target.kind, folder: target.folder ?? "" },
|
|
1077
|
+
dependents: [...dependents.entries()].map(([name, accumulator]) => ({
|
|
1078
|
+
name,
|
|
1079
|
+
kind: accumulator.kind,
|
|
1080
|
+
importedSymbols: [...accumulator.importedSymbols].sort(),
|
|
1081
|
+
viaIndexOnly: accumulator.viaIndexOnly
|
|
1082
|
+
})).sort((left, right) => left.name < right.name ? -1 : 1),
|
|
1083
|
+
routes: (feature?.routes ?? []).map((route) => ({
|
|
1084
|
+
method: route.method,
|
|
1085
|
+
path: route.path,
|
|
1086
|
+
description: route.description
|
|
1087
|
+
})),
|
|
1088
|
+
ownedTables,
|
|
1089
|
+
tableReadersOutsideOwner,
|
|
1090
|
+
findings,
|
|
1091
|
+
testCommand: testCommandFor(target, [...dependents.keys()], entries),
|
|
1092
|
+
scope: {
|
|
1093
|
+
scannedFolders: scannedFolders.sort(),
|
|
1094
|
+
coveredKinds: [...new Set(entries.map((entry) => entry.kind))],
|
|
1095
|
+
outOfReach: [...outOfReach, ...outOfReachOf(target)]
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
return ImpactReportSchema.parse(report);
|
|
1099
|
+
}
|
|
1100
|
+
function clauseOf(statement) {
|
|
1101
|
+
const match = /\bimport\b([\s\S]*?)\bfrom\b/.exec(statement);
|
|
1102
|
+
return match?.[1] ?? "";
|
|
1103
|
+
}
|
|
1104
|
+
function tablesOf(manifest, featureName) {
|
|
1105
|
+
const feature = manifest.features.find((candidate) => candidate.name === featureName);
|
|
1106
|
+
return feature === void 0 ? [] : dbTableNamesOf(feature);
|
|
1107
|
+
}
|
|
1108
|
+
function outOfReachOf(target) {
|
|
1109
|
+
const limits = [
|
|
1110
|
+
"code that no manifest entry declares \u2014 the map is the only chart",
|
|
1111
|
+
"anything outside this repo: no network, no npm registry"
|
|
1112
|
+
];
|
|
1113
|
+
if (target.kind === "framework-package" || target.kind === "package") {
|
|
1114
|
+
limits.unshift(
|
|
1115
|
+
`consumers of "${target.name}" OUTSIDE this repo \u2014 it is a package, and a published package is imported by projects this analysis cannot see`
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
return limits;
|
|
1119
|
+
}
|
|
1120
|
+
function manifestDependencyFindings(manifest, target, dependents) {
|
|
1121
|
+
const findings = [];
|
|
1122
|
+
const declared = manifest.features.filter(
|
|
1123
|
+
(feature) => feature.dependsOn.includes(target.name)
|
|
1124
|
+
);
|
|
1125
|
+
for (const feature of declared) {
|
|
1126
|
+
if (!dependents.has(feature.name)) {
|
|
1127
|
+
findings.push(
|
|
1128
|
+
ogygieError({
|
|
1129
|
+
code: "MANIFEST_DEPENDENCY_STALE",
|
|
1130
|
+
message: `Feature "${feature.name}" declares dependsOn "${target.name}" but never imports it.`,
|
|
1131
|
+
file: `ogygie.manifest.json#features.${feature.name}`,
|
|
1132
|
+
cause: `dependsOn contains "${target.name}", no import of it found in its code`,
|
|
1133
|
+
suggestedFix: "Remove the stale dependsOn entry, or make the dependency real through the entry's index.ts."
|
|
1134
|
+
})
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
for (const [name, accumulator] of dependents) {
|
|
1139
|
+
if (accumulator.kind !== "feature") continue;
|
|
1140
|
+
if (declared.some((feature) => feature.name === name)) continue;
|
|
1141
|
+
findings.push(
|
|
1142
|
+
ogygieError({
|
|
1143
|
+
code: "MANIFEST_DEPENDENCY_UNDECLARED",
|
|
1144
|
+
message: `Feature "${name}" imports "${target.name}" but does not declare it in dependsOn.`,
|
|
1145
|
+
file: `ogygie.manifest.json#features.${name}`,
|
|
1146
|
+
cause: `imports of "${target.name}" found in its code, absent from its dependsOn`,
|
|
1147
|
+
suggestedFix: `Add "${target.name}" to the dependsOn of "${name}" \u2014 the manifest must never lie by omission.`
|
|
1148
|
+
})
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
return findings;
|
|
1152
|
+
}
|
|
1153
|
+
function testCommandFor(target, dependentNames, entries) {
|
|
1154
|
+
const folders = [target, ...entries.filter((entry) => dependentNames.includes(entry.name))].map((entry) => entry.folder).filter((folder) => folder !== null);
|
|
1155
|
+
return ["pnpm test --", ...[...new Set(folders)].sort()].join(" ");
|
|
848
1156
|
}
|
|
849
1157
|
|
|
850
1158
|
// src/tools.ts
|
|
@@ -1007,6 +1315,11 @@ async function readGovernance(repo, input) {
|
|
|
1007
1315
|
provenance: await provenanceOf(repo, input.file)
|
|
1008
1316
|
};
|
|
1009
1317
|
}
|
|
1318
|
+
async function impact(repo, input) {
|
|
1319
|
+
const manifest = await readManifest(repo);
|
|
1320
|
+
const report = await analyzeImpact({ manifest, adapter: repo.adapter }, input.feature);
|
|
1321
|
+
return { ...report, provenance: await provenanceOf(repo, "ogygie.manifest.json") };
|
|
1322
|
+
}
|
|
1010
1323
|
async function readFeature(repo, input) {
|
|
1011
1324
|
const manifest = await readManifest(repo);
|
|
1012
1325
|
const owner = findSpecOwner(manifest, input.feature);
|
|
@@ -1121,6 +1434,12 @@ function createOgygieMcpServer(deps) {
|
|
|
1121
1434
|
ReadGovernanceInput,
|
|
1122
1435
|
async (input) => readGovernance(repoOf(deps.repos, input.repo), input)
|
|
1123
1436
|
);
|
|
1437
|
+
register(
|
|
1438
|
+
"ogygie_impact",
|
|
1439
|
+
"Blast radius of a change to a feature or package: who depends on it, which symbols they import, tables owned, tests to re-run \u2014 plus what the analysis could NOT see.",
|
|
1440
|
+
ImpactInput,
|
|
1441
|
+
async (input) => impact(repoOf(deps.repos, input.repo), input)
|
|
1442
|
+
);
|
|
1124
1443
|
register(
|
|
1125
1444
|
"ogygie_read_feature",
|
|
1126
1445
|
"Lists the files of a feature declared in the manifest, or reads one of them with `file`. The folder comes from the map: a feature the manifest does not declare does not exist.",
|
package/dist/server.js
CHANGED
|
@@ -613,6 +613,7 @@ var ListSpecsInput = { repo: RepoIdSchema, status: SpecStatusSchema.optional() }
|
|
|
613
613
|
var ReadSpecInput = { repo: RepoIdSchema, id: SpecIdSchema };
|
|
614
614
|
var ReadDecisionsInput = { repo: RepoIdSchema, decision: DecisionIdSchema.optional() };
|
|
615
615
|
var ReadGovernanceInput = { repo: RepoIdSchema, file: GovernanceFileSchema };
|
|
616
|
+
var ImpactInput = { repo: RepoIdSchema, feature: FeatureNameSchema2 };
|
|
616
617
|
var ReadFeatureInput = {
|
|
617
618
|
repo: RepoIdSchema,
|
|
618
619
|
feature: FeatureNameSchema2,
|
|
@@ -800,46 +801,353 @@ function extractDecisionSection(decisionsMarkdown, decisionId) {
|
|
|
800
801
|
return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
|
|
801
802
|
}
|
|
802
803
|
|
|
804
|
+
// ../ogygie-core/src/impact.ts
|
|
805
|
+
import { z as z6 } from "zod";
|
|
806
|
+
|
|
803
807
|
// ../ogygie-core/src/manifest-owners.ts
|
|
804
808
|
var ADVANCEMENT = ["skeleton", "in-progress", "delivered"];
|
|
805
809
|
function specIdOf(specPath) {
|
|
806
810
|
return (specPath.split("/").pop() ?? specPath).replace(/\.spec\.md$/, "");
|
|
807
811
|
}
|
|
808
|
-
function
|
|
809
|
-
const
|
|
810
|
-
|
|
812
|
+
function declaredEntries(manifest) {
|
|
813
|
+
const entries = [];
|
|
814
|
+
for (const feature of manifest.features) {
|
|
811
815
|
const owningApp = feature.app === void 0 ? manifest.apps.find((app) => app.type === "api") : manifest.apps.find((app) => app.name === feature.app);
|
|
812
|
-
|
|
816
|
+
entries.push({
|
|
813
817
|
kind: "feature",
|
|
814
818
|
name: feature.name,
|
|
815
819
|
status: feature.status,
|
|
816
|
-
folder: owningApp === void 0 ? null : `${owningApp.path}/${feature.folder}
|
|
817
|
-
|
|
820
|
+
folder: owningApp === void 0 ? null : `${owningApp.path}/${feature.folder}`,
|
|
821
|
+
specId: feature.name
|
|
822
|
+
});
|
|
818
823
|
}
|
|
819
|
-
const productPackage
|
|
820
|
-
(
|
|
821
|
-
);
|
|
822
|
-
if (productPackage !== void 0) {
|
|
823
|
-
return {
|
|
824
|
+
for (const productPackage of manifest.packages ?? []) {
|
|
825
|
+
entries.push({
|
|
824
826
|
kind: "package",
|
|
825
827
|
name: productPackage.name,
|
|
826
828
|
status: productPackage.status,
|
|
827
|
-
folder: productPackage.path
|
|
828
|
-
|
|
829
|
+
folder: productPackage.path,
|
|
830
|
+
specId: productPackage.name
|
|
831
|
+
});
|
|
829
832
|
}
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
);
|
|
833
|
-
const leastAdvanced = frameworkPackages.slice().sort((a, b) => ADVANCEMENT.indexOf(a.status) - ADVANCEMENT.indexOf(b.status))[0];
|
|
834
|
-
if (leastAdvanced !== void 0) {
|
|
835
|
-
return {
|
|
833
|
+
for (const frameworkPackage of manifest.framework?.packages ?? []) {
|
|
834
|
+
entries.push({
|
|
836
835
|
kind: "framework-package",
|
|
837
|
-
name:
|
|
838
|
-
status:
|
|
839
|
-
folder:
|
|
840
|
-
|
|
836
|
+
name: frameworkPackage.name,
|
|
837
|
+
status: frameworkPackage.status,
|
|
838
|
+
folder: frameworkPackage.path,
|
|
839
|
+
// A published npm name is not a spec id: the link is DECLARED.
|
|
840
|
+
specId: frameworkPackage.spec === null ? null : specIdOf(frameworkPackage.spec)
|
|
841
|
+
});
|
|
841
842
|
}
|
|
842
|
-
return
|
|
843
|
+
return entries;
|
|
844
|
+
}
|
|
845
|
+
function findSpecOwner(manifest, specId) {
|
|
846
|
+
const owning = declaredEntries(manifest).filter((entry) => entry.specId === specId);
|
|
847
|
+
if (owning.length === 0) return void 0;
|
|
848
|
+
const byPriority = owning.slice().sort((left, right) => {
|
|
849
|
+
if (left.kind !== right.kind) {
|
|
850
|
+
const order = ["feature", "package", "framework-package"];
|
|
851
|
+
return order.indexOf(left.kind) - order.indexOf(right.kind);
|
|
852
|
+
}
|
|
853
|
+
return ADVANCEMENT.indexOf(left.status) - ADVANCEMENT.indexOf(right.status);
|
|
854
|
+
});
|
|
855
|
+
const owner = byPriority[0];
|
|
856
|
+
if (owner === void 0) return void 0;
|
|
857
|
+
return { kind: owner.kind, name: owner.name, status: owner.status, folder: owner.folder };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ../ogygie-core/src/module-purity.ts
|
|
861
|
+
var IMPORT_STATEMENT = /\bimport\b(?![.(])([\s\S]*?)from\s*["']([^"']+)["']|\bimport\s+["']([^"']+)["']/g;
|
|
862
|
+
var DYNAMIC_LOAD = /\b(?:import\s*\(|require\s*\()\s*["']([^"']+)["']/g;
|
|
863
|
+
var REEXPORT_STATEMENT = /\bexport\s+(type\s+)?(\*(?:\s+as\s+[A-Za-z_$][\w$]*)?|\{[^}]*\})\s*from\s*["']([^"']+)["']/g;
|
|
864
|
+
function parseModuleImports(source) {
|
|
865
|
+
const code = stripComments(source);
|
|
866
|
+
const imports = [];
|
|
867
|
+
for (const match of code.matchAll(IMPORT_STATEMENT)) {
|
|
868
|
+
const [statement, clause, fromSpecifier, bareSpecifier] = match;
|
|
869
|
+
const specifier = fromSpecifier ?? bareSpecifier;
|
|
870
|
+
if (specifier === void 0) continue;
|
|
871
|
+
imports.push({
|
|
872
|
+
specifier,
|
|
873
|
+
typeOnly: clause !== void 0 && isTypeOnlyClause(clause),
|
|
874
|
+
statement: normalizeWhitespace(statement)
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
for (const match of code.matchAll(REEXPORT_STATEMENT)) {
|
|
878
|
+
const [statement, typeKeyword, clause, specifier] = match;
|
|
879
|
+
if (specifier === void 0 || clause === void 0) continue;
|
|
880
|
+
imports.push({
|
|
881
|
+
specifier,
|
|
882
|
+
typeOnly: typeKeyword !== void 0 || isTypeOnlyClause(clause),
|
|
883
|
+
statement: normalizeWhitespace(statement)
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
for (const match of code.matchAll(DYNAMIC_LOAD)) {
|
|
887
|
+
const [statement, specifier] = match;
|
|
888
|
+
if (specifier === void 0) continue;
|
|
889
|
+
imports.push({
|
|
890
|
+
specifier,
|
|
891
|
+
typeOnly: false,
|
|
892
|
+
statement: normalizeWhitespace(statement)
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
return imports;
|
|
896
|
+
}
|
|
897
|
+
function isTypeOnlyClause(clause) {
|
|
898
|
+
const trimmed = clause.trim();
|
|
899
|
+
if (trimmed.startsWith("type ")) return true;
|
|
900
|
+
const named = /^\{([\s\S]*)\}$/.exec(trimmed);
|
|
901
|
+
if (named === null || named[1] === void 0) return false;
|
|
902
|
+
const bindings = named[1].split(",").map((binding) => binding.trim()).filter((binding) => binding.length > 0);
|
|
903
|
+
return bindings.length > 0 && bindings.every((binding) => binding.startsWith("type "));
|
|
904
|
+
}
|
|
905
|
+
function stripComments(source) {
|
|
906
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
907
|
+
}
|
|
908
|
+
function normalizeWhitespace(statement) {
|
|
909
|
+
return statement.replace(/\s+/g, " ").trim();
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// ../ogygie-core/src/shared-tables.ts
|
|
913
|
+
function dbTableNameOf(declaration) {
|
|
914
|
+
return typeof declaration === "string" ? declaration : declaration.name;
|
|
915
|
+
}
|
|
916
|
+
function dbTableNamesOf(feature) {
|
|
917
|
+
return feature.dbTables.map(dbTableNameOf);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ../ogygie-core/src/impact.ts
|
|
921
|
+
var MapEntryKindSchema = z6.enum(["feature", "package", "framework-package"]);
|
|
922
|
+
var ImpactScopeSchema = z6.object({
|
|
923
|
+
scannedFolders: z6.array(z6.string()),
|
|
924
|
+
coveredKinds: z6.array(MapEntryKindSchema),
|
|
925
|
+
outOfReach: z6.array(z6.string())
|
|
926
|
+
});
|
|
927
|
+
var ImpactReportSchema = z6.object({
|
|
928
|
+
target: z6.object({
|
|
929
|
+
name: z6.string(),
|
|
930
|
+
kind: MapEntryKindSchema,
|
|
931
|
+
folder: z6.string()
|
|
932
|
+
}),
|
|
933
|
+
dependents: z6.array(
|
|
934
|
+
z6.object({
|
|
935
|
+
name: z6.string(),
|
|
936
|
+
kind: MapEntryKindSchema,
|
|
937
|
+
importedSymbols: z6.array(z6.string()),
|
|
938
|
+
viaIndexOnly: z6.boolean()
|
|
939
|
+
})
|
|
940
|
+
),
|
|
941
|
+
routes: z6.array(
|
|
942
|
+
z6.object({ method: z6.string(), path: z6.string(), description: z6.string() })
|
|
943
|
+
),
|
|
944
|
+
ownedTables: z6.array(z6.string()),
|
|
945
|
+
tableReadersOutsideOwner: z6.array(
|
|
946
|
+
z6.object({ table: z6.string(), feature: z6.string(), file: z6.string() })
|
|
947
|
+
),
|
|
948
|
+
findings: z6.array(OgygieErrorSchema),
|
|
949
|
+
testCommand: z6.string(),
|
|
950
|
+
scope: ImpactScopeSchema
|
|
951
|
+
});
|
|
952
|
+
var SOURCE_EXTENSIONS = [".ts", ".tsx"];
|
|
953
|
+
function isSource(path) {
|
|
954
|
+
return SOURCE_EXTENSIONS.some((extension) => path.endsWith(extension));
|
|
955
|
+
}
|
|
956
|
+
function isTest(path) {
|
|
957
|
+
return /\.test\.tsx?$/.test(path);
|
|
958
|
+
}
|
|
959
|
+
function symbolsOfImportClause(clause) {
|
|
960
|
+
const symbols = [];
|
|
961
|
+
const braces = /\{([^}]*)\}/.exec(clause);
|
|
962
|
+
if (braces?.[1] !== void 0) {
|
|
963
|
+
for (const rawEntry of braces[1].split(",")) {
|
|
964
|
+
const name = rawEntry.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
|
|
965
|
+
if (name !== void 0 && name !== "") symbols.push(name);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
const outsideBraces = clause.replace(/\{[^}]*\}/, "").replace(/^type\s+/, "").trim();
|
|
969
|
+
if (/\*\s*as\s+/.test(outsideBraces)) symbols.push("*");
|
|
970
|
+
else if (outsideBraces.replace(/,/g, "").trim() !== "") symbols.push("default");
|
|
971
|
+
return symbols;
|
|
972
|
+
}
|
|
973
|
+
function specifiersMatching(target, source) {
|
|
974
|
+
const folder = target.folder;
|
|
975
|
+
if (folder === null) return false;
|
|
976
|
+
const directory = folder.split("/").pop() ?? folder;
|
|
977
|
+
if (new RegExp(`(^|/)\\.\\./${directory}/`).test(source)) return true;
|
|
978
|
+
if (source.startsWith(`../${directory}/`) || source === `../${directory}`) return true;
|
|
979
|
+
if (source === target.name || source.startsWith(`${target.name}/`)) return true;
|
|
980
|
+
const segments = source.split("/");
|
|
981
|
+
return segments.includes(directory) && !source.startsWith(".");
|
|
982
|
+
}
|
|
983
|
+
function piercesTheIndex(specifier) {
|
|
984
|
+
if (!specifier.startsWith(".")) return false;
|
|
985
|
+
return !/(^|\/)index(\.js)?$/.test(specifier);
|
|
986
|
+
}
|
|
987
|
+
async function analyzeImpact(deps, targetName) {
|
|
988
|
+
const entries = declaredEntries(deps.manifest);
|
|
989
|
+
const target = entries.find((entry) => entry.name === targetName);
|
|
990
|
+
if (target === void 0) {
|
|
991
|
+
return throwOgygieError({
|
|
992
|
+
code: "IMPACT_UNKNOWN_FEATURE",
|
|
993
|
+
message: `"${targetName}" is unknown to the manifest.`,
|
|
994
|
+
file: "ogygie.manifest.json",
|
|
995
|
+
cause: `no entry of the map is named "${targetName}"`,
|
|
996
|
+
suggestedFix: `Pick one of: ${entries.map((entry) => entry.name).join(", ") || "<the map declares nothing>"}.`
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
return buildReport(deps, entries, target);
|
|
1000
|
+
}
|
|
1001
|
+
async function buildReport(deps, entries, target) {
|
|
1002
|
+
const findings = [];
|
|
1003
|
+
const scannedFolders = [];
|
|
1004
|
+
const outOfReach = [];
|
|
1005
|
+
if (target.folder === null) {
|
|
1006
|
+
outOfReach.push(
|
|
1007
|
+
`the target "${target.name}" has no folder the map can resolve \u2014 its app is not declared`
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
const dependents = /* @__PURE__ */ new Map();
|
|
1011
|
+
const tableReadersOutsideOwner = [];
|
|
1012
|
+
const ownedTables = target.kind === "feature" ? tablesOf(deps.manifest, target.name) : [];
|
|
1013
|
+
for (const entry of entries) {
|
|
1014
|
+
if (entry.name === target.name) continue;
|
|
1015
|
+
if (entry.folder === null) {
|
|
1016
|
+
outOfReach.push(
|
|
1017
|
+
`"${entry.name}" (${entry.kind}) \u2014 the map declares no resolvable folder for it`
|
|
1018
|
+
);
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
const files = (await deps.adapter.listDirectory(entry.folder)).filter(
|
|
1022
|
+
(file) => isSource(file.path)
|
|
1023
|
+
);
|
|
1024
|
+
if (files.length > 0) scannedFolders.push(entry.folder);
|
|
1025
|
+
for (const file of files) {
|
|
1026
|
+
const filePath = `${entry.folder}/${file.path}`;
|
|
1027
|
+
const content = await deps.adapter.readFile(filePath);
|
|
1028
|
+
for (const imported of parseModuleImports(content)) {
|
|
1029
|
+
if (!specifiersMatching(target, imported.specifier)) continue;
|
|
1030
|
+
const accumulator = dependents.get(entry.name) ?? {
|
|
1031
|
+
kind: entry.kind,
|
|
1032
|
+
importedSymbols: /* @__PURE__ */ new Set(),
|
|
1033
|
+
viaIndexOnly: true
|
|
1034
|
+
};
|
|
1035
|
+
for (const symbol of symbolsOfImportClause(clauseOf(imported.statement))) {
|
|
1036
|
+
accumulator.importedSymbols.add(symbol);
|
|
1037
|
+
}
|
|
1038
|
+
if (piercesTheIndex(imported.specifier)) {
|
|
1039
|
+
accumulator.viaIndexOnly = false;
|
|
1040
|
+
findings.push(
|
|
1041
|
+
ogygieError({
|
|
1042
|
+
code: "FEATURE_BOUNDARY_VIOLATION",
|
|
1043
|
+
message: `"${entry.name}" imports an internal file of "${target.name}" instead of its index.ts.`,
|
|
1044
|
+
file: filePath,
|
|
1045
|
+
cause: `import from "${imported.specifier}" bypasses the public index of ${target.name}`,
|
|
1046
|
+
suggestedFix: `Export the needed symbols from the index of ${target.name} and import them from there.`
|
|
1047
|
+
})
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
dependents.set(entry.name, accumulator);
|
|
1051
|
+
}
|
|
1052
|
+
if (isTest(file.path)) continue;
|
|
1053
|
+
for (const table of ownedTables) {
|
|
1054
|
+
if (!content.includes(`"${table}"`)) continue;
|
|
1055
|
+
tableReadersOutsideOwner.push({ table, feature: entry.name, file: filePath });
|
|
1056
|
+
findings.push(
|
|
1057
|
+
ogygieError({
|
|
1058
|
+
code: "TABLE_REFERENCED_OUTSIDE_OWNER",
|
|
1059
|
+
message: `Table "${table}" is owned by "${target.name}" but referenced by production code of "${entry.name}".`,
|
|
1060
|
+
file: filePath,
|
|
1061
|
+
cause: `"${table}" found in a non-test file outside its owner`,
|
|
1062
|
+
suggestedFix: `Go through the public contracts of ${target.name} instead of touching the table \u2014 one table, one owner.`
|
|
1063
|
+
})
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
findings.push(...manifestDependencyFindings(deps.manifest, target, dependents));
|
|
1069
|
+
const feature = deps.manifest.features.find((candidate) => candidate.name === target.name);
|
|
1070
|
+
const report = {
|
|
1071
|
+
target: { name: target.name, kind: target.kind, folder: target.folder ?? "" },
|
|
1072
|
+
dependents: [...dependents.entries()].map(([name, accumulator]) => ({
|
|
1073
|
+
name,
|
|
1074
|
+
kind: accumulator.kind,
|
|
1075
|
+
importedSymbols: [...accumulator.importedSymbols].sort(),
|
|
1076
|
+
viaIndexOnly: accumulator.viaIndexOnly
|
|
1077
|
+
})).sort((left, right) => left.name < right.name ? -1 : 1),
|
|
1078
|
+
routes: (feature?.routes ?? []).map((route) => ({
|
|
1079
|
+
method: route.method,
|
|
1080
|
+
path: route.path,
|
|
1081
|
+
description: route.description
|
|
1082
|
+
})),
|
|
1083
|
+
ownedTables,
|
|
1084
|
+
tableReadersOutsideOwner,
|
|
1085
|
+
findings,
|
|
1086
|
+
testCommand: testCommandFor(target, [...dependents.keys()], entries),
|
|
1087
|
+
scope: {
|
|
1088
|
+
scannedFolders: scannedFolders.sort(),
|
|
1089
|
+
coveredKinds: [...new Set(entries.map((entry) => entry.kind))],
|
|
1090
|
+
outOfReach: [...outOfReach, ...outOfReachOf(target)]
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
return ImpactReportSchema.parse(report);
|
|
1094
|
+
}
|
|
1095
|
+
function clauseOf(statement) {
|
|
1096
|
+
const match = /\bimport\b([\s\S]*?)\bfrom\b/.exec(statement);
|
|
1097
|
+
return match?.[1] ?? "";
|
|
1098
|
+
}
|
|
1099
|
+
function tablesOf(manifest, featureName) {
|
|
1100
|
+
const feature = manifest.features.find((candidate) => candidate.name === featureName);
|
|
1101
|
+
return feature === void 0 ? [] : dbTableNamesOf(feature);
|
|
1102
|
+
}
|
|
1103
|
+
function outOfReachOf(target) {
|
|
1104
|
+
const limits = [
|
|
1105
|
+
"code that no manifest entry declares \u2014 the map is the only chart",
|
|
1106
|
+
"anything outside this repo: no network, no npm registry"
|
|
1107
|
+
];
|
|
1108
|
+
if (target.kind === "framework-package" || target.kind === "package") {
|
|
1109
|
+
limits.unshift(
|
|
1110
|
+
`consumers of "${target.name}" OUTSIDE this repo \u2014 it is a package, and a published package is imported by projects this analysis cannot see`
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
return limits;
|
|
1114
|
+
}
|
|
1115
|
+
function manifestDependencyFindings(manifest, target, dependents) {
|
|
1116
|
+
const findings = [];
|
|
1117
|
+
const declared = manifest.features.filter(
|
|
1118
|
+
(feature) => feature.dependsOn.includes(target.name)
|
|
1119
|
+
);
|
|
1120
|
+
for (const feature of declared) {
|
|
1121
|
+
if (!dependents.has(feature.name)) {
|
|
1122
|
+
findings.push(
|
|
1123
|
+
ogygieError({
|
|
1124
|
+
code: "MANIFEST_DEPENDENCY_STALE",
|
|
1125
|
+
message: `Feature "${feature.name}" declares dependsOn "${target.name}" but never imports it.`,
|
|
1126
|
+
file: `ogygie.manifest.json#features.${feature.name}`,
|
|
1127
|
+
cause: `dependsOn contains "${target.name}", no import of it found in its code`,
|
|
1128
|
+
suggestedFix: "Remove the stale dependsOn entry, or make the dependency real through the entry's index.ts."
|
|
1129
|
+
})
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
for (const [name, accumulator] of dependents) {
|
|
1134
|
+
if (accumulator.kind !== "feature") continue;
|
|
1135
|
+
if (declared.some((feature) => feature.name === name)) continue;
|
|
1136
|
+
findings.push(
|
|
1137
|
+
ogygieError({
|
|
1138
|
+
code: "MANIFEST_DEPENDENCY_UNDECLARED",
|
|
1139
|
+
message: `Feature "${name}" imports "${target.name}" but does not declare it in dependsOn.`,
|
|
1140
|
+
file: `ogygie.manifest.json#features.${name}`,
|
|
1141
|
+
cause: `imports of "${target.name}" found in its code, absent from its dependsOn`,
|
|
1142
|
+
suggestedFix: `Add "${target.name}" to the dependsOn of "${name}" \u2014 the manifest must never lie by omission.`
|
|
1143
|
+
})
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
return findings;
|
|
1147
|
+
}
|
|
1148
|
+
function testCommandFor(target, dependentNames, entries) {
|
|
1149
|
+
const folders = [target, ...entries.filter((entry) => dependentNames.includes(entry.name))].map((entry) => entry.folder).filter((folder) => folder !== null);
|
|
1150
|
+
return ["pnpm test --", ...[...new Set(folders)].sort()].join(" ");
|
|
843
1151
|
}
|
|
844
1152
|
|
|
845
1153
|
// src/tools.ts
|
|
@@ -1002,6 +1310,11 @@ async function readGovernance(repo, input) {
|
|
|
1002
1310
|
provenance: await provenanceOf(repo, input.file)
|
|
1003
1311
|
};
|
|
1004
1312
|
}
|
|
1313
|
+
async function impact(repo, input) {
|
|
1314
|
+
const manifest = await readManifest(repo);
|
|
1315
|
+
const report = await analyzeImpact({ manifest, adapter: repo.adapter }, input.feature);
|
|
1316
|
+
return { ...report, provenance: await provenanceOf(repo, "ogygie.manifest.json") };
|
|
1317
|
+
}
|
|
1005
1318
|
async function readFeature(repo, input) {
|
|
1006
1319
|
const manifest = await readManifest(repo);
|
|
1007
1320
|
const owner = findSpecOwner(manifest, input.feature);
|
|
@@ -1116,6 +1429,12 @@ function createOgygieMcpServer(deps) {
|
|
|
1116
1429
|
ReadGovernanceInput,
|
|
1117
1430
|
async (input) => readGovernance(repoOf(deps.repos, input.repo), input)
|
|
1118
1431
|
);
|
|
1432
|
+
register(
|
|
1433
|
+
"ogygie_impact",
|
|
1434
|
+
"Blast radius of a change to a feature or package: who depends on it, which symbols they import, tables owned, tests to re-run \u2014 plus what the analysis could NOT see.",
|
|
1435
|
+
ImpactInput,
|
|
1436
|
+
async (input) => impact(repoOf(deps.repos, input.repo), input)
|
|
1437
|
+
);
|
|
1119
1438
|
register(
|
|
1120
1439
|
"ogygie_read_feature",
|
|
1121
1440
|
"Lists the files of a feature declared in the manifest, or reads one of them with `file`. The folder comes from the map: a feature the manifest does not declare does not exist.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygie/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Ogygie MCP server: read-only access to the governance files and features of any Ogygie repo, the manifest as index. Ships built; runnable standalone or through `ogygie mcp`.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -18,20 +18,20 @@
|
|
|
18
18
|
"exports": {
|
|
19
19
|
"./server": "./dist/server.js"
|
|
20
20
|
},
|
|
21
|
-
"scripts": {
|
|
22
|
-
"typecheck": "tsc -p tsconfig.json",
|
|
23
|
-
"build": "tsx scripts/build.ts"
|
|
24
|
-
},
|
|
25
21
|
"dependencies": {
|
|
26
22
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
27
23
|
"zod": "^4.0.0"
|
|
28
24
|
},
|
|
29
25
|
"devDependencies": {
|
|
30
|
-
"@ogygie/core": "workspace:*",
|
|
31
26
|
"@types/node": "^24.0.0",
|
|
32
27
|
"esbuild": "^0.28.0",
|
|
33
28
|
"tsx": "^4.20.0",
|
|
34
29
|
"typescript": "^5.8.0",
|
|
35
|
-
"vitest": "^3.2.0"
|
|
30
|
+
"vitest": "^3.2.0",
|
|
31
|
+
"@ogygie/core": "0.10.0"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
35
|
+
"build": "tsx scripts/build.ts"
|
|
36
36
|
}
|
|
37
|
-
}
|
|
37
|
+
}
|