zefiro 0.9.0 → 0.10.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/README.md CHANGED
@@ -17,7 +17,7 @@ bun add -g zefiro
17
17
  zefiro init
18
18
 
19
19
  # 2. Explore a running application
20
- zefiro scan # static AST scan (optional)
20
+ zefiro explore
21
21
  ```
22
22
 
23
23
  ---
@@ -52,27 +52,18 @@ On re-run (`.qai/zefiro/agents/` already exists), preserves your existing setup
52
52
  **After init:**
53
53
  1. Generate `.qai/zefiro/context.md` using the `init-agent` prompt in your AI tool, or via MCP (`zefiro_scan_codebase`)
54
54
  2. Review the generated context
55
- 3. Run `zefiro scan`
55
+ 3. Run `zefiro explore`
56
56
 
57
57
  ---
58
58
 
59
- ### `zefiro scan`
59
+ ### `zefiro explore`
60
60
 
61
- Scan your codebase AST extracts routes, components, hooks, imports, and dependencies.
61
+ Explore a running web application via browser automation. Navigates pages, captures screenshots, and generates structured documentation.
62
62
 
63
63
  ```bash
64
- zefiro scan
65
- zefiro scan --scan-dir src/app
66
- zefiro scan --output custom-scan.json
67
- zefiro scan --no-cache
64
+ zefiro explore
68
65
  ```
69
66
 
70
- | Option | Description |
71
- |--------|-------------|
72
- | `--output <file>` | Output file (default: `.qai/zefiro/ast-scan.json`) |
73
- | `--scan-dir <dir>` | Directory to scan (overrides config) |
74
- | `--no-cache` | Disable file-level MD5 caching |
75
-
76
67
  ---
77
68
 
78
69
  ### `zefiro auth`
@@ -0,0 +1,167 @@
1
+ // src/browser/url-tracker.ts
2
+ import { createHash } from "node:crypto";
3
+ var SKIP_EXTENSIONS = new Set([
4
+ ".pdf",
5
+ ".zip",
6
+ ".csv",
7
+ ".xlsx",
8
+ ".xls",
9
+ ".doc",
10
+ ".docx",
11
+ ".png",
12
+ ".jpg",
13
+ ".jpeg",
14
+ ".gif",
15
+ ".svg",
16
+ ".ico",
17
+ ".webp",
18
+ ".mp3",
19
+ ".mp4",
20
+ ".wav",
21
+ ".avi",
22
+ ".mov"
23
+ ]);
24
+ var SKIP_PATTERNS = [
25
+ /\/logout/i,
26
+ /\/signout/i,
27
+ /\/api\//i,
28
+ /\/auth\/callback/i,
29
+ /\/oauth/i,
30
+ /javascript:/i,
31
+ /mailto:/i,
32
+ /tel:/i,
33
+ /#$/
34
+ ];
35
+ function normalizeUrl(url, baseUrl) {
36
+ try {
37
+ const resolved = new URL(url, baseUrl);
38
+ resolved.hash = "";
39
+ let normalized = resolved.href;
40
+ if (normalized.endsWith("/") && resolved.pathname !== "/") {
41
+ normalized = normalized.slice(0, -1);
42
+ }
43
+ return normalized;
44
+ } catch {
45
+ return url;
46
+ }
47
+ }
48
+ function shouldVisit(url, baseUrl, excludePatterns, visited) {
49
+ try {
50
+ const parsed = new URL(url);
51
+ const base = new URL(baseUrl);
52
+ if (parsed.origin !== base.origin)
53
+ return false;
54
+ const normalized = normalizeUrl(url, baseUrl);
55
+ if (visited.has(normalized))
56
+ return false;
57
+ const ext = parsed.pathname.match(/\.\w+$/)?.[0]?.toLowerCase();
58
+ if (ext && SKIP_EXTENSIONS.has(ext))
59
+ return false;
60
+ for (const pattern of SKIP_PATTERNS) {
61
+ if (pattern.test(url))
62
+ return false;
63
+ }
64
+ for (const pattern of excludePatterns) {
65
+ try {
66
+ if (new RegExp(pattern).test(url))
67
+ return false;
68
+ } catch {
69
+ if (url.includes(pattern))
70
+ return false;
71
+ }
72
+ }
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+ function extractHrefsFromElements(elements) {
79
+ return elements.filter((el) => el.role === "link").map((el) => el.ref);
80
+ }
81
+ function urlToSlug(url, baseUrl) {
82
+ try {
83
+ const parsed = new URL(url, baseUrl);
84
+ const path = parsed.pathname;
85
+ if (path === "/" || path === "")
86
+ return "home";
87
+ return path.replace(/^\/|\/$/g, "").replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").toLowerCase();
88
+ } catch {
89
+ return "unknown";
90
+ }
91
+ }
92
+ function buildTopology(pages) {
93
+ const root = { path: "/", slug: "root", stableId: generatePageId("root"), title: "Application", children: [] };
94
+ const nodeMap = new Map;
95
+ nodeMap.set("/", root);
96
+ const sortedPages = Array.from(pages.values()).filter((p) => p.status === "visited").sort((a, b) => a.depth - b.depth);
97
+ for (const page of sortedPages) {
98
+ const segments = page.path.replace(/^\/|\/$/g, "").split("/").filter(Boolean);
99
+ const node = {
100
+ path: page.path,
101
+ slug: page.slug,
102
+ stableId: page.stableId,
103
+ title: page.title || page.slug,
104
+ children: []
105
+ };
106
+ let parentPath = "/";
107
+ if (segments.length > 1) {
108
+ parentPath = "/" + segments.slice(0, -1).join("/");
109
+ }
110
+ const parent = nodeMap.get(parentPath) ?? root;
111
+ parent.children.push(node);
112
+ nodeMap.set(page.path, node);
113
+ }
114
+ return root.children;
115
+ }
116
+ function labelHash(label) {
117
+ return createHash("sha256").update(label.trim().toLowerCase()).digest("hex").slice(0, 8);
118
+ }
119
+ function toKebab(s) {
120
+ return s.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
121
+ }
122
+ function generatePageId(slug) {
123
+ return `page:${slug}`;
124
+ }
125
+ function generateSectionId(pageSlug, sectionName) {
126
+ return `section:${pageSlug}:${toKebab(sectionName)}`;
127
+ }
128
+ function generateElementId(pageSlug, role, label) {
129
+ return `element:${pageSlug}:${role}:${labelHash(label)}`;
130
+ }
131
+ function generateEdgeId(sourceId, targetId) {
132
+ return `edge:${sourceId}->${targetId}`;
133
+ }
134
+ function classifySectionType(sectionType) {
135
+ const t = sectionType.toLowerCase();
136
+ if (t.includes("modal") || t.includes("dialog") || t.includes("popup"))
137
+ return "dialog";
138
+ if (t.includes("form"))
139
+ return "form";
140
+ if (t.includes("table") || t.includes("list") || t.includes("grid"))
141
+ return "table";
142
+ if (t.includes("tab"))
143
+ return "tab";
144
+ if (t.includes("filter") || t.includes("search"))
145
+ return "filter";
146
+ if (t.includes("action") || t.includes("toolbar") || t.includes("button"))
147
+ return "action";
148
+ if (t.includes("panel") || t.includes("card") || t.includes("section"))
149
+ return "page";
150
+ return "page";
151
+ }
152
+ function topologyToAscii(nodes, prefix = "") {
153
+ const lines = [];
154
+ for (let i = 0;i < nodes.length; i++) {
155
+ const isLast = i === nodes.length - 1;
156
+ const connector = isLast ? "└── " : "├── ";
157
+ const childPrefix = isLast ? " " : "│ ";
158
+ lines.push(`${prefix}${connector}${nodes[i].path} — ${nodes[i].title}`);
159
+ if (nodes[i].children.length > 0) {
160
+ lines.push(topologyToAscii(nodes[i].children, prefix + childPrefix));
161
+ }
162
+ }
163
+ return lines.join(`
164
+ `);
165
+ }
166
+
167
+ export { normalizeUrl, shouldVisit, extractHrefsFromElements, urlToSlug, buildTopology, generatePageId, generateSectionId, generateElementId, generateEdgeId, classifySectionType, topologyToAscii };
@@ -0,0 +1,150 @@
1
+ import {
2
+ AgentBrowser,
3
+ authenticate,
4
+ error,
5
+ info,
6
+ success,
7
+ verbose
8
+ } from "./cli-b26q1e27.js";
9
+ import {
10
+ generateSectionMarkdown
11
+ } from "./cli-kjyet1n8.js";
12
+ import {
13
+ ensureDir,
14
+ writeFile
15
+ } from "./cli-zvk8gwe4.js";
16
+ import {
17
+ buildTopology,
18
+ classifySectionType,
19
+ extractHrefsFromElements,
20
+ generateElementId,
21
+ generatePageId,
22
+ generateSectionId,
23
+ normalizeUrl,
24
+ shouldVisit,
25
+ urlToSlug
26
+ } from "./cli-5w708rbb.js";
27
+
28
+ // src/browser/explorer.ts
29
+ import { join } from "node:path";
30
+ async function runExploration(config) {
31
+ const state = {
32
+ baseUrl: config.baseUrl,
33
+ startedAt: new Date().toISOString(),
34
+ pages: new Map,
35
+ queue: [],
36
+ topology: []
37
+ };
38
+ const browser = new AgentBrowser({
39
+ session: config.sessionName,
40
+ headed: config.headed || config.auth.method === "manual"
41
+ });
42
+ const visited = new Set;
43
+ const screenshotsDir = join(config.outputDir, "screenshots");
44
+ const sectionsDir = join(config.outputDir, "sections");
45
+ ensureDir(screenshotsDir);
46
+ ensureDir(sectionsDir);
47
+ try {
48
+ await authenticate(browser, config);
49
+ await browser.setViewport(1440, 900);
50
+ const startUrl = normalizeUrl(config.baseUrl, config.baseUrl);
51
+ state.queue.push(startUrl);
52
+ let pageCount = 0;
53
+ while (state.queue.length > 0 && pageCount < config.maxPages) {
54
+ const url = state.queue.shift();
55
+ const normalized = normalizeUrl(url, config.baseUrl);
56
+ if (visited.has(normalized))
57
+ continue;
58
+ if (!shouldVisit(url, config.baseUrl, config.excludePatterns, visited)) {
59
+ continue;
60
+ }
61
+ visited.add(normalized);
62
+ const slug = urlToSlug(url, config.baseUrl);
63
+ info(`[${pageCount + 1}/${config.maxPages}] Exploring: ${url}`);
64
+ const page = {
65
+ url: normalized,
66
+ path: new URL(normalized).pathname,
67
+ title: "",
68
+ slug,
69
+ stableId: generatePageId(slug),
70
+ depth: 0,
71
+ screenshot: "",
72
+ elements: [],
73
+ outgoingLinks: [],
74
+ status: "pending"
75
+ };
76
+ try {
77
+ await browser.open(url);
78
+ await browser.waitForLoad(config.waitStrategy, config.waitDelay);
79
+ const actualUrl = await browser.getUrl();
80
+ const actualNormalized = normalizeUrl(actualUrl, config.baseUrl);
81
+ if (actualNormalized !== normalized) {
82
+ page.url = actualNormalized;
83
+ page.path = new URL(actualNormalized).pathname;
84
+ page.slug = urlToSlug(actualUrl, config.baseUrl);
85
+ page.stableId = generatePageId(page.slug);
86
+ if (visited.has(actualNormalized)) {
87
+ verbose(` Redirected to already-visited ${actualUrl}, skipping`);
88
+ continue;
89
+ }
90
+ visited.add(actualNormalized);
91
+ }
92
+ page.title = await browser.getTitle();
93
+ const slugDir = join(screenshotsDir, page.slug);
94
+ ensureDir(slugDir);
95
+ const screenshotPath = join(slugDir, "01-default.png");
96
+ await browser.screenshot(screenshotPath);
97
+ page.screenshot = `screenshots/${page.slug}/01-default.png`;
98
+ page.elements = await browser.snapshot();
99
+ for (const el of page.elements) {
100
+ el.stableId = generateElementId(page.slug, el.role, el.label);
101
+ }
102
+ if (page.analysis?.sections) {
103
+ for (const section of page.analysis.sections) {
104
+ section.stableId = generateSectionId(page.slug, section.name);
105
+ section.nodeType = classifySectionType(section.type);
106
+ }
107
+ }
108
+ const linkRefs = extractHrefsFromElements(page.elements);
109
+ for (const ref of linkRefs) {
110
+ try {
111
+ const href = await browser.getAttribute(ref, "href");
112
+ if (href) {
113
+ const linkUrl = normalizeUrl(href, actualUrl);
114
+ page.outgoingLinks.push(linkUrl);
115
+ if (shouldVisit(linkUrl, config.baseUrl, config.excludePatterns, visited)) {
116
+ state.queue.push(linkUrl);
117
+ }
118
+ }
119
+ } catch {}
120
+ }
121
+ page.depth = calculateDepth(page.path);
122
+ const relatedPages = Array.from(state.pages.values()).filter((p) => p.status === "visited").map((p) => ({ slug: p.slug, name: p.analysis?.pageName ?? p.title, path: p.path }));
123
+ const markdown = generateSectionMarkdown(page, relatedPages);
124
+ writeFile(join(sectionsDir, `${page.slug}.md`), markdown);
125
+ page.status = "visited";
126
+ page.visitedAt = new Date().toISOString();
127
+ pageCount++;
128
+ success(` ${page.title || page.slug} — ${page.elements.length} elements, ${page.outgoingLinks.length} links`);
129
+ } catch (err) {
130
+ page.status = "error";
131
+ page.error = err.message;
132
+ error(` Error: ${err.message}`);
133
+ }
134
+ state.pages.set(page.url, page);
135
+ }
136
+ state.topology = buildTopology(state.pages);
137
+ state.completedAt = new Date().toISOString();
138
+ await browser.close();
139
+ } catch (err) {
140
+ error(`Exploration failed: ${err.message}`);
141
+ await browser.close();
142
+ throw err;
143
+ }
144
+ return state;
145
+ }
146
+ function calculateDepth(path) {
147
+ const segments = path.replace(/^\/|\/$/g, "").split("/").filter(Boolean);
148
+ return segments.length;
149
+ }
150
+ export { runExploration };
@@ -0,0 +1,145 @@
1
+ import {
2
+ classifySectionType,
3
+ generateEdgeId,
4
+ generateSectionId
5
+ } from "./cli-5w708rbb.js";
6
+
7
+ // src/browser/graph-builder.ts
8
+ function toReportNodeType(sectionType) {
9
+ const classified = classifySectionType(sectionType);
10
+ switch (classified) {
11
+ case "form":
12
+ return "page";
13
+ case "table":
14
+ return "page";
15
+ case "tab":
16
+ return "page";
17
+ default:
18
+ return classified;
19
+ }
20
+ }
21
+ var SECTION_ICONS = {
22
+ home: "home",
23
+ dashboard: "layout-dashboard",
24
+ settings: "settings",
25
+ auth: "shield",
26
+ login: "log-in",
27
+ users: "users",
28
+ admin: "shield-check",
29
+ reports: "bar-chart",
30
+ analytics: "trending-up"
31
+ };
32
+ function guessIcon(slug) {
33
+ for (const [key, icon] of Object.entries(SECTION_ICONS)) {
34
+ if (slug.includes(key))
35
+ return icon;
36
+ }
37
+ return;
38
+ }
39
+ function buildReportGraph(state) {
40
+ const nodes = [];
41
+ const edges = [];
42
+ const edgeSet = new Set;
43
+ const visitedPages = Array.from(state.pages.values()).filter((p) => p.status === "visited");
44
+ const slugToStableId = new Map;
45
+ for (const page of visitedPages) {
46
+ slugToStableId.set(page.slug, page.stableId);
47
+ nodes.push({
48
+ id: page.stableId,
49
+ sectionSlug: page.slug,
50
+ type: "page",
51
+ label: page.analysis?.pageName ?? page.title ?? page.slug,
52
+ description: page.analysis?.description ?? `Page at ${page.path}`,
53
+ route: page.path,
54
+ screenshotFilename: "01-default.png"
55
+ });
56
+ if (page.analysis?.sections) {
57
+ for (const section of page.analysis.sections) {
58
+ const sectionId = section.stableId ?? generateSectionId(page.slug, section.name);
59
+ const nodeType = toReportNodeType(section.type);
60
+ nodes.push({
61
+ id: sectionId,
62
+ sectionSlug: page.slug,
63
+ type: nodeType,
64
+ label: section.name,
65
+ description: `${section.type} section with ${section.elements.length} elements`
66
+ });
67
+ const containEdgeId = generateEdgeId(page.stableId, sectionId);
68
+ if (!edgeSet.has(containEdgeId)) {
69
+ edgeSet.add(containEdgeId);
70
+ edges.push({
71
+ id: containEdgeId,
72
+ source: page.stableId,
73
+ target: sectionId,
74
+ label: "contains"
75
+ });
76
+ }
77
+ }
78
+ }
79
+ }
80
+ const urlToStableId = new Map;
81
+ for (const page of visitedPages) {
82
+ urlToStableId.set(page.url, page.stableId);
83
+ }
84
+ for (const page of visitedPages) {
85
+ for (const linkUrl of page.outgoingLinks) {
86
+ const targetId = urlToStableId.get(linkUrl);
87
+ if (targetId && targetId !== page.stableId) {
88
+ const navEdgeId = generateEdgeId(page.stableId, targetId);
89
+ if (!edgeSet.has(navEdgeId)) {
90
+ edgeSet.add(navEdgeId);
91
+ edges.push({
92
+ id: navEdgeId,
93
+ source: page.stableId,
94
+ target: targetId,
95
+ label: "navigates"
96
+ });
97
+ }
98
+ }
99
+ }
100
+ }
101
+ const sectionGroups = buildSectionGroups(state.topology, visitedPages);
102
+ return { nodes, edges, sectionGroups };
103
+ }
104
+ function buildSectionGroups(topology, visitedPages) {
105
+ const groups = [];
106
+ const assignedSlugs = new Set;
107
+ for (const branch of topology) {
108
+ const descendantSlugs = collectSlugs(branch);
109
+ for (const s of descendantSlugs)
110
+ assignedSlugs.add(s);
111
+ groups.push({
112
+ sectionSlug: branch.slug,
113
+ name: branch.title || branch.slug,
114
+ icon: guessIcon(branch.slug)
115
+ });
116
+ }
117
+ for (const page of visitedPages) {
118
+ if (!assignedSlugs.has(page.slug)) {
119
+ groups.push({
120
+ sectionSlug: page.slug,
121
+ name: page.analysis?.pageName ?? page.title ?? page.slug,
122
+ icon: guessIcon(page.slug)
123
+ });
124
+ }
125
+ }
126
+ return groups;
127
+ }
128
+ function collectSlugs(node) {
129
+ const slugs = [node.slug];
130
+ for (const child of node.children) {
131
+ slugs.push(...collectSlugs(child));
132
+ }
133
+ return slugs;
134
+ }
135
+ function buildReportSections(visitedPages, sectionMarkdowns) {
136
+ return visitedPages.map((page, i) => ({
137
+ slug: page.slug,
138
+ name: page.analysis?.pageName ?? page.title ?? page.slug,
139
+ description: page.analysis?.description,
140
+ markdown: sectionMarkdowns.get(page.slug) ?? "",
141
+ sortOrder: i
142
+ }));
143
+ }
144
+
145
+ export { buildReportGraph, buildReportSections };