@ogygie/mcp 0.9.0 → 0.10.1
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 +368 -30
- package/dist/server.js +368 -30
- 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,365 @@ 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
|
+
appType: owningApp?.type ?? null
|
|
828
|
+
});
|
|
823
829
|
}
|
|
824
|
-
const productPackage
|
|
825
|
-
(
|
|
826
|
-
);
|
|
827
|
-
if (productPackage !== void 0) {
|
|
828
|
-
return {
|
|
830
|
+
for (const productPackage of manifest.packages ?? []) {
|
|
831
|
+
entries.push({
|
|
829
832
|
kind: "package",
|
|
830
833
|
name: productPackage.name,
|
|
831
834
|
status: productPackage.status,
|
|
832
|
-
folder: productPackage.path
|
|
833
|
-
|
|
835
|
+
folder: productPackage.path,
|
|
836
|
+
specId: productPackage.name,
|
|
837
|
+
appType: null
|
|
838
|
+
});
|
|
834
839
|
}
|
|
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 {
|
|
840
|
+
for (const frameworkPackage of manifest.framework?.packages ?? []) {
|
|
841
|
+
entries.push({
|
|
841
842
|
kind: "framework-package",
|
|
842
|
-
name:
|
|
843
|
-
status:
|
|
844
|
-
folder:
|
|
845
|
-
|
|
843
|
+
name: frameworkPackage.name,
|
|
844
|
+
status: frameworkPackage.status,
|
|
845
|
+
folder: frameworkPackage.path,
|
|
846
|
+
// A published npm name is not a spec id: the link is DECLARED.
|
|
847
|
+
specId: frameworkPackage.spec === null ? null : specIdOf(frameworkPackage.spec),
|
|
848
|
+
appType: null
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
return entries;
|
|
852
|
+
}
|
|
853
|
+
function findSpecOwner(manifest, specId) {
|
|
854
|
+
const owning = declaredEntries(manifest).filter((entry) => entry.specId === specId);
|
|
855
|
+
if (owning.length === 0) return void 0;
|
|
856
|
+
const byPriority = owning.slice().sort((left, right) => {
|
|
857
|
+
if (left.kind !== right.kind) {
|
|
858
|
+
const order = ["feature", "package", "framework-package"];
|
|
859
|
+
return order.indexOf(left.kind) - order.indexOf(right.kind);
|
|
860
|
+
}
|
|
861
|
+
return ADVANCEMENT.indexOf(left.status) - ADVANCEMENT.indexOf(right.status);
|
|
862
|
+
});
|
|
863
|
+
const owner = byPriority[0];
|
|
864
|
+
if (owner === void 0) return void 0;
|
|
865
|
+
return { kind: owner.kind, name: owner.name, status: owner.status, folder: owner.folder };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ../ogygie-core/src/module-purity.ts
|
|
869
|
+
var IMPORT_STATEMENT = /\bimport\b(?![.(])([\s\S]*?)from\s*["']([^"']+)["']|\bimport\s+["']([^"']+)["']/g;
|
|
870
|
+
var DYNAMIC_LOAD = /\b(?:import\s*\(|require\s*\()\s*["']([^"']+)["']/g;
|
|
871
|
+
var REEXPORT_STATEMENT = /\bexport\s+(type\s+)?(\*(?:\s+as\s+[A-Za-z_$][\w$]*)?|\{[^}]*\})\s*from\s*["']([^"']+)["']/g;
|
|
872
|
+
function parseModuleImports(source) {
|
|
873
|
+
const code = stripComments(source);
|
|
874
|
+
const imports = [];
|
|
875
|
+
for (const match of code.matchAll(IMPORT_STATEMENT)) {
|
|
876
|
+
const [statement, clause, fromSpecifier, bareSpecifier] = match;
|
|
877
|
+
const specifier = fromSpecifier ?? bareSpecifier;
|
|
878
|
+
if (specifier === void 0) continue;
|
|
879
|
+
imports.push({
|
|
880
|
+
specifier,
|
|
881
|
+
typeOnly: clause !== void 0 && isTypeOnlyClause(clause),
|
|
882
|
+
statement: normalizeWhitespace(statement)
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
for (const match of code.matchAll(REEXPORT_STATEMENT)) {
|
|
886
|
+
const [statement, typeKeyword, clause, specifier] = match;
|
|
887
|
+
if (specifier === void 0 || clause === void 0) continue;
|
|
888
|
+
imports.push({
|
|
889
|
+
specifier,
|
|
890
|
+
typeOnly: typeKeyword !== void 0 || isTypeOnlyClause(clause),
|
|
891
|
+
statement: normalizeWhitespace(statement)
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
for (const match of code.matchAll(DYNAMIC_LOAD)) {
|
|
895
|
+
const [statement, specifier] = match;
|
|
896
|
+
if (specifier === void 0) continue;
|
|
897
|
+
imports.push({
|
|
898
|
+
specifier,
|
|
899
|
+
typeOnly: false,
|
|
900
|
+
statement: normalizeWhitespace(statement)
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return imports;
|
|
904
|
+
}
|
|
905
|
+
function isTypeOnlyClause(clause) {
|
|
906
|
+
const trimmed = clause.trim();
|
|
907
|
+
if (trimmed.startsWith("type ")) return true;
|
|
908
|
+
const named = /^\{([\s\S]*)\}$/.exec(trimmed);
|
|
909
|
+
if (named === null || named[1] === void 0) return false;
|
|
910
|
+
const bindings = named[1].split(",").map((binding) => binding.trim()).filter((binding) => binding.length > 0);
|
|
911
|
+
return bindings.length > 0 && bindings.every((binding) => binding.startsWith("type "));
|
|
912
|
+
}
|
|
913
|
+
function stripComments(source) {
|
|
914
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
915
|
+
}
|
|
916
|
+
function normalizeWhitespace(statement) {
|
|
917
|
+
return statement.replace(/\s+/g, " ").trim();
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ../ogygie-core/src/shared-tables.ts
|
|
921
|
+
function dbTableNameOf(declaration) {
|
|
922
|
+
return typeof declaration === "string" ? declaration : declaration.name;
|
|
923
|
+
}
|
|
924
|
+
function dbTableNamesOf(feature) {
|
|
925
|
+
return feature.dbTables.map(dbTableNameOf);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// ../ogygie-core/src/impact.ts
|
|
929
|
+
var MapEntryKindSchema = z6.enum(["feature", "package", "framework-package"]);
|
|
930
|
+
var ImpactScopeSchema = z6.object({
|
|
931
|
+
scannedFolders: z6.array(z6.string()),
|
|
932
|
+
coveredKinds: z6.array(MapEntryKindSchema),
|
|
933
|
+
outOfReach: z6.array(z6.string())
|
|
934
|
+
});
|
|
935
|
+
var ImpactReportSchema = z6.object({
|
|
936
|
+
target: z6.object({
|
|
937
|
+
name: z6.string(),
|
|
938
|
+
kind: MapEntryKindSchema,
|
|
939
|
+
folder: z6.string()
|
|
940
|
+
}),
|
|
941
|
+
dependents: z6.array(
|
|
942
|
+
z6.object({
|
|
943
|
+
name: z6.string(),
|
|
944
|
+
kind: MapEntryKindSchema,
|
|
945
|
+
importedSymbols: z6.array(z6.string()),
|
|
946
|
+
viaIndexOnly: z6.boolean()
|
|
947
|
+
})
|
|
948
|
+
),
|
|
949
|
+
routes: z6.array(
|
|
950
|
+
z6.object({ method: z6.string(), path: z6.string(), description: z6.string() })
|
|
951
|
+
),
|
|
952
|
+
ownedTables: z6.array(z6.string()),
|
|
953
|
+
tableReadersOutsideOwner: z6.array(
|
|
954
|
+
z6.object({ table: z6.string(), feature: z6.string(), file: z6.string() })
|
|
955
|
+
),
|
|
956
|
+
findings: z6.array(OgygieErrorSchema),
|
|
957
|
+
testCommand: z6.string(),
|
|
958
|
+
scope: ImpactScopeSchema
|
|
959
|
+
});
|
|
960
|
+
var SOURCE_EXTENSIONS = [".ts", ".tsx"];
|
|
961
|
+
function isSource(path) {
|
|
962
|
+
return SOURCE_EXTENSIONS.some((extension) => path.endsWith(extension));
|
|
963
|
+
}
|
|
964
|
+
function isTest(path) {
|
|
965
|
+
return /\.test\.tsx?$/.test(path);
|
|
966
|
+
}
|
|
967
|
+
function symbolsOfImportClause(clause) {
|
|
968
|
+
const symbols = [];
|
|
969
|
+
const braces = /\{([^}]*)\}/.exec(clause);
|
|
970
|
+
if (braces?.[1] !== void 0) {
|
|
971
|
+
for (const rawEntry of braces[1].split(",")) {
|
|
972
|
+
const name = rawEntry.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
|
|
973
|
+
if (name !== void 0 && name !== "") symbols.push(name);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const outsideBraces = clause.replace(/\{[^}]*\}/, "").replace(/^type\s+/, "").trim();
|
|
977
|
+
if (/\*\s*as\s+/.test(outsideBraces)) symbols.push("*");
|
|
978
|
+
else if (outsideBraces.replace(/,/g, "").trim() !== "") symbols.push("default");
|
|
979
|
+
return symbols;
|
|
980
|
+
}
|
|
981
|
+
function specifiersMatching(target, source) {
|
|
982
|
+
const folder = target.folder;
|
|
983
|
+
if (folder === null) return false;
|
|
984
|
+
const directory = folder.split("/").pop() ?? folder;
|
|
985
|
+
if (new RegExp(`(^|/)\\.\\./${directory}/`).test(source)) return true;
|
|
986
|
+
if (source.startsWith(`../${directory}/`) || source === `../${directory}`) return true;
|
|
987
|
+
if (source === target.name || source.startsWith(`${target.name}/`)) return true;
|
|
988
|
+
const segments = source.split("/");
|
|
989
|
+
return segments.includes(directory) && !source.startsWith(".");
|
|
990
|
+
}
|
|
991
|
+
function piercesTheIndex(specifier) {
|
|
992
|
+
if (!specifier.startsWith(".")) return false;
|
|
993
|
+
return !/(^|\/)index(\.js)?$/.test(specifier);
|
|
994
|
+
}
|
|
995
|
+
async function analyzeImpact(deps, targetName) {
|
|
996
|
+
const entries = declaredEntries(deps.manifest);
|
|
997
|
+
const target = entries.find((entry) => entry.name === targetName);
|
|
998
|
+
if (target === void 0) {
|
|
999
|
+
return throwOgygieError({
|
|
1000
|
+
code: "IMPACT_UNKNOWN_FEATURE",
|
|
1001
|
+
message: `"${targetName}" is unknown to the manifest.`,
|
|
1002
|
+
file: "ogygie.manifest.json",
|
|
1003
|
+
cause: `no entry of the map is named "${targetName}"`,
|
|
1004
|
+
suggestedFix: `Pick one of: ${entries.map((entry) => entry.name).join(", ") || "<the map declares nothing>"}.`
|
|
1005
|
+
});
|
|
846
1006
|
}
|
|
847
|
-
return
|
|
1007
|
+
return buildReport(deps, entries, target);
|
|
1008
|
+
}
|
|
1009
|
+
async function buildReport(deps, entries, target) {
|
|
1010
|
+
const findings = [];
|
|
1011
|
+
const scannedFolders = [];
|
|
1012
|
+
const outOfReach = [];
|
|
1013
|
+
if (target.folder === null) {
|
|
1014
|
+
outOfReach.push(
|
|
1015
|
+
`the target "${target.name}" has no folder the map can resolve \u2014 its app is not declared`
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
const dependents = /* @__PURE__ */ new Map();
|
|
1019
|
+
const tableReadersOutsideOwner = [];
|
|
1020
|
+
const ownedTables = target.kind === "feature" ? tablesOf(deps.manifest, target.name) : [];
|
|
1021
|
+
for (const entry of entries) {
|
|
1022
|
+
if (entry.name === target.name) continue;
|
|
1023
|
+
if (entry.folder === null) {
|
|
1024
|
+
outOfReach.push(
|
|
1025
|
+
`"${entry.name}" (${entry.kind}) \u2014 the map declares no resolvable folder for it`
|
|
1026
|
+
);
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
const files = (await deps.adapter.listDirectory(entry.folder)).filter(
|
|
1030
|
+
(file) => isSource(file.path)
|
|
1031
|
+
);
|
|
1032
|
+
if (files.length > 0) scannedFolders.push(entry.folder);
|
|
1033
|
+
for (const file of files) {
|
|
1034
|
+
const filePath = `${entry.folder}/${file.path}`;
|
|
1035
|
+
const content = await deps.adapter.readFile(filePath);
|
|
1036
|
+
for (const imported of parseModuleImports(content)) {
|
|
1037
|
+
if (!specifiersMatching(target, imported.specifier)) continue;
|
|
1038
|
+
const accumulator = dependents.get(entry.name) ?? {
|
|
1039
|
+
kind: entry.kind,
|
|
1040
|
+
importedSymbols: /* @__PURE__ */ new Set(),
|
|
1041
|
+
viaIndexOnly: true
|
|
1042
|
+
};
|
|
1043
|
+
for (const symbol of symbolsOfImportClause(clauseOf(imported.statement))) {
|
|
1044
|
+
accumulator.importedSymbols.add(symbol);
|
|
1045
|
+
}
|
|
1046
|
+
if (piercesTheIndex(imported.specifier)) {
|
|
1047
|
+
accumulator.viaIndexOnly = false;
|
|
1048
|
+
findings.push(
|
|
1049
|
+
ogygieError({
|
|
1050
|
+
code: "FEATURE_BOUNDARY_VIOLATION",
|
|
1051
|
+
message: `"${entry.name}" imports an internal file of "${target.name}" instead of its index.ts.`,
|
|
1052
|
+
file: filePath,
|
|
1053
|
+
cause: `import from "${imported.specifier}" bypasses the public index of ${target.name}`,
|
|
1054
|
+
suggestedFix: `Export the needed symbols from the index of ${target.name} and import them from there.`
|
|
1055
|
+
})
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
dependents.set(entry.name, accumulator);
|
|
1059
|
+
}
|
|
1060
|
+
if (isTest(file.path)) continue;
|
|
1061
|
+
if (entry.appType === "front" || entry.appType === "gateway") continue;
|
|
1062
|
+
for (const table of ownedTables) {
|
|
1063
|
+
if (!content.includes(`"${table}"`)) continue;
|
|
1064
|
+
tableReadersOutsideOwner.push({ table, feature: entry.name, file: filePath });
|
|
1065
|
+
findings.push(
|
|
1066
|
+
ogygieError({
|
|
1067
|
+
code: "TABLE_REFERENCED_OUTSIDE_OWNER",
|
|
1068
|
+
message: `Table "${table}" is owned by "${target.name}" but referenced by production code of "${entry.name}".`,
|
|
1069
|
+
file: filePath,
|
|
1070
|
+
cause: `"${table}" found in a non-test file outside its owner`,
|
|
1071
|
+
suggestedFix: `Go through the public contracts of ${target.name} instead of touching the table \u2014 one table, one owner.`
|
|
1072
|
+
})
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
findings.push(...manifestDependencyFindings(deps.manifest, target, dependents));
|
|
1078
|
+
const feature = deps.manifest.features.find((candidate) => candidate.name === target.name);
|
|
1079
|
+
const report = {
|
|
1080
|
+
target: { name: target.name, kind: target.kind, folder: target.folder ?? "" },
|
|
1081
|
+
dependents: [...dependents.entries()].map(([name, accumulator]) => ({
|
|
1082
|
+
name,
|
|
1083
|
+
kind: accumulator.kind,
|
|
1084
|
+
importedSymbols: [...accumulator.importedSymbols].sort(),
|
|
1085
|
+
viaIndexOnly: accumulator.viaIndexOnly
|
|
1086
|
+
})).sort((left, right) => left.name < right.name ? -1 : 1),
|
|
1087
|
+
routes: (feature?.routes ?? []).map((route) => ({
|
|
1088
|
+
method: route.method,
|
|
1089
|
+
path: route.path,
|
|
1090
|
+
description: route.description
|
|
1091
|
+
})),
|
|
1092
|
+
ownedTables,
|
|
1093
|
+
tableReadersOutsideOwner,
|
|
1094
|
+
findings,
|
|
1095
|
+
testCommand: testCommandFor(target, [...dependents.keys()], entries),
|
|
1096
|
+
scope: {
|
|
1097
|
+
scannedFolders: scannedFolders.sort(),
|
|
1098
|
+
coveredKinds: [...new Set(entries.map((entry) => entry.kind))],
|
|
1099
|
+
outOfReach: [...outOfReach, ...outOfReachOf(target, entries)]
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
return ImpactReportSchema.parse(report);
|
|
1103
|
+
}
|
|
1104
|
+
function clauseOf(statement) {
|
|
1105
|
+
const match = /\bimport\b([\s\S]*?)\bfrom\b/.exec(statement);
|
|
1106
|
+
return match?.[1] ?? "";
|
|
1107
|
+
}
|
|
1108
|
+
function tablesOf(manifest, featureName) {
|
|
1109
|
+
const feature = manifest.features.find((candidate) => candidate.name === featureName);
|
|
1110
|
+
return feature === void 0 ? [] : dbTableNamesOf(feature);
|
|
1111
|
+
}
|
|
1112
|
+
function outOfReachOf(target, entries) {
|
|
1113
|
+
const limits = [
|
|
1114
|
+
"code that no manifest entry declares \u2014 the map is the only chart",
|
|
1115
|
+
"anything outside this repo: no network, no npm registry"
|
|
1116
|
+
];
|
|
1117
|
+
const spared = entries.filter(
|
|
1118
|
+
(entry) => entry.appType === "front" || entry.appType === "gateway"
|
|
1119
|
+
);
|
|
1120
|
+
if (spared.length > 0 && target.kind === "feature") {
|
|
1121
|
+
limits.push(
|
|
1122
|
+
`table ownership was NOT checked in ${spared.length} front/gateway entr${spared.length === 1 ? "y" : "ies"} \u2014 that code holds no database client, so a table name there is a query key or a route`
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
if (target.kind === "framework-package" || target.kind === "package") {
|
|
1126
|
+
limits.unshift(
|
|
1127
|
+
`consumers of "${target.name}" OUTSIDE this repo \u2014 it is a package, and a published package is imported by projects this analysis cannot see`
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
return limits;
|
|
1131
|
+
}
|
|
1132
|
+
function manifestDependencyFindings(manifest, target, dependents) {
|
|
1133
|
+
const findings = [];
|
|
1134
|
+
const declared = manifest.features.filter(
|
|
1135
|
+
(feature) => feature.dependsOn.includes(target.name)
|
|
1136
|
+
);
|
|
1137
|
+
for (const feature of declared) {
|
|
1138
|
+
if (!dependents.has(feature.name)) {
|
|
1139
|
+
findings.push(
|
|
1140
|
+
ogygieError({
|
|
1141
|
+
code: "MANIFEST_DEPENDENCY_STALE",
|
|
1142
|
+
message: `Feature "${feature.name}" declares dependsOn "${target.name}" but never imports it.`,
|
|
1143
|
+
file: `ogygie.manifest.json#features.${feature.name}`,
|
|
1144
|
+
cause: `dependsOn contains "${target.name}", no import of it found in its code`,
|
|
1145
|
+
suggestedFix: "Remove the stale dependsOn entry, or make the dependency real through the entry's index.ts."
|
|
1146
|
+
})
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
for (const [name, accumulator] of dependents) {
|
|
1151
|
+
if (accumulator.kind !== "feature") continue;
|
|
1152
|
+
if (declared.some((feature) => feature.name === name)) continue;
|
|
1153
|
+
findings.push(
|
|
1154
|
+
ogygieError({
|
|
1155
|
+
code: "MANIFEST_DEPENDENCY_UNDECLARED",
|
|
1156
|
+
message: `Feature "${name}" imports "${target.name}" but does not declare it in dependsOn.`,
|
|
1157
|
+
file: `ogygie.manifest.json#features.${name}`,
|
|
1158
|
+
cause: `imports of "${target.name}" found in its code, absent from its dependsOn`,
|
|
1159
|
+
suggestedFix: `Add "${target.name}" to the dependsOn of "${name}" \u2014 the manifest must never lie by omission.`
|
|
1160
|
+
})
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
return findings;
|
|
1164
|
+
}
|
|
1165
|
+
function testCommandFor(target, dependentNames, entries) {
|
|
1166
|
+
const folders = [target, ...entries.filter((entry) => dependentNames.includes(entry.name))].map((entry) => entry.folder).filter((folder) => folder !== null);
|
|
1167
|
+
return ["pnpm test --", ...[...new Set(folders)].sort()].join(" ");
|
|
848
1168
|
}
|
|
849
1169
|
|
|
850
1170
|
// src/tools.ts
|
|
@@ -1007,6 +1327,11 @@ async function readGovernance(repo, input) {
|
|
|
1007
1327
|
provenance: await provenanceOf(repo, input.file)
|
|
1008
1328
|
};
|
|
1009
1329
|
}
|
|
1330
|
+
async function impact(repo, input) {
|
|
1331
|
+
const manifest = await readManifest(repo);
|
|
1332
|
+
const report = await analyzeImpact({ manifest, adapter: repo.adapter }, input.feature);
|
|
1333
|
+
return { ...report, provenance: await provenanceOf(repo, "ogygie.manifest.json") };
|
|
1334
|
+
}
|
|
1010
1335
|
async function readFeature(repo, input) {
|
|
1011
1336
|
const manifest = await readManifest(repo);
|
|
1012
1337
|
const owner = findSpecOwner(manifest, input.feature);
|
|
@@ -1121,6 +1446,12 @@ function createOgygieMcpServer(deps) {
|
|
|
1121
1446
|
ReadGovernanceInput,
|
|
1122
1447
|
async (input) => readGovernance(repoOf(deps.repos, input.repo), input)
|
|
1123
1448
|
);
|
|
1449
|
+
register(
|
|
1450
|
+
"ogygie_impact",
|
|
1451
|
+
"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.",
|
|
1452
|
+
ImpactInput,
|
|
1453
|
+
async (input) => impact(repoOf(deps.repos, input.repo), input)
|
|
1454
|
+
);
|
|
1124
1455
|
register(
|
|
1125
1456
|
"ogygie_read_feature",
|
|
1126
1457
|
"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.",
|
|
@@ -1169,11 +1500,18 @@ function refuse(response, status, error) {
|
|
|
1169
1500
|
response.writeHead(status, { "content-type": "application/json" });
|
|
1170
1501
|
response.end(JSON.stringify(error));
|
|
1171
1502
|
}
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1503
|
+
var SECRET_PATH_PREFIX = "/mcp-";
|
|
1504
|
+
function isAuthorized(authorization, expected, requestPath) {
|
|
1505
|
+
if (authorization !== void 0) {
|
|
1506
|
+
const [scheme, value] = authorization.split(" ");
|
|
1507
|
+
if (scheme?.toLowerCase() === "bearer" && value !== void 0) {
|
|
1508
|
+
if (tokenMatches(expected, value)) return true;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
if (requestPath === void 0) return false;
|
|
1512
|
+
const [pathOnly] = requestPath.split("?");
|
|
1513
|
+
if (pathOnly === void 0 || !pathOnly.startsWith(SECRET_PATH_PREFIX)) return false;
|
|
1514
|
+
return tokenMatches(expected, pathOnly.slice(SECRET_PATH_PREFIX.length));
|
|
1177
1515
|
}
|
|
1178
1516
|
async function startHttpTransport(createServerInstance, options) {
|
|
1179
1517
|
const token = assertUsableToken(options.token);
|
|
@@ -1188,7 +1526,7 @@ async function startHttpTransport(createServerInstance, options) {
|
|
|
1188
1526
|
await transport.handleRequest(request, response, body);
|
|
1189
1527
|
};
|
|
1190
1528
|
const httpServer = createServer((request, response) => {
|
|
1191
|
-
if (!isAuthorized(request.headers.authorization, token)) {
|
|
1529
|
+
if (!isAuthorized(request.headers.authorization, token, request.url)) {
|
|
1192
1530
|
refuse(
|
|
1193
1531
|
response,
|
|
1194
1532
|
401,
|
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,365 @@ 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
|
+
appType: owningApp?.type ?? null
|
|
823
|
+
});
|
|
818
824
|
}
|
|
819
|
-
const productPackage
|
|
820
|
-
(
|
|
821
|
-
);
|
|
822
|
-
if (productPackage !== void 0) {
|
|
823
|
-
return {
|
|
825
|
+
for (const productPackage of manifest.packages ?? []) {
|
|
826
|
+
entries.push({
|
|
824
827
|
kind: "package",
|
|
825
828
|
name: productPackage.name,
|
|
826
829
|
status: productPackage.status,
|
|
827
|
-
folder: productPackage.path
|
|
828
|
-
|
|
830
|
+
folder: productPackage.path,
|
|
831
|
+
specId: productPackage.name,
|
|
832
|
+
appType: null
|
|
833
|
+
});
|
|
829
834
|
}
|
|
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 {
|
|
835
|
+
for (const frameworkPackage of manifest.framework?.packages ?? []) {
|
|
836
|
+
entries.push({
|
|
836
837
|
kind: "framework-package",
|
|
837
|
-
name:
|
|
838
|
-
status:
|
|
839
|
-
folder:
|
|
840
|
-
|
|
838
|
+
name: frameworkPackage.name,
|
|
839
|
+
status: frameworkPackage.status,
|
|
840
|
+
folder: frameworkPackage.path,
|
|
841
|
+
// A published npm name is not a spec id: the link is DECLARED.
|
|
842
|
+
specId: frameworkPackage.spec === null ? null : specIdOf(frameworkPackage.spec),
|
|
843
|
+
appType: null
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
return entries;
|
|
847
|
+
}
|
|
848
|
+
function findSpecOwner(manifest, specId) {
|
|
849
|
+
const owning = declaredEntries(manifest).filter((entry) => entry.specId === specId);
|
|
850
|
+
if (owning.length === 0) return void 0;
|
|
851
|
+
const byPriority = owning.slice().sort((left, right) => {
|
|
852
|
+
if (left.kind !== right.kind) {
|
|
853
|
+
const order = ["feature", "package", "framework-package"];
|
|
854
|
+
return order.indexOf(left.kind) - order.indexOf(right.kind);
|
|
855
|
+
}
|
|
856
|
+
return ADVANCEMENT.indexOf(left.status) - ADVANCEMENT.indexOf(right.status);
|
|
857
|
+
});
|
|
858
|
+
const owner = byPriority[0];
|
|
859
|
+
if (owner === void 0) return void 0;
|
|
860
|
+
return { kind: owner.kind, name: owner.name, status: owner.status, folder: owner.folder };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// ../ogygie-core/src/module-purity.ts
|
|
864
|
+
var IMPORT_STATEMENT = /\bimport\b(?![.(])([\s\S]*?)from\s*["']([^"']+)["']|\bimport\s+["']([^"']+)["']/g;
|
|
865
|
+
var DYNAMIC_LOAD = /\b(?:import\s*\(|require\s*\()\s*["']([^"']+)["']/g;
|
|
866
|
+
var REEXPORT_STATEMENT = /\bexport\s+(type\s+)?(\*(?:\s+as\s+[A-Za-z_$][\w$]*)?|\{[^}]*\})\s*from\s*["']([^"']+)["']/g;
|
|
867
|
+
function parseModuleImports(source) {
|
|
868
|
+
const code = stripComments(source);
|
|
869
|
+
const imports = [];
|
|
870
|
+
for (const match of code.matchAll(IMPORT_STATEMENT)) {
|
|
871
|
+
const [statement, clause, fromSpecifier, bareSpecifier] = match;
|
|
872
|
+
const specifier = fromSpecifier ?? bareSpecifier;
|
|
873
|
+
if (specifier === void 0) continue;
|
|
874
|
+
imports.push({
|
|
875
|
+
specifier,
|
|
876
|
+
typeOnly: clause !== void 0 && isTypeOnlyClause(clause),
|
|
877
|
+
statement: normalizeWhitespace(statement)
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
for (const match of code.matchAll(REEXPORT_STATEMENT)) {
|
|
881
|
+
const [statement, typeKeyword, clause, specifier] = match;
|
|
882
|
+
if (specifier === void 0 || clause === void 0) continue;
|
|
883
|
+
imports.push({
|
|
884
|
+
specifier,
|
|
885
|
+
typeOnly: typeKeyword !== void 0 || isTypeOnlyClause(clause),
|
|
886
|
+
statement: normalizeWhitespace(statement)
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
for (const match of code.matchAll(DYNAMIC_LOAD)) {
|
|
890
|
+
const [statement, specifier] = match;
|
|
891
|
+
if (specifier === void 0) continue;
|
|
892
|
+
imports.push({
|
|
893
|
+
specifier,
|
|
894
|
+
typeOnly: false,
|
|
895
|
+
statement: normalizeWhitespace(statement)
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return imports;
|
|
899
|
+
}
|
|
900
|
+
function isTypeOnlyClause(clause) {
|
|
901
|
+
const trimmed = clause.trim();
|
|
902
|
+
if (trimmed.startsWith("type ")) return true;
|
|
903
|
+
const named = /^\{([\s\S]*)\}$/.exec(trimmed);
|
|
904
|
+
if (named === null || named[1] === void 0) return false;
|
|
905
|
+
const bindings = named[1].split(",").map((binding) => binding.trim()).filter((binding) => binding.length > 0);
|
|
906
|
+
return bindings.length > 0 && bindings.every((binding) => binding.startsWith("type "));
|
|
907
|
+
}
|
|
908
|
+
function stripComments(source) {
|
|
909
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
910
|
+
}
|
|
911
|
+
function normalizeWhitespace(statement) {
|
|
912
|
+
return statement.replace(/\s+/g, " ").trim();
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ../ogygie-core/src/shared-tables.ts
|
|
916
|
+
function dbTableNameOf(declaration) {
|
|
917
|
+
return typeof declaration === "string" ? declaration : declaration.name;
|
|
918
|
+
}
|
|
919
|
+
function dbTableNamesOf(feature) {
|
|
920
|
+
return feature.dbTables.map(dbTableNameOf);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// ../ogygie-core/src/impact.ts
|
|
924
|
+
var MapEntryKindSchema = z6.enum(["feature", "package", "framework-package"]);
|
|
925
|
+
var ImpactScopeSchema = z6.object({
|
|
926
|
+
scannedFolders: z6.array(z6.string()),
|
|
927
|
+
coveredKinds: z6.array(MapEntryKindSchema),
|
|
928
|
+
outOfReach: z6.array(z6.string())
|
|
929
|
+
});
|
|
930
|
+
var ImpactReportSchema = z6.object({
|
|
931
|
+
target: z6.object({
|
|
932
|
+
name: z6.string(),
|
|
933
|
+
kind: MapEntryKindSchema,
|
|
934
|
+
folder: z6.string()
|
|
935
|
+
}),
|
|
936
|
+
dependents: z6.array(
|
|
937
|
+
z6.object({
|
|
938
|
+
name: z6.string(),
|
|
939
|
+
kind: MapEntryKindSchema,
|
|
940
|
+
importedSymbols: z6.array(z6.string()),
|
|
941
|
+
viaIndexOnly: z6.boolean()
|
|
942
|
+
})
|
|
943
|
+
),
|
|
944
|
+
routes: z6.array(
|
|
945
|
+
z6.object({ method: z6.string(), path: z6.string(), description: z6.string() })
|
|
946
|
+
),
|
|
947
|
+
ownedTables: z6.array(z6.string()),
|
|
948
|
+
tableReadersOutsideOwner: z6.array(
|
|
949
|
+
z6.object({ table: z6.string(), feature: z6.string(), file: z6.string() })
|
|
950
|
+
),
|
|
951
|
+
findings: z6.array(OgygieErrorSchema),
|
|
952
|
+
testCommand: z6.string(),
|
|
953
|
+
scope: ImpactScopeSchema
|
|
954
|
+
});
|
|
955
|
+
var SOURCE_EXTENSIONS = [".ts", ".tsx"];
|
|
956
|
+
function isSource(path) {
|
|
957
|
+
return SOURCE_EXTENSIONS.some((extension) => path.endsWith(extension));
|
|
958
|
+
}
|
|
959
|
+
function isTest(path) {
|
|
960
|
+
return /\.test\.tsx?$/.test(path);
|
|
961
|
+
}
|
|
962
|
+
function symbolsOfImportClause(clause) {
|
|
963
|
+
const symbols = [];
|
|
964
|
+
const braces = /\{([^}]*)\}/.exec(clause);
|
|
965
|
+
if (braces?.[1] !== void 0) {
|
|
966
|
+
for (const rawEntry of braces[1].split(",")) {
|
|
967
|
+
const name = rawEntry.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
|
|
968
|
+
if (name !== void 0 && name !== "") symbols.push(name);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
const outsideBraces = clause.replace(/\{[^}]*\}/, "").replace(/^type\s+/, "").trim();
|
|
972
|
+
if (/\*\s*as\s+/.test(outsideBraces)) symbols.push("*");
|
|
973
|
+
else if (outsideBraces.replace(/,/g, "").trim() !== "") symbols.push("default");
|
|
974
|
+
return symbols;
|
|
975
|
+
}
|
|
976
|
+
function specifiersMatching(target, source) {
|
|
977
|
+
const folder = target.folder;
|
|
978
|
+
if (folder === null) return false;
|
|
979
|
+
const directory = folder.split("/").pop() ?? folder;
|
|
980
|
+
if (new RegExp(`(^|/)\\.\\./${directory}/`).test(source)) return true;
|
|
981
|
+
if (source.startsWith(`../${directory}/`) || source === `../${directory}`) return true;
|
|
982
|
+
if (source === target.name || source.startsWith(`${target.name}/`)) return true;
|
|
983
|
+
const segments = source.split("/");
|
|
984
|
+
return segments.includes(directory) && !source.startsWith(".");
|
|
985
|
+
}
|
|
986
|
+
function piercesTheIndex(specifier) {
|
|
987
|
+
if (!specifier.startsWith(".")) return false;
|
|
988
|
+
return !/(^|\/)index(\.js)?$/.test(specifier);
|
|
989
|
+
}
|
|
990
|
+
async function analyzeImpact(deps, targetName) {
|
|
991
|
+
const entries = declaredEntries(deps.manifest);
|
|
992
|
+
const target = entries.find((entry) => entry.name === targetName);
|
|
993
|
+
if (target === void 0) {
|
|
994
|
+
return throwOgygieError({
|
|
995
|
+
code: "IMPACT_UNKNOWN_FEATURE",
|
|
996
|
+
message: `"${targetName}" is unknown to the manifest.`,
|
|
997
|
+
file: "ogygie.manifest.json",
|
|
998
|
+
cause: `no entry of the map is named "${targetName}"`,
|
|
999
|
+
suggestedFix: `Pick one of: ${entries.map((entry) => entry.name).join(", ") || "<the map declares nothing>"}.`
|
|
1000
|
+
});
|
|
841
1001
|
}
|
|
842
|
-
return
|
|
1002
|
+
return buildReport(deps, entries, target);
|
|
1003
|
+
}
|
|
1004
|
+
async function buildReport(deps, entries, target) {
|
|
1005
|
+
const findings = [];
|
|
1006
|
+
const scannedFolders = [];
|
|
1007
|
+
const outOfReach = [];
|
|
1008
|
+
if (target.folder === null) {
|
|
1009
|
+
outOfReach.push(
|
|
1010
|
+
`the target "${target.name}" has no folder the map can resolve \u2014 its app is not declared`
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
const dependents = /* @__PURE__ */ new Map();
|
|
1014
|
+
const tableReadersOutsideOwner = [];
|
|
1015
|
+
const ownedTables = target.kind === "feature" ? tablesOf(deps.manifest, target.name) : [];
|
|
1016
|
+
for (const entry of entries) {
|
|
1017
|
+
if (entry.name === target.name) continue;
|
|
1018
|
+
if (entry.folder === null) {
|
|
1019
|
+
outOfReach.push(
|
|
1020
|
+
`"${entry.name}" (${entry.kind}) \u2014 the map declares no resolvable folder for it`
|
|
1021
|
+
);
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
const files = (await deps.adapter.listDirectory(entry.folder)).filter(
|
|
1025
|
+
(file) => isSource(file.path)
|
|
1026
|
+
);
|
|
1027
|
+
if (files.length > 0) scannedFolders.push(entry.folder);
|
|
1028
|
+
for (const file of files) {
|
|
1029
|
+
const filePath = `${entry.folder}/${file.path}`;
|
|
1030
|
+
const content = await deps.adapter.readFile(filePath);
|
|
1031
|
+
for (const imported of parseModuleImports(content)) {
|
|
1032
|
+
if (!specifiersMatching(target, imported.specifier)) continue;
|
|
1033
|
+
const accumulator = dependents.get(entry.name) ?? {
|
|
1034
|
+
kind: entry.kind,
|
|
1035
|
+
importedSymbols: /* @__PURE__ */ new Set(),
|
|
1036
|
+
viaIndexOnly: true
|
|
1037
|
+
};
|
|
1038
|
+
for (const symbol of symbolsOfImportClause(clauseOf(imported.statement))) {
|
|
1039
|
+
accumulator.importedSymbols.add(symbol);
|
|
1040
|
+
}
|
|
1041
|
+
if (piercesTheIndex(imported.specifier)) {
|
|
1042
|
+
accumulator.viaIndexOnly = false;
|
|
1043
|
+
findings.push(
|
|
1044
|
+
ogygieError({
|
|
1045
|
+
code: "FEATURE_BOUNDARY_VIOLATION",
|
|
1046
|
+
message: `"${entry.name}" imports an internal file of "${target.name}" instead of its index.ts.`,
|
|
1047
|
+
file: filePath,
|
|
1048
|
+
cause: `import from "${imported.specifier}" bypasses the public index of ${target.name}`,
|
|
1049
|
+
suggestedFix: `Export the needed symbols from the index of ${target.name} and import them from there.`
|
|
1050
|
+
})
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
dependents.set(entry.name, accumulator);
|
|
1054
|
+
}
|
|
1055
|
+
if (isTest(file.path)) continue;
|
|
1056
|
+
if (entry.appType === "front" || entry.appType === "gateway") continue;
|
|
1057
|
+
for (const table of ownedTables) {
|
|
1058
|
+
if (!content.includes(`"${table}"`)) continue;
|
|
1059
|
+
tableReadersOutsideOwner.push({ table, feature: entry.name, file: filePath });
|
|
1060
|
+
findings.push(
|
|
1061
|
+
ogygieError({
|
|
1062
|
+
code: "TABLE_REFERENCED_OUTSIDE_OWNER",
|
|
1063
|
+
message: `Table "${table}" is owned by "${target.name}" but referenced by production code of "${entry.name}".`,
|
|
1064
|
+
file: filePath,
|
|
1065
|
+
cause: `"${table}" found in a non-test file outside its owner`,
|
|
1066
|
+
suggestedFix: `Go through the public contracts of ${target.name} instead of touching the table \u2014 one table, one owner.`
|
|
1067
|
+
})
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
findings.push(...manifestDependencyFindings(deps.manifest, target, dependents));
|
|
1073
|
+
const feature = deps.manifest.features.find((candidate) => candidate.name === target.name);
|
|
1074
|
+
const report = {
|
|
1075
|
+
target: { name: target.name, kind: target.kind, folder: target.folder ?? "" },
|
|
1076
|
+
dependents: [...dependents.entries()].map(([name, accumulator]) => ({
|
|
1077
|
+
name,
|
|
1078
|
+
kind: accumulator.kind,
|
|
1079
|
+
importedSymbols: [...accumulator.importedSymbols].sort(),
|
|
1080
|
+
viaIndexOnly: accumulator.viaIndexOnly
|
|
1081
|
+
})).sort((left, right) => left.name < right.name ? -1 : 1),
|
|
1082
|
+
routes: (feature?.routes ?? []).map((route) => ({
|
|
1083
|
+
method: route.method,
|
|
1084
|
+
path: route.path,
|
|
1085
|
+
description: route.description
|
|
1086
|
+
})),
|
|
1087
|
+
ownedTables,
|
|
1088
|
+
tableReadersOutsideOwner,
|
|
1089
|
+
findings,
|
|
1090
|
+
testCommand: testCommandFor(target, [...dependents.keys()], entries),
|
|
1091
|
+
scope: {
|
|
1092
|
+
scannedFolders: scannedFolders.sort(),
|
|
1093
|
+
coveredKinds: [...new Set(entries.map((entry) => entry.kind))],
|
|
1094
|
+
outOfReach: [...outOfReach, ...outOfReachOf(target, entries)]
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
return ImpactReportSchema.parse(report);
|
|
1098
|
+
}
|
|
1099
|
+
function clauseOf(statement) {
|
|
1100
|
+
const match = /\bimport\b([\s\S]*?)\bfrom\b/.exec(statement);
|
|
1101
|
+
return match?.[1] ?? "";
|
|
1102
|
+
}
|
|
1103
|
+
function tablesOf(manifest, featureName) {
|
|
1104
|
+
const feature = manifest.features.find((candidate) => candidate.name === featureName);
|
|
1105
|
+
return feature === void 0 ? [] : dbTableNamesOf(feature);
|
|
1106
|
+
}
|
|
1107
|
+
function outOfReachOf(target, entries) {
|
|
1108
|
+
const limits = [
|
|
1109
|
+
"code that no manifest entry declares \u2014 the map is the only chart",
|
|
1110
|
+
"anything outside this repo: no network, no npm registry"
|
|
1111
|
+
];
|
|
1112
|
+
const spared = entries.filter(
|
|
1113
|
+
(entry) => entry.appType === "front" || entry.appType === "gateway"
|
|
1114
|
+
);
|
|
1115
|
+
if (spared.length > 0 && target.kind === "feature") {
|
|
1116
|
+
limits.push(
|
|
1117
|
+
`table ownership was NOT checked in ${spared.length} front/gateway entr${spared.length === 1 ? "y" : "ies"} \u2014 that code holds no database client, so a table name there is a query key or a route`
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
if (target.kind === "framework-package" || target.kind === "package") {
|
|
1121
|
+
limits.unshift(
|
|
1122
|
+
`consumers of "${target.name}" OUTSIDE this repo \u2014 it is a package, and a published package is imported by projects this analysis cannot see`
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
return limits;
|
|
1126
|
+
}
|
|
1127
|
+
function manifestDependencyFindings(manifest, target, dependents) {
|
|
1128
|
+
const findings = [];
|
|
1129
|
+
const declared = manifest.features.filter(
|
|
1130
|
+
(feature) => feature.dependsOn.includes(target.name)
|
|
1131
|
+
);
|
|
1132
|
+
for (const feature of declared) {
|
|
1133
|
+
if (!dependents.has(feature.name)) {
|
|
1134
|
+
findings.push(
|
|
1135
|
+
ogygieError({
|
|
1136
|
+
code: "MANIFEST_DEPENDENCY_STALE",
|
|
1137
|
+
message: `Feature "${feature.name}" declares dependsOn "${target.name}" but never imports it.`,
|
|
1138
|
+
file: `ogygie.manifest.json#features.${feature.name}`,
|
|
1139
|
+
cause: `dependsOn contains "${target.name}", no import of it found in its code`,
|
|
1140
|
+
suggestedFix: "Remove the stale dependsOn entry, or make the dependency real through the entry's index.ts."
|
|
1141
|
+
})
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
for (const [name, accumulator] of dependents) {
|
|
1146
|
+
if (accumulator.kind !== "feature") continue;
|
|
1147
|
+
if (declared.some((feature) => feature.name === name)) continue;
|
|
1148
|
+
findings.push(
|
|
1149
|
+
ogygieError({
|
|
1150
|
+
code: "MANIFEST_DEPENDENCY_UNDECLARED",
|
|
1151
|
+
message: `Feature "${name}" imports "${target.name}" but does not declare it in dependsOn.`,
|
|
1152
|
+
file: `ogygie.manifest.json#features.${name}`,
|
|
1153
|
+
cause: `imports of "${target.name}" found in its code, absent from its dependsOn`,
|
|
1154
|
+
suggestedFix: `Add "${target.name}" to the dependsOn of "${name}" \u2014 the manifest must never lie by omission.`
|
|
1155
|
+
})
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
return findings;
|
|
1159
|
+
}
|
|
1160
|
+
function testCommandFor(target, dependentNames, entries) {
|
|
1161
|
+
const folders = [target, ...entries.filter((entry) => dependentNames.includes(entry.name))].map((entry) => entry.folder).filter((folder) => folder !== null);
|
|
1162
|
+
return ["pnpm test --", ...[...new Set(folders)].sort()].join(" ");
|
|
843
1163
|
}
|
|
844
1164
|
|
|
845
1165
|
// src/tools.ts
|
|
@@ -1002,6 +1322,11 @@ async function readGovernance(repo, input) {
|
|
|
1002
1322
|
provenance: await provenanceOf(repo, input.file)
|
|
1003
1323
|
};
|
|
1004
1324
|
}
|
|
1325
|
+
async function impact(repo, input) {
|
|
1326
|
+
const manifest = await readManifest(repo);
|
|
1327
|
+
const report = await analyzeImpact({ manifest, adapter: repo.adapter }, input.feature);
|
|
1328
|
+
return { ...report, provenance: await provenanceOf(repo, "ogygie.manifest.json") };
|
|
1329
|
+
}
|
|
1005
1330
|
async function readFeature(repo, input) {
|
|
1006
1331
|
const manifest = await readManifest(repo);
|
|
1007
1332
|
const owner = findSpecOwner(manifest, input.feature);
|
|
@@ -1116,6 +1441,12 @@ function createOgygieMcpServer(deps) {
|
|
|
1116
1441
|
ReadGovernanceInput,
|
|
1117
1442
|
async (input) => readGovernance(repoOf(deps.repos, input.repo), input)
|
|
1118
1443
|
);
|
|
1444
|
+
register(
|
|
1445
|
+
"ogygie_impact",
|
|
1446
|
+
"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.",
|
|
1447
|
+
ImpactInput,
|
|
1448
|
+
async (input) => impact(repoOf(deps.repos, input.repo), input)
|
|
1449
|
+
);
|
|
1119
1450
|
register(
|
|
1120
1451
|
"ogygie_read_feature",
|
|
1121
1452
|
"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.",
|
|
@@ -1164,11 +1495,18 @@ function refuse(response, status, error) {
|
|
|
1164
1495
|
response.writeHead(status, { "content-type": "application/json" });
|
|
1165
1496
|
response.end(JSON.stringify(error));
|
|
1166
1497
|
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1498
|
+
var SECRET_PATH_PREFIX = "/mcp-";
|
|
1499
|
+
function isAuthorized(authorization, expected, requestPath) {
|
|
1500
|
+
if (authorization !== void 0) {
|
|
1501
|
+
const [scheme, value] = authorization.split(" ");
|
|
1502
|
+
if (scheme?.toLowerCase() === "bearer" && value !== void 0) {
|
|
1503
|
+
if (tokenMatches(expected, value)) return true;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
if (requestPath === void 0) return false;
|
|
1507
|
+
const [pathOnly] = requestPath.split("?");
|
|
1508
|
+
if (pathOnly === void 0 || !pathOnly.startsWith(SECRET_PATH_PREFIX)) return false;
|
|
1509
|
+
return tokenMatches(expected, pathOnly.slice(SECRET_PATH_PREFIX.length));
|
|
1172
1510
|
}
|
|
1173
1511
|
async function startHttpTransport(createServerInstance, options) {
|
|
1174
1512
|
const token = assertUsableToken(options.token);
|
|
@@ -1183,7 +1521,7 @@ async function startHttpTransport(createServerInstance, options) {
|
|
|
1183
1521
|
await transport.handleRequest(request, response, body);
|
|
1184
1522
|
};
|
|
1185
1523
|
const httpServer = createServer((request, response) => {
|
|
1186
|
-
if (!isAuthorized(request.headers.authorization, token)) {
|
|
1524
|
+
if (!isAuthorized(request.headers.authorization, token, request.url)) {
|
|
1187
1525
|
refuse(
|
|
1188
1526
|
response,
|
|
1189
1527
|
401,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygie/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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.1"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
35
|
+
"build": "tsx scripts/build.ts"
|
|
36
36
|
}
|
|
37
|
-
}
|
|
37
|
+
}
|