@cairnvibe/indexer 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/dist/cli.js +143 -0
- package/dist/concurrency.js +72 -0
- package/dist/crawl-describe.js +87 -0
- package/dist/crawl.js +174 -0
- package/dist/diff.js +83 -0
- package/dist/docs.js +38 -0
- package/dist/index.js +21 -0
- package/dist/init.js +173 -0
- package/dist/key-rotator.js +30 -0
- package/dist/l1-scan.js +317 -0
- package/dist/l2-reachability.js +116 -0
- package/dist/l3-describe.js +165 -0
- package/dist/llm.js +158 -0
- package/dist/manifest.js +77 -0
- package/dist/package.json +1 -0
- package/dist/routes.js +40 -0
- package/dist/types.js +5 -0
- package/package.json +34 -0
package/dist/init.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `cairn init` — scaffolds what it safely can, prints the rest. Only ever
|
|
3
|
+
// writes NEW files (never touches an existing file, e.g. a layout the
|
|
4
|
+
// user already has) — auto-editing arbitrary existing project files is
|
|
5
|
+
// not something a generic CLI should do blind.
|
|
6
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
7
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
8
|
+
};
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.runInit = runInit;
|
|
11
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
const ENV_TEMPLATE = `# Pick one LLM provider:
|
|
14
|
+
ANTHROPIC_API_KEY=
|
|
15
|
+
# or:
|
|
16
|
+
# GROQ_API_KEYS=
|
|
17
|
+
|
|
18
|
+
# Optional — voice (transcription/spoken answers/realtime conversation):
|
|
19
|
+
DEEPGRAM_API_KEY=
|
|
20
|
+
|
|
21
|
+
# Which "do" actions this deployment allows (comma-separated, empty = none):
|
|
22
|
+
CAIRN_REGISTERED_ACTIONS=
|
|
23
|
+
|
|
24
|
+
# Optional — caps what the agent can do regardless of registered actions:
|
|
25
|
+
# explain | guide | act (default act) — see README.md's Voice & conversation section
|
|
26
|
+
CAIRN_CAPABILITY=
|
|
27
|
+
CAIRN_PERSONA=
|
|
28
|
+
`;
|
|
29
|
+
function writeIfAbsent(filePath, content, result) {
|
|
30
|
+
if (node_fs_1.default.existsSync(filePath)) {
|
|
31
|
+
result.filesSkipped.push(filePath);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
|
|
35
|
+
node_fs_1.default.writeFileSync(filePath, content);
|
|
36
|
+
result.filesWritten.push(filePath);
|
|
37
|
+
}
|
|
38
|
+
function runInit(dir) {
|
|
39
|
+
const absDir = node_path_1.default.resolve(dir);
|
|
40
|
+
const pkgPath = node_path_1.default.join(absDir, "package.json");
|
|
41
|
+
let pkg = {};
|
|
42
|
+
if (node_fs_1.default.existsSync(pkgPath)) {
|
|
43
|
+
try {
|
|
44
|
+
pkg = JSON.parse(node_fs_1.default.readFileSync(pkgPath, "utf8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// malformed package.json — fall through to the generic (non-Next) path
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
51
|
+
const isNext = !!deps.next;
|
|
52
|
+
const hasAppDir = node_fs_1.default.existsSync(node_path_1.default.join(absDir, "app"));
|
|
53
|
+
const result = {
|
|
54
|
+
framework: !isNext ? "other" : hasAppDir ? "next-app-router" : "next-pages-router",
|
|
55
|
+
filesWritten: [],
|
|
56
|
+
filesSkipped: [],
|
|
57
|
+
nextSteps: [],
|
|
58
|
+
};
|
|
59
|
+
writeIfAbsent(node_path_1.default.join(absDir, ".env.example"), ENV_TEMPLATE, result);
|
|
60
|
+
if (result.framework === "next-app-router") {
|
|
61
|
+
writeIfAbsent(node_path_1.default.join(absDir, "app", "api", "copilot", "route.ts"), NEXT_APP_ROUTE, result);
|
|
62
|
+
result.nextSteps.push("1. cp .env.example .env and fill in your key(s).", "2. Add the widget to app/layout.tsx:", ' import { Copilot } from "@cairnvibe/sdk";', ' <Copilot registeredActions={[]} onDo={(action, target) => { /* run it */ }} />', "3. npx cairn build . (scans this Next.js app's source)", "4. npm run dev, then ask it a question.");
|
|
63
|
+
}
|
|
64
|
+
else if (result.framework === "next-pages-router") {
|
|
65
|
+
writeIfAbsent(node_path_1.default.join(absDir, "pages", "api", "copilot.ts"), NEXT_PAGES_API_ROUTE, result);
|
|
66
|
+
result.nextSteps.push("1. cp .env.example .env and fill in your key(s).", "2. Add the widget to pages/_app.tsx:", ' import { Copilot } from "@cairnvibe/sdk";', ' <Copilot registeredActions={[]} onDo={(action, target) => { /* run it */ }} />', "3. npx cairn build . (scans this Next.js app's source)", "4. npm run dev, then ask it a question.");
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
writeIfAbsent(node_path_1.default.join(absDir, "cairn-server.cjs"), STANDALONE_SERVER, result);
|
|
70
|
+
result.nextSteps.push("1. cp .env.example .env and fill in your key(s).", "2. npm install express @cairnvibe/sdk @cairnvibe/core (@cairnvibe/sdk isn't on npm yet — see README.md's Quick start for the file: install path)", "3. Start your app, then: npx cairn build http://localhost:PORT (crawls the running app — works for any framework)", "4. node cairn-server.cjs (the copilot backend, separate from your app's own server)", "5. Add this to your HTML, pointed at wherever cairn-server.cjs is running:", ' <script src="/cairn-widget.js"></script>', ' <cairn-widget endpoint="http://localhost:4000/api/copilot"></cairn-widget>', " (copy node_modules/@cairnvibe/sdk/dist/cairn-widget.js into your app's static assets as cairn-widget.js)");
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
const NEXT_APP_ROUTE = `import fs from "node:fs";
|
|
75
|
+
import path from "node:path";
|
|
76
|
+
import { NextResponse } from "next/server";
|
|
77
|
+
import { createCopilotHandler } from "@cairnvibe/sdk/server";
|
|
78
|
+
import { ManifestSchema, type Manifest } from "@cairnvibe/core";
|
|
79
|
+
|
|
80
|
+
function loadManifest(): Manifest {
|
|
81
|
+
const manifestPath = path.join(process.cwd(), "ui-manifest.json");
|
|
82
|
+
if (fs.existsSync(manifestPath)) {
|
|
83
|
+
return ManifestSchema.parse(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
|
|
84
|
+
}
|
|
85
|
+
console.warn("[cairn] no ui-manifest.json — run \`npx cairn build .\` first. Serving an empty manifest.");
|
|
86
|
+
return { version: "1", commit: "unbuilt", generatedAt: new Date().toISOString(), pages: [], dead: [], conflicts: [] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function POST(request: Request) {
|
|
90
|
+
const handler = createCopilotHandler(loadManifest(), {
|
|
91
|
+
provider: process.env.CAIRN_RUNTIME_PROVIDER === "anthropic" ? "anthropic" : "groq",
|
|
92
|
+
registeredActions: (process.env.CAIRN_REGISTERED_ACTIONS ?? "").split(",").map((a) => a.trim()).filter(Boolean),
|
|
93
|
+
capability: (process.env.CAIRN_CAPABILITY as "explain" | "guide" | "act" | undefined) ?? "act",
|
|
94
|
+
persona: process.env.CAIRN_PERSONA || undefined,
|
|
95
|
+
});
|
|
96
|
+
const body = await request.json().catch(() => null);
|
|
97
|
+
const result = await handler(body);
|
|
98
|
+
return NextResponse.json(result.body, { status: result.status });
|
|
99
|
+
}
|
|
100
|
+
`;
|
|
101
|
+
const NEXT_PAGES_API_ROUTE = `import fs from "node:fs";
|
|
102
|
+
import path from "node:path";
|
|
103
|
+
import type { NextApiRequest, NextApiResponse } from "next";
|
|
104
|
+
import { createCopilotHandler } from "@cairnvibe/sdk/server";
|
|
105
|
+
import { ManifestSchema, type Manifest } from "@cairnvibe/core";
|
|
106
|
+
|
|
107
|
+
function loadManifest(): Manifest {
|
|
108
|
+
const manifestPath = path.join(process.cwd(), "ui-manifest.json");
|
|
109
|
+
if (fs.existsSync(manifestPath)) {
|
|
110
|
+
return ManifestSchema.parse(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
|
|
111
|
+
}
|
|
112
|
+
console.warn("[cairn] no ui-manifest.json — run \`npx cairn build .\` first. Serving an empty manifest.");
|
|
113
|
+
return { version: "1", commit: "unbuilt", generatedAt: new Date().toISOString(), pages: [], dead: [], conflicts: [] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|
117
|
+
if (req.method !== "POST") return res.status(405).end();
|
|
118
|
+
const copilotHandler = createCopilotHandler(loadManifest(), {
|
|
119
|
+
provider: process.env.CAIRN_RUNTIME_PROVIDER === "anthropic" ? "anthropic" : "groq",
|
|
120
|
+
registeredActions: (process.env.CAIRN_REGISTERED_ACTIONS ?? "").split(",").map((a) => a.trim()).filter(Boolean),
|
|
121
|
+
capability: (process.env.CAIRN_CAPABILITY as "explain" | "guide" | "act" | undefined) ?? "act",
|
|
122
|
+
persona: process.env.CAIRN_PERSONA || undefined,
|
|
123
|
+
});
|
|
124
|
+
const result = await copilotHandler(req.body);
|
|
125
|
+
res.status(result.status).json(result.body);
|
|
126
|
+
}
|
|
127
|
+
`;
|
|
128
|
+
const STANDALONE_SERVER = `// cairn-server.cjs — generated by \`cairn init\`. Any backend framework
|
|
129
|
+
// works here (createCopilotHandler is plain Node) — this is just the
|
|
130
|
+
// simplest one to scaffold. Run: node cairn-server.cjs
|
|
131
|
+
require("dotenv").config();
|
|
132
|
+
const express = require("express");
|
|
133
|
+
const fs = require("node:fs");
|
|
134
|
+
const path = require("node:path");
|
|
135
|
+
const { createCopilotHandler } = require("@cairnvibe/sdk/server");
|
|
136
|
+
const { ManifestSchema } = require("@cairnvibe/core");
|
|
137
|
+
|
|
138
|
+
function loadManifest() {
|
|
139
|
+
const manifestPath = path.join(__dirname, "ui-manifest.json");
|
|
140
|
+
if (fs.existsSync(manifestPath)) {
|
|
141
|
+
return ManifestSchema.parse(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
|
|
142
|
+
}
|
|
143
|
+
console.warn("[cairn] no ui-manifest.json — run \`npx cairn build <your-app-url>\` first. Serving an empty manifest.");
|
|
144
|
+
return { version: "1", commit: "unbuilt", generatedAt: new Date().toISOString(), pages: [], dead: [], conflicts: [] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const app = express();
|
|
148
|
+
app.use(express.json());
|
|
149
|
+
|
|
150
|
+
app.post("/api/copilot", async (req, res) => {
|
|
151
|
+
const handler = createCopilotHandler(loadManifest(), {
|
|
152
|
+
provider: process.env.CAIRN_RUNTIME_PROVIDER === "anthropic" ? "anthropic" : "groq",
|
|
153
|
+
registeredActions: (process.env.CAIRN_REGISTERED_ACTIONS ?? "").split(",").map((a) => a.trim()).filter(Boolean),
|
|
154
|
+
capability: process.env.CAIRN_CAPABILITY ?? "act",
|
|
155
|
+
persona: process.env.CAIRN_PERSONA || undefined,
|
|
156
|
+
});
|
|
157
|
+
const result = await handler(req.body);
|
|
158
|
+
res.status(result.status).json(result.body);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (process.env.DEEPGRAM_API_KEY) {
|
|
162
|
+
const { createSpeakHandler } = require("@cairnvibe/sdk/speak-server");
|
|
163
|
+
const speak = createSpeakHandler({ apiKey: process.env.DEEPGRAM_API_KEY });
|
|
164
|
+
app.post("/api/copilot/speak", async (req, res) => {
|
|
165
|
+
const result = await speak(req.body?.text ?? "");
|
|
166
|
+
if ("error" in result.body) return res.status(result.status).json(result.body);
|
|
167
|
+
res.status(result.status).type(result.body.contentType).send(Buffer.from(result.body.audio));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const port = process.env.PORT || 4000;
|
|
172
|
+
app.listen(port, () => console.log(\`Cairn backend listening on http://localhost:\${port}\`));
|
|
173
|
+
`;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Round-robins across a comma-separated list of API keys (e.g. `GROQ_API_KEYS`)
|
|
3
|
+
// so a batch of L3 describe calls can spread across several free-tier rate
|
|
4
|
+
// limits instead of hammering a single key.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.KeyRotator = void 0;
|
|
7
|
+
class KeyRotator {
|
|
8
|
+
keys;
|
|
9
|
+
next = 0;
|
|
10
|
+
constructor(keys) {
|
|
11
|
+
if (keys.length === 0)
|
|
12
|
+
throw new Error("KeyRotator: at least one key is required");
|
|
13
|
+
this.keys = keys;
|
|
14
|
+
}
|
|
15
|
+
static fromEnvList(value) {
|
|
16
|
+
if (!value)
|
|
17
|
+
return null;
|
|
18
|
+
const keys = value
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((k) => k.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
return keys.length > 0 ? new KeyRotator(keys) : null;
|
|
23
|
+
}
|
|
24
|
+
take() {
|
|
25
|
+
const key = this.keys[this.next % this.keys.length];
|
|
26
|
+
this.next += 1;
|
|
27
|
+
return key;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
exports.KeyRotator = KeyRotator;
|
package/dist/l1-scan.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// L1 — pure AST facts extraction. No LLM, no interpretation, no timestamps.
|
|
3
|
+
// Same source in must produce byte-identical `RawFacts` out (see
|
|
4
|
+
// scripts/check-determinism.sh). Every collection here is explicitly sorted
|
|
5
|
+
// before being returned so directory-listing order can never leak in.
|
|
6
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
7
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
8
|
+
};
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.scanL1 = scanL1;
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const ts_morph_1 = require("ts-morph");
|
|
13
|
+
const routes_1 = require("./routes");
|
|
14
|
+
const INTERACTIVE_TAGS = new Set(["button", "a", "form", "input"]);
|
|
15
|
+
const DEFAULT_ROOTS = ["app", "components", "lib", "pages"];
|
|
16
|
+
// Files the framework invokes directly rather than a page importing them —
|
|
17
|
+
// they must count as reachability roots too, or L2 would flag every layout,
|
|
18
|
+
// API route handler, and _app/_document as dead code.
|
|
19
|
+
const APP_ROUTER_SPECIAL_FILE_RE = /^(layout|loading|error|not-found|template|default|route)\.(tsx|ts|jsx|js)$/;
|
|
20
|
+
const PAGES_ROUTER_SPECIAL_FILE_RE = /^(_app|_document|_error|404|500)\.(tsx|ts|jsx|js)$/;
|
|
21
|
+
// Custom components matching this convention (e.g. <PrimaryButton onClick=...>)
|
|
22
|
+
// are treated as buttons — a heuristic, not real component resolution.
|
|
23
|
+
const BUTTON_LIKE_COMPONENT_RE = /Button$/;
|
|
24
|
+
function isFrameworkSpecialFile(absRoot, filePath) {
|
|
25
|
+
const rel = toPosix(node_path_1.default.relative(absRoot, filePath));
|
|
26
|
+
if (rel.startsWith("pages/api/"))
|
|
27
|
+
return true;
|
|
28
|
+
const base = node_path_1.default.basename(filePath);
|
|
29
|
+
if (rel.startsWith("pages/") && PAGES_ROUTER_SPECIAL_FILE_RE.test(base))
|
|
30
|
+
return true;
|
|
31
|
+
return APP_ROUTER_SPECIAL_FILE_RE.test(base);
|
|
32
|
+
}
|
|
33
|
+
/** Which route (if any) a source file defines, and via which router convention. Null for anything that isn't a page. */
|
|
34
|
+
function deriveRoute(absRoot, filePath) {
|
|
35
|
+
const rel = toPosix(node_path_1.default.relative(absRoot, filePath));
|
|
36
|
+
if (rel.startsWith("app/")) {
|
|
37
|
+
return /(^|\/)page\.(tsx|ts)$/.test(rel) ? (0, routes_1.routeFromPagePath)(absRoot, filePath) : null;
|
|
38
|
+
}
|
|
39
|
+
if (rel.startsWith("pages/")) {
|
|
40
|
+
if (isFrameworkSpecialFile(absRoot, filePath))
|
|
41
|
+
return null;
|
|
42
|
+
return (0, routes_1.routeFromPagesRouterPath)(absRoot, filePath);
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function isRelevant(filePath) {
|
|
47
|
+
return (!filePath.endsWith(".d.ts") &&
|
|
48
|
+
!/\.(test|spec)\.(ts|tsx)$/.test(filePath));
|
|
49
|
+
}
|
|
50
|
+
function toPosix(p) {
|
|
51
|
+
return p.split(node_path_1.default.sep).join("/");
|
|
52
|
+
}
|
|
53
|
+
function scanL1(rootDir) {
|
|
54
|
+
const absRoot = node_path_1.default.resolve(rootDir);
|
|
55
|
+
const project = new ts_morph_1.Project({
|
|
56
|
+
useInMemoryFileSystem: false,
|
|
57
|
+
skipAddingFilesFromTsConfig: true,
|
|
58
|
+
compilerOptions: {
|
|
59
|
+
jsx: ts_morph_1.ts.JsxEmit.ReactJSX,
|
|
60
|
+
allowJs: true,
|
|
61
|
+
esModuleInterop: true,
|
|
62
|
+
target: ts_morph_1.ts.ScriptTarget.ES2022,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const patterns = DEFAULT_ROOTS.map((dir) => toPosix(node_path_1.default.join(absRoot, dir, "**/*.{ts,tsx}")));
|
|
66
|
+
project.addSourceFilesAtPaths(patterns);
|
|
67
|
+
const allScannedFiles = project
|
|
68
|
+
.getSourceFiles()
|
|
69
|
+
.map((sf) => toPosix(node_path_1.default.relative(absRoot, sf.getFilePath())))
|
|
70
|
+
.filter(isRelevant)
|
|
71
|
+
.sort();
|
|
72
|
+
const pageFiles = project
|
|
73
|
+
.getSourceFiles()
|
|
74
|
+
.filter((sf) => isRelevant(sf.getFilePath()) && deriveRoute(absRoot, sf.getFilePath()) !== null);
|
|
75
|
+
const pages = pageFiles.map((pageFile) => {
|
|
76
|
+
const reachable = new Set();
|
|
77
|
+
const elements = [];
|
|
78
|
+
walkImports(pageFile, absRoot, reachable, elements, new Set());
|
|
79
|
+
return {
|
|
80
|
+
route: deriveRoute(absRoot, pageFile.getFilePath()),
|
|
81
|
+
file: toPosix(node_path_1.default.relative(absRoot, pageFile.getFilePath())),
|
|
82
|
+
reachableFiles: Array.from(reachable).sort(),
|
|
83
|
+
elements: elements.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file))),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
pages.sort((a, b) => a.route.localeCompare(b.route));
|
|
87
|
+
const specialFiles = project
|
|
88
|
+
.getSourceFiles()
|
|
89
|
+
.filter((sf) => isRelevant(sf.getFilePath()) && isFrameworkSpecialFile(absRoot, sf.getFilePath()));
|
|
90
|
+
const frameworkReachable = new Set();
|
|
91
|
+
const frameworkElements = [];
|
|
92
|
+
const frameworkVisited = new Set();
|
|
93
|
+
for (const sf of specialFiles) {
|
|
94
|
+
// Framework files (layout.tsx, _app.tsx, ...) can have their own
|
|
95
|
+
// interactive elements — e.g. a nav bar — that render on every page but
|
|
96
|
+
// aren't reachable from any single page.tsx. Collected separately so L3
|
|
97
|
+
// can describe them once and the manifest can attach them to every page.
|
|
98
|
+
walkImports(sf, absRoot, frameworkReachable, frameworkElements, frameworkVisited);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
version: "1",
|
|
102
|
+
pages,
|
|
103
|
+
allScannedFiles,
|
|
104
|
+
frameworkReachableFiles: Array.from(frameworkReachable).sort(),
|
|
105
|
+
frameworkElements: frameworkElements.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file))),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function walkImports(sf, absRoot, reachableRel, elements, visitedAbs) {
|
|
109
|
+
const abs = sf.getFilePath();
|
|
110
|
+
if (visitedAbs.has(abs) || !isRelevant(abs))
|
|
111
|
+
return;
|
|
112
|
+
visitedAbs.add(abs);
|
|
113
|
+
const rel = toPosix(node_path_1.default.relative(absRoot, abs));
|
|
114
|
+
reachableRel.add(rel);
|
|
115
|
+
elements.push(...findInteractiveElements(sf, rel));
|
|
116
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
117
|
+
const target = imp.getModuleSpecifierSourceFile();
|
|
118
|
+
if (!target)
|
|
119
|
+
continue; // unresolved: node_modules / path-alias we don't chase
|
|
120
|
+
if (target.getFilePath().includes("node_modules"))
|
|
121
|
+
continue;
|
|
122
|
+
walkImports(target, absRoot, reachableRel, elements, visitedAbs);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function findInteractiveElements(sf, relFile) {
|
|
126
|
+
const results = [];
|
|
127
|
+
const nodes = [
|
|
128
|
+
...sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxOpeningElement),
|
|
129
|
+
...sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxSelfClosingElement),
|
|
130
|
+
];
|
|
131
|
+
for (const node of nodes) {
|
|
132
|
+
const rawTag = node.getTagNameNode().getText();
|
|
133
|
+
const lowerTag = rawTag.toLowerCase();
|
|
134
|
+
const attrs = node.getAttributes();
|
|
135
|
+
const onClickInit = getAttrInitializerNode(attrs, "onClick");
|
|
136
|
+
const onSubmitInit = getAttrInitializerNode(attrs, "onSubmit");
|
|
137
|
+
// Literal HTML elements first; then a couple of documented heuristics
|
|
138
|
+
// for common component conventions (next/link, *Button wrappers) — not
|
|
139
|
+
// full component resolution, see LATER.md.
|
|
140
|
+
let bucket = null;
|
|
141
|
+
if (INTERACTIVE_TAGS.has(lowerTag)) {
|
|
142
|
+
bucket = lowerTag;
|
|
143
|
+
}
|
|
144
|
+
else if (rawTag === "Link") {
|
|
145
|
+
bucket = "a";
|
|
146
|
+
}
|
|
147
|
+
else if (BUTTON_LIKE_COMPONENT_RE.test(rawTag) && onClickInit) {
|
|
148
|
+
bucket = "button";
|
|
149
|
+
}
|
|
150
|
+
if (!bucket)
|
|
151
|
+
continue;
|
|
152
|
+
const dataAi = getAttrStringValue(attrs, "data-ai");
|
|
153
|
+
const ariaLabel = getAttrStringValue(attrs, "aria-label");
|
|
154
|
+
const text = node.getKind() === ts_morph_1.SyntaxKind.JsxOpeningElement ? getElementText(node) : null;
|
|
155
|
+
let handlerCall = resolveHandlerCall(sf, onClickInit ?? onSubmitInit);
|
|
156
|
+
if (!handlerCall && rawTag === "Link") {
|
|
157
|
+
const href = getAttrStringValue(attrs, "href");
|
|
158
|
+
if (href)
|
|
159
|
+
handlerCall = `navigate ${href}`;
|
|
160
|
+
}
|
|
161
|
+
const line = node.getStartLineNumber();
|
|
162
|
+
// Raw text/aria-label, NOT slugified, when there's no data-ai — the
|
|
163
|
+
// runtime widget's findElement() ladder (element-ladder.ts) matches
|
|
164
|
+
// aria-label and text exactly as they appear on the element (case/
|
|
165
|
+
// whitespace-normalized, but never hyphenated). A slugified id like
|
|
166
|
+
// "new-invoice" would never match a button whose actual text is "New
|
|
167
|
+
// Invoice" — this was a real, live latent gap: crawl.ts's runtime-DOM
|
|
168
|
+
// analyzer never had it (built after this one, with the ladder's real
|
|
169
|
+
// matching rules in mind), and this fix brings the source-reading path
|
|
170
|
+
// in line with it. Only the last-resort synthetic fallback (no data-ai,
|
|
171
|
+
// no aria-label, no text at all — an icon-only button with no
|
|
172
|
+
// accessible name) still gets slugified; that case was already
|
|
173
|
+
// unfindable via the ladder regardless of formatting, so slugifying it
|
|
174
|
+
// doesn't make anything newly broken, just keeps the id readable.
|
|
175
|
+
const id = dataAi ?? text ?? ariaLabel ?? slugify(`${bucket}-${line}`);
|
|
176
|
+
results.push({
|
|
177
|
+
id,
|
|
178
|
+
tag: bucket,
|
|
179
|
+
dataAi,
|
|
180
|
+
ariaLabel,
|
|
181
|
+
text,
|
|
182
|
+
handlerCall,
|
|
183
|
+
file: relFile,
|
|
184
|
+
line,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return results;
|
|
188
|
+
}
|
|
189
|
+
function getAttrStringValue(attrs, name) {
|
|
190
|
+
for (const attr of attrs) {
|
|
191
|
+
if (!ts_morph_1.Node.isJsxAttribute(attr) || attr.getNameNode().getText() !== name)
|
|
192
|
+
continue;
|
|
193
|
+
const init = attr.getInitializer();
|
|
194
|
+
if (!init)
|
|
195
|
+
return "true";
|
|
196
|
+
if (ts_morph_1.Node.isStringLiteral(init))
|
|
197
|
+
return init.getLiteralText();
|
|
198
|
+
if (ts_morph_1.Node.isJsxExpression(init)) {
|
|
199
|
+
const inner = init.getExpression();
|
|
200
|
+
if (inner && ts_morph_1.Node.isStringLiteral(inner))
|
|
201
|
+
return inner.getLiteralText();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
function getAttrInitializerNode(attrs, name) {
|
|
207
|
+
for (const attr of attrs) {
|
|
208
|
+
if (ts_morph_1.Node.isJsxAttribute(attr) && attr.getNameNode().getText() === name) {
|
|
209
|
+
return attr.getInitializer();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
function getElementText(opening) {
|
|
215
|
+
const parent = opening.getParentIfKind(ts_morph_1.SyntaxKind.JsxElement);
|
|
216
|
+
if (!parent)
|
|
217
|
+
return null;
|
|
218
|
+
const texts = [];
|
|
219
|
+
for (const child of parent.getJsxChildren()) {
|
|
220
|
+
if (ts_morph_1.Node.isJsxText(child)) {
|
|
221
|
+
const t = child.getText().trim().replace(/\s+/g, " ");
|
|
222
|
+
if (t)
|
|
223
|
+
texts.push(t);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const joined = texts.join(" ").trim();
|
|
227
|
+
return joined.length > 0 ? joined : null;
|
|
228
|
+
}
|
|
229
|
+
function resolveHandlerCall(sf, initializer) {
|
|
230
|
+
if (!initializer)
|
|
231
|
+
return null;
|
|
232
|
+
let expr = initializer;
|
|
233
|
+
if (ts_morph_1.Node.isJsxExpression(expr)) {
|
|
234
|
+
expr = expr.getExpression();
|
|
235
|
+
}
|
|
236
|
+
if (!expr)
|
|
237
|
+
return null;
|
|
238
|
+
let target = expr;
|
|
239
|
+
if (ts_morph_1.Node.isIdentifier(expr)) {
|
|
240
|
+
const fn = findNamedFunctionLike(sf, expr.getText());
|
|
241
|
+
if (!fn)
|
|
242
|
+
return null;
|
|
243
|
+
target = fn;
|
|
244
|
+
}
|
|
245
|
+
return findApiCallIn(target);
|
|
246
|
+
}
|
|
247
|
+
/** Finds a function/arrow-function declared anywhere in the file (not just top-level — most handlers are nested inside the component). */
|
|
248
|
+
function findNamedFunctionLike(sf, name) {
|
|
249
|
+
for (const fn of sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.FunctionDeclaration)) {
|
|
250
|
+
if (fn.getName() === name)
|
|
251
|
+
return fn;
|
|
252
|
+
}
|
|
253
|
+
for (const decl of sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.VariableDeclaration)) {
|
|
254
|
+
if (decl.getName() === name)
|
|
255
|
+
return decl;
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
function findApiCallIn(node) {
|
|
260
|
+
for (const call of node.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
261
|
+
const calleeExpr = call.getExpression();
|
|
262
|
+
const exprText = calleeExpr.getText();
|
|
263
|
+
if (exprText === "fetch") {
|
|
264
|
+
const result = describeFetchCall(call);
|
|
265
|
+
if (result)
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
else if (ts_morph_1.Node.isPropertyAccessExpression(calleeExpr) && exprText.startsWith("axios.")) {
|
|
269
|
+
const result = describeAxiosCall(call);
|
|
270
|
+
if (result)
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
function describeFetchCall(call) {
|
|
277
|
+
if (!ts_morph_1.Node.isCallExpression(call))
|
|
278
|
+
return null;
|
|
279
|
+
const args = call.getArguments();
|
|
280
|
+
const urlArg = args[0];
|
|
281
|
+
if (!urlArg)
|
|
282
|
+
return null;
|
|
283
|
+
const url = ts_morph_1.Node.isStringLiteral(urlArg) ? urlArg.getLiteralText() : urlArg.getText();
|
|
284
|
+
let method = "GET";
|
|
285
|
+
const optsArg = args[1];
|
|
286
|
+
if (optsArg && ts_morph_1.Node.isObjectLiteralExpression(optsArg)) {
|
|
287
|
+
const methodProp = optsArg.getProperty("method");
|
|
288
|
+
if (methodProp && ts_morph_1.Node.isPropertyAssignment(methodProp)) {
|
|
289
|
+
const init = methodProp.getInitializer();
|
|
290
|
+
if (init && ts_morph_1.Node.isStringLiteral(init))
|
|
291
|
+
method = init.getLiteralText().toUpperCase();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return `${method} ${url}`;
|
|
295
|
+
}
|
|
296
|
+
function describeAxiosCall(call) {
|
|
297
|
+
if (!ts_morph_1.Node.isCallExpression(call))
|
|
298
|
+
return null;
|
|
299
|
+
const calleeExpr = call.getExpression();
|
|
300
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(calleeExpr))
|
|
301
|
+
return null;
|
|
302
|
+
const method = calleeExpr.getName().toUpperCase();
|
|
303
|
+
const args = call.getArguments();
|
|
304
|
+
const urlArg = args[0];
|
|
305
|
+
if (!urlArg)
|
|
306
|
+
return null;
|
|
307
|
+
const url = ts_morph_1.Node.isStringLiteral(urlArg) ? urlArg.getLiteralText() : urlArg.getText();
|
|
308
|
+
return `${method} ${url}`;
|
|
309
|
+
}
|
|
310
|
+
function slugify(s) {
|
|
311
|
+
const slug = s
|
|
312
|
+
.toLowerCase()
|
|
313
|
+
.trim()
|
|
314
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
315
|
+
.replace(/(^-|-$)/g, "");
|
|
316
|
+
return slug || "element";
|
|
317
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// L2 — reachability + conflict adjudication. Deterministic graph walk over
|
|
3
|
+
// what L1 already found; the only non-source-derived input is git recency,
|
|
4
|
+
// used purely as a tertiary tiebreaker (never changes the L1 `scan` output,
|
|
5
|
+
// so it doesn't affect the determinism regression test).
|
|
6
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
7
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
8
|
+
};
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.computeL2 = computeL2;
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const node_child_process_1 = require("node:child_process");
|
|
13
|
+
const ts_morph_1 = require("ts-morph");
|
|
14
|
+
function computeL2(rootDir, facts) {
|
|
15
|
+
const absRoot = node_path_1.default.resolve(rootDir);
|
|
16
|
+
const reachable = new Set();
|
|
17
|
+
for (const page of facts.pages) {
|
|
18
|
+
for (const f of page.reachableFiles)
|
|
19
|
+
reachable.add(f);
|
|
20
|
+
}
|
|
21
|
+
for (const f of facts.frameworkReachableFiles)
|
|
22
|
+
reachable.add(f);
|
|
23
|
+
const dead = facts.allScannedFiles.filter((f) => !reachable.has(f)).sort();
|
|
24
|
+
const conflicts = findConflicts(absRoot, facts.allScannedFiles, reachable);
|
|
25
|
+
return { dead, conflicts };
|
|
26
|
+
}
|
|
27
|
+
function baseName(file) {
|
|
28
|
+
const name = node_path_1.default.basename(file).replace(/\.(tsx|ts)$/, "");
|
|
29
|
+
return name.replace(/(V\d+|Copy|Old)$/i, "").toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
function isRoutingFile(file) {
|
|
32
|
+
return /^(page|layout|route|loading|error|not-found|template)\.(tsx|ts)$/.test(node_path_1.default.basename(file));
|
|
33
|
+
}
|
|
34
|
+
function findConflicts(absRoot, allFiles, reachable) {
|
|
35
|
+
const groups = new Map();
|
|
36
|
+
for (const f of allFiles) {
|
|
37
|
+
if (isRoutingFile(f))
|
|
38
|
+
continue;
|
|
39
|
+
const key = baseName(f);
|
|
40
|
+
const list = groups.get(key) ?? [];
|
|
41
|
+
list.push(f);
|
|
42
|
+
groups.set(key, list);
|
|
43
|
+
}
|
|
44
|
+
const inboundCounts = countInboundImports(absRoot, allFiles);
|
|
45
|
+
const conflicts = [];
|
|
46
|
+
for (const candidates of groups.values()) {
|
|
47
|
+
if (candidates.length < 2)
|
|
48
|
+
continue;
|
|
49
|
+
const scored = [...candidates].sort().map((file) => ({
|
|
50
|
+
file,
|
|
51
|
+
reachable: reachable.has(file),
|
|
52
|
+
inbound: inboundCounts.get(file) ?? 0,
|
|
53
|
+
recency: gitRecency(absRoot, file),
|
|
54
|
+
}));
|
|
55
|
+
scored.sort((a, b) => {
|
|
56
|
+
if (a.reachable !== b.reachable)
|
|
57
|
+
return a.reachable ? -1 : 1;
|
|
58
|
+
if (a.inbound !== b.inbound)
|
|
59
|
+
return b.inbound - a.inbound;
|
|
60
|
+
if (a.recency !== b.recency)
|
|
61
|
+
return b.recency - a.recency;
|
|
62
|
+
return a.file.localeCompare(b.file);
|
|
63
|
+
});
|
|
64
|
+
const winner = scored[0];
|
|
65
|
+
const loser = scored[1];
|
|
66
|
+
const reasonParts = [
|
|
67
|
+
winner.reachable ? "reachable from router" : "not reachable from router",
|
|
68
|
+
`${winner.inbound} inbound import(s)`,
|
|
69
|
+
];
|
|
70
|
+
if (loser && !loser.reachable && loser.inbound === 0) {
|
|
71
|
+
reasonParts.push("other candidate has zero inbound imports and is unreachable");
|
|
72
|
+
}
|
|
73
|
+
conflicts.push({
|
|
74
|
+
candidates: scored.map((s) => s.file),
|
|
75
|
+
chose: winner.file,
|
|
76
|
+
reason: reasonParts.join("; "),
|
|
77
|
+
confidence: winner.reachable && winner.inbound > 0 ? 0.9 : winner.reachable ? 0.75 : 0.5,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
conflicts.sort((a, b) => a.chose.localeCompare(b.chose));
|
|
81
|
+
return conflicts;
|
|
82
|
+
}
|
|
83
|
+
function countInboundImports(absRoot, allFiles) {
|
|
84
|
+
const project = new ts_morph_1.Project({
|
|
85
|
+
skipAddingFilesFromTsConfig: true,
|
|
86
|
+
compilerOptions: { jsx: ts_morph_1.ts.JsxEmit.ReactJSX, allowJs: true, esModuleInterop: true },
|
|
87
|
+
});
|
|
88
|
+
for (const f of allFiles) {
|
|
89
|
+
project.addSourceFileAtPath(node_path_1.default.join(absRoot, f));
|
|
90
|
+
}
|
|
91
|
+
const counts = new Map(allFiles.map((f) => [f, 0]));
|
|
92
|
+
for (const sf of project.getSourceFiles()) {
|
|
93
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
94
|
+
const target = imp.getModuleSpecifierSourceFile();
|
|
95
|
+
if (!target)
|
|
96
|
+
continue;
|
|
97
|
+
const rel = node_path_1.default.relative(absRoot, target.getFilePath()).split(node_path_1.default.sep).join("/");
|
|
98
|
+
if (counts.has(rel))
|
|
99
|
+
counts.set(rel, (counts.get(rel) ?? 0) + 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return counts;
|
|
103
|
+
}
|
|
104
|
+
function gitRecency(absRoot, file) {
|
|
105
|
+
try {
|
|
106
|
+
const out = (0, node_child_process_1.execFileSync)("git", ["log", "-1", "--format=%ct", "--", file], {
|
|
107
|
+
cwd: absRoot,
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
110
|
+
}).trim();
|
|
111
|
+
return out ? parseInt(out, 10) : 0;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
}
|