@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cairn contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/cli.js ADDED
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const core_1 = require("@cairnvibe/core");
10
+ const l1_scan_1 = require("./l1-scan");
11
+ const l2_reachability_1 = require("./l2-reachability");
12
+ const l3_describe_1 = require("./l3-describe");
13
+ const crawl_1 = require("./crawl");
14
+ const crawl_describe_1 = require("./crawl-describe");
15
+ const llm_1 = require("./llm");
16
+ const manifest_1 = require("./manifest");
17
+ const diff_1 = require("./diff");
18
+ const docs_1 = require("./docs");
19
+ const init_1 = require("./init");
20
+ function parseArgs(rest) {
21
+ const positional = [];
22
+ const flags = {};
23
+ for (let i = 0; i < rest.length; i++) {
24
+ const arg = rest[i];
25
+ if (arg.startsWith("--")) {
26
+ flags[arg.slice(2)] = rest[i + 1] ?? "";
27
+ i++;
28
+ }
29
+ else {
30
+ positional.push(arg);
31
+ }
32
+ }
33
+ return { positional, flags };
34
+ }
35
+ async function main() {
36
+ const [command, ...rest] = process.argv.slice(2);
37
+ const { positional, flags } = parseArgs(rest);
38
+ const dir = positional[0] ?? ".";
39
+ if (command === "scan") {
40
+ const facts = (0, l1_scan_1.scanL1)(dir);
41
+ process.stdout.write(JSON.stringify(facts, null, 2) + "\n");
42
+ return;
43
+ }
44
+ if (command === "build") {
45
+ const provider = flags.provider === "groq" ? "groq" : "anthropic";
46
+ if (provider === "anthropic" && !process.env.ANTHROPIC_API_KEY) {
47
+ console.error("cairn build: ANTHROPIC_API_KEY is not set. Export it, or pass --provider groq, and re-run.");
48
+ process.exit(1);
49
+ }
50
+ if (provider === "groq" && !process.env.GROQ_API_KEYS) {
51
+ console.error("cairn build --provider groq: GROQ_API_KEYS is not set (comma-separated). Export it and re-run.");
52
+ process.exit(1);
53
+ }
54
+ const client = provider === "groq" ? new llm_1.GroqDescribeClient() : new llm_1.AnthropicDescribeClient();
55
+ // Crawl mode: the positional is a URL to a running app, not a source
56
+ // directory — auto-detected (a directory is never a URL), or forced
57
+ // via --mode=crawl. This is the framework-agnostic path (see
58
+ // ROADMAP.md Phase 2): reads the *rendered* DOM instead of parsing
59
+ // Next.js-specific source conventions, so it works on any framework's
60
+ // output, at the cost of less precision than reading real source.
61
+ const isCrawl = flags.mode === "crawl" || /^https?:\/\//.test(dir);
62
+ if (isCrawl) {
63
+ const outDir = flags.out ?? ".";
64
+ if (flags["storage-state"]) {
65
+ console.error(`cairn build --mode=crawl: replaying saved session from ${flags["storage-state"]}`);
66
+ }
67
+ console.error(`cairn build --mode=crawl: launching a headless browser against ${dir} ...`);
68
+ const facts = await (0, crawl_1.crawlSite)({ startUrl: dir, storageStatePath: flags["storage-state"] });
69
+ if (facts.pages.length === 0) {
70
+ console.error(`cairn build --mode=crawl: found no reachable pages at ${dir} — is it actually running?`);
71
+ process.exit(1);
72
+ }
73
+ const l3 = await (0, crawl_describe_1.describeCrawled)(outDir, facts, client);
74
+ const manifest = (0, manifest_1.assembleManifest)(outDir, facts, { dead: [], conflicts: [] }, l3);
75
+ const validated = core_1.ManifestSchema.parse(manifest);
76
+ const outPath = node_path_1.default.join(node_path_1.default.resolve(outDir), "ui-manifest.json");
77
+ node_fs_1.default.writeFileSync(outPath, JSON.stringify(validated, null, 2) + "\n");
78
+ console.error(`cairn build --mode=crawl (${provider}): ${validated.pages.length} page(s) crawled — ` +
79
+ `L3 cache: ${l3.cacheHits} hit / ${l3.cacheMisses} miss.`);
80
+ console.error(`wrote ${outPath}`);
81
+ return;
82
+ }
83
+ const facts = (0, l1_scan_1.scanL1)(dir);
84
+ const l2 = (0, l2_reachability_1.computeL2)(dir, facts);
85
+ const l3 = await (0, l3_describe_1.describeAll)(dir, facts, client);
86
+ const manifest = (0, manifest_1.assembleManifest)(dir, facts, l2, l3);
87
+ const validated = core_1.ManifestSchema.parse(manifest);
88
+ const outPath = node_path_1.default.join(node_path_1.default.resolve(dir), "ui-manifest.json");
89
+ node_fs_1.default.writeFileSync(outPath, JSON.stringify(validated, null, 2) + "\n");
90
+ console.error(`cairn build (${provider}): ${validated.pages.length} page(s), ${validated.dead.length} dead file(s), ` +
91
+ `${validated.conflicts.length} conflict(s) — L3 cache: ${l3.cacheHits} hit / ${l3.cacheMisses} miss.`);
92
+ console.error(`wrote ${outPath}`);
93
+ return;
94
+ }
95
+ if (command === "init") {
96
+ const result = (0, init_1.runInit)(dir);
97
+ console.error(`cairn init: detected ${result.framework}.`);
98
+ for (const f of result.filesWritten)
99
+ console.error(` wrote ${node_path_1.default.relative(process.cwd(), f) || f}`);
100
+ for (const f of result.filesSkipped)
101
+ console.error(` skipped ${node_path_1.default.relative(process.cwd(), f) || f} (already exists)`);
102
+ console.error("");
103
+ console.error("Next steps:");
104
+ for (const step of result.nextSteps)
105
+ console.error(` ${step}`);
106
+ return;
107
+ }
108
+ if (command === "diff") {
109
+ const [oldPath, newPath] = positional;
110
+ if (!oldPath || !newPath) {
111
+ console.error("usage: cairn diff <old-manifest.json> <new-manifest.json>");
112
+ process.exit(1);
113
+ }
114
+ const before = core_1.ManifestSchema.parse(JSON.parse(node_fs_1.default.readFileSync(oldPath, "utf8")));
115
+ const after = core_1.ManifestSchema.parse(JSON.parse(node_fs_1.default.readFileSync(newPath, "utf8")));
116
+ console.log((0, diff_1.formatDiffAsText)((0, diff_1.diffManifests)(before, after)));
117
+ return;
118
+ }
119
+ if (command === "docs") {
120
+ const manifestPath = node_path_1.default.join(node_path_1.default.resolve(dir), "ui-manifest.json");
121
+ if (!node_fs_1.default.existsSync(manifestPath)) {
122
+ console.error(`cairn docs: no ${manifestPath} — run \`cairn build ${dir}\` first.`);
123
+ process.exit(1);
124
+ }
125
+ const manifest = core_1.ManifestSchema.parse(JSON.parse(node_fs_1.default.readFileSync(manifestPath, "utf8")));
126
+ const outPath = node_path_1.default.join(node_path_1.default.resolve(dir), "CAIRN_DOCS.md");
127
+ node_fs_1.default.writeFileSync(outPath, (0, docs_1.generateDocsMarkdown)(manifest) + "\n");
128
+ console.error(`wrote ${outPath}`);
129
+ return;
130
+ }
131
+ console.error("usage:");
132
+ console.error(" cairn init <dir> (scaffolds the API route/server + .env.example, detects your framework)");
133
+ console.error(" cairn scan <dir>");
134
+ console.error(" cairn build <dir> [--provider anthropic|groq] (Next.js source scan)");
135
+ console.error(" cairn build <url> [--provider anthropic|groq] [--out <dir>] [--storage-state <file>] (any framework — crawls a running app; --storage-state replays a saved logged-in session for auth-gated apps)");
136
+ console.error(" cairn diff <old-manifest.json> <new-manifest.json>");
137
+ console.error(" cairn docs <dir> (reads <dir>/ui-manifest.json, writes <dir>/CAIRN_DOCS.md)");
138
+ process.exit(command ? 1 : 0);
139
+ }
140
+ main().catch((err) => {
141
+ console.error(err);
142
+ process.exit(1);
143
+ });
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ // A fixed-size worker pool, not Promise.all(items.map(...)) — for a large
3
+ // app (hundreds of pages), firing every LLM call at once would slam
4
+ // straight into provider rate limits; sequential (the original L3
5
+ // implementation) is correct but leaves real throughput on the table —
6
+ // GroqDescribeClient's KeyRotator already round-robins multiple API keys
7
+ // specifically for this, but a for-loop awaiting one call at a time never
8
+ // actually exercised more than one key at once. A bounded pool is the
9
+ // middle ground: real parallelism, still capped.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.mapWithConcurrency = mapWithConcurrency;
12
+ exports.withRetry = withRetry;
13
+ async function mapWithConcurrency(items, limit, fn) {
14
+ const results = new Array(items.length);
15
+ let nextIndex = 0;
16
+ async function worker() {
17
+ for (;;) {
18
+ const i = nextIndex++;
19
+ if (i >= items.length)
20
+ return;
21
+ results[i] = await fn(items[i], i);
22
+ }
23
+ }
24
+ const workerCount = Math.max(1, Math.min(limit, items.length));
25
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
26
+ return results;
27
+ }
28
+ /**
29
+ * Retries a rate-limited (429) or transient-server-error (5xx) call with
30
+ * backoff — found live and necessary, not theoretical: raising describeAll's
31
+ * concurrency (see DEFAULT_DESCRIBE_CONCURRENCY) surfaced a real 429 from
32
+ * Groq on a 40-page build within seconds ("Rate limit reached... tokens per
33
+ * minute"), which without this would have thrown straight out of
34
+ * mapWithConcurrency's Promise.all and aborted the *entire* build,
35
+ * discarding every other page's already-completed work too. Honors the
36
+ * provider's Retry-After header when present (both the Anthropic and Groq
37
+ * SDKs expose one on a 429), falls back to exponential backoff otherwise.
38
+ */
39
+ async function withRetry(fn, opts) {
40
+ const maxAttempts = opts?.maxAttempts ?? 4;
41
+ const baseDelayMs = opts?.baseDelayMs ?? 1000;
42
+ let lastErr;
43
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
44
+ try {
45
+ return await fn();
46
+ }
47
+ catch (err) {
48
+ lastErr = err;
49
+ if (!isRetryable(err) || attempt === maxAttempts)
50
+ throw err;
51
+ const delayMs = retryAfterMs(err) ?? baseDelayMs * 2 ** (attempt - 1);
52
+ console.error(`[cairn] retryable error (attempt ${attempt}/${maxAttempts}), waiting ${Math.round(delayMs)}ms:`, errorMessage(err));
53
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
54
+ }
55
+ }
56
+ throw lastErr;
57
+ }
58
+ function isRetryable(err) {
59
+ const status = err?.status;
60
+ return status === 429 || (typeof status === "number" && status >= 500);
61
+ }
62
+ function retryAfterMs(err) {
63
+ const headers = err?.headers;
64
+ const raw = headers?.get?.("retry-after");
65
+ if (!raw)
66
+ return null;
67
+ const seconds = Number(raw);
68
+ return Number.isFinite(seconds) ? seconds * 1000 : null;
69
+ }
70
+ function errorMessage(err) {
71
+ return err instanceof Error ? err.message : String(err);
72
+ }
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ // Crawl mode's equivalent of l3-describe.ts's describeAll — same LLM
3
+ // description step, same content-hash caching (a warm build with no
4
+ // change in what was crawled never re-calls the model), but hashing the
5
+ // crawled page's rendered text + elements instead of reading source off
6
+ // disk (crawl mode has no source file — see types.ts's RawPage.renderedText).
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.describeCrawled = describeCrawled;
12
+ const node_fs_1 = __importDefault(require("node:fs"));
13
+ const node_path_1 = __importDefault(require("node:path"));
14
+ const node_crypto_1 = require("node:crypto");
15
+ const concurrency_1 = require("./concurrency");
16
+ const CACHE_DIR = ".cairn-cache";
17
+ const DEFAULT_DESCRIBE_CONCURRENCY = 6;
18
+ async function describeCrawled(outDir, facts, client, concurrency = DEFAULT_DESCRIBE_CONCURRENCY) {
19
+ const absOut = node_path_1.default.resolve(outDir);
20
+ const cacheDir = node_path_1.default.join(absOut, CACHE_DIR);
21
+ node_fs_1.default.mkdirSync(cacheDir, { recursive: true });
22
+ const descriptions = new Map();
23
+ let cacheHits = 0;
24
+ let cacheMisses = 0;
25
+ const toDescribe = [];
26
+ for (const page of facts.pages) {
27
+ const hash = hashCrawledPage(page.route, page.renderedText ?? "", page.elements);
28
+ const cachePath = node_path_1.default.join(cacheDir, `${hash}.json`);
29
+ if (node_fs_1.default.existsSync(cachePath)) {
30
+ descriptions.set(page.route, JSON.parse(node_fs_1.default.readFileSync(cachePath, "utf8")));
31
+ cacheHits += 1;
32
+ continue;
33
+ }
34
+ toDescribe.push({ page, cachePath });
35
+ }
36
+ await (0, concurrency_1.mapWithConcurrency)(toDescribe, concurrency, async ({ page, cachePath }) => {
37
+ let description;
38
+ let succeeded = true;
39
+ try {
40
+ description = await (0, concurrency_1.withRetry)(() => client.describePage({
41
+ route: page.route,
42
+ file: page.file, // a URL in crawl mode, not a filesystem path
43
+ source: page.renderedText ?? "",
44
+ elements: page.elements.map(toDescribeElementInput),
45
+ }));
46
+ }
47
+ catch (err) {
48
+ console.error(`[cairn crawl] describing ${page.route} failed after retries — degrading this page only:`, err);
49
+ description = {
50
+ title: "(description unavailable)",
51
+ purpose: "Could not be described — the description service failed after retries.",
52
+ whenToUse: "Unknown.",
53
+ confidence: 0,
54
+ elements: page.elements.map((el) => ({ id: el.id, does: "Unknown — description generation failed.", confidence: 0 })),
55
+ };
56
+ succeeded = false;
57
+ }
58
+ // Not cached when degraded — a re-crawl should naturally retry this
59
+ // page instead of being permanently pinned to a failure placeholder.
60
+ if (succeeded)
61
+ node_fs_1.default.writeFileSync(cachePath, JSON.stringify(description, null, 2));
62
+ descriptions.set(page.route, description);
63
+ cacheMisses += 1;
64
+ });
65
+ // Crawl mode has no static concept of "present on every page" (that's a
66
+ // component-import fact l1-scan.ts derives from source) — nav bars etc.
67
+ // just show up as regular elements on every crawled page that renders
68
+ // them, described individually per page like anything else.
69
+ return { descriptions, globalElements: [], cacheHits, cacheMisses };
70
+ }
71
+ function toDescribeElementInput(el) {
72
+ return {
73
+ id: el.id,
74
+ tag: el.tag,
75
+ text: el.text,
76
+ dataAi: el.dataAi,
77
+ ariaLabel: el.ariaLabel,
78
+ handlerCall: el.handlerCall,
79
+ };
80
+ }
81
+ function hashCrawledPage(route, renderedText, elements) {
82
+ const hash = (0, node_crypto_1.createHash)("sha256");
83
+ hash.update(route);
84
+ hash.update(renderedText);
85
+ hash.update(JSON.stringify(elements));
86
+ return hash.digest("hex");
87
+ }
package/dist/crawl.js ADDED
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ // Framework-agnostic analyzer: instead of parsing a framework's *source
3
+ // code* (l1-scan.ts, Next.js-only), this crawls the *rendered* app with a
4
+ // headless browser and reads the live DOM — by the time a page reaches the
5
+ // browser, Vue/Angular/Svelte/Next.js output is just DOM, so this one
6
+ // crawler works on any of them without a framework-specific parser.
7
+ //
8
+ // Trade-off, stated plainly (see ROADMAP.md Phase 2): needs a running
9
+ // server to crawl, and can't see handler/API-call evidence the way reading
10
+ // real source can — an element's "does" description is inferred from its
11
+ // visible text and page context alone. Output is the exact same RawFacts
12
+ // shape l1-scan.ts produces, so everything downstream (computeL2,
13
+ // describeAll's DescribeClient, assembleManifest) needs zero changes.
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.crawlSite = crawlSite;
16
+ const playwright_1 = require("playwright");
17
+ const concurrency_1 = require("./concurrency");
18
+ const DEFAULT_MAX_PAGES = 30;
19
+ const DEFAULT_MAX_DEPTH = 3;
20
+ const DEFAULT_PAGE_TIMEOUT_MS = 15_000;
21
+ const DEFAULT_CRAWL_CONCURRENCY = 4;
22
+ /**
23
+ * Visits every page reachable within maxDepth hops of startUrl, one BFS
24
+ * "level" at a time — same-depth pages are visited concurrently (bounded
25
+ * pool, real parallel tabs in the same browser context), then their
26
+ * discovered links become the next level. Processing depth-by-depth rather
27
+ * than a single shared work queue sidesteps the coordination problem of
28
+ * "is a concurrent worker really done, or about to discover more work" —
29
+ * simpler to reason about correctly, at the minor cost of a slightly
30
+ * bursty (not perfectly smoothed) request pattern.
31
+ */
32
+ async function crawlSite(opts) {
33
+ const maxPages = opts.maxPages ?? DEFAULT_MAX_PAGES;
34
+ const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
35
+ const pageTimeoutMs = opts.pageTimeoutMs ?? DEFAULT_PAGE_TIMEOUT_MS;
36
+ const concurrency = opts.concurrency ?? DEFAULT_CRAWL_CONCURRENCY;
37
+ const startUrl = new URL(opts.startUrl);
38
+ const origin = startUrl.origin;
39
+ const browser = await playwright_1.chromium.launch();
40
+ const pages = [];
41
+ const visited = new Set([normalizeForDedup(startUrl.toString())]);
42
+ try {
43
+ const context = await browser.newContext(opts.storageStatePath ? { storageState: opts.storageStatePath } : {});
44
+ let currentLevel = [startUrl.toString()];
45
+ let depth = 0;
46
+ while (currentLevel.length > 0 && depth <= maxDepth && pages.length < maxPages) {
47
+ const budget = Math.max(0, maxPages - pages.length);
48
+ const levelUrls = currentLevel.slice(0, budget);
49
+ const linkBatches = await (0, concurrency_1.mapWithConcurrency)(levelUrls, concurrency, async (url) => {
50
+ const page = await context.newPage();
51
+ try {
52
+ const response = await page.goto(url, { waitUntil: "networkidle", timeout: pageTimeoutMs });
53
+ if (!response || !response.ok()) {
54
+ console.error(`[cairn crawl] skipping ${url} — HTTP ${response?.status() ?? "no response"}`);
55
+ return [];
56
+ }
57
+ const finalUrl = page.url();
58
+ const extracted = await extractPageData(page);
59
+ const route = new URL(finalUrl).pathname || "/";
60
+ pages.push({
61
+ route,
62
+ file: finalUrl,
63
+ reachableFiles: [],
64
+ elements: extracted.elements,
65
+ renderedText: extracted.bodyText,
66
+ });
67
+ if (depth >= maxDepth)
68
+ return [];
69
+ const nextLinks = [];
70
+ for (const href of extracted.links) {
71
+ let abs;
72
+ try {
73
+ abs = new URL(href, finalUrl);
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ if (abs.origin !== origin)
79
+ continue;
80
+ nextLinks.push(abs.toString());
81
+ }
82
+ return nextLinks;
83
+ }
84
+ catch (err) {
85
+ console.error(`[cairn crawl] skipping ${url} — ${err instanceof Error ? err.message : String(err)}`);
86
+ return [];
87
+ }
88
+ finally {
89
+ await page.close();
90
+ }
91
+ });
92
+ const nextLevel = [];
93
+ for (const links of linkBatches) {
94
+ for (const url of links) {
95
+ const key = normalizeForDedup(url);
96
+ if (visited.has(key))
97
+ continue;
98
+ visited.add(key);
99
+ nextLevel.push(url);
100
+ }
101
+ }
102
+ currentLevel = nextLevel;
103
+ depth += 1;
104
+ }
105
+ await context.close();
106
+ }
107
+ finally {
108
+ await browser.close();
109
+ }
110
+ return {
111
+ version: "1",
112
+ pages,
113
+ allScannedFiles: [],
114
+ frameworkReachableFiles: [],
115
+ frameworkElements: [],
116
+ };
117
+ }
118
+ function normalizeForDedup(url) {
119
+ const u = new URL(url);
120
+ return u.origin + u.pathname; // query/hash don't distinguish routes for crawl purposes
121
+ }
122
+ /**
123
+ * Runs inside the page (page.evaluate — this function is serialized and
124
+ * executed in the browser's context, not Node's), so it can only use
125
+ * plain DOM APIs, nothing from the surrounding module.
126
+ */
127
+ async function extractPageData(page) {
128
+ return page.evaluate(() => {
129
+ function normalizeText(t) {
130
+ return (t ?? "").trim().replace(/\s+/g, " ");
131
+ }
132
+ const seen = new Set();
133
+ const elements = [];
134
+ const candidates = Array.from(document.querySelectorAll("button, a, [role='button'], input[type='submit'], input[type='button'], [data-ai]"));
135
+ for (const el of candidates) {
136
+ const rect = el.getBoundingClientRect();
137
+ if (rect.width === 0 && rect.height === 0)
138
+ continue; // not actually visible
139
+ const dataAi = el.getAttribute("data-ai");
140
+ const ariaLabel = el.getAttribute("aria-label");
141
+ const text = normalizeText(el.textContent);
142
+ // Id is the raw (not slugified) data-ai/aria-label/text value, on
143
+ // purpose — the runtime widget's findElement() ladder matches
144
+ // aria-label and text *exactly as they appear on the element*, so a
145
+ // slugified id (e.g. "new-invoice") would never actually be found at
146
+ // runtime for an element with no data-ai. Nothing to identify or
147
+ // meaningfully describe an element by (no data-ai, no aria-label, no
148
+ // text) means it's skipped, not guessed at.
149
+ const id = dataAi || ariaLabel || text;
150
+ if (!id)
151
+ continue;
152
+ if (seen.has(id))
153
+ continue; // first occurrence of a repeated element (e.g. every row's "Archive") stands in for all of them
154
+ seen.add(id);
155
+ const tagName = el.tagName.toLowerCase();
156
+ const tag = tagName === "a" ? "a" : tagName === "input" ? "input" : tagName === "form" ? "form" : "button";
157
+ elements.push({
158
+ id,
159
+ tag,
160
+ dataAi: dataAi || null,
161
+ ariaLabel: ariaLabel || null,
162
+ text: text || null,
163
+ handlerCall: null,
164
+ file: location.href,
165
+ line: 0,
166
+ });
167
+ }
168
+ const links = Array.from(document.querySelectorAll("a[href]"))
169
+ .map((a) => a.getAttribute("href"))
170
+ .filter((h) => !!h && !/^(mailto:|tel:|javascript:|#)/.test(h));
171
+ const bodyText = document.body.innerText.trim().replace(/\n{3,}/g, "\n\n").slice(0, 12_000);
172
+ return { elements, links, bodyText };
173
+ });
174
+ }
package/dist/diff.js ADDED
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ // "What changed" between two manifest builds — e.g. CI comparing the
3
+ // current build against the last one on main. Pure comparison, no I/O.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.diffManifests = diffManifests;
6
+ exports.formatDiffAsText = formatDiffAsText;
7
+ function diffManifests(before, after) {
8
+ const beforeByRoute = new Map(before.pages.map((p) => [p.route, p]));
9
+ const afterByRoute = new Map(after.pages.map((p) => [p.route, p]));
10
+ const pagesAdded = [...afterByRoute.keys()].filter((r) => !beforeByRoute.has(r)).sort();
11
+ const pagesRemoved = [...beforeByRoute.keys()].filter((r) => !afterByRoute.has(r)).sort();
12
+ const pagesChanged = [];
13
+ for (const [route, beforePage] of beforeByRoute) {
14
+ const afterPage = afterByRoute.get(route);
15
+ if (!afterPage)
16
+ continue;
17
+ const change = diffPage(beforePage, afterPage);
18
+ if (change)
19
+ pagesChanged.push(change);
20
+ }
21
+ pagesChanged.sort((a, b) => a.route.localeCompare(b.route));
22
+ return {
23
+ pagesAdded,
24
+ pagesRemoved,
25
+ pagesChanged,
26
+ deadAdded: after.dead.filter((f) => !before.dead.includes(f)).sort(),
27
+ deadRemoved: before.dead.filter((f) => !after.dead.includes(f)).sort(),
28
+ };
29
+ }
30
+ function diffPage(before, after) {
31
+ const beforeElements = new Map(before.elements.map((e) => [e.id, e]));
32
+ const afterElements = new Map(after.elements.map((e) => [e.id, e]));
33
+ const elementsAdded = [...afterElements.keys()].filter((id) => !beforeElements.has(id)).sort();
34
+ const elementsRemoved = [...beforeElements.keys()].filter((id) => !afterElements.has(id)).sort();
35
+ const elementsChanged = [];
36
+ for (const [id, beforeEl] of beforeElements) {
37
+ const afterEl = afterElements.get(id);
38
+ if (!afterEl)
39
+ continue;
40
+ if (elementContentChanged(beforeEl, afterEl)) {
41
+ elementsChanged.push({
42
+ id,
43
+ doesBefore: beforeEl.does,
44
+ doesAfter: afterEl.does,
45
+ confidenceBefore: beforeEl.confidence,
46
+ confidenceAfter: afterEl.confidence,
47
+ });
48
+ }
49
+ }
50
+ elementsChanged.sort((a, b) => a.id.localeCompare(b.id));
51
+ const purposeChanged = before.purpose !== after.purpose || before.whenToUse !== after.whenToUse;
52
+ if (!purposeChanged && elementsAdded.length === 0 && elementsRemoved.length === 0 && elementsChanged.length === 0) {
53
+ return null;
54
+ }
55
+ return { route: before.route, purposeChanged, elementsAdded, elementsRemoved, elementsChanged };
56
+ }
57
+ function elementContentChanged(a, b) {
58
+ return a.does !== b.does || a.confidence !== b.confidence;
59
+ }
60
+ function formatDiffAsText(diff) {
61
+ const lines = [];
62
+ if (diff.pagesAdded.length)
63
+ lines.push(`+ pages added: ${diff.pagesAdded.join(", ")}`);
64
+ if (diff.pagesRemoved.length)
65
+ lines.push(`- pages removed: ${diff.pagesRemoved.join(", ")}`);
66
+ for (const change of diff.pagesChanged) {
67
+ lines.push(`~ ${change.route}:`);
68
+ if (change.purposeChanged)
69
+ lines.push(` purpose/whenToUse changed`);
70
+ for (const id of change.elementsAdded)
71
+ lines.push(` + element added: ${id}`);
72
+ for (const id of change.elementsRemoved)
73
+ lines.push(` - element removed: ${id}`);
74
+ for (const c of change.elementsChanged) {
75
+ lines.push(` ~ ${c.id}: "${c.doesBefore}" -> "${c.doesAfter}" (confidence ${c.confidenceBefore} -> ${c.confidenceAfter})`);
76
+ }
77
+ }
78
+ if (diff.deadAdded.length)
79
+ lines.push(`+ newly dead: ${diff.deadAdded.join(", ")}`);
80
+ if (diff.deadRemoved.length)
81
+ lines.push(`- no longer dead: ${diff.deadRemoved.join(", ")}`);
82
+ return lines.length > 0 ? lines.join("\n") : "no changes";
83
+ }
package/dist/docs.js ADDED
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ // Turns a manifest into human-readable Markdown docs. The content already
3
+ // exists in the manifest — this is just formatting, no LLM call.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.generateDocsMarkdown = generateDocsMarkdown;
6
+ function generateDocsMarkdown(manifest) {
7
+ const sections = [
8
+ `# App Reference`,
9
+ ``,
10
+ `Generated by \`cairn docs\` from commit \`${manifest.commit}\` at ${manifest.generatedAt}. Do not edit by hand — regenerate instead.`,
11
+ ``,
12
+ ];
13
+ const pages = [...manifest.pages].sort((a, b) => a.route.localeCompare(b.route));
14
+ for (const page of pages) {
15
+ sections.push(`## ${page.title} (\`${page.route}\`)`, ``, page.purpose, ``, `**When to use:** ${page.whenToUse}`, ``);
16
+ if (page.elements.length > 0) {
17
+ sections.push(`| Element | Does |`, `|---|---|`);
18
+ for (const el of page.elements) {
19
+ sections.push(`| ${el.label} | ${el.does} |`);
20
+ }
21
+ sections.push(``);
22
+ }
23
+ }
24
+ if (manifest.dead.length > 0) {
25
+ sections.push(`## Dead code`, ``, `Not reachable from any route — safe-ish to remove:`, ``);
26
+ for (const file of [...manifest.dead].sort())
27
+ sections.push(`- \`${file}\``);
28
+ sections.push(``);
29
+ }
30
+ if (manifest.conflicts.length > 0) {
31
+ sections.push(`## Naming conflicts resolved during the last build`, ``);
32
+ for (const c of manifest.conflicts) {
33
+ sections.push(`- Between ${c.candidates.map((f) => `\`${f}\``).join(" and ")}, chose \`${c.chose}\` — ${c.reason}.`);
34
+ }
35
+ sections.push(``);
36
+ }
37
+ return sections.join("\n");
38
+ }
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KeyRotator = exports.generateDocsMarkdown = exports.formatDiffAsText = exports.diffManifests = exports.GroqDescribeClient = exports.AnthropicDescribeClient = exports.assembleManifest = exports.describeAll = exports.computeL2 = exports.scanL1 = void 0;
4
+ var l1_scan_1 = require("./l1-scan");
5
+ Object.defineProperty(exports, "scanL1", { enumerable: true, get: function () { return l1_scan_1.scanL1; } });
6
+ var l2_reachability_1 = require("./l2-reachability");
7
+ Object.defineProperty(exports, "computeL2", { enumerable: true, get: function () { return l2_reachability_1.computeL2; } });
8
+ var l3_describe_1 = require("./l3-describe");
9
+ Object.defineProperty(exports, "describeAll", { enumerable: true, get: function () { return l3_describe_1.describeAll; } });
10
+ var manifest_1 = require("./manifest");
11
+ Object.defineProperty(exports, "assembleManifest", { enumerable: true, get: function () { return manifest_1.assembleManifest; } });
12
+ var llm_1 = require("./llm");
13
+ Object.defineProperty(exports, "AnthropicDescribeClient", { enumerable: true, get: function () { return llm_1.AnthropicDescribeClient; } });
14
+ Object.defineProperty(exports, "GroqDescribeClient", { enumerable: true, get: function () { return llm_1.GroqDescribeClient; } });
15
+ var diff_1 = require("./diff");
16
+ Object.defineProperty(exports, "diffManifests", { enumerable: true, get: function () { return diff_1.diffManifests; } });
17
+ Object.defineProperty(exports, "formatDiffAsText", { enumerable: true, get: function () { return diff_1.formatDiffAsText; } });
18
+ var docs_1 = require("./docs");
19
+ Object.defineProperty(exports, "generateDocsMarkdown", { enumerable: true, get: function () { return docs_1.generateDocsMarkdown; } });
20
+ var key_rotator_1 = require("./key-rotator");
21
+ Object.defineProperty(exports, "KeyRotator", { enumerable: true, get: function () { return key_rotator_1.KeyRotator; } });