@toolbaux/guardian 0.1.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/LICENSE +21 -0
- package/README.md +366 -0
- package/dist/adapters/csharp-adapter.js +149 -0
- package/dist/adapters/go-adapter.js +96 -0
- package/dist/adapters/index.js +16 -0
- package/dist/adapters/java-adapter.js +122 -0
- package/dist/adapters/python-adapter.js +183 -0
- package/dist/adapters/runner.js +69 -0
- package/dist/adapters/types.js +1 -0
- package/dist/adapters/typescript-adapter.js +179 -0
- package/dist/benchmarking/framework.js +91 -0
- package/dist/cli.js +343 -0
- package/dist/commands/analyze-depth.js +43 -0
- package/dist/commands/api-spec-extractor.js +52 -0
- package/dist/commands/breaking-change-analyzer.js +334 -0
- package/dist/commands/config-compliance.js +219 -0
- package/dist/commands/constraints.js +221 -0
- package/dist/commands/context.js +101 -0
- package/dist/commands/data-flow-tracer.js +291 -0
- package/dist/commands/dependency-impact-analyzer.js +27 -0
- package/dist/commands/diff.js +146 -0
- package/dist/commands/discrepancy.js +71 -0
- package/dist/commands/doc-generate.js +163 -0
- package/dist/commands/doc-html.js +120 -0
- package/dist/commands/drift.js +88 -0
- package/dist/commands/extract.js +16 -0
- package/dist/commands/feature-context.js +116 -0
- package/dist/commands/generate.js +339 -0
- package/dist/commands/guard.js +182 -0
- package/dist/commands/init.js +209 -0
- package/dist/commands/intel.js +20 -0
- package/dist/commands/license-dependency-auditor.js +33 -0
- package/dist/commands/performance-hotspot-profiler.js +42 -0
- package/dist/commands/search.js +314 -0
- package/dist/commands/security-boundary-auditor.js +359 -0
- package/dist/commands/simulate.js +294 -0
- package/dist/commands/summary.js +27 -0
- package/dist/commands/test-coverage-mapper.js +264 -0
- package/dist/commands/verify-drift.js +62 -0
- package/dist/config.js +441 -0
- package/dist/extract/ai-context-hints.js +107 -0
- package/dist/extract/analyzers/backend.js +1704 -0
- package/dist/extract/analyzers/depth.js +264 -0
- package/dist/extract/analyzers/frontend.js +2221 -0
- package/dist/extract/api-usage-tracker.js +19 -0
- package/dist/extract/cache.js +53 -0
- package/dist/extract/codebase-intel.js +190 -0
- package/dist/extract/compress.js +452 -0
- package/dist/extract/context-block.js +356 -0
- package/dist/extract/contracts.js +183 -0
- package/dist/extract/discrepancies.js +233 -0
- package/dist/extract/docs-loader.js +110 -0
- package/dist/extract/docs.js +2379 -0
- package/dist/extract/drift.js +1578 -0
- package/dist/extract/duplicates.js +435 -0
- package/dist/extract/feature-arcs.js +138 -0
- package/dist/extract/graph.js +76 -0
- package/dist/extract/html-doc.js +1409 -0
- package/dist/extract/ignore.js +45 -0
- package/dist/extract/index.js +455 -0
- package/dist/extract/llm-client.js +159 -0
- package/dist/extract/pattern-registry.js +141 -0
- package/dist/extract/product-doc.js +497 -0
- package/dist/extract/python.js +1202 -0
- package/dist/extract/runtime.js +193 -0
- package/dist/extract/schema-evolution-validator.js +35 -0
- package/dist/extract/test-gap-analyzer.js +20 -0
- package/dist/extract/tests.js +74 -0
- package/dist/extract/types.js +1 -0
- package/dist/extract/validate-backend.js +30 -0
- package/dist/extract/writer.js +11 -0
- package/dist/output-layout.js +37 -0
- package/dist/project-discovery.js +309 -0
- package/dist/schema/architecture.js +350 -0
- package/dist/schema/feature-spec.js +89 -0
- package/dist/schema/index.js +8 -0
- package/dist/schema/ux.js +46 -0
- package/package.json +75 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export function createIgnoreMatcher(config, baseRoot) {
|
|
3
|
+
const ignoreDirs = new Set(config.ignore?.directories ?? []);
|
|
4
|
+
const ignorePaths = normalizeIgnorePaths(config.ignore?.paths ?? [], baseRoot);
|
|
5
|
+
const isIgnoredPath = (relativePath) => {
|
|
6
|
+
const normalized = normalizeRelative(relativePath);
|
|
7
|
+
if (!normalized) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
for (const prefix of ignorePaths) {
|
|
11
|
+
if (normalized === prefix || normalized.startsWith(`${prefix}/`)) {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
16
|
+
return segments.some((segment) => ignoreDirs.has(segment));
|
|
17
|
+
};
|
|
18
|
+
const isIgnoredDir = (name, fullPath) => {
|
|
19
|
+
if (name.startsWith(".")) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
if (ignoreDirs.has(name)) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
const relative = path.relative(baseRoot, fullPath);
|
|
26
|
+
return isIgnoredPath(relative);
|
|
27
|
+
};
|
|
28
|
+
return { isIgnoredDir, isIgnoredPath };
|
|
29
|
+
}
|
|
30
|
+
function normalizeIgnorePaths(paths, baseRoot) {
|
|
31
|
+
const normalized = new Set();
|
|
32
|
+
for (const entry of paths) {
|
|
33
|
+
const resolved = path.isAbsolute(entry) ? path.relative(baseRoot, entry) : entry;
|
|
34
|
+
const cleaned = normalizeRelative(resolved);
|
|
35
|
+
if (cleaned) {
|
|
36
|
+
normalized.add(cleaned);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return Array.from(normalized);
|
|
40
|
+
}
|
|
41
|
+
function normalizeRelative(relativePath) {
|
|
42
|
+
const cleaned = relativePath.split(path.sep).join("/");
|
|
43
|
+
const trimmed = cleaned.startsWith("./") ? cleaned.slice(2) : cleaned;
|
|
44
|
+
return trimmed.trim();
|
|
45
|
+
}
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import { analyzeBackend } from "./analyzers/backend.js";
|
|
5
|
+
import { analyzeFrontend } from "./analyzers/frontend.js";
|
|
6
|
+
import { writeSnapshots } from "./writer.js";
|
|
7
|
+
import { writeDocs } from "./docs.js";
|
|
8
|
+
import { analyzeRuntime } from "./runtime.js";
|
|
9
|
+
import { computeDriftReport, buildFunctionGraph } from "./drift.js";
|
|
10
|
+
import { writeCompressionOutputs } from "./compress.js";
|
|
11
|
+
import { buildCrossStackContracts } from "./contracts.js";
|
|
12
|
+
import { validateArchitectureSnapshot, validateUxSnapshot } from "../schema/index.js";
|
|
13
|
+
import { getOutputLayout } from "../output-layout.js";
|
|
14
|
+
import { logResolvedProjectPaths, resolveProjectPaths } from "../project-discovery.js";
|
|
15
|
+
import { analyzeDepth } from "./analyzers/depth.js";
|
|
16
|
+
export async function buildSnapshots(options) {
|
|
17
|
+
const startedAt = Date.now();
|
|
18
|
+
const includeFileGraph = options.includeFileGraph ?? false;
|
|
19
|
+
const resolvedProject = await resolveProjectPaths({
|
|
20
|
+
projectRoot: options.projectRoot,
|
|
21
|
+
backendRoot: options.backendRoot,
|
|
22
|
+
frontendRoot: options.frontendRoot,
|
|
23
|
+
configPath: options.configPath
|
|
24
|
+
});
|
|
25
|
+
const { workspaceRoot, backendRoot: resolvedBackendRoot, frontendRoot: resolvedFrontendRoot } = resolvedProject;
|
|
26
|
+
logResolvedProjectPaths(resolvedProject);
|
|
27
|
+
const reportedBackendRoot = formatOutputPath(resolvedBackendRoot);
|
|
28
|
+
const reportedFrontendRoot = formatOutputPath(resolvedFrontendRoot);
|
|
29
|
+
const reportedWorkspaceRoot = formatOutputPath(workspaceRoot);
|
|
30
|
+
const config = resolvedProject.config;
|
|
31
|
+
const backend = await analyzeBackend(resolvedBackendRoot, config);
|
|
32
|
+
const frontend = await analyzeFrontend(resolvedFrontendRoot, config);
|
|
33
|
+
const projectRoot = workspaceRoot;
|
|
34
|
+
const runtime = await analyzeRuntime(workspaceRoot, config);
|
|
35
|
+
const projectName = deriveProjectName(resolvedBackendRoot);
|
|
36
|
+
const normalizedFrontendCalls = buildFrontendCallIndex(frontend.apiCalls);
|
|
37
|
+
const unusedEndpoints = backend.endpoints
|
|
38
|
+
.filter((endpoint) => !isEndpointUsed(endpoint, normalizedFrontendCalls))
|
|
39
|
+
.map((endpoint) => `${endpoint.method} ${endpoint.path}`)
|
|
40
|
+
.sort((a, b) => a.localeCompare(b));
|
|
41
|
+
const dataFlows = buildDataFlows(frontend.uxPages, backend.endpoints, backend.endpointModelUsage);
|
|
42
|
+
const crossStackContracts = buildCrossStackContracts({
|
|
43
|
+
endpoints: backend.endpoints,
|
|
44
|
+
apiCalls: frontend.apiCalls,
|
|
45
|
+
ux: {
|
|
46
|
+
version: "0.2",
|
|
47
|
+
components: frontend.components,
|
|
48
|
+
component_graph: frontend.componentGraph,
|
|
49
|
+
pages: frontend.uxPages
|
|
50
|
+
},
|
|
51
|
+
dataModels: backend.dataModels
|
|
52
|
+
});
|
|
53
|
+
const endpointTestCoverage = buildEndpointTestCoverage(backend.endpoints, backend.testCoverage);
|
|
54
|
+
const functionTestCoverage = await buildFunctionTestCoverage({
|
|
55
|
+
backendRoot: resolvedBackendRoot,
|
|
56
|
+
modules: backend.modules,
|
|
57
|
+
projectRoot,
|
|
58
|
+
config,
|
|
59
|
+
testCoverage: backend.testCoverage
|
|
60
|
+
});
|
|
61
|
+
const drift = await computeDriftReport({
|
|
62
|
+
backendRoot: resolvedBackendRoot,
|
|
63
|
+
modules: backend.modules,
|
|
64
|
+
moduleGraph: backend.moduleGraph,
|
|
65
|
+
fileGraph: backend.fileGraph,
|
|
66
|
+
circularDependencies: backend.circularDependencies,
|
|
67
|
+
config,
|
|
68
|
+
projectRoot
|
|
69
|
+
});
|
|
70
|
+
const generatedAt = new Date().toISOString();
|
|
71
|
+
const extractedTests = [...backend.tests, ...frontend.tests];
|
|
72
|
+
const architecture = {
|
|
73
|
+
version: "1.0",
|
|
74
|
+
metadata: {
|
|
75
|
+
generated_at: generatedAt,
|
|
76
|
+
duration_ms: Date.now() - startedAt,
|
|
77
|
+
target_backend: reportedBackendRoot,
|
|
78
|
+
target_frontend: reportedFrontendRoot
|
|
79
|
+
},
|
|
80
|
+
project: {
|
|
81
|
+
name: projectName,
|
|
82
|
+
workspace_root: reportedWorkspaceRoot,
|
|
83
|
+
backend_root: reportedBackendRoot,
|
|
84
|
+
frontend_root: reportedFrontendRoot,
|
|
85
|
+
resolution_source: resolvedProject.resolutionSource,
|
|
86
|
+
entrypoints: backend.entrypoints
|
|
87
|
+
},
|
|
88
|
+
modules: backend.modules,
|
|
89
|
+
frontend_files: frontend.files,
|
|
90
|
+
frontend: {
|
|
91
|
+
pages: frontend.pages,
|
|
92
|
+
api_calls: frontend.apiCalls
|
|
93
|
+
},
|
|
94
|
+
endpoints: backend.endpoints,
|
|
95
|
+
data_models: backend.dataModels,
|
|
96
|
+
enums: backend.enums,
|
|
97
|
+
constants: backend.constants,
|
|
98
|
+
endpoint_model_usage: backend.endpointModelUsage,
|
|
99
|
+
cross_stack_contracts: crossStackContracts,
|
|
100
|
+
tasks: backend.tasks,
|
|
101
|
+
runtime,
|
|
102
|
+
data_flows: dataFlows,
|
|
103
|
+
tests: extractedTests,
|
|
104
|
+
dependencies: {
|
|
105
|
+
module_graph: backend.moduleGraph,
|
|
106
|
+
file_graph: includeFileGraph ? dedupeFileGraph([...backend.fileGraph, ...frontend.fileGraph]) : []
|
|
107
|
+
},
|
|
108
|
+
drift,
|
|
109
|
+
analysis: {
|
|
110
|
+
circular_dependencies: backend.circularDependencies,
|
|
111
|
+
orphan_modules: backend.orphanModules,
|
|
112
|
+
orphan_files: backend.orphanFiles,
|
|
113
|
+
frontend_orphan_files: frontend.orphanFiles,
|
|
114
|
+
module_usage: backend.moduleUsage,
|
|
115
|
+
unused_exports: backend.unusedExports,
|
|
116
|
+
frontend_unused_exports: frontend.unusedExports,
|
|
117
|
+
unused_endpoints: unusedEndpoints,
|
|
118
|
+
frontend_unused_api_calls: frontend.apiCalls
|
|
119
|
+
.filter((call) => !backend.endpoints.some((ep) => (ep.method.toUpperCase() === "ANY" || ep.method.toUpperCase() === call.method.toUpperCase()) &&
|
|
120
|
+
normalizePathPattern(ep.path) === normalizePathPattern(call.path)))
|
|
121
|
+
.map((call) => `${call.method} ${call.path}`),
|
|
122
|
+
duplicate_functions: backend.duplicateFunctions,
|
|
123
|
+
similar_functions: backend.similarFunctions,
|
|
124
|
+
test_coverage: backend.testCoverage,
|
|
125
|
+
endpoint_test_coverage: endpointTestCoverage,
|
|
126
|
+
function_test_coverage: functionTestCoverage
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const ux = {
|
|
130
|
+
version: "0.2",
|
|
131
|
+
components: frontend.components,
|
|
132
|
+
component_graph: frontend.componentGraph,
|
|
133
|
+
pages: frontend.uxPages
|
|
134
|
+
};
|
|
135
|
+
validateArchitectureSnapshot(architecture);
|
|
136
|
+
validateUxSnapshot(ux);
|
|
137
|
+
return {
|
|
138
|
+
architecture,
|
|
139
|
+
ux,
|
|
140
|
+
projectRoot,
|
|
141
|
+
config,
|
|
142
|
+
frontendRoot: resolvedFrontendRoot,
|
|
143
|
+
backendRoot: resolvedBackendRoot
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export async function extractProject(options) {
|
|
147
|
+
const layout = getOutputLayout(options.output);
|
|
148
|
+
const previous = await loadPreviousSnapshots(layout.machineDir, layout.rootDir);
|
|
149
|
+
const { architecture, ux, projectRoot, config, backendRoot } = await buildSnapshots(options);
|
|
150
|
+
const docsMode = options.docsMode ?? config.docs?.mode ?? "lean";
|
|
151
|
+
const internalDir = config.docs?.internalDir ?? "internal";
|
|
152
|
+
const result = await writeSnapshots(layout.machineDir, architecture, ux);
|
|
153
|
+
await writeCompressionOutputs({
|
|
154
|
+
outputDir: layout.machineDir,
|
|
155
|
+
architecture,
|
|
156
|
+
ux,
|
|
157
|
+
context: {
|
|
158
|
+
projectRoot,
|
|
159
|
+
backendRoot,
|
|
160
|
+
config
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
await writeDocs(layout.rootDir, architecture, ux, {
|
|
164
|
+
projectRoot,
|
|
165
|
+
driftHistoryPath: config.drift?.historyPath,
|
|
166
|
+
previous,
|
|
167
|
+
docsMode,
|
|
168
|
+
internalDir
|
|
169
|
+
});
|
|
170
|
+
// Generate Structural Intelligence reports for the top backend modules
|
|
171
|
+
const siReports = await generateStructuralIntelligenceReports(architecture);
|
|
172
|
+
if (siReports.length > 0) {
|
|
173
|
+
const siPath = path.join(layout.machineDir, "structural-intelligence.json");
|
|
174
|
+
await fs.writeFile(siPath, JSON.stringify(siReports, null, 2), "utf8");
|
|
175
|
+
console.log(`Wrote ${siPath}`);
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
async function generateStructuralIntelligenceReports(architecture) {
|
|
180
|
+
// Pick top modules by any measure of size: endpoints, files, or imports
|
|
181
|
+
const topModules = architecture.modules
|
|
182
|
+
.filter((m) => m.files.length > 0 || m.imports.length > 0)
|
|
183
|
+
.map((m) => ({
|
|
184
|
+
id: m.id,
|
|
185
|
+
score: m.endpoints.length * 3 + m.files.length + m.imports.length
|
|
186
|
+
}))
|
|
187
|
+
.sort((a, b) => b.score - a.score)
|
|
188
|
+
.slice(0, 8)
|
|
189
|
+
.map((m) => m.id);
|
|
190
|
+
const reports = [];
|
|
191
|
+
for (const query of topModules) {
|
|
192
|
+
try {
|
|
193
|
+
const report = analyzeDepth({
|
|
194
|
+
query,
|
|
195
|
+
modules: architecture.modules,
|
|
196
|
+
moduleGraph: architecture.dependencies.module_graph,
|
|
197
|
+
fileGraph: architecture.dependencies.file_graph,
|
|
198
|
+
circularDependencies: architecture.analysis.circular_dependencies
|
|
199
|
+
});
|
|
200
|
+
// Persist any report with a non-trivial subgraph (at least 1 node matched)
|
|
201
|
+
if (report.structure.nodes > 0) {
|
|
202
|
+
reports.push(report);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Skip modules that error during analysis
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return reports;
|
|
210
|
+
}
|
|
211
|
+
function deriveProjectName(backendRoot) {
|
|
212
|
+
const base = path.basename(path.resolve(backendRoot));
|
|
213
|
+
if (base.toLowerCase() === "backend" || base.toLowerCase() === "src") {
|
|
214
|
+
return path.basename(path.dirname(path.resolve(backendRoot)));
|
|
215
|
+
}
|
|
216
|
+
return base || "unknown";
|
|
217
|
+
}
|
|
218
|
+
function formatOutputPath(targetPath) {
|
|
219
|
+
const relative = path.relative(process.cwd(), targetPath);
|
|
220
|
+
if (!relative) {
|
|
221
|
+
return ".";
|
|
222
|
+
}
|
|
223
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
224
|
+
return targetPath;
|
|
225
|
+
}
|
|
226
|
+
return relative.startsWith(".") ? relative : `./${relative}`;
|
|
227
|
+
}
|
|
228
|
+
function dedupeFileGraph(edges) {
|
|
229
|
+
const seen = new Set();
|
|
230
|
+
const result = [];
|
|
231
|
+
for (const edge of edges) {
|
|
232
|
+
const key = `${edge.from}|${edge.to}`;
|
|
233
|
+
if (seen.has(key)) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
seen.add(key);
|
|
237
|
+
result.push(edge);
|
|
238
|
+
}
|
|
239
|
+
result.sort((a, b) => {
|
|
240
|
+
const from = a.from.localeCompare(b.from);
|
|
241
|
+
if (from !== 0)
|
|
242
|
+
return from;
|
|
243
|
+
return a.to.localeCompare(b.to);
|
|
244
|
+
});
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
function buildEndpointTestCoverage(endpoints, coverage) {
|
|
248
|
+
const coverageMap = new Map();
|
|
249
|
+
for (const entry of coverage.coverage_map) {
|
|
250
|
+
if (!entry.source_file) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const current = coverageMap.get(entry.source_file) ?? [];
|
|
254
|
+
current.push(entry.test_file);
|
|
255
|
+
coverageMap.set(entry.source_file, current);
|
|
256
|
+
}
|
|
257
|
+
return endpoints.map((endpoint) => {
|
|
258
|
+
const tests = coverageMap.get(endpoint.file) ?? [];
|
|
259
|
+
return {
|
|
260
|
+
endpoint: `${endpoint.method} ${endpoint.path}`,
|
|
261
|
+
method: endpoint.method,
|
|
262
|
+
path: endpoint.path,
|
|
263
|
+
file: endpoint.file,
|
|
264
|
+
covered: tests.length > 0,
|
|
265
|
+
coverage_type: tests.length > 0 ? "file" : "none",
|
|
266
|
+
test_files: tests
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async function buildFunctionTestCoverage(params) {
|
|
271
|
+
const { backendRoot, modules, projectRoot, config, testCoverage } = params;
|
|
272
|
+
const requestedScales = config.drift?.scales ?? [];
|
|
273
|
+
const shouldBuild = requestedScales.includes("function") ||
|
|
274
|
+
config.drift?.graphLevel === "function" ||
|
|
275
|
+
config.drift?.graphLevel === "auto";
|
|
276
|
+
if (!shouldBuild) {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
const fnGraph = await buildFunctionGraph({
|
|
280
|
+
backendRoot,
|
|
281
|
+
modules,
|
|
282
|
+
projectRoot
|
|
283
|
+
});
|
|
284
|
+
if (!fnGraph) {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
const coverageMap = new Map();
|
|
288
|
+
for (const entry of testCoverage.coverage_map) {
|
|
289
|
+
if (!entry.source_file) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const current = coverageMap.get(entry.source_file) ?? [];
|
|
293
|
+
current.push(entry.test_file);
|
|
294
|
+
coverageMap.set(entry.source_file, current);
|
|
295
|
+
}
|
|
296
|
+
return fnGraph.nodes.map((node) => {
|
|
297
|
+
const file = node.split("#")[0] ?? node;
|
|
298
|
+
const tests = coverageMap.get(file) ?? [];
|
|
299
|
+
return {
|
|
300
|
+
function_id: node,
|
|
301
|
+
file,
|
|
302
|
+
covered: tests.length > 0,
|
|
303
|
+
coverage_type: tests.length > 0 ? "file" : "none",
|
|
304
|
+
test_files: tests
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
function findCommonRoot(paths) {
|
|
309
|
+
if (paths.length === 0) {
|
|
310
|
+
return process.cwd();
|
|
311
|
+
}
|
|
312
|
+
const splitPaths = paths.map((entry) => path.resolve(entry).split(path.sep));
|
|
313
|
+
const minLength = Math.min(...splitPaths.map((parts) => parts.length));
|
|
314
|
+
const shared = [];
|
|
315
|
+
for (let i = 0; i < minLength; i += 1) {
|
|
316
|
+
const segment = splitPaths[0][i];
|
|
317
|
+
if (splitPaths.every((parts) => parts[i] === segment)) {
|
|
318
|
+
shared.push(segment);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (shared.length === 0) {
|
|
325
|
+
return path.parse(paths[0]).root;
|
|
326
|
+
}
|
|
327
|
+
return shared.join(path.sep);
|
|
328
|
+
}
|
|
329
|
+
async function loadPreviousSnapshots(machineDir, rootDir) {
|
|
330
|
+
const result = {};
|
|
331
|
+
const candidates = [
|
|
332
|
+
{
|
|
333
|
+
archPath: path.join(machineDir, "architecture.snapshot.yaml"),
|
|
334
|
+
uxPath: path.join(machineDir, "ux.snapshot.yaml")
|
|
335
|
+
}
|
|
336
|
+
];
|
|
337
|
+
if (rootDir) {
|
|
338
|
+
candidates.push({
|
|
339
|
+
archPath: path.join(rootDir, "architecture.snapshot.yaml"),
|
|
340
|
+
uxPath: path.join(rootDir, "ux.snapshot.yaml")
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
for (const candidate of candidates) {
|
|
344
|
+
if (!result.architecture) {
|
|
345
|
+
try {
|
|
346
|
+
const raw = await fs.readFile(candidate.archPath, "utf8");
|
|
347
|
+
const parsed = yaml.load(raw);
|
|
348
|
+
if (parsed && typeof parsed === "object") {
|
|
349
|
+
result.architecture = parsed;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// ignore
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (!result.ux) {
|
|
357
|
+
try {
|
|
358
|
+
const raw = await fs.readFile(candidate.uxPath, "utf8");
|
|
359
|
+
const parsed = yaml.load(raw);
|
|
360
|
+
if (parsed && typeof parsed === "object") {
|
|
361
|
+
result.ux = parsed;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
// ignore
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
function normalizePathPattern(value) {
|
|
372
|
+
if (!value) {
|
|
373
|
+
return "/";
|
|
374
|
+
}
|
|
375
|
+
const withoutQuery = value.split("?")[0] ?? value;
|
|
376
|
+
return withoutQuery
|
|
377
|
+
.replace(/\$\{[^}]+\}/g, ":param")
|
|
378
|
+
.replace(/\{[^}]+\}/g, ":param")
|
|
379
|
+
.replace(/<[^>]+>/g, ":param")
|
|
380
|
+
.replace(/:\w+/g, ":param")
|
|
381
|
+
.replace(/\/+/g, "/")
|
|
382
|
+
.replace(/\/$/, "") || "/";
|
|
383
|
+
}
|
|
384
|
+
function normalizeMethod(method) {
|
|
385
|
+
return method ? method.toUpperCase() : "GET";
|
|
386
|
+
}
|
|
387
|
+
function buildFrontendCallIndex(calls) {
|
|
388
|
+
const index = new Map();
|
|
389
|
+
for (const call of calls) {
|
|
390
|
+
const pathKey = normalizePathPattern(call.path);
|
|
391
|
+
const method = normalizeMethod(call.method);
|
|
392
|
+
const entry = index.get(pathKey) ?? new Set();
|
|
393
|
+
entry.add(method);
|
|
394
|
+
index.set(pathKey, entry);
|
|
395
|
+
}
|
|
396
|
+
return index;
|
|
397
|
+
}
|
|
398
|
+
function isEndpointUsed(endpoint, index) {
|
|
399
|
+
const pathKey = normalizePathPattern(endpoint.path);
|
|
400
|
+
const methods = index.get(pathKey);
|
|
401
|
+
if (!methods || methods.size === 0) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
if (endpoint.method.toUpperCase() === "ANY") {
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
return methods.has(endpoint.method.toUpperCase());
|
|
408
|
+
}
|
|
409
|
+
function buildDataFlows(pages, endpoints, endpointUsage) {
|
|
410
|
+
const endpointByKey = new Map();
|
|
411
|
+
for (const endpoint of endpoints) {
|
|
412
|
+
const key = `${normalizeMethod(endpoint.method)} ${normalizePathPattern(endpoint.path)}`;
|
|
413
|
+
const current = endpointByKey.get(key) ?? [];
|
|
414
|
+
current.push(endpoint);
|
|
415
|
+
endpointByKey.set(key, current);
|
|
416
|
+
}
|
|
417
|
+
const modelsByEndpoint = new Map();
|
|
418
|
+
for (const usage of endpointUsage) {
|
|
419
|
+
modelsByEndpoint.set(usage.endpoint_id, usage.models.map((model) => model.name));
|
|
420
|
+
}
|
|
421
|
+
const flows = [];
|
|
422
|
+
const seen = new Set();
|
|
423
|
+
for (const page of pages) {
|
|
424
|
+
for (const call of page.api_calls) {
|
|
425
|
+
const parts = call.split(" ");
|
|
426
|
+
if (parts.length < 2) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const method = normalizeMethod(parts[0]);
|
|
430
|
+
const pathValue = normalizePathPattern(parts.slice(1).join(" "));
|
|
431
|
+
const key = `${method} ${pathValue}`;
|
|
432
|
+
const candidates = endpointByKey.get(key) ?? endpointByKey.get(`ANY ${pathValue}`) ?? [];
|
|
433
|
+
for (const endpoint of candidates) {
|
|
434
|
+
const models = modelsByEndpoint.get(endpoint.id) ?? [];
|
|
435
|
+
const flowKey = `${page.path}|${endpoint.id}|${models.join(",")}`;
|
|
436
|
+
if (seen.has(flowKey)) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
seen.add(flowKey);
|
|
440
|
+
flows.push({
|
|
441
|
+
page: page.path,
|
|
442
|
+
endpoint_id: endpoint.id,
|
|
443
|
+
models
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
flows.sort((a, b) => {
|
|
449
|
+
const pageCmp = a.page.localeCompare(b.page);
|
|
450
|
+
if (pageCmp !== 0)
|
|
451
|
+
return pageCmp;
|
|
452
|
+
return a.endpoint_id.localeCompare(b.endpoint_id);
|
|
453
|
+
});
|
|
454
|
+
return flows;
|
|
455
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Client — thin HTTP client for LLM-powered doc generation.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order:
|
|
5
|
+
* 1. Configured LLM — SPECGUARD_LLM_ENDPOINT + SPECGUARD_LLM_API_KEY both set
|
|
6
|
+
* 2. Ollama fallback — SPECGUARD_LLM_ENDPOINT/KEY not set, Ollama reachable at localhost
|
|
7
|
+
* 3. None — returns null from loadLlmConfig(), callers write placeholder text
|
|
8
|
+
*
|
|
9
|
+
* Env vars:
|
|
10
|
+
* SPECGUARD_LLM_ENDPOINT — full URL e.g. https://api.anthropic.com/v1/messages
|
|
11
|
+
* SPECGUARD_LLM_API_KEY — API key (not required for Ollama)
|
|
12
|
+
* SPECGUARD_LLM_MODEL — model ID (optional)
|
|
13
|
+
* SPECGUARD_OLLAMA_HOST — Ollama base URL (default: http://localhost:11434)
|
|
14
|
+
* SPECGUARD_OLLAMA_MODEL — Ollama model (default: llama3.2)
|
|
15
|
+
*
|
|
16
|
+
* Wire formats (auto-detected from endpoint URL):
|
|
17
|
+
* anthropic — POST /v1/messages { model, max_tokens, system, messages }
|
|
18
|
+
* openai — POST /v1/chat/completions { model, max_tokens, messages }
|
|
19
|
+
* ollama — POST /api/chat { model, stream:false, messages }
|
|
20
|
+
*
|
|
21
|
+
* No SDK dependency. Pure fetch.
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_CLOUD_MODEL = "claude-haiku-4-5-20251001";
|
|
24
|
+
const DEFAULT_OLLAMA_MODEL = "llama3.2";
|
|
25
|
+
const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
|
|
26
|
+
const DEFAULT_MAX_TOKENS = 2048;
|
|
27
|
+
/**
|
|
28
|
+
* Load LLM config from environment variables.
|
|
29
|
+
*
|
|
30
|
+
* Resolution order:
|
|
31
|
+
* 1. SPECGUARD_LLM_ENDPOINT + SPECGUARD_LLM_API_KEY → configured cloud/local LLM
|
|
32
|
+
* 2. Ollama reachable at SPECGUARD_OLLAMA_HOST (or localhost:11434) → Ollama fallback
|
|
33
|
+
* 3. null → no LLM available
|
|
34
|
+
*/
|
|
35
|
+
export async function loadLlmConfig() {
|
|
36
|
+
// Priority 1: explicit endpoint + key
|
|
37
|
+
const endpoint = process.env["SPECGUARD_LLM_ENDPOINT"];
|
|
38
|
+
const apiKey = process.env["SPECGUARD_LLM_API_KEY"];
|
|
39
|
+
if (endpoint && apiKey) {
|
|
40
|
+
const model = process.env["SPECGUARD_LLM_MODEL"] ?? DEFAULT_CLOUD_MODEL;
|
|
41
|
+
return { endpoint, apiKey, model, provider: detectProvider(endpoint) };
|
|
42
|
+
}
|
|
43
|
+
// Priority 2: Ollama fallback
|
|
44
|
+
const ollamaHost = process.env["SPECGUARD_OLLAMA_HOST"] ?? DEFAULT_OLLAMA_HOST;
|
|
45
|
+
const ollamaModel = process.env["SPECGUARD_OLLAMA_MODEL"] ?? DEFAULT_OLLAMA_MODEL;
|
|
46
|
+
if (await isOllamaReachable(ollamaHost)) {
|
|
47
|
+
return {
|
|
48
|
+
endpoint: `${ollamaHost}/api/chat`,
|
|
49
|
+
apiKey: "",
|
|
50
|
+
model: ollamaModel,
|
|
51
|
+
provider: "ollama",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Synchronous version — only checks env vars, does NOT probe Ollama.
|
|
58
|
+
* Use when async is not possible, or when you want to skip Ollama discovery.
|
|
59
|
+
*/
|
|
60
|
+
export function loadLlmConfigSync() {
|
|
61
|
+
const endpoint = process.env["SPECGUARD_LLM_ENDPOINT"];
|
|
62
|
+
const apiKey = process.env["SPECGUARD_LLM_API_KEY"];
|
|
63
|
+
if (!endpoint || !apiKey)
|
|
64
|
+
return null;
|
|
65
|
+
const model = process.env["SPECGUARD_LLM_MODEL"] ?? DEFAULT_CLOUD_MODEL;
|
|
66
|
+
return { endpoint, apiKey, model, provider: detectProvider(endpoint) };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Send messages to the LLM and return the response text.
|
|
70
|
+
* Throws on HTTP errors.
|
|
71
|
+
*/
|
|
72
|
+
export async function llmComplete(config, messages, maxTokens = DEFAULT_MAX_TOKENS) {
|
|
73
|
+
const headers = {
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
};
|
|
76
|
+
let body;
|
|
77
|
+
if (config.provider === "anthropic") {
|
|
78
|
+
headers["x-api-key"] = config.apiKey;
|
|
79
|
+
headers["anthropic-version"] = "2023-06-01";
|
|
80
|
+
body = buildAnthropicBody(config.model, messages, maxTokens);
|
|
81
|
+
}
|
|
82
|
+
else if (config.provider === "ollama") {
|
|
83
|
+
// Ollama: no auth header needed
|
|
84
|
+
body = buildOllamaBody(config.model, messages);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// openai-compatible
|
|
88
|
+
if (config.apiKey)
|
|
89
|
+
headers["Authorization"] = `Bearer ${config.apiKey}`;
|
|
90
|
+
body = buildOpenAIBody(config.model, messages, maxTokens);
|
|
91
|
+
}
|
|
92
|
+
const response = await fetch(config.endpoint, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers,
|
|
95
|
+
body: JSON.stringify(body),
|
|
96
|
+
});
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
const text = await response.text().catch(() => "(no body)");
|
|
99
|
+
throw new Error(`LLM request failed [${config.provider}]: ${response.status} ${response.statusText} — ${text}`);
|
|
100
|
+
}
|
|
101
|
+
const data = await response.json();
|
|
102
|
+
return extractText(config.provider, data);
|
|
103
|
+
}
|
|
104
|
+
// ── Format detection ──────────────────────────────────────────────────────
|
|
105
|
+
function detectProvider(endpoint) {
|
|
106
|
+
const lower = endpoint.toLowerCase();
|
|
107
|
+
if (lower.includes("anthropic") || lower.includes("/v1/messages"))
|
|
108
|
+
return "anthropic";
|
|
109
|
+
if (lower.includes("/api/chat") || lower.includes("11434"))
|
|
110
|
+
return "ollama";
|
|
111
|
+
return "openai";
|
|
112
|
+
}
|
|
113
|
+
// ── Body builders ─────────────────────────────────────────────────────────
|
|
114
|
+
function buildAnthropicBody(model, messages, maxTokens) {
|
|
115
|
+
const systemMessages = messages.filter((m) => m.role === "system");
|
|
116
|
+
const nonSystemMessages = messages.filter((m) => m.role !== "system");
|
|
117
|
+
const systemText = systemMessages.map((m) => m.content).join("\n\n");
|
|
118
|
+
const body = {
|
|
119
|
+
model,
|
|
120
|
+
max_tokens: maxTokens,
|
|
121
|
+
messages: nonSystemMessages,
|
|
122
|
+
};
|
|
123
|
+
if (systemText)
|
|
124
|
+
body["system"] = systemText;
|
|
125
|
+
return body;
|
|
126
|
+
}
|
|
127
|
+
function buildOpenAIBody(model, messages, maxTokens) {
|
|
128
|
+
return { model, max_tokens: maxTokens, messages };
|
|
129
|
+
}
|
|
130
|
+
function buildOllamaBody(model, messages) {
|
|
131
|
+
return { model, stream: false, messages };
|
|
132
|
+
}
|
|
133
|
+
// ── Response extraction ───────────────────────────────────────────────────
|
|
134
|
+
function extractText(provider, data) {
|
|
135
|
+
if (provider === "anthropic") {
|
|
136
|
+
const content = data["content"] ?? [];
|
|
137
|
+
return content.find((c) => c.type === "text")?.text ?? "";
|
|
138
|
+
}
|
|
139
|
+
if (provider === "ollama") {
|
|
140
|
+
const message = data["message"];
|
|
141
|
+
return message?.content ?? "";
|
|
142
|
+
}
|
|
143
|
+
// openai
|
|
144
|
+
const choices = data["choices"] ?? [];
|
|
145
|
+
return choices[0]?.message?.content ?? "";
|
|
146
|
+
}
|
|
147
|
+
// ── Ollama availability probe ─────────────────────────────────────────────
|
|
148
|
+
async function isOllamaReachable(host) {
|
|
149
|
+
try {
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
152
|
+
const res = await fetch(`${host}/api/tags`, { signal: controller.signal });
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
return res.ok;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|