@cairnvibe/indexer 0.2.8 → 0.2.9

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 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) {
@@ -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 key = this.keys[this.next % this.keys.length];
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
- for (const child of parent.getJsxChildren()) {
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
- constructor(options) {
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
- const completion = await client.chat.completions.create({
134
- model: this.model,
135
- messages: [
136
- { role: "system", content: SYSTEM_PROMPT },
137
- { role: "user", content: userContent },
138
- ],
139
- tools: [
140
- {
141
- type: "function",
142
- function: {
143
- name: DESCRIBE_TOOL_NAME,
144
- description: DESCRIBE_TOOL.description,
145
- parameters: DESCRIBE_TOOL.input_schema,
146
- },
147
- },
148
- ],
149
- tool_choice: { type: "function", function: { name: DESCRIBE_TOOL_NAME } },
150
- });
151
- const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
152
- if (!toolCall) {
153
- throw new Error(`L3 describe (groq): no tool call in response for ${input.route}`);
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
- const globalElements = facts.frameworkElements.map((el) => toManifestElement(el, l3.globalElements.find((e) => e.id === el.id), "present in the root layout"));
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnvibe/indexer",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
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" },