@cairnvibe/indexer 0.2.8 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crawl.js +4 -0
- package/dist/key-rotator.js +39 -4
- package/dist/l1-api-routes.js +101 -0
- package/dist/l1-business-rules.js +101 -0
- package/dist/l1-data-shapes.js +154 -0
- package/dist/l1-in-app-copy.js +52 -0
- package/dist/l1-scan.js +42 -3
- package/dist/llm.js +69 -24
- package/dist/manifest.js +48 -4
- package/dist/setup.js +14 -1
- package/package.json +1 -1
package/dist/crawl.js
CHANGED
|
@@ -62,6 +62,8 @@ async function crawlSite(opts) {
|
|
|
62
62
|
file: finalUrl,
|
|
63
63
|
reachableFiles: [],
|
|
64
64
|
elements: extracted.elements,
|
|
65
|
+
dataShapes: [], // no source file to read in crawl mode
|
|
66
|
+
inAppCopy: [], // no source file to read in crawl mode
|
|
65
67
|
renderedText: extracted.bodyText,
|
|
66
68
|
});
|
|
67
69
|
if (depth >= maxDepth)
|
|
@@ -113,6 +115,8 @@ async function crawlSite(opts) {
|
|
|
113
115
|
allScannedFiles: [],
|
|
114
116
|
frameworkReachableFiles: [],
|
|
115
117
|
frameworkElements: [],
|
|
118
|
+
apiRouteHandlers: [], // no source file to read in crawl mode
|
|
119
|
+
businessRules: [], // no source file to read in crawl mode
|
|
116
120
|
};
|
|
117
121
|
}
|
|
118
122
|
function normalizeForDedup(url) {
|
package/dist/key-rotator.js
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
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
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
3
|
exports.KeyRotator = void 0;
|
|
4
|
+
// Round-robins across a comma-separated list of API keys (e.g. `GROQ_API_KEYS`)
|
|
5
|
+
// so a batch of L3 describe calls can spread across several free-tier rate
|
|
6
|
+
// limits instead of hammering a single key. Mirrors packages/sdk/src/
|
|
7
|
+
// key-rotator.ts — small enough that duplicating it beats adding a shared
|
|
8
|
+
// package for it.
|
|
9
|
+
//
|
|
10
|
+
// Real, live-found gap this closes (same one the sdk copy fixes): a key
|
|
11
|
+
// that's genuinely invalid/expired (a real 401 — see llm.ts's
|
|
12
|
+
// isInvalidKeyError) used to just keep getting handed out by `take()` on
|
|
13
|
+
// every pass through the rotation, forever, for the life of this `cairn
|
|
14
|
+
// build` process — wasting a real API round trip and a real retry
|
|
15
|
+
// attempt on a key already proven dead, over and over, on every page
|
|
16
|
+
// that happened to land on it. `markDead` excludes a confirmed-invalid
|
|
17
|
+
// key from rotation for the rest of THIS build.
|
|
7
18
|
class KeyRotator {
|
|
8
19
|
keys;
|
|
9
20
|
next = 0;
|
|
21
|
+
deadKeys = new Set();
|
|
10
22
|
constructor(keys) {
|
|
11
23
|
if (keys.length === 0)
|
|
12
24
|
throw new Error("KeyRotator: at least one key is required");
|
|
@@ -21,10 +33,33 @@ class KeyRotator {
|
|
|
21
33
|
.filter(Boolean);
|
|
22
34
|
return keys.length > 0 ? new KeyRotator(keys) : null;
|
|
23
35
|
}
|
|
36
|
+
/** Round-robins across whichever keys haven't been confirmed dead yet.
|
|
37
|
+
* Falls back to the full original list if every key has been marked
|
|
38
|
+
* dead — a bounded retry loop still needs something real to try. */
|
|
24
39
|
take() {
|
|
25
|
-
const
|
|
40
|
+
const liveKeys = this.keys.filter((k) => !this.deadKeys.has(k));
|
|
41
|
+
const pool = liveKeys.length > 0 ? liveKeys : this.keys;
|
|
42
|
+
const key = pool[this.next % pool.length];
|
|
26
43
|
this.next += 1;
|
|
27
44
|
return key;
|
|
28
45
|
}
|
|
46
|
+
/** Marks a key as confirmed invalid (a real 401, not a rate limit) —
|
|
47
|
+
* excluded from `take()`'s rotation for the rest of this build. Logs
|
|
48
|
+
* once per key, naming only its last 4 characters. */
|
|
49
|
+
markDead(key) {
|
|
50
|
+
if (this.deadKeys.has(key))
|
|
51
|
+
return;
|
|
52
|
+
this.deadKeys.add(key);
|
|
53
|
+
const remaining = this.keys.length - this.deadKeys.size;
|
|
54
|
+
console.warn(`[cairn] API key ending in "${key.slice(-4)}" is invalid (confirmed via a real 401) — excluded from rotation for the rest of this build. ${remaining} of ${this.keys.length} configured key(s) remain.`);
|
|
55
|
+
}
|
|
56
|
+
/** How many distinct keys are configured. */
|
|
57
|
+
get size() {
|
|
58
|
+
return this.keys.length;
|
|
59
|
+
}
|
|
60
|
+
/** How many configured keys have NOT been marked dead. */
|
|
61
|
+
get liveSize() {
|
|
62
|
+
return this.keys.length - this.deadKeys.size;
|
|
63
|
+
}
|
|
29
64
|
}
|
|
30
65
|
exports.KeyRotator = KeyRotator;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// L1 addendum — API route handlers (Phase 4, layer 6 of the deep-runtime-
|
|
3
|
+
// context plan: "which pages/components call which APIs"). Still pure AST
|
|
4
|
+
// facts, still deterministic: resolves an HTTP-method export
|
|
5
|
+
// (`export async function POST() {}` or `export const POST = async () =>
|
|
6
|
+
// {}`) in a Next.js App Router `app/api/**/route.ts` file to the real,
|
|
7
|
+
// imported, project-local function names its body actually calls — e.g.
|
|
8
|
+
// `app/api/invoices/route.ts`'s `POST` calling `createInvoice()` from
|
|
9
|
+
// `lib/invoices.ts`. This is the second hop of a real dependency graph
|
|
10
|
+
// whose first hop (a click's onClick -> a `fetch(url, {method})` call)
|
|
11
|
+
// l1-scan.ts's findApiCallIn/resolveHandlerCall already trace — connecting
|
|
12
|
+
// the two tells the agent not just THAT a button calls POST /api/invoices,
|
|
13
|
+
// but WHAT REAL CODE actually runs when it does.
|
|
14
|
+
//
|
|
15
|
+
// Deliberately App Router only (`app/api/**/route.ts`) — Pages Router API
|
|
16
|
+
// routes (`pages/api/*.ts`) export one default handler that dispatches on
|
|
17
|
+
// `req.method` internally, a materially different (and less statically
|
|
18
|
+
// clean) shape; skipped rather than guessed at, same "only claim what's
|
|
19
|
+
// genuinely traceable" discipline as l1-data-shapes.ts's own explicit-
|
|
20
|
+
// return-type-only boundary.
|
|
21
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
22
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
23
|
+
};
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.mapApiRouteHandlers = mapApiRouteHandlers;
|
|
26
|
+
exports.getCallableBody = getCallableBody;
|
|
27
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
28
|
+
const ts_morph_1 = require("ts-morph");
|
|
29
|
+
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
30
|
+
function mapApiRouteHandlers(project, absRoot) {
|
|
31
|
+
const handlers = [];
|
|
32
|
+
for (const sf of project.getSourceFiles()) {
|
|
33
|
+
const url = deriveApiRoute(absRoot, sf.getFilePath());
|
|
34
|
+
if (!url)
|
|
35
|
+
continue;
|
|
36
|
+
const file = toPosix(node_path_1.default.relative(absRoot, sf.getFilePath()));
|
|
37
|
+
const exported = sf.getExportedDeclarations();
|
|
38
|
+
for (const method of HTTP_METHODS) {
|
|
39
|
+
const decls = exported.get(method);
|
|
40
|
+
if (!decls || decls.length === 0)
|
|
41
|
+
continue;
|
|
42
|
+
const calls = new Set();
|
|
43
|
+
for (const decl of decls) {
|
|
44
|
+
const body = getCallableBody(decl);
|
|
45
|
+
if (!body)
|
|
46
|
+
continue;
|
|
47
|
+
for (const name of tracedCallsIn(sf, body))
|
|
48
|
+
calls.add(name);
|
|
49
|
+
}
|
|
50
|
+
handlers.push({ method, url, file, calls: Array.from(calls).sort() });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return handlers.sort((a, b) => (a.url === b.url ? a.method.localeCompare(b.method) : a.url.localeCompare(b.url)));
|
|
54
|
+
}
|
|
55
|
+
/** App Router convention only: app/api/**\/route.ts -> /api/... . Null for anything else (Pages Router API, a non-route.ts file, a route.ts outside app/api/). */
|
|
56
|
+
function deriveApiRoute(absRoot, filePath) {
|
|
57
|
+
const rel = toPosix(node_path_1.default.relative(absRoot, filePath));
|
|
58
|
+
if (!rel.startsWith("app/api/"))
|
|
59
|
+
return null;
|
|
60
|
+
if (!/\/route\.(ts|js)$/.test(rel))
|
|
61
|
+
return null;
|
|
62
|
+
return "/" + rel.slice("app/".length).replace(/\/route\.(ts|js)$/, "");
|
|
63
|
+
}
|
|
64
|
+
/** Exported for reuse by l1-business-rules.ts (Phase 4 layer 3) — the same "get the actual callable node, whichever of function-declaration/arrow/function-expression shape it is" resolution, not a second copy. */
|
|
65
|
+
function getCallableBody(decl) {
|
|
66
|
+
if (ts_morph_1.Node.isFunctionDeclaration(decl) || ts_morph_1.Node.isArrowFunction(decl) || ts_morph_1.Node.isFunctionExpression(decl))
|
|
67
|
+
return decl;
|
|
68
|
+
if (ts_morph_1.Node.isVariableDeclaration(decl)) {
|
|
69
|
+
const init = decl.getInitializer();
|
|
70
|
+
if (init && (ts_morph_1.Node.isArrowFunction(init) || ts_morph_1.Node.isFunctionExpression(init)))
|
|
71
|
+
return init;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/** Real, imported, project-local function names called anywhere in `body` — same "identifier callee, resolves to a named import whose module isn't node_modules" filter l1-data-shapes.ts's resolveImportedFunction uses, so a library/global call (NextResponse.json, db.prepare) is never mistaken for app logic. */
|
|
76
|
+
function tracedCallsIn(sf, body) {
|
|
77
|
+
const names = new Set();
|
|
78
|
+
for (const call of body.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
79
|
+
const callee = call.getExpression();
|
|
80
|
+
if (!ts_morph_1.Node.isIdentifier(callee))
|
|
81
|
+
continue;
|
|
82
|
+
if (resolvesToProjectFunction(sf, callee.getText()))
|
|
83
|
+
names.add(callee.getText());
|
|
84
|
+
}
|
|
85
|
+
return Array.from(names);
|
|
86
|
+
}
|
|
87
|
+
function resolvesToProjectFunction(sf, name) {
|
|
88
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
89
|
+
const named = imp.getNamedImports().find((n) => (n.getAliasNode()?.getText() ?? n.getName()) === name);
|
|
90
|
+
if (!named)
|
|
91
|
+
continue;
|
|
92
|
+
const target = imp.getModuleSpecifierSourceFile();
|
|
93
|
+
if (!target || target.getFilePath().includes("node_modules"))
|
|
94
|
+
continue;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
function toPosix(p) {
|
|
100
|
+
return p.split(node_path_1.default.sep).join("/");
|
|
101
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// L1 addendum — business rules & validation constraints (Phase 4, layer 3
|
|
3
|
+
// of the deep-runtime-context plan: "legal state transitions, validation
|
|
4
|
+
// constraints, permission/role gating... without this the agent can
|
|
5
|
+
// attempt actions that are structurally possible to click but
|
|
6
|
+
// semantically invalid"). Still pure AST facts, still deterministic.
|
|
7
|
+
//
|
|
8
|
+
// Real research before writing this (an Explore-agent pass over
|
|
9
|
+
// examples/demo-app's actual mutating functions) found the honest truth:
|
|
10
|
+
// this app has almost NOTHING resembling a real domain rule — its
|
|
11
|
+
// mutating functions (archiveInvoice, moveCard, updateCard) apply
|
|
12
|
+
// unconditionally, no transition/permission logic anywhere. The one real
|
|
13
|
+
// exception, `lib/shop.ts`'s `placeOrder`, gates on `isLoggedIn()`. What
|
|
14
|
+
// IS genuinely common and real: every API route's own required-field/
|
|
15
|
+
// not-found guards (`if (!body.email) return NextResponse.json(...)`).
|
|
16
|
+
// This extractor reports BOTH kinds uniformly, as "a real guard this
|
|
17
|
+
// function enforces" — it does not, and cannot, reliably tell a domain
|
|
18
|
+
// permission check apart from an input-validation check by AST shape
|
|
19
|
+
// alone; that distinction is left to whoever reads the result.
|
|
20
|
+
//
|
|
21
|
+
// Deliberately reuses l1-api-routes.ts's own already-proven traversal
|
|
22
|
+
// (which route handler exports which HTTP method, which real functions
|
|
23
|
+
// it calls) rather than re-deriving it — resolveImportedFunction is
|
|
24
|
+
// l1-data-shapes.ts's own real declaration resolver, getCallableBody is
|
|
25
|
+
// l1-api-routes.ts's own callable-node resolver; both exported for this,
|
|
26
|
+
// not duplicated.
|
|
27
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
28
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
29
|
+
};
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.extractBusinessRules = extractBusinessRules;
|
|
32
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
33
|
+
const ts_morph_1 = require("ts-morph");
|
|
34
|
+
const l1_data_shapes_1 = require("./l1-data-shapes");
|
|
35
|
+
const l1_api_routes_1 = require("./l1-api-routes");
|
|
36
|
+
function extractBusinessRules(project, absRoot, handlers) {
|
|
37
|
+
const rules = [];
|
|
38
|
+
const seen = new Set(); // dedupe: the same guarded function can be called from several routes
|
|
39
|
+
for (const handler of handlers) {
|
|
40
|
+
const sf = project.getSourceFile(node_path_1.default.join(absRoot, handler.file));
|
|
41
|
+
if (!sf)
|
|
42
|
+
continue;
|
|
43
|
+
// Guards written directly in the route handler's own body — the
|
|
44
|
+
// common, real case in practice (required-field/not-found checks).
|
|
45
|
+
const handlerDecl = sf.getExportedDeclarations().get(handler.method)?.[0];
|
|
46
|
+
const handlerBody = handlerDecl ? (0, l1_api_routes_1.getCallableBody)(handlerDecl) : null;
|
|
47
|
+
if (handlerBody)
|
|
48
|
+
collect(`${handler.method} ${handler.url}`, handlerBody, handler.file, rules, seen);
|
|
49
|
+
// Guards inside a real function this handler calls — the rarer,
|
|
50
|
+
// more genuinely domain-flavored case (e.g. placeOrder's isLoggedIn check).
|
|
51
|
+
for (const callName of handler.calls) {
|
|
52
|
+
const decl = (0, l1_data_shapes_1.resolveImportedFunction)(sf, callName);
|
|
53
|
+
const body = decl ? (0, l1_api_routes_1.getCallableBody)(decl) : null;
|
|
54
|
+
if (!body)
|
|
55
|
+
continue;
|
|
56
|
+
const calleeSource = toPosixRel(absRoot, body.getSourceFile().getFilePath());
|
|
57
|
+
collect(callName, body, calleeSource, rules, seen);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return rules.sort((a, b) => (a.functionName === b.functionName ? a.condition.localeCompare(b.condition) : a.functionName.localeCompare(b.functionName)));
|
|
61
|
+
}
|
|
62
|
+
function collect(functionName, body, source, rules, seen) {
|
|
63
|
+
for (const guard of findGuardClauses(body)) {
|
|
64
|
+
const key = `${functionName}::${guard.condition}`;
|
|
65
|
+
if (seen.has(key))
|
|
66
|
+
continue;
|
|
67
|
+
seen.add(key);
|
|
68
|
+
rules.push({ functionName, condition: guard.condition, consequence: guard.consequence, source });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A "guard" = an `if` whose THEN branch is a single return/throw
|
|
73
|
+
* statement (bare, or the sole statement in a `{ }` block) — the
|
|
74
|
+
* syntactic shape both a real domain check and a plain input-validation
|
|
75
|
+
* check share. A multi-statement then-branch is deliberately skipped —
|
|
76
|
+
* a guard with real side effects beyond returning is a more complex
|
|
77
|
+
* case this doesn't try to summarize as one simple condition/consequence
|
|
78
|
+
* pair.
|
|
79
|
+
*/
|
|
80
|
+
function findGuardClauses(body) {
|
|
81
|
+
const results = [];
|
|
82
|
+
for (const ifStmt of body.getDescendantsOfKind(ts_morph_1.SyntaxKind.IfStatement)) {
|
|
83
|
+
const consequenceNode = unwrapSingleStatementBlock(ifStmt.getThenStatement());
|
|
84
|
+
if (!consequenceNode)
|
|
85
|
+
continue;
|
|
86
|
+
if (ts_morph_1.Node.isReturnStatement(consequenceNode) || ts_morph_1.Node.isThrowStatement(consequenceNode)) {
|
|
87
|
+
results.push({ condition: ifStmt.getExpression().getText(), consequence: consequenceNode.getText() });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
function unwrapSingleStatementBlock(stmt) {
|
|
93
|
+
if (ts_morph_1.Node.isBlock(stmt)) {
|
|
94
|
+
const statements = stmt.getStatements();
|
|
95
|
+
return statements.length === 1 ? statements[0] : null;
|
|
96
|
+
}
|
|
97
|
+
return stmt;
|
|
98
|
+
}
|
|
99
|
+
function toPosixRel(absRoot, absFile) {
|
|
100
|
+
return node_path_1.default.relative(absRoot, absFile).split(node_path_1.default.sep).join("/");
|
|
101
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// L1 addendum — data shapes (Phase 4, layer 2 of the deep-runtime-context
|
|
3
|
+
// plan). Still pure AST facts, still deterministic: only EXPLICIT
|
|
4
|
+
// return-type annotations are read, never the type checker's inferred type
|
|
5
|
+
// (`getType()`), matching l1-scan.ts's own "read syntax, not semantics"
|
|
6
|
+
// discipline — a function without an explicit return-type annotation
|
|
7
|
+
// contributes nothing here rather than falling back to checker inference,
|
|
8
|
+
// which is slower and less stable across ts-morph/TS versions.
|
|
9
|
+
//
|
|
10
|
+
// Concretely: for every file reachable from a page (already computed by
|
|
11
|
+
// l1-scan.ts's walkImports), find calls to an imported function whose
|
|
12
|
+
// return type names an interface or object-shaped type alias, and report
|
|
13
|
+
// that type's real fields — e.g. `listInvoices(): Invoice[]` in
|
|
14
|
+
// lib/invoices.ts surfaces Invoice's real `status: "Paid" | "Overdue" |
|
|
15
|
+
// "Archived"` union onto the page that renders it, instead of the agent
|
|
16
|
+
// only ever seeing button labels.
|
|
17
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
18
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
19
|
+
};
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.extractDataShapes = extractDataShapes;
|
|
22
|
+
exports.resolveImportedFunction = resolveImportedFunction;
|
|
23
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
24
|
+
const ts_morph_1 = require("ts-morph");
|
|
25
|
+
function extractDataShapes(project, absRoot, reachableAbsFiles) {
|
|
26
|
+
const shapes = new Map();
|
|
27
|
+
for (const absFile of reachableAbsFiles) {
|
|
28
|
+
const sf = project.getSourceFile(absFile);
|
|
29
|
+
if (!sf)
|
|
30
|
+
continue;
|
|
31
|
+
for (const call of sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
32
|
+
const callee = call.getExpression();
|
|
33
|
+
if (!ts_morph_1.Node.isIdentifier(callee))
|
|
34
|
+
continue;
|
|
35
|
+
const decl = resolveImportedFunction(sf, callee.getText());
|
|
36
|
+
if (!decl)
|
|
37
|
+
continue;
|
|
38
|
+
const shape = shapeFromReturnType(decl, absRoot);
|
|
39
|
+
if (shape && !shapes.has(shape.name))
|
|
40
|
+
shapes.set(shape.name, shape);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return Array.from(shapes.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
44
|
+
}
|
|
45
|
+
/** Finds the declaration an imported identifier resolves to — a top-level function or a const-assigned arrow/function expression — in its own module. Exported for reuse by l1-business-rules.ts (Phase 4 layer 3), which needs the real declaration itself (to walk its body), not just its name. */
|
|
46
|
+
function resolveImportedFunction(sf, name) {
|
|
47
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
48
|
+
const named = imp.getNamedImports().find((n) => (n.getAliasNode()?.getText() ?? n.getName()) === name);
|
|
49
|
+
if (!named)
|
|
50
|
+
continue;
|
|
51
|
+
const target = imp.getModuleSpecifierSourceFile();
|
|
52
|
+
if (!target)
|
|
53
|
+
continue;
|
|
54
|
+
const realName = named.getName();
|
|
55
|
+
const fn = target.getFunctions().find((f) => f.getName() === realName);
|
|
56
|
+
if (fn)
|
|
57
|
+
return fn;
|
|
58
|
+
return target.getVariableDeclarations().find((d) => d.getName() === realName);
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
function shapeFromReturnType(decl, absRoot) {
|
|
63
|
+
let returnTypeNode;
|
|
64
|
+
if (ts_morph_1.Node.isFunctionDeclaration(decl)) {
|
|
65
|
+
returnTypeNode = decl.getReturnTypeNode();
|
|
66
|
+
}
|
|
67
|
+
else if (ts_morph_1.Node.isVariableDeclaration(decl)) {
|
|
68
|
+
const init = decl.getInitializer();
|
|
69
|
+
if (init && (ts_morph_1.Node.isArrowFunction(init) || ts_morph_1.Node.isFunctionExpression(init))) {
|
|
70
|
+
returnTypeNode = init.getReturnTypeNode();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!returnTypeNode)
|
|
74
|
+
return null;
|
|
75
|
+
const typeName = baseTypeName(returnTypeNode);
|
|
76
|
+
if (!typeName)
|
|
77
|
+
return null;
|
|
78
|
+
const found = findTypeDeclaration(returnTypeNode.getSourceFile(), typeName);
|
|
79
|
+
if (!found)
|
|
80
|
+
return null;
|
|
81
|
+
if (found.iface) {
|
|
82
|
+
const iface = found.iface;
|
|
83
|
+
const fields = iface
|
|
84
|
+
.getProperties()
|
|
85
|
+
.map((p) => ({ name: p.getName(), type: p.getTypeNode()?.getText() ?? "unknown", optional: p.hasQuestionToken() }))
|
|
86
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
87
|
+
return { name: iface.getName(), fields, source: toPosixRel(absRoot, iface.getSourceFile().getFilePath()) };
|
|
88
|
+
}
|
|
89
|
+
const alias = found.alias;
|
|
90
|
+
const aliasTypeNode = alias.getTypeNode();
|
|
91
|
+
if (!aliasTypeNode || !ts_morph_1.Node.isTypeLiteral(aliasTypeNode))
|
|
92
|
+
return null; // union/primitive aliases aren't a "shape" — no fields to report
|
|
93
|
+
const fields = aliasTypeNode
|
|
94
|
+
.getMembers()
|
|
95
|
+
.filter(ts_morph_1.Node.isPropertySignature)
|
|
96
|
+
.map((p) => ({ name: p.getName(), type: p.getTypeNode()?.getText() ?? "unknown", optional: p.hasQuestionToken() }))
|
|
97
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
98
|
+
return { name: alias.getName(), fields, source: toPosixRel(absRoot, alias.getSourceFile().getFilePath()) };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A return-type annotation (`BoardColumn[]`) very often names a type that's
|
|
102
|
+
* only IMPORTED into this file, not declared here — types split into their
|
|
103
|
+
* own `*-types.ts` module is a common real convention (this repo's own
|
|
104
|
+
* demo-app does it, for a real reason: keeping a server-only db import out
|
|
105
|
+
* of a "use client" component's bundle). Falls back to the file's own
|
|
106
|
+
* imports, one hop, before giving up — not a general module-resolution
|
|
107
|
+
* walk, just enough to cover the common "split types file" shape.
|
|
108
|
+
*/
|
|
109
|
+
function findTypeDeclaration(sf, typeName) {
|
|
110
|
+
const localIface = sf.getInterface(typeName);
|
|
111
|
+
if (localIface)
|
|
112
|
+
return { iface: localIface };
|
|
113
|
+
const localAlias = sf.getTypeAlias(typeName);
|
|
114
|
+
if (localAlias)
|
|
115
|
+
return { alias: localAlias };
|
|
116
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
117
|
+
const named = imp.getNamedImports().find((n) => (n.getAliasNode()?.getText() ?? n.getName()) === typeName);
|
|
118
|
+
if (!named)
|
|
119
|
+
continue;
|
|
120
|
+
const target = imp.getModuleSpecifierSourceFile();
|
|
121
|
+
if (!target)
|
|
122
|
+
continue;
|
|
123
|
+
const realName = named.getName();
|
|
124
|
+
const iface = target.getInterface(realName);
|
|
125
|
+
if (iface)
|
|
126
|
+
return { iface };
|
|
127
|
+
const alias = target.getTypeAlias(realName);
|
|
128
|
+
if (alias)
|
|
129
|
+
return { alias };
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
/** Strips `[]` and a `| null` / `| undefined` union member, then requires a plain named type reference (no generics like `Promise<X>` — out of scope for v1). */
|
|
134
|
+
function baseTypeName(typeNode) {
|
|
135
|
+
let node = typeNode;
|
|
136
|
+
if (ts_morph_1.Node.isArrayTypeNode(node)) {
|
|
137
|
+
node = node.getElementTypeNode();
|
|
138
|
+
}
|
|
139
|
+
if (ts_morph_1.Node.isUnionTypeNode(node)) {
|
|
140
|
+
const members = node.getTypeNodes().filter((n) => n.getText() !== "null" && n.getText() !== "undefined");
|
|
141
|
+
if (members.length !== 1)
|
|
142
|
+
return null;
|
|
143
|
+
node = members[0];
|
|
144
|
+
if (ts_morph_1.Node.isArrayTypeNode(node))
|
|
145
|
+
node = node.getElementTypeNode();
|
|
146
|
+
}
|
|
147
|
+
if (ts_morph_1.Node.isTypeReference(node)) {
|
|
148
|
+
return node.getTypeName().getText();
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function toPosixRel(absRoot, absFile) {
|
|
153
|
+
return node_path_1.default.relative(absRoot, absFile).split(node_path_1.default.sep).join("/");
|
|
154
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// L1 addendum — in-app copy (Phase 4, layer 4 of the deep-runtime-context
|
|
3
|
+
// plan: "help text, tooltips, onboarding copy... real authored semantics
|
|
4
|
+
// to mine instead of only LLM-guessed purpose"). Still pure AST facts,
|
|
5
|
+
// still deterministic: walks a page's own reachable files (the same set
|
|
6
|
+
// l1-scan.ts already computes) for heading/paragraph JSX elements and
|
|
7
|
+
// extracts their real text content — the same JSX-text-reading logic
|
|
8
|
+
// l1-scan.ts already uses for an interactive element's own label, just
|
|
9
|
+
// applied to the NON-interactive elements around it.
|
|
10
|
+
//
|
|
11
|
+
// Confirmed real, not hypothetical, before writing this: examples/demo-app's
|
|
12
|
+
// own pages each open with a real, human-authored <h1>/<p> pair
|
|
13
|
+
// ("Invoices" / "Every invoice you've sent, with its status and amount.")
|
|
14
|
+
// that today's L3 LLM-generated purpose/title can only ever GUESS at,
|
|
15
|
+
// even though the real answer is sitting right there in the source.
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.extractInAppCopy = extractInAppCopy;
|
|
18
|
+
const ts_morph_1 = require("ts-morph");
|
|
19
|
+
const l1_scan_1 = require("./l1-scan");
|
|
20
|
+
const COPY_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6", "p"]);
|
|
21
|
+
/**
|
|
22
|
+
* @param reachableAbsFiles Absolute paths, same set l1-scan.ts already
|
|
23
|
+
* computed for this page (its own file plus every file it imports).
|
|
24
|
+
* @param relPathOf Converts an absolute path back to the same repo-
|
|
25
|
+
* relative, posix-separated form every other L1 fact uses — passed in
|
|
26
|
+
* rather than reimplemented, so this module never has its own opinion
|
|
27
|
+
* about path formatting.
|
|
28
|
+
*/
|
|
29
|
+
function extractInAppCopy(project, reachableAbsFiles, relPathOf) {
|
|
30
|
+
const blocks = [];
|
|
31
|
+
for (const absFile of reachableAbsFiles) {
|
|
32
|
+
const sf = project.getSourceFile(absFile);
|
|
33
|
+
if (!sf)
|
|
34
|
+
continue;
|
|
35
|
+
const relFile = relPathOf(absFile);
|
|
36
|
+
const nodes = [...sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxOpeningElement), ...sf.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxSelfClosingElement)];
|
|
37
|
+
for (const node of nodes) {
|
|
38
|
+
const tag = node.getTagNameNode().getText().toLowerCase();
|
|
39
|
+
if (!COPY_TAGS.has(tag))
|
|
40
|
+
continue;
|
|
41
|
+
// Self-closing copy elements (<p />) never carry text — only
|
|
42
|
+
// getElementText's own JsxElement-children path can find any.
|
|
43
|
+
if (node.getKind() !== ts_morph_1.SyntaxKind.JsxOpeningElement)
|
|
44
|
+
continue;
|
|
45
|
+
const text = (0, l1_scan_1.getElementText)(node);
|
|
46
|
+
if (!text)
|
|
47
|
+
continue;
|
|
48
|
+
blocks.push({ tag: tag, text, file: relFile, line: node.getStartLineNumber() });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return blocks.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file)));
|
|
52
|
+
}
|
package/dist/l1-scan.js
CHANGED
|
@@ -8,9 +8,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
8
8
|
};
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
10
|
exports.scanL1 = scanL1;
|
|
11
|
+
exports.getElementText = getElementText;
|
|
11
12
|
const node_path_1 = __importDefault(require("node:path"));
|
|
12
13
|
const ts_morph_1 = require("ts-morph");
|
|
13
14
|
const routes_1 = require("./routes");
|
|
15
|
+
const l1_data_shapes_1 = require("./l1-data-shapes");
|
|
16
|
+
const l1_api_routes_1 = require("./l1-api-routes");
|
|
17
|
+
const l1_in_app_copy_1 = require("./l1-in-app-copy");
|
|
18
|
+
const l1_business_rules_1 = require("./l1-business-rules");
|
|
14
19
|
const INTERACTIVE_TAGS = new Set(["button", "a", "form", "input"]);
|
|
15
20
|
const DEFAULT_ROOTS = ["app", "components", "lib", "pages"];
|
|
16
21
|
// Files the framework invokes directly rather than a page importing them —
|
|
@@ -76,11 +81,14 @@ function scanL1(rootDir) {
|
|
|
76
81
|
const reachable = new Set();
|
|
77
82
|
const elements = [];
|
|
78
83
|
walkImports(pageFile, absRoot, reachable, elements, new Set());
|
|
84
|
+
const reachableAbsFiles = Array.from(reachable).map((rel) => node_path_1.default.join(absRoot, rel));
|
|
79
85
|
return {
|
|
80
86
|
route: deriveRoute(absRoot, pageFile.getFilePath()),
|
|
81
87
|
file: toPosix(node_path_1.default.relative(absRoot, pageFile.getFilePath())),
|
|
82
88
|
reachableFiles: Array.from(reachable).sort(),
|
|
83
89
|
elements: elements.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file))),
|
|
90
|
+
dataShapes: (0, l1_data_shapes_1.extractDataShapes)(project, absRoot, reachableAbsFiles),
|
|
91
|
+
inAppCopy: (0, l1_in_app_copy_1.extractInAppCopy)(project, reachableAbsFiles, (absPath) => toPosix(node_path_1.default.relative(absRoot, absPath))),
|
|
84
92
|
};
|
|
85
93
|
});
|
|
86
94
|
pages.sort((a, b) => a.route.localeCompare(b.route));
|
|
@@ -97,12 +105,15 @@ function scanL1(rootDir) {
|
|
|
97
105
|
// can describe them once and the manifest can attach them to every page.
|
|
98
106
|
walkImports(sf, absRoot, frameworkReachable, frameworkElements, frameworkVisited);
|
|
99
107
|
}
|
|
108
|
+
const apiRouteHandlers = (0, l1_api_routes_1.mapApiRouteHandlers)(project, absRoot);
|
|
100
109
|
return {
|
|
101
110
|
version: "1",
|
|
102
111
|
pages,
|
|
103
112
|
allScannedFiles,
|
|
104
113
|
frameworkReachableFiles: Array.from(frameworkReachable).sort(),
|
|
105
114
|
frameworkElements: frameworkElements.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file))),
|
|
115
|
+
apiRouteHandlers,
|
|
116
|
+
businessRules: (0, l1_business_rules_1.extractBusinessRules)(project, absRoot, apiRouteHandlers),
|
|
106
117
|
};
|
|
107
118
|
}
|
|
108
119
|
function walkImports(sf, absRoot, reachableRel, elements, visitedAbs) {
|
|
@@ -211,20 +222,48 @@ function getAttrInitializerNode(attrs, name) {
|
|
|
211
222
|
}
|
|
212
223
|
return undefined;
|
|
213
224
|
}
|
|
225
|
+
/** Exported for reuse by l1-in-app-copy.ts (Phase 4 layer 4) — the exact
|
|
226
|
+
* same "read a JSX element's own text" logic, not a second copy.
|
|
227
|
+
*
|
|
228
|
+
* Real, live-found bug this fixes: only DIRECT JsxText children were ever
|
|
229
|
+
* read — an element whose label sits inside a wrapper (`<a><span><Icon/>
|
|
230
|
+
* Go to Invoices</span></a>`, an extremely common real-world icon+label
|
|
231
|
+
* pattern, confirmed live in examples/demo-app's own landing page) came
|
|
232
|
+
* back with NO text at all. With no text and no aria-label, manifest.ts's
|
|
233
|
+
* elementFallbackSelector had nothing to fall back to but the bare tag
|
|
234
|
+
* name ("a") — a selector matching every link on the page, useless for
|
|
235
|
+
* actually finding the ONE the manifest meant. That's the real, traced
|
|
236
|
+
* cause of "Could not find that element on the page" repeating for the
|
|
237
|
+
* landing page's own nav cards — not a runtime bug at all, a static-
|
|
238
|
+
* analysis gap in how a label gets extracted in the first place. Now
|
|
239
|
+
* recurses into nested JsxElement children (never into a JsxExpression's
|
|
240
|
+
* `{dynamic value}` or a JsxSelfClosingElement icon, which have no real
|
|
241
|
+
* static text to read) so any REAL, human-authored text anywhere inside
|
|
242
|
+
* the element is found, no matter how deeply it's wrapped. */
|
|
214
243
|
function getElementText(opening) {
|
|
215
244
|
const parent = opening.getParentIfKind(ts_morph_1.SyntaxKind.JsxElement);
|
|
216
245
|
if (!parent)
|
|
217
246
|
return null;
|
|
218
247
|
const texts = [];
|
|
219
|
-
|
|
248
|
+
collectJsxText(parent, texts);
|
|
249
|
+
const joined = texts.join(" ").trim().replace(/\s+/g, " ");
|
|
250
|
+
return joined.length > 0 ? joined : null;
|
|
251
|
+
}
|
|
252
|
+
function collectJsxText(element, texts) {
|
|
253
|
+
for (const child of element.getJsxChildren()) {
|
|
220
254
|
if (ts_morph_1.Node.isJsxText(child)) {
|
|
221
255
|
const t = child.getText().trim().replace(/\s+/g, " ");
|
|
222
256
|
if (t)
|
|
223
257
|
texts.push(t);
|
|
224
258
|
}
|
|
259
|
+
else if (ts_morph_1.Node.isJsxElement(child)) {
|
|
260
|
+
collectJsxText(child, texts);
|
|
261
|
+
}
|
|
262
|
+
// JsxSelfClosingElement (an icon like <FileText/>) and JsxExpression
|
|
263
|
+
// (a dynamic value like {count}) are deliberately skipped — neither
|
|
264
|
+
// has real static text to read, and a dynamic value must never be
|
|
265
|
+
// guessed at.
|
|
225
266
|
}
|
|
226
|
-
const joined = texts.join(" ").trim();
|
|
227
|
-
return joined.length > 0 ? joined : null;
|
|
228
267
|
}
|
|
229
268
|
function resolveHandlerCall(sf, initializer) {
|
|
230
269
|
if (!initializer)
|
package/dist/llm.js
CHANGED
|
@@ -114,10 +114,30 @@ exports.AnthropicDescribeClient = AnthropicDescribeClient;
|
|
|
114
114
|
// Model list changes over time — verified live against GET /openai/v1/models
|
|
115
115
|
// while building this; check that endpoint again if this starts 404ing.
|
|
116
116
|
const GROQ_DEFAULT_MODEL = "openai/gpt-oss-120b";
|
|
117
|
+
/**
|
|
118
|
+
* Same defensive-shape-checking approach as concurrency.ts's own
|
|
119
|
+
* error-inspection helpers — checked directly against the real Groq API
|
|
120
|
+
* before writing this (not guessed): a real 401 from an invalid/expired
|
|
121
|
+
* key throws with `.status === 401` and a doubly-nested
|
|
122
|
+
* `.error.error.code === "invalid_api_key"`.
|
|
123
|
+
*/
|
|
124
|
+
function isInvalidKeyError(err) {
|
|
125
|
+
if (!err || typeof err !== "object")
|
|
126
|
+
return false;
|
|
127
|
+
const e = err;
|
|
128
|
+
if (e.status === 401)
|
|
129
|
+
return true;
|
|
130
|
+
const code = e.code ?? e.error?.code ?? e.error?.error?.code;
|
|
131
|
+
if (code === "invalid_api_key")
|
|
132
|
+
return true;
|
|
133
|
+
const message = typeof e.message === "string" ? e.message : "";
|
|
134
|
+
return message.includes("invalid_api_key") || message.includes("Invalid API Key");
|
|
135
|
+
}
|
|
117
136
|
class GroqDescribeClient {
|
|
118
137
|
keys;
|
|
119
138
|
model;
|
|
120
|
-
|
|
139
|
+
clientFactory;
|
|
140
|
+
constructor(options, clientFactory = (apiKey) => new groq_sdk_1.default({ apiKey })) {
|
|
121
141
|
const rotator = options?.apiKeys
|
|
122
142
|
? new key_rotator_1.KeyRotator(options.apiKeys)
|
|
123
143
|
: key_rotator_1.KeyRotator.fromEnvList(process.env.GROQ_API_KEYS);
|
|
@@ -126,33 +146,58 @@ class GroqDescribeClient {
|
|
|
126
146
|
}
|
|
127
147
|
this.keys = rotator;
|
|
128
148
|
this.model = options?.model ?? process.env.GROQ_MODEL ?? GROQ_DEFAULT_MODEL;
|
|
149
|
+
this.clientFactory = clientFactory;
|
|
129
150
|
}
|
|
130
151
|
async describePage(input) {
|
|
131
|
-
const client = new groq_sdk_1.default({ apiKey: this.keys.take() });
|
|
132
152
|
const userContent = buildUserContent(input);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
// Real, live-found gap this closes: a genuinely dead/expired key used
|
|
154
|
+
// to just throw straight out of this method on every page that
|
|
155
|
+
// happened to land on it via round-robin, for the entire build —
|
|
156
|
+
// wasting a real API round trip on a key already proven dead, over
|
|
157
|
+
// and over. A bounded loop over the real configured key count,
|
|
158
|
+
// separate from (and orthogonal to) withRetry's own 429/5xx backoff
|
|
159
|
+
// retries at the caller — an invalid key isn't a transient condition
|
|
160
|
+
// that backing off helps with, it's a permanent fact about that one
|
|
161
|
+
// key, so this retries IMMEDIATELY on a different key instead of
|
|
162
|
+
// waiting. See KeyRotator.markDead's own doc comment.
|
|
163
|
+
const maxKeyAttempts = Math.max(this.keys.size, 1);
|
|
164
|
+
for (let attempt = 0;; attempt++) {
|
|
165
|
+
const key = this.keys.take();
|
|
166
|
+
try {
|
|
167
|
+
const client = this.clientFactory(key);
|
|
168
|
+
const completion = await client.chat.completions.create({
|
|
169
|
+
model: this.model,
|
|
170
|
+
messages: [
|
|
171
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
172
|
+
{ role: "user", content: userContent },
|
|
173
|
+
],
|
|
174
|
+
tools: [
|
|
175
|
+
{
|
|
176
|
+
type: "function",
|
|
177
|
+
function: {
|
|
178
|
+
name: DESCRIBE_TOOL_NAME,
|
|
179
|
+
description: DESCRIBE_TOOL.description,
|
|
180
|
+
parameters: DESCRIBE_TOOL.input_schema,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
tool_choice: { type: "function", function: { name: DESCRIBE_TOOL_NAME } },
|
|
185
|
+
});
|
|
186
|
+
const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
|
|
187
|
+
if (!toolCall) {
|
|
188
|
+
throw new Error(`L3 describe (groq): no tool call in response for ${input.route}`);
|
|
189
|
+
}
|
|
190
|
+
return toPageDescription(JSON.parse(toolCall.function.arguments));
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
if (isInvalidKeyError(err)) {
|
|
194
|
+
this.keys.markDead(key);
|
|
195
|
+
if (attempt < maxKeyAttempts - 1)
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
154
200
|
}
|
|
155
|
-
return toPageDescription(JSON.parse(toolCall.function.arguments));
|
|
156
201
|
}
|
|
157
202
|
}
|
|
158
203
|
exports.GroqDescribeClient = GroqDescribeClient;
|
package/dist/manifest.js
CHANGED
|
@@ -4,10 +4,26 @@ exports.assembleManifest = assembleManifest;
|
|
|
4
4
|
exports.parseApiCall = parseApiCall;
|
|
5
5
|
const node_child_process_1 = require("node:child_process");
|
|
6
6
|
function assembleManifest(rootDir, facts, l2, l3) {
|
|
7
|
-
|
|
7
|
+
// Phase 4, layer 6 — keyed once per build, not per element, so
|
|
8
|
+
// enriching every element's apiCall stays a cheap map lookup.
|
|
9
|
+
const routeHandlersByKey = new Map(facts.apiRouteHandlers.map((h) => [`${h.method} ${h.url}`, h]));
|
|
10
|
+
// Phase 4, layer 3 — keyed by BusinessRule.functionName, which is
|
|
11
|
+
// EITHER a route key ("POST /api/shop/checkout", for a guard written
|
|
12
|
+
// directly in the handler) OR a real called function's own name (for
|
|
13
|
+
// a guard found inside it) — see enrichApiCall for how both get
|
|
14
|
+
// looked up together for one apiCall.
|
|
15
|
+
const businessRulesByKey = new Map();
|
|
16
|
+
for (const rule of facts.businessRules) {
|
|
17
|
+
const existing = businessRulesByKey.get(rule.functionName);
|
|
18
|
+
if (existing)
|
|
19
|
+
existing.push(rule);
|
|
20
|
+
else
|
|
21
|
+
businessRulesByKey.set(rule.functionName, [rule]);
|
|
22
|
+
}
|
|
23
|
+
const globalElements = facts.frameworkElements.map((el) => toManifestElement(el, l3.globalElements.find((e) => e.id === el.id), "present in the root layout", routeHandlersByKey, businessRulesByKey));
|
|
8
24
|
const pages = facts.pages.map((rawPage) => {
|
|
9
25
|
const desc = l3.descriptions.get(rawPage.route);
|
|
10
|
-
const ownElements = rawPage.elements.map((el) => toManifestElement(el, desc?.elements.find((e) => e.id === el.id), `reachable from route ${rawPage.route}
|
|
26
|
+
const ownElements = rawPage.elements.map((el) => toManifestElement(el, desc?.elements.find((e) => e.id === el.id), `reachable from route ${rawPage.route}`, routeHandlersByKey, businessRulesByKey));
|
|
11
27
|
return {
|
|
12
28
|
id: slugifyRoute(rawPage.route),
|
|
13
29
|
route: rawPage.route,
|
|
@@ -17,6 +33,8 @@ function assembleManifest(rootDir, facts, l2, l3) {
|
|
|
17
33
|
whenToUse: desc?.whenToUse ?? "Unknown — no description generated for this page.",
|
|
18
34
|
confidence: desc?.confidence ?? 0,
|
|
19
35
|
elements: [...ownElements, ...globalElements],
|
|
36
|
+
dataShapes: rawPage.dataShapes,
|
|
37
|
+
inAppCopy: rawPage.inAppCopy,
|
|
20
38
|
};
|
|
21
39
|
});
|
|
22
40
|
return {
|
|
@@ -62,7 +80,7 @@ function parseApiCall(handlerCall) {
|
|
|
62
80
|
return null;
|
|
63
81
|
return { method: method, url };
|
|
64
82
|
}
|
|
65
|
-
function toManifestElement(el, elDesc, baseEvidence) {
|
|
83
|
+
function toManifestElement(el, elDesc, baseEvidence, routeHandlersByKey, businessRulesByKey) {
|
|
66
84
|
const evidence = [baseEvidence];
|
|
67
85
|
if (el.handlerCall)
|
|
68
86
|
evidence.push(`onClick calls ${el.handlerCall}`);
|
|
@@ -76,9 +94,35 @@ function toManifestElement(el, elDesc, baseEvidence) {
|
|
|
76
94
|
does: elDesc?.does ?? "Unknown — no description generated for this element.",
|
|
77
95
|
confidence: elDesc?.confidence ?? 0,
|
|
78
96
|
evidence,
|
|
79
|
-
apiCall: parseApiCall(el.handlerCall),
|
|
97
|
+
apiCall: enrichApiCall(parseApiCall(el.handlerCall), routeHandlersByKey, businessRulesByKey),
|
|
80
98
|
};
|
|
81
99
|
}
|
|
100
|
+
/** Phase 4, layer 6 — attaches the real backend function name(s) that
|
|
101
|
+
* actually run when this apiCall fires, when Cairn found and traced the
|
|
102
|
+
* matching route handler (l1-api-routes.ts). Absent when no handler
|
|
103
|
+
* matched — a route Cairn didn't scan, or one whose body called nothing
|
|
104
|
+
* traceable — never invented. Phase 4, layer 3 — ALSO attaches any real
|
|
105
|
+
* guard clauses found either in the route handler's own body or in a
|
|
106
|
+
* function it calls (l1-business-rules.ts), formatted as readable
|
|
107
|
+
* "condition → consequence" strings. Absent when none were found —
|
|
108
|
+
* most real mutating functions in a typical app have none (confirmed
|
|
109
|
+
* live against examples/demo-app before building this), which is a
|
|
110
|
+
* real, honest finding, not a bug in the extractor. */
|
|
111
|
+
function enrichApiCall(apiCall, routeHandlersByKey, businessRulesByKey) {
|
|
112
|
+
if (!apiCall)
|
|
113
|
+
return null;
|
|
114
|
+
let enriched = apiCall;
|
|
115
|
+
const handler = routeHandlersByKey.get(`${apiCall.method} ${apiCall.url}`);
|
|
116
|
+
if (handler && handler.calls.length > 0)
|
|
117
|
+
enriched = { ...enriched, handledBy: handler.calls };
|
|
118
|
+
const relevantFunctionNames = [`${apiCall.method} ${apiCall.url}`, ...(enriched.handledBy ?? [])];
|
|
119
|
+
const constraints = relevantFunctionNames
|
|
120
|
+
.flatMap((name) => businessRulesByKey.get(name) ?? [])
|
|
121
|
+
.map((rule) => `${rule.condition} → ${rule.consequence}`);
|
|
122
|
+
if (constraints.length > 0)
|
|
123
|
+
enriched = { ...enriched, constraints };
|
|
124
|
+
return enriched;
|
|
125
|
+
}
|
|
82
126
|
function elementFallbackSelector(el) {
|
|
83
127
|
if (el.ariaLabel)
|
|
84
128
|
return `[aria-label='${el.ariaLabel}']`;
|
package/dist/setup.js
CHANGED
|
@@ -171,8 +171,21 @@ async function runSetup(dir) {
|
|
|
171
171
|
(0, node_child_process_1.execSync)(`npm install ${PACKAGES.join(" ")}`, { cwd: absDir, stdio: "pipe" });
|
|
172
172
|
spinner.stop((0, ui_1.green)(`✓ installed ${PACKAGES.join(", ")}`));
|
|
173
173
|
}
|
|
174
|
-
catch {
|
|
174
|
+
catch (err) {
|
|
175
175
|
spinner.stop((0, ui_1.red)("✗ npm install failed"));
|
|
176
|
+
// Real, live-found bug this closes: `catch {}` (no bound error) threw
|
|
177
|
+
// away npm's own stderr — the ONE thing that actually explains why it
|
|
178
|
+
// failed (a real registry error, an ERESOLVE conflict, a permissions
|
|
179
|
+
// problem, no network) — leaving a user with nothing but "failed, try
|
|
180
|
+
// again," which just fails the same way for the same unknown reason.
|
|
181
|
+
// execSync's thrown error carries the captured output on
|
|
182
|
+
// `.stderr`/`.stdout` (Buffers, from the `stdio: "pipe"` above) even
|
|
183
|
+
// though the command itself never printed anything to this process's
|
|
184
|
+
// own stderr — surface it instead of discarding it.
|
|
185
|
+
const e = err;
|
|
186
|
+
const detail = (e.stderr?.toString().trim() || e.stdout?.toString().trim() || "").trim();
|
|
187
|
+
if (detail)
|
|
188
|
+
console.error(`\n${detail}\n`);
|
|
176
189
|
console.error(`Install these yourself and re-run \`cairn setup\`:\n npm install ${PACKAGES.join(" ")}`);
|
|
177
190
|
return;
|
|
178
191
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cairnvibe/indexer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "Cairn's analyzer and installer (the `cairn` CLI) — scans Next.js source or crawls any running app, and scaffolds the backend either way.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": { "access": "public" },
|