@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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -0
  3. package/dist/adapters/csharp-adapter.js +149 -0
  4. package/dist/adapters/go-adapter.js +96 -0
  5. package/dist/adapters/index.js +16 -0
  6. package/dist/adapters/java-adapter.js +122 -0
  7. package/dist/adapters/python-adapter.js +183 -0
  8. package/dist/adapters/runner.js +69 -0
  9. package/dist/adapters/types.js +1 -0
  10. package/dist/adapters/typescript-adapter.js +179 -0
  11. package/dist/benchmarking/framework.js +91 -0
  12. package/dist/cli.js +343 -0
  13. package/dist/commands/analyze-depth.js +43 -0
  14. package/dist/commands/api-spec-extractor.js +52 -0
  15. package/dist/commands/breaking-change-analyzer.js +334 -0
  16. package/dist/commands/config-compliance.js +219 -0
  17. package/dist/commands/constraints.js +221 -0
  18. package/dist/commands/context.js +101 -0
  19. package/dist/commands/data-flow-tracer.js +291 -0
  20. package/dist/commands/dependency-impact-analyzer.js +27 -0
  21. package/dist/commands/diff.js +146 -0
  22. package/dist/commands/discrepancy.js +71 -0
  23. package/dist/commands/doc-generate.js +163 -0
  24. package/dist/commands/doc-html.js +120 -0
  25. package/dist/commands/drift.js +88 -0
  26. package/dist/commands/extract.js +16 -0
  27. package/dist/commands/feature-context.js +116 -0
  28. package/dist/commands/generate.js +339 -0
  29. package/dist/commands/guard.js +182 -0
  30. package/dist/commands/init.js +209 -0
  31. package/dist/commands/intel.js +20 -0
  32. package/dist/commands/license-dependency-auditor.js +33 -0
  33. package/dist/commands/performance-hotspot-profiler.js +42 -0
  34. package/dist/commands/search.js +314 -0
  35. package/dist/commands/security-boundary-auditor.js +359 -0
  36. package/dist/commands/simulate.js +294 -0
  37. package/dist/commands/summary.js +27 -0
  38. package/dist/commands/test-coverage-mapper.js +264 -0
  39. package/dist/commands/verify-drift.js +62 -0
  40. package/dist/config.js +441 -0
  41. package/dist/extract/ai-context-hints.js +107 -0
  42. package/dist/extract/analyzers/backend.js +1704 -0
  43. package/dist/extract/analyzers/depth.js +264 -0
  44. package/dist/extract/analyzers/frontend.js +2221 -0
  45. package/dist/extract/api-usage-tracker.js +19 -0
  46. package/dist/extract/cache.js +53 -0
  47. package/dist/extract/codebase-intel.js +190 -0
  48. package/dist/extract/compress.js +452 -0
  49. package/dist/extract/context-block.js +356 -0
  50. package/dist/extract/contracts.js +183 -0
  51. package/dist/extract/discrepancies.js +233 -0
  52. package/dist/extract/docs-loader.js +110 -0
  53. package/dist/extract/docs.js +2379 -0
  54. package/dist/extract/drift.js +1578 -0
  55. package/dist/extract/duplicates.js +435 -0
  56. package/dist/extract/feature-arcs.js +138 -0
  57. package/dist/extract/graph.js +76 -0
  58. package/dist/extract/html-doc.js +1409 -0
  59. package/dist/extract/ignore.js +45 -0
  60. package/dist/extract/index.js +455 -0
  61. package/dist/extract/llm-client.js +159 -0
  62. package/dist/extract/pattern-registry.js +141 -0
  63. package/dist/extract/product-doc.js +497 -0
  64. package/dist/extract/python.js +1202 -0
  65. package/dist/extract/runtime.js +193 -0
  66. package/dist/extract/schema-evolution-validator.js +35 -0
  67. package/dist/extract/test-gap-analyzer.js +20 -0
  68. package/dist/extract/tests.js +74 -0
  69. package/dist/extract/types.js +1 -0
  70. package/dist/extract/validate-backend.js +30 -0
  71. package/dist/extract/writer.js +11 -0
  72. package/dist/output-layout.js +37 -0
  73. package/dist/project-discovery.js +309 -0
  74. package/dist/schema/architecture.js +350 -0
  75. package/dist/schema/feature-spec.js +89 -0
  76. package/dist/schema/index.js +8 -0
  77. package/dist/schema/ux.js +46 -0
  78. 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
+ }