@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,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `guardian init` — initialize guardian for a project.
|
|
3
|
+
*
|
|
4
|
+
* Creates:
|
|
5
|
+
* 1. guardian.config.json (if missing)
|
|
6
|
+
* 2. .specs/ directory
|
|
7
|
+
* 3. Pre-commit hook that auto-runs extract + context injection
|
|
8
|
+
* 4. Injects guardian context block into CLAUDE.md
|
|
9
|
+
* 5. Adds .specs/ to .gitignore exclusion (tracked by default)
|
|
10
|
+
*
|
|
11
|
+
* Safe to run multiple times — only creates what's missing.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
const DEFAULT_CONFIG = {
|
|
16
|
+
project: {
|
|
17
|
+
backendRoot: "./backend",
|
|
18
|
+
frontendRoot: "./frontend",
|
|
19
|
+
},
|
|
20
|
+
docs: {
|
|
21
|
+
mode: "full",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const HOOK_SCRIPT = `#!/bin/sh
|
|
25
|
+
# guardian pre-commit hook — keeps architecture context fresh
|
|
26
|
+
# Installed by: guardian init
|
|
27
|
+
|
|
28
|
+
# Only run if guardian is available
|
|
29
|
+
if ! command -v guardian >/dev/null 2>&1; then
|
|
30
|
+
exit 0
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Run extract + ai-context generation (fast, ~2-5s)
|
|
34
|
+
guardian extract --output .specs 2>/dev/null
|
|
35
|
+
guardian generate --ai-context --output .specs 2>/dev/null
|
|
36
|
+
|
|
37
|
+
# Inject context into CLAUDE.md if it exists
|
|
38
|
+
if [ -f CLAUDE.md ]; then
|
|
39
|
+
guardian context --input .specs --output CLAUDE.md 2>/dev/null
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# Auto-stage the updated files
|
|
43
|
+
git add .specs/machine/architecture-context.md 2>/dev/null
|
|
44
|
+
git add CLAUDE.md 2>/dev/null
|
|
45
|
+
|
|
46
|
+
exit 0
|
|
47
|
+
`;
|
|
48
|
+
export async function runInit(options) {
|
|
49
|
+
const root = path.resolve(options.projectRoot || process.cwd());
|
|
50
|
+
const specsDir = path.join(root, options.output || ".specs");
|
|
51
|
+
console.log(`Initializing Guardian in ${root}\n`);
|
|
52
|
+
// 1. Create .specs/ directory
|
|
53
|
+
await fs.mkdir(path.join(specsDir, "machine", "docs"), { recursive: true });
|
|
54
|
+
await fs.mkdir(path.join(specsDir, "human", "docs"), { recursive: true });
|
|
55
|
+
console.log(" ✓ Created .specs/ directory");
|
|
56
|
+
// 2. Create guardian.config.json if missing
|
|
57
|
+
const configPath = path.join(root, "guardian.config.json");
|
|
58
|
+
if (!(await fileExists(configPath))) {
|
|
59
|
+
const config = { ...DEFAULT_CONFIG };
|
|
60
|
+
// Auto-detect roots
|
|
61
|
+
if (options.backendRoot) {
|
|
62
|
+
config.project.backendRoot = options.backendRoot;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
for (const candidate of ["./backend", "./server", "./api", "./src"]) {
|
|
66
|
+
if (await dirExists(path.join(root, candidate))) {
|
|
67
|
+
config.project.backendRoot = candidate;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (options.frontendRoot) {
|
|
73
|
+
config.project.frontendRoot = options.frontendRoot;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
for (const candidate of ["./frontend", "./client", "./web", "./app"]) {
|
|
77
|
+
if (await dirExists(path.join(root, candidate))) {
|
|
78
|
+
config.project.frontendRoot = candidate;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
84
|
+
console.log(` ✓ Created guardian.config.json (backend: ${config.project.backendRoot}, frontend: ${config.project.frontendRoot})`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
console.log(" · guardian.config.json already exists");
|
|
88
|
+
}
|
|
89
|
+
// 3. Install pre-commit hook
|
|
90
|
+
if (!options.skipHook) {
|
|
91
|
+
const gitDir = path.join(root, ".git");
|
|
92
|
+
if (await dirExists(gitDir)) {
|
|
93
|
+
const hooksDir = path.join(gitDir, "hooks");
|
|
94
|
+
await fs.mkdir(hooksDir, { recursive: true });
|
|
95
|
+
const hookPath = path.join(hooksDir, "pre-commit");
|
|
96
|
+
let shouldInstall = true;
|
|
97
|
+
if (await fileExists(hookPath)) {
|
|
98
|
+
const existing = await fs.readFile(hookPath, "utf8");
|
|
99
|
+
if (existing.includes("guardian")) {
|
|
100
|
+
console.log(" · Pre-commit hook already has guardian");
|
|
101
|
+
shouldInstall = false;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
// Append to existing hook
|
|
105
|
+
const appended = existing.trimEnd() + "\n\n" + HOOK_SCRIPT.split("\n").slice(1).join("\n");
|
|
106
|
+
await fs.writeFile(hookPath, appended, "utf8");
|
|
107
|
+
await fs.chmod(hookPath, 0o755);
|
|
108
|
+
console.log(" ✓ Appended guardian to existing pre-commit hook");
|
|
109
|
+
shouldInstall = false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (shouldInstall) {
|
|
113
|
+
await fs.writeFile(hookPath, HOOK_SCRIPT, "utf8");
|
|
114
|
+
await fs.chmod(hookPath, 0o755);
|
|
115
|
+
console.log(" ✓ Installed pre-commit hook (.git/hooks/pre-commit)");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
console.log(" · No .git directory found — skipping hook installation");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// 4. Inject context block into CLAUDE.md
|
|
123
|
+
const claudeMdPath = path.join(root, "CLAUDE.md");
|
|
124
|
+
if (await fileExists(claudeMdPath)) {
|
|
125
|
+
const existing = await fs.readFile(claudeMdPath, "utf8");
|
|
126
|
+
if (!existing.includes("guardian:auto-context")) {
|
|
127
|
+
const block = [
|
|
128
|
+
"",
|
|
129
|
+
"<!-- guardian:auto-context -->",
|
|
130
|
+
"<!-- This block is auto-updated by guardian. Do not edit manually. -->",
|
|
131
|
+
`<!-- Run: guardian extract --output .specs && guardian context --input .specs --output CLAUDE.md -->`,
|
|
132
|
+
"<!-- /guardian:auto-context -->",
|
|
133
|
+
"",
|
|
134
|
+
].join("\n");
|
|
135
|
+
await fs.writeFile(claudeMdPath, existing.trimEnd() + "\n" + block, "utf8");
|
|
136
|
+
console.log(" ✓ Added guardian placeholder to CLAUDE.md");
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
console.log(" · CLAUDE.md already has guardian context block");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// Create minimal CLAUDE.md
|
|
144
|
+
const projectName = path.basename(root);
|
|
145
|
+
const content = [
|
|
146
|
+
`# ${projectName}`,
|
|
147
|
+
"",
|
|
148
|
+
"## SpecGuard Architecture Context",
|
|
149
|
+
"",
|
|
150
|
+
"<!-- guardian:auto-context -->",
|
|
151
|
+
"<!-- This block is auto-updated by guardian. Do not edit manually. -->",
|
|
152
|
+
`<!-- Run: guardian extract --output .specs && guardian context --input .specs --output CLAUDE.md -->`,
|
|
153
|
+
"<!-- /guardian:auto-context -->",
|
|
154
|
+
"",
|
|
155
|
+
].join("\n");
|
|
156
|
+
await fs.writeFile(claudeMdPath, content, "utf8");
|
|
157
|
+
console.log(" ✓ Created CLAUDE.md with guardian context block");
|
|
158
|
+
}
|
|
159
|
+
// 5. Run initial extract + context injection
|
|
160
|
+
console.log("\n Running initial extraction...");
|
|
161
|
+
try {
|
|
162
|
+
const { runExtract } = await import("./extract.js");
|
|
163
|
+
await runExtract({
|
|
164
|
+
projectRoot: root,
|
|
165
|
+
backendRoot: options.backendRoot,
|
|
166
|
+
frontendRoot: options.frontendRoot,
|
|
167
|
+
output: specsDir,
|
|
168
|
+
includeFileGraph: true,
|
|
169
|
+
});
|
|
170
|
+
const { runGenerate } = await import("./generate.js");
|
|
171
|
+
await runGenerate({
|
|
172
|
+
projectRoot: root,
|
|
173
|
+
backendRoot: options.backendRoot,
|
|
174
|
+
frontendRoot: options.frontendRoot,
|
|
175
|
+
output: specsDir,
|
|
176
|
+
aiContext: true,
|
|
177
|
+
});
|
|
178
|
+
// Inject context into CLAUDE.md
|
|
179
|
+
const { runContext } = await import("./context.js");
|
|
180
|
+
await runContext({
|
|
181
|
+
input: specsDir,
|
|
182
|
+
output: claudeMdPath,
|
|
183
|
+
});
|
|
184
|
+
console.log("\n✓ SpecGuard initialized. Architecture context is in CLAUDE.md and .specs/");
|
|
185
|
+
console.log(" Pre-commit hook will keep it fresh on every commit.");
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
console.error(`\n ⚠ Initial extraction failed: ${err.message}`);
|
|
189
|
+
console.log(" Run manually: guardian extract --output .specs");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function fileExists(p) {
|
|
193
|
+
try {
|
|
194
|
+
const stat = await fs.stat(p);
|
|
195
|
+
return stat.isFile();
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function dirExists(p) {
|
|
202
|
+
try {
|
|
203
|
+
const stat = await fs.stat(p);
|
|
204
|
+
return stat.isDirectory();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `specguard intel` — build codebase-intelligence.json from existing snapshots.
|
|
3
|
+
*
|
|
4
|
+
* Reads: specs-out/machine/architecture.snapshot.yaml + ux.snapshot.yaml
|
|
5
|
+
* Writes: specs-out/machine/codebase-intelligence.json
|
|
6
|
+
*
|
|
7
|
+
* Also auto-runs at the end of `specguard extract`.
|
|
8
|
+
*/
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { writeCodebaseIntelligence } from "../extract/codebase-intel.js";
|
|
11
|
+
import { getOutputLayout } from "../output-layout.js";
|
|
12
|
+
export async function runIntel(options) {
|
|
13
|
+
const specsDir = path.resolve(options.specs);
|
|
14
|
+
const layout = getOutputLayout(specsDir);
|
|
15
|
+
const outputPath = options.output
|
|
16
|
+
? path.resolve(options.output)
|
|
17
|
+
: path.join(layout.machineDir, "codebase-intelligence.json");
|
|
18
|
+
await writeCodebaseIntelligence(specsDir, outputPath);
|
|
19
|
+
console.log(`Wrote ${outputPath}`);
|
|
20
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export async function run() {
|
|
4
|
+
const root = process.cwd();
|
|
5
|
+
const pkgPath = path.join(root, 'package.json');
|
|
6
|
+
let pkg = {};
|
|
7
|
+
try {
|
|
8
|
+
pkg = JSON.parse(await fs.promises.readFile(pkgPath, 'utf8'));
|
|
9
|
+
}
|
|
10
|
+
catch (_) { }
|
|
11
|
+
const deps = Object.keys(Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {}));
|
|
12
|
+
const results = [];
|
|
13
|
+
for (const d of deps) {
|
|
14
|
+
try {
|
|
15
|
+
const p = path.join(root, 'node_modules', d, 'package.json');
|
|
16
|
+
const text = await fs.promises.readFile(p, 'utf8');
|
|
17
|
+
const pj = JSON.parse(text);
|
|
18
|
+
results.push({ name: d, license: pj.license || pj.licenses && pj.licenses[0] && pj.licenses[0].type });
|
|
19
|
+
}
|
|
20
|
+
catch (_) {
|
|
21
|
+
results.push({ name: d, license: 'UNKNOWN' });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const out = path.join(root, 'out', 'license-audit.json');
|
|
25
|
+
await fs.promises.mkdir(path.dirname(out), { recursive: true });
|
|
26
|
+
await fs.promises.writeFile(out, JSON.stringify(results, null, 2), 'utf8');
|
|
27
|
+
console.log('Wrote license audit to', out);
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
const _isMain = (typeof require !== 'undefined' && (require.main === module)) || (process.argv[1] && process.argv[1].endsWith('license-dependency-auditor.ts'));
|
|
31
|
+
if (_isMain) {
|
|
32
|
+
run().catch(err => { console.error(err); process.exit(1); });
|
|
33
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
async function readDirRecursive(dir) {
|
|
4
|
+
try {
|
|
5
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
6
|
+
const files = [];
|
|
7
|
+
for (const e of entries) {
|
|
8
|
+
const res = path.join(dir, e.name);
|
|
9
|
+
if (e.isDirectory())
|
|
10
|
+
files.push(...await readDirRecursive(res));
|
|
11
|
+
else
|
|
12
|
+
files.push(res);
|
|
13
|
+
}
|
|
14
|
+
return files;
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function run() {
|
|
21
|
+
const root = process.cwd();
|
|
22
|
+
const srcDir = path.join(root, 'src');
|
|
23
|
+
const files = await readDirRecursive(srcDir);
|
|
24
|
+
const candidates = [];
|
|
25
|
+
for (const f of files) {
|
|
26
|
+
if (!f.endsWith('.ts') && !f.endsWith('.js'))
|
|
27
|
+
continue;
|
|
28
|
+
const text = await fs.promises.readFile(f, 'utf8');
|
|
29
|
+
const ln = text.split('\n').length;
|
|
30
|
+
if (ln > 200)
|
|
31
|
+
candidates.push({ file: path.relative(root, f), lines: ln });
|
|
32
|
+
}
|
|
33
|
+
const out = path.join(root, 'out', 'perf-hotspots.json');
|
|
34
|
+
await fs.promises.mkdir(path.dirname(out), { recursive: true });
|
|
35
|
+
await fs.promises.writeFile(out, JSON.stringify(candidates, null, 2), 'utf8');
|
|
36
|
+
console.log('Wrote performance hotspots to', out);
|
|
37
|
+
return candidates;
|
|
38
|
+
}
|
|
39
|
+
const _isMain = (typeof require !== 'undefined' && (require.main === module)) || (process.argv[1] && process.argv[1].endsWith('performance-hotspot-profiler.ts'));
|
|
40
|
+
if (_isMain) {
|
|
41
|
+
run().catch(err => { console.error(err); process.exit(1); });
|
|
42
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import { loadHeatmap } from "../extract/compress.js";
|
|
5
|
+
import { resolveMachineInputDir } from "../output-layout.js";
|
|
6
|
+
export async function runSearch(options) {
|
|
7
|
+
const inputDir = await resolveMachineInputDir(options.input || "specs-out");
|
|
8
|
+
const { architecture, ux } = await loadSnapshots(inputDir);
|
|
9
|
+
const heatmap = await loadHeatmap(inputDir);
|
|
10
|
+
const types = normalizeTypes(options.types);
|
|
11
|
+
const matches = searchSnapshots({
|
|
12
|
+
architecture,
|
|
13
|
+
ux,
|
|
14
|
+
query: options.query,
|
|
15
|
+
types,
|
|
16
|
+
heatmap
|
|
17
|
+
});
|
|
18
|
+
const content = renderSearchMarkdown(options.query, matches);
|
|
19
|
+
if (options.output) {
|
|
20
|
+
const outputPath = path.resolve(options.output);
|
|
21
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
22
|
+
await fs.writeFile(outputPath, content, "utf8");
|
|
23
|
+
console.log(`Wrote ${outputPath}`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
console.log(content);
|
|
27
|
+
}
|
|
28
|
+
async function loadSnapshots(inputDir) {
|
|
29
|
+
const architecturePath = path.join(inputDir, "architecture.snapshot.yaml");
|
|
30
|
+
const uxPath = path.join(inputDir, "ux.snapshot.yaml");
|
|
31
|
+
let architectureRaw;
|
|
32
|
+
let uxRaw;
|
|
33
|
+
try {
|
|
34
|
+
[architectureRaw, uxRaw] = await Promise.all([
|
|
35
|
+
fs.readFile(architecturePath, "utf8"),
|
|
36
|
+
fs.readFile(uxPath, "utf8")
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code === "ENOENT") {
|
|
41
|
+
throw new Error(`Could not find snapshots in ${inputDir}. Run \`specguard extract\` first.`);
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
architecture: yaml.load(architectureRaw),
|
|
47
|
+
ux: yaml.load(uxRaw)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function normalizeTypes(types) {
|
|
51
|
+
if (!types || types.length === 0) {
|
|
52
|
+
return new Set(["models", "endpoints", "components", "modules", "tasks"]);
|
|
53
|
+
}
|
|
54
|
+
const normalized = new Set();
|
|
55
|
+
for (const entry of types) {
|
|
56
|
+
for (const part of entry.split(",").map((value) => value.trim().toLowerCase())) {
|
|
57
|
+
if (part === "models" ||
|
|
58
|
+
part === "endpoints" ||
|
|
59
|
+
part === "components" ||
|
|
60
|
+
part === "modules" ||
|
|
61
|
+
part === "tasks") {
|
|
62
|
+
normalized.add(part);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return normalized.size > 0
|
|
67
|
+
? normalized
|
|
68
|
+
: new Set(["models", "endpoints", "components", "modules", "tasks"]);
|
|
69
|
+
}
|
|
70
|
+
function tokenize(value) {
|
|
71
|
+
return value
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.split(/[^a-z0-9_/.{}-]+/)
|
|
74
|
+
.map((token) => token.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
function scoreItem(queryTokens, item) {
|
|
78
|
+
if (queryTokens.length === 0) {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
const normalizedName = item.name.toLowerCase();
|
|
82
|
+
const normalizedFile = (item.file ?? "").toLowerCase();
|
|
83
|
+
const normalizedText = item.text
|
|
84
|
+
.filter((entry) => typeof entry === "string")
|
|
85
|
+
.map((entry) => entry.toLowerCase());
|
|
86
|
+
let total = 0;
|
|
87
|
+
for (const token of queryTokens) {
|
|
88
|
+
if (normalizedName === token) {
|
|
89
|
+
total += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (normalizedName.includes(token)) {
|
|
93
|
+
total += 0.7;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (normalizedText.some((entry) => entry.includes(token))) {
|
|
97
|
+
total += 0.4;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (normalizedFile.includes(token)) {
|
|
101
|
+
total += 0.2;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const phrase = queryTokens.join(" ");
|
|
106
|
+
if (phrase && normalizedName.includes(phrase)) {
|
|
107
|
+
total += 0.15;
|
|
108
|
+
}
|
|
109
|
+
return Math.min(1, total / queryTokens.length);
|
|
110
|
+
}
|
|
111
|
+
function searchSnapshots(params) {
|
|
112
|
+
const { architecture, ux, query, types, heatmap } = params;
|
|
113
|
+
const queryTokens = tokenize(query);
|
|
114
|
+
const matches = [];
|
|
115
|
+
const pageUsage = buildComponentPageUsage(ux);
|
|
116
|
+
const moduleHeatmap = new Map((heatmap?.levels.find((level) => level.level === "module")?.entries ?? []).map((entry) => [
|
|
117
|
+
entry.id,
|
|
118
|
+
entry.score
|
|
119
|
+
]));
|
|
120
|
+
if (types.has("models")) {
|
|
121
|
+
for (const model of architecture.data_models) {
|
|
122
|
+
const score = scoreItem(queryTokens, {
|
|
123
|
+
name: model.name,
|
|
124
|
+
file: model.file,
|
|
125
|
+
text: [...model.fields, ...model.relationships, model.framework]
|
|
126
|
+
});
|
|
127
|
+
if (score <= 0) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
matches.push({
|
|
131
|
+
type: "models",
|
|
132
|
+
name: model.name,
|
|
133
|
+
score,
|
|
134
|
+
markdown: [
|
|
135
|
+
`**${model.name}** · ${model.file}`,
|
|
136
|
+
`Fields: ${formatList(model.fields, 8)}`,
|
|
137
|
+
`Relations: ${formatList(model.relationships, 8)}`
|
|
138
|
+
]
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (types.has("endpoints")) {
|
|
143
|
+
for (const endpoint of architecture.endpoints) {
|
|
144
|
+
const score = scoreItem(queryTokens, {
|
|
145
|
+
name: `${endpoint.method} ${endpoint.path}`,
|
|
146
|
+
file: endpoint.file,
|
|
147
|
+
text: [
|
|
148
|
+
endpoint.handler,
|
|
149
|
+
endpoint.module,
|
|
150
|
+
endpoint.request_schema ?? "",
|
|
151
|
+
endpoint.response_schema ?? "",
|
|
152
|
+
...endpoint.service_calls
|
|
153
|
+
]
|
|
154
|
+
});
|
|
155
|
+
if (score <= 0) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
matches.push({
|
|
159
|
+
type: "endpoints",
|
|
160
|
+
name: `${endpoint.method} ${endpoint.path}`,
|
|
161
|
+
score,
|
|
162
|
+
markdown: [
|
|
163
|
+
`${endpoint.method.padEnd(4, " ")} ${endpoint.path} → ${endpoint.handler} (${endpoint.file})`,
|
|
164
|
+
`Module: ${endpoint.module} · Request: ${endpoint.request_schema ?? "none"} · Response: ${endpoint.response_schema ?? "none"}`
|
|
165
|
+
]
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (types.has("components")) {
|
|
170
|
+
for (const component of ux.components) {
|
|
171
|
+
const score = scoreItem(queryTokens, {
|
|
172
|
+
name: component.name,
|
|
173
|
+
file: component.file,
|
|
174
|
+
text: [
|
|
175
|
+
component.kind,
|
|
176
|
+
component.export_kind,
|
|
177
|
+
...(component.props ?? []).map((prop) => `${prop.name}:${prop.type}`)
|
|
178
|
+
]
|
|
179
|
+
});
|
|
180
|
+
if (score <= 0) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
matches.push({
|
|
184
|
+
type: "components",
|
|
185
|
+
name: component.name,
|
|
186
|
+
score,
|
|
187
|
+
markdown: [
|
|
188
|
+
`**${component.name}** · ${component.file} · import: ${component.export_kind ?? "unknown"}`,
|
|
189
|
+
`Props: ${formatProps(component.props)}`,
|
|
190
|
+
`Used by: ${formatList(pageUsage.get(component.id) ?? [], 4)}`
|
|
191
|
+
]
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (types.has("modules")) {
|
|
196
|
+
for (const module of architecture.modules) {
|
|
197
|
+
const score = scoreItem(queryTokens, {
|
|
198
|
+
name: module.id,
|
|
199
|
+
file: module.path,
|
|
200
|
+
text: [...module.files, ...module.endpoints, ...module.imports]
|
|
201
|
+
});
|
|
202
|
+
if (score <= 0) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const couplingScore = moduleHeatmap.get(module.id);
|
|
206
|
+
matches.push({
|
|
207
|
+
type: "modules",
|
|
208
|
+
name: module.id,
|
|
209
|
+
score,
|
|
210
|
+
markdown: [
|
|
211
|
+
`**${module.id}** · ${module.path} · ${module.files.length} files${typeof couplingScore === "number"
|
|
212
|
+
? ` · coupling score: ${couplingScore.toFixed(2)}`
|
|
213
|
+
: ""}`,
|
|
214
|
+
`Contains: ${formatList(module.files, 4)}`
|
|
215
|
+
]
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (types.has("tasks")) {
|
|
220
|
+
for (const task of architecture.tasks) {
|
|
221
|
+
const score = scoreItem(queryTokens, {
|
|
222
|
+
name: task.name,
|
|
223
|
+
file: task.file,
|
|
224
|
+
text: [task.kind, task.queue ?? "", task.schedule ?? ""]
|
|
225
|
+
});
|
|
226
|
+
if (score <= 0) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
matches.push({
|
|
230
|
+
type: "tasks",
|
|
231
|
+
name: task.name,
|
|
232
|
+
score,
|
|
233
|
+
markdown: [
|
|
234
|
+
`**${task.name}** · ${task.file}`,
|
|
235
|
+
`Kind: ${task.kind}${task.queue ? ` · Queue: ${task.queue}` : ""}${task.schedule ? ` · Schedule: ${task.schedule}` : ""}`
|
|
236
|
+
]
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return matches.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
241
|
+
}
|
|
242
|
+
function buildComponentPageUsage(ux) {
|
|
243
|
+
const usage = new Map();
|
|
244
|
+
for (const page of ux.pages) {
|
|
245
|
+
const pageLabel = `${page.component} (${page.path})`;
|
|
246
|
+
const ids = new Set([
|
|
247
|
+
page.component_id,
|
|
248
|
+
...page.components_direct_ids,
|
|
249
|
+
...page.components_descendants_ids
|
|
250
|
+
]);
|
|
251
|
+
for (const id of ids) {
|
|
252
|
+
const entry = usage.get(id) ?? new Set();
|
|
253
|
+
entry.add(pageLabel);
|
|
254
|
+
usage.set(id, entry);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return new Map(Array.from(usage.entries()).map(([id, pages]) => [
|
|
258
|
+
id,
|
|
259
|
+
Array.from(pages).sort((a, b) => a.localeCompare(b))
|
|
260
|
+
]));
|
|
261
|
+
}
|
|
262
|
+
function formatList(items, limit) {
|
|
263
|
+
if (!items || items.length === 0) {
|
|
264
|
+
return "none";
|
|
265
|
+
}
|
|
266
|
+
if (items.length <= limit) {
|
|
267
|
+
return items.join(", ");
|
|
268
|
+
}
|
|
269
|
+
return `${items.slice(0, limit).join(", ")} +${items.length - limit} more`;
|
|
270
|
+
}
|
|
271
|
+
function formatProps(props) {
|
|
272
|
+
if (!props || props.length === 0) {
|
|
273
|
+
return "none";
|
|
274
|
+
}
|
|
275
|
+
return props
|
|
276
|
+
.slice(0, 6)
|
|
277
|
+
.map((prop) => `${prop.name}${prop.optional ? "?" : ""}: ${prop.type}`)
|
|
278
|
+
.join(", ");
|
|
279
|
+
}
|
|
280
|
+
function renderSearchMarkdown(query, matches) {
|
|
281
|
+
const grouped = new Map();
|
|
282
|
+
for (const match of matches) {
|
|
283
|
+
const entry = grouped.get(match.type) ?? [];
|
|
284
|
+
entry.push(match);
|
|
285
|
+
grouped.set(match.type, entry);
|
|
286
|
+
}
|
|
287
|
+
const labels = [
|
|
288
|
+
["models", "Data Models"],
|
|
289
|
+
["endpoints", "Endpoints"],
|
|
290
|
+
["components", "Components"],
|
|
291
|
+
["modules", "Modules"],
|
|
292
|
+
["tasks", "Tasks"]
|
|
293
|
+
];
|
|
294
|
+
const lines = [];
|
|
295
|
+
lines.push(`# Search: "${query}" - ${matches.length} matches`);
|
|
296
|
+
lines.push("");
|
|
297
|
+
if (matches.length === 0) {
|
|
298
|
+
lines.push("*No matches found.*");
|
|
299
|
+
return lines.join("\n");
|
|
300
|
+
}
|
|
301
|
+
for (const [type, label] of labels) {
|
|
302
|
+
const entries = grouped.get(type) ?? [];
|
|
303
|
+
if (entries.length === 0) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
lines.push(`## ${label} (${entries.length})`);
|
|
307
|
+
lines.push("");
|
|
308
|
+
for (const entry of entries.slice(0, 8)) {
|
|
309
|
+
lines.push(...entry.markdown);
|
|
310
|
+
lines.push("");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return lines.join("\n").trimEnd();
|
|
314
|
+
}
|