@tryjourney/architecture 0.1.0 → 0.2.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
@@ -7,3 +7,10 @@ environment values, absolute paths, or file modification times.
7
7
 
8
8
  It is used by `@tryjourney/cli` to observe project architecture while Journey
9
9
  MCP is running.
10
+
11
+ The v2 scanner discovers Git-visible files, package boundaries, framework apps,
12
+ public APIs, exported SDKs, independently started workers, Python API processes,
13
+ and runtime use of PostgreSQL, S3-compatible storage, OpenAI, and email providers.
14
+ It follows local imports from API and worker entry points so provider connections
15
+ are attributed to the boundary that actually reaches them. Dependencies, routes,
16
+ scripts, and environment-variable names remain inventory rather than canvas nodes.
package/dist/index.d.ts CHANGED
@@ -1,37 +1,6 @@
1
- export type Component = {
2
- id: string;
3
- name: string;
4
- type: string;
5
- workspace: string;
6
- technology: string[];
7
- evidence: {
8
- path: string;
9
- line: number;
10
- }[];
11
- confidence: "high" | "medium";
12
- };
13
- export type Connection = {
14
- id: string;
15
- from: string;
16
- to: string;
17
- type: string;
18
- evidence: {
19
- path: string;
20
- line: number;
21
- }[];
22
- };
23
- export type Graph = {
24
- schema_version: 1;
25
- components: Component[];
26
- connections: Connection[];
27
- coverage: string[];
28
- };
29
- export type Change = {
30
- type: "added" | "removed" | "changed";
31
- entity: "component" | "connection";
32
- id: string;
33
- };
1
+ import type { Change, Graph } from "./types.js";
2
+ export * from "./types.js";
3
+ export declare function readExclusions(root: string): string[];
34
4
  export declare function scan(root: string, extraExclusions?: string[]): Graph;
35
5
  export declare function fingerprint(graph: Graph): string;
36
6
  export declare function diff(before: Graph | null, after: Graph): Change[];
37
- export declare function readExclusions(root: string): string[];
package/dist/index.js CHANGED
@@ -1,81 +1,189 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, readdirSync, readFileSync } from "node:fs";
3
- import { join, relative, sep } from "node:path";
4
- const excluded = new Set([".git", ".astro", ".next", "dist", ".venv", "__pycache__", "node_modules", ".wrangler", ".codex", ".claude", "coverage"]);
5
- const recognized = new Set(["package.json", "pnpm-workspace.yaml", "astro.config.mjs", "next.config.ts", "vite.config.ts", "wrangler.jsonc", "pyproject.toml", "requirements.txt", "alembic.ini", "Dockerfile", "main.py", "server.py", "orm.py", "database.py", "index.ts", "mcp.ts", "api.ts"]);
6
- function digest(value) { return createHash("sha256").update(value).digest("hex"); }
7
- function safeRelative(root, path) { return relative(root, path).split(sep).join("/"); }
8
- export function scan(root, extraExclusions = []) {
9
- const files = [];
10
- const omitted = new Set([...excluded, ...extraExclusions]);
11
- function visit(dir) {
12
- for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
13
- if (entry.isSymbolicLink() || omitted.has(entry.name) || entry.name.startsWith(".env") || entry.name.endsWith(".db"))
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { dirname, extname, join, relative, resolve, sep } from "node:path";
5
+ export * from "./types.js";
6
+ const VERSION = "2.1.0", MAX_FILES = 20_000, MAX_FILE_SIZE = 512 * 1024;
7
+ const EXCLUDED = new Set([".git", ".astro", ".next", ".turbo", ".vercel", ".wrangler", ".codex", ".claude", ".idea", ".vscode", "node_modules", "dist", "build", "out", "coverage", ".venv", "venv", "__pycache__", "examples", "example", "demo", "fixtures"]);
8
+ const BINARY = /\.(?:map|db|sqlite|png|jpe?g|gif|webp|ico|pdf|zip|gz|woff2?|ttf|mp4|mov)$/i;
9
+ const SOURCE = /\.(?:[cm]?[jt]sx?|py)$/i;
10
+ const TEST_FILE = /(?:^|\/)(?:test|tests|__tests__|fixtures)(?:\/|$)|\.(?:test|spec)\.[^.]+$/i;
11
+ const hash = (value) => createHash("sha256").update(value).digest("hex");
12
+ const repoPath = (root, path) => relative(root, path).split(sep).join("/");
13
+ const lineAt = (text, at) => text.slice(0, Math.max(0, at)).split("\n").length;
14
+ const stableId = (...parts) => parts.join(":").toLowerCase().replace(/[^a-z0-9:_-]+/g, "-").replace(/-+/g, "-");
15
+ const unique = (values) => [...new Set(values)];
16
+ function canonical(value) { if (Array.isArray(value))
17
+ return `[${value.map(canonical).join(",")}]`; if (value && typeof value === "object")
18
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`; return JSON.stringify(value); }
19
+ function offsets(file, re) { const flags = re.flags.includes("g") ? re.flags : `${re.flags}g`; return [...file.text.matchAll(new RegExp(re.source, flags))].map(match => match.index ?? 0); }
20
+ function ev(file, detector, category, at = 0) { return { path: file.path, line: lineAt(file.text, at), detector, category }; }
21
+ function ignored(path, patterns) { return patterns.some(raw => { const value = raw.trim().replace(/^\/+/, ""); if (!value || value.startsWith("!"))
22
+ return false; const escaped = value.replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&").replace(/\*\*/g, "§§").replace(/\*/g, "[^/]*").replace(/§§/g, ".*"); return new RegExp(`^${escaped}(?:/.*)?$`).test(path); }); }
23
+ export function readExclusions(root) { const file = join(root, ".journeyignore"); return existsSync(file) ? readFileSync(file, "utf8").split(/\r?\n/).map(value => value.trim()).filter(value => value && !value.startsWith("#")) : []; }
24
+ function discover(root, patterns) {
25
+ const candidates = [], skipped = [];
26
+ let gitAvailable = false;
27
+ try {
28
+ const output = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
29
+ candidates.push(...output.split("\0").filter(Boolean).sort());
30
+ gitAvailable = true;
31
+ }
32
+ catch {
33
+ const visit = (directory) => { for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
34
+ const absolute = join(directory, entry.name), path = repoPath(root, absolute);
35
+ if (entry.isSymbolicLink() || (entry.isDirectory() && EXCLUDED.has(entry.name)) || ignored(path, patterns)) {
36
+ skipped.push(path);
14
37
  continue;
15
- const path = join(dir, entry.name);
38
+ }
16
39
  if (entry.isDirectory())
17
- visit(path);
18
- else if (entry.isFile() && recognized.has(entry.name))
19
- files.push(safeRelative(root, path));
40
+ visit(absolute);
41
+ else if (entry.isFile())
42
+ candidates.push(path);
43
+ if (candidates.length >= MAX_FILES)
44
+ return;
45
+ } };
46
+ visit(root);
47
+ }
48
+ const files = [];
49
+ for (const path of unique(candidates).sort()) {
50
+ if (files.length >= MAX_FILES)
51
+ break;
52
+ if (path.split("/").some(part => EXCLUDED.has(part)) || ignored(path, patterns)) {
53
+ skipped.push(path);
54
+ continue;
55
+ }
56
+ const absolute = join(root, path);
57
+ try {
58
+ if (lstatSync(absolute).isSymbolicLink() || !statSync(absolute).isFile() || statSync(absolute).size > MAX_FILE_SIZE || BINARY.test(path) || /(?:^|\/)\.env(?:\.|$)/.test(path)) {
59
+ skipped.push(path);
60
+ continue;
61
+ }
62
+ files.push({ path, text: readFileSync(absolute, "utf8") });
63
+ }
64
+ catch {
65
+ skipped.push(path);
20
66
  }
21
67
  }
22
- visit(root);
23
- const has = (path) => files.includes(path);
24
- const components = [];
25
- const add = (id, name, type, workspace, technology, evidence) => {
26
- const found = evidence.filter(has);
27
- if (!found.length)
28
- return;
29
- components.push({ id, name, type, workspace, technology, evidence: found.map(path => ({ path, line: 1 })), confidence: "high" });
30
- };
31
- // Manifests and conventional entry points are evidence. No source text or package values leave the machine.
32
- add("landing", "Landing site", "web", "apps/landing", ["Astro"], ["apps/landing/astro.config.mjs"]);
33
- add("web", "Web app", "web", "apps/web", ["Next.js"], ["apps/web/next.config.ts"]);
34
- add("cli", "CLI and MCP gateway", "cli", "packages/cli", ["Node.js", "MCP"], ["packages/cli/src/index.ts", "packages/cli/src/commands/mcp.ts"]);
35
- add("api", "API and MCP server", "service", "apps/server", ["Python", "FastAPI", "FastMCP"], ["apps/server/app/main.py", "apps/server/app/mcp/server.py"]);
36
- add("persistence", "Persistence", "database", "apps/server", ["SQLAlchemy", "Alembic"], ["apps/server/app/db/orm.py", "apps/server/alembic.ini"]);
37
- add("deployment", "Cloudflare deployment", "deployment", "infra/cloudflare", ["Cloudflare"], ["infra/cloudflare/api/wrangler.jsonc", "apps/web/wrangler.jsonc"]);
38
- if (!components.length) {
39
- if (has("astro.config.mjs"))
40
- add("root:astro", "Astro site", "web", ".", ["Astro"], ["astro.config.mjs"]);
41
- if (has("next.config.ts"))
42
- add("root:next", "Next.js app", "web", ".", ["Next.js"], ["next.config.ts"]);
43
- if (has("app/main.py"))
44
- add("root:api", "Python API", "service", ".", ["Python", "FastAPI"], ["app/main.py"]);
45
- if (has("package.json"))
46
- add("root:package", "Node package", "package", ".", ["Node.js"], ["package.json"]);
68
+ return { files, skipped: unique(skipped).sort(), gitAvailable, truncated: candidates.length >= MAX_FILES };
69
+ }
70
+ function parseManifest(file) { try {
71
+ const value = JSON.parse(file.text), root = file.path === "package.json" ? "." : dirname(file.path);
72
+ return { file, root, name: typeof value.name === "string" ? value.name : root.split("/").at(-1) ?? "Application", scripts: typeof value.scripts === "object" && value.scripts ? value.scripts : {}, dependencies: typeof value.dependencies === "object" && value.dependencies ? value.dependencies : {}, devDependencies: typeof value.devDependencies === "object" && value.devDependencies ? value.devDependencies : {}, bin: value.bin, exports: value.exports };
73
+ }
74
+ catch {
75
+ return null;
76
+ } }
77
+ function scriptEntry(root, command) { const match = command.match(/(?:^|\s)([\w./-]+\.(?:[cm]?[jt]s|py))(?:\s|$)/); if (!match)
78
+ return null; const candidate = match[1].replace(/^\.\//, ""); return root === "." ? candidate : `${root}/${candidate}`; }
79
+ function resolveImport(from, specifier, fileMap) { if (!specifier.startsWith("."))
80
+ return null; const base = resolve("/repo", dirname(from), specifier).slice(6).replaceAll("\\", "/"); const candidates = extname(base) ? [base] : [base, ...[".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py"].map(ext => `${base}${ext}`), ...[".ts", ".tsx", ".js", ".jsx", ".py"].map(ext => `${base}/index${ext}`)]; return candidates.find(candidate => fileMap.has(candidate)) ?? null; }
81
+ function reachable(entries, fileMap) { const visited = new Set(), queue = entries.filter(entry => fileMap.has(entry)); while (queue.length && visited.size < 2_000) {
82
+ const path = queue.shift();
83
+ if (visited.has(path))
84
+ continue;
85
+ visited.add(path);
86
+ const file = fileMap.get(path);
87
+ for (const match of file.text.matchAll(/(?:import|export)\s+(?:[^"']+?\s+from\s+)?["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\)|import\(\s*["']([^"']+)["']\s*\)/g)) {
88
+ const target = resolveImport(path, match[1] ?? match[2] ?? match[3], fileMap);
89
+ if (target && !visited.has(target))
90
+ queue.push(target);
91
+ }
92
+ } return visited; }
93
+ function sortGraph(graph) { const sortEv = (a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.detector.localeCompare(b.detector); for (const component of graph.components) {
94
+ component.evidence = unique(component.evidence.map(canonical)).map(item => JSON.parse(item)).sort(sortEv).slice(0, 30);
95
+ component.files = unique(component.files).sort().slice(0, 100);
96
+ component.technology = unique(component.technology).sort();
97
+ component.details.sort((a, b) => `${a.kind}:${a.value}`.localeCompare(`${b.kind}:${b.value}`));
98
+ } for (const connection of graph.connections)
99
+ connection.evidence.sort(sortEv); graph.components.sort((a, b) => a.id.localeCompare(b.id)); graph.connections.sort((a, b) => a.id.localeCompare(b.id)); for (const values of Object.values(graph.inventory))
100
+ values.splice(0, values.length, ...unique(values).sort().slice(0, 1_000)); return graph; }
101
+ export function scan(root, extraExclusions = []) {
102
+ const discovery = discover(root, [...readExclusions(root), ...extraExclusions]), files = discovery.files, fileMap = new Map(files.map(file => [file.path, file])), runtimeFiles = files.filter(file => SOURCE.test(file.path) && !TEST_FILE.test(file.path) && !/(?:^|\/)public\//.test(file.path));
103
+ const manifests = files.filter(file => file.path === "package.json" || file.path.endsWith("/package.json")).map(parseManifest).filter((item) => Boolean(item));
104
+ const components = [], ownership = new Map();
105
+ const inventory = { dependencies: [], dev_dependencies: [], frameworks: [], routes: [], scripts: [], environment_variables: [], docker_services: [] };
106
+ const make = (value, details = []) => ({ ...value, files: value.evidence.map(item => item.path), details });
107
+ const add = (component, owned = []) => { const current = components.find(item => item.id === component.id); if (current) {
108
+ current.evidence.push(...component.evidence);
109
+ current.files.push(...component.files);
110
+ current.technology.push(...component.technology);
111
+ current.details.push(...component.details);
112
+ }
113
+ else
114
+ components.push(component); ownership.set(component.id, new Set([...(ownership.get(component.id) ?? []), ...owned])); };
115
+ for (const manifest of manifests) {
116
+ const deps = Object.keys(manifest.dependencies), dev = Object.keys(manifest.devDependencies);
117
+ inventory.dependencies.push(...deps);
118
+ inventory.dev_dependencies.push(...dev);
119
+ inventory.scripts.push(...Object.keys(manifest.scripts).map(name => `${manifest.root}:${name}`));
120
+ const framework = deps.includes("next") ? "Next.js" : deps.includes("astro") ? "Astro" : deps.includes("vite") ? "Vite" : null;
121
+ if (framework) {
122
+ inventory.frameworks.push(framework);
123
+ const owned = runtimeFiles.filter(file => manifest.root === "." || file.path.startsWith(`${manifest.root}/`)).map(file => file.path);
124
+ add(make({ id: stableId("application", manifest.root), name: /web/i.test(manifest.name) ? "Web App" : manifest.name, kind: "application", boundary: "deployment", workspace: manifest.root, technology: [framework], environment: "both", optional: false, confidence: "high", evidence: [ev(manifest.file, "framework-application", "manifest")] }, Object.keys(manifest.scripts).map(name => ({ kind: "script", value: name, path: manifest.file.path, line: lineAt(manifest.file.text, manifest.file.text.indexOf(`"${name}"`)) }))), owned);
125
+ }
126
+ if (manifest.bin || manifest.exports) {
127
+ const owned = runtimeFiles.filter(file => manifest.root === "." || file.path.startsWith(`${manifest.root}/`)).map(file => file.path), isCli = Boolean(manifest.bin);
128
+ add(make({ id: stableId(isCli ? "cli" : "sdk", manifest.root), name: isCli ? manifest.name : /sdk/i.test(manifest.name) ? "Browser SDK / Widgets" : manifest.name, kind: isCli ? "cli" : "sdk", boundary: "logical", workspace: manifest.root, technology: ["Node.js"], environment: "both", optional: false, confidence: "high", evidence: [ev(manifest.file, "package-interface", "manifest")] }), owned);
129
+ }
130
+ for (const [name, command] of Object.entries(manifest.scripts)) {
131
+ if (!/worker|jobs?|consumer|cron|scheduler/i.test(name) && !/(?:worker|consumer|scheduler)\.[cm]?[jt]s/.test(command))
132
+ continue;
133
+ const entry = scriptEntry(manifest.root, command);
134
+ if (!entry || !fileMap.has(entry))
135
+ continue;
136
+ const owned = reachable([entry], fileMap);
137
+ add(make({ id: stableId("worker", entry), name: /cron|schedule/i.test(name) ? "Scheduled Job" : "Background Worker", kind: /cron|schedule/i.test(name) ? "job" : "worker", boundary: "runtime", workspace: dirname(entry), technology: [entry.endsWith(".py") ? "Python" : "Node.js"], environment: "both", optional: false, confidence: "high", evidence: [ev(manifest.file, "runtime-script", "entry-point", manifest.file.text.indexOf(`"${name}"`))] }, [{ kind: "entry-point", value: entry, path: manifest.file.path, line: lineAt(manifest.file.text, manifest.file.text.indexOf(command)) }]), owned);
138
+ }
139
+ }
140
+ for (const file of runtimeFiles.filter(item => item.path.endsWith(".py") && /(?:FastAPI|FastMCP)\s*\(/.test(item.text))) {
141
+ const workspace = dirname(file.path);
142
+ add(make({ id: stableId("application", workspace), name: "API Service", kind: "application", boundary: "deployment", workspace, technology: ["Python", ...(file.text.includes("FastAPI") ? ["FastAPI"] : []), ...(file.text.includes("FastMCP") ? ["FastMCP"] : [])], environment: "both", optional: false, confidence: "high", evidence: [ev(file, "python-application", "entry-point", file.text.search(/FastAPI|FastMCP/))] }), reachable([file.path], fileMap));
143
+ }
144
+ for (const file of runtimeFiles)
145
+ for (const match of file.text.matchAll(/(?:process\.env\.([A-Z][A-Z0-9_]*)|os\.environ(?:\.get)?\s*(?:\[|\()\s*["']([A-Z][A-Z0-9_]*)|getenv\s*\(\s*["']([A-Z][A-Z0-9_]*))/g))
146
+ inventory.environment_variables.push(match[1] ?? match[2] ?? match[3]);
147
+ const routeFiles = runtimeFiles.filter(file => /(?:^|\/)(?:src\/)?app\/api\/(?:.*\/)?route\.[cm]?[jt]sx?$|(?:^|\/)api\/.+\.py$/.test(file.path) && /(?:export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|PATCH|DELETE)|router\.(?:get|post|put|patch|delete))/.test(file.text));
148
+ inventory.routes.push(...routeFiles.map(file => file.path));
149
+ if (routeFiles.length) {
150
+ const owner = components.find(component => component.kind === "application" && component.workspace && routeFiles.some(file => component.workspace === "." || file.path.startsWith(`${component.workspace}/`))), owned = reachable(routeFiles.map(file => file.path), fileMap);
151
+ add(make({ id: stableId("api", owner?.workspace ?? "."), name: "Public API", kind: "api", boundary: "logical", workspace: owner?.workspace ?? ".", technology: owner?.technology ?? ["HTTP"], environment: "both", optional: false, confidence: "high", evidence: routeFiles.map(file => ev(file, "http-routes", "route", file.text.search(/GET|POST|router\./))).slice(0, 30) }, routeFiles.map(file => ({ kind: "route", value: file.path, path: file.path, line: 1 }))), owned);
152
+ }
153
+ const specs = [
154
+ { name: "PostgreSQL", kind: "database", technology: ["PostgreSQL"], expressions: [/(?:new\s+(?:Pool|Client)|postgres\s*\(|create_engine\s*\(|sqlalchemy|\.(?:query|execute)\s*\()/gi], optional: false },
155
+ { name: "File Storage", kind: "storage", technology: ["S3-compatible"], expressions: [/(?:new\s+S3Client|PutObjectCommand|GetObjectCommand|DeleteObjectCommand|\.put_object\s*\()/g], optional: true },
156
+ { name: "OpenAI", kind: "external-service", technology: ["OpenAI"], expressions: [/(?:new\s+OpenAI|OpenAI\s*\(|\.responses\.create|\.embeddings\.create)/g], optional: true },
157
+ { name: "Email Provider", kind: "external-service", technology: ["Resend", "SMTP"], expressions: [/(?:new\s+Resend|\.emails\.send|\.sendMail\s*\(|createTransport\s*\()/g], optional: true },
158
+ ];
159
+ for (const spec of specs) {
160
+ const hits = runtimeFiles.flatMap(file => spec.expressions.flatMap(expression => offsets(file, expression).map(at => ({ file, at }))));
161
+ if (hits.length)
162
+ add(make({ id: stableId(spec.kind, spec.name), name: spec.name, kind: spec.kind, boundary: "external", workspace: null, technology: spec.technology, environment: spec.optional ? "both" : "production", optional: spec.optional, confidence: "high", evidence: hits.map(({ file, at }) => ev(file, "runtime-provider", "runtime-call", at)).slice(0, 30) }, spec.technology.map(value => ({ kind: "provider", value }))));
47
163
  }
48
164
  const connections = [];
49
- const link = (from, to, type, path) => {
50
- if (components.some(c => c.id === from) && components.some(c => c.id === to) && has(path))
51
- connections.push({ id: `${from}:${to}:${type}`, from, to, type, evidence: [{ path, line: 1 }] });
52
- };
53
- link("cli", "api", "MCP over HTTPS", "packages/cli/src/commands/mcp.ts");
54
- link("web", "api", "HTTP API", "apps/web/lib/api.ts");
55
- link("api", "persistence", "SQLAlchemy", "apps/server/app/db/database.py");
56
- components.sort((a, b) => a.id.localeCompare(b.id));
57
- connections.sort((a, b) => a.id.localeCompare(b.id));
58
- return { schema_version: 1, components, connections, coverage: ["Recognized manifests and entry points only; unrecognized runtime relationships may be missing."] };
59
- }
60
- function canonical(value) { if (Array.isArray(value))
61
- return `[${value.map(canonical).join(",")}]`; if (value && typeof value === "object")
62
- return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`; return JSON.stringify(value); }
63
- export function fingerprint(graph) { return digest(canonical(graph)); }
64
- export function diff(before, after) {
65
- const changes = [];
66
- for (const entity of ["component", "connection"]) {
67
- const previous = new Map((before?.[entity === "component" ? "components" : "connections"] ?? []).map(item => [item.id, JSON.stringify(item)]));
68
- const current = new Map(after[entity === "component" ? "components" : "connections"].map(item => [item.id, JSON.stringify(item)]));
69
- for (const [id, value] of current)
70
- if (!previous.has(id) || previous.get(id) !== value)
71
- changes.push({ type: previous.has(id) ? "changed" : "added", entity, id });
72
- for (const id of previous.keys())
73
- if (!current.has(id))
74
- changes.push({ type: "removed", entity, id });
165
+ const connect = (from, to, kind, label, expressions, optional = false) => { if (!to || from.id === to.id)
166
+ return; const hits = [...(ownership.get(from.id) ?? [])].map(path => fileMap.get(path)).filter((file) => Boolean(file)).flatMap(file => expressions.flatMap(expression => offsets(file, expression).map(at => ({ file, at })))); if (!hits.length)
167
+ return; const id = stableId("edge", from.id, to.id, kind); if (!connections.some(item => item.id === id))
168
+ connections.push({ id, from: from.id, to: to.id, kind, label, runtime: true, optional: optional || to.optional, confidence: "high", evidence: hits.map(({ file, at }) => ev(file, "boundary-connection", "runtime-call", at)).slice(0, 30) }); };
169
+ const database = components.find(item => item.name === "PostgreSQL"), storage = components.find(item => item.name === "File Storage"), openai = components.find(item => item.name === "OpenAI"), email = components.find(item => item.name === "Email Provider"), api = components.find(item => item.kind === "api");
170
+ for (const from of components.filter(item => ["api", "worker", "job"].includes(item.kind))) {
171
+ connect(from, database, "database", from.kind === "worker" ? "claims jobs / reads / writes" : "reads / writes", [/(?:postgres\s*\(|\.(?:query|execute)\s*\(|sql`)/g]);
172
+ connect(from, storage, "storage", "stores / reads files", [/(?:S3Client|PutObjectCommand|GetObjectCommand|DeleteObjectCommand|\.put_object\s*\()/g]);
173
+ connect(from, openai, "runtime-call", "calls", [/(?:new\s+OpenAI|responses\.create|embeddings\.create)/g], true);
174
+ connect(from, email, "runtime-call", "sends email", [/(?:new\s+Resend|emails\.send|sendMail\s*\()/g], true);
75
175
  }
76
- return changes.sort((a, b) => `${a.entity}:${a.id}`.localeCompare(`${b.entity}:${b.id}`));
77
- }
78
- export function readExclusions(root) {
79
- const path = join(root, ".journeyignore");
80
- return existsSync(path) ? readFileSync(path, "utf8").split(/\r?\n/).map(s => s.trim()).filter(s => s && !s.startsWith("#") && !s.includes("/")) : [];
176
+ for (const from of components.filter(item => item.kind === "sdk" || item.kind === "application"))
177
+ connect(from, api, "http", "HTTP requests", [/(?:fetch\s*\(|axios\.|\.request\s*\()/g]);
178
+ return sortGraph({ schema_version: 2, scanner_version: VERSION, components, connections, inventory, coverage: { languages: unique(files.flatMap(file => file.path.endsWith(".py") ? ["Python"] : SOURCE.test(file.path) ? ["JavaScript/TypeScript"] : [])).sort(), frameworks: unique(inventory.frameworks).sort(), skipped: discovery.skipped, unsupported: [], warnings: components.length ? [] : ["No supported runtime boundary detected."], git_available: discovery.gitAvailable, truncated: discovery.truncated } });
81
179
  }
180
+ export function fingerprint(graph) { return hash(canonical({ schema_version: graph.schema_version, scanner_version: graph.scanner_version, components: graph.components, connections: graph.connections })); }
181
+ export function diff(before, after) { const changes = []; for (const [entity, key] of [["component", "components"], ["connection", "connections"]]) {
182
+ const previous = new Map((before?.[key] ?? []).map(item => [item.id, canonical(item)])), next = new Map(after[key].map(item => [item.id, canonical(item)]));
183
+ for (const [id, value] of next)
184
+ if (previous.get(id) !== value)
185
+ changes.push({ type: previous.has(id) ? "changed" : "added", entity, id });
186
+ for (const id of previous.keys())
187
+ if (!next.has(id))
188
+ changes.push({ type: "removed", entity, id });
189
+ } return changes.sort((a, b) => `${a.entity}:${a.id}`.localeCompare(`${b.entity}:${b.id}`)); }
@@ -0,0 +1,67 @@
1
+ export type ArchitectureEvidenceV2 = {
2
+ path: string;
3
+ line: number;
4
+ detector: string;
5
+ category: "manifest" | "entry-point" | "route" | "import" | "runtime-call" | "deployment" | "configuration";
6
+ };
7
+ export type ArchitectureDetailV2 = {
8
+ kind: string;
9
+ value: string;
10
+ path?: string;
11
+ line?: number;
12
+ };
13
+ export type ArchitectureComponentV2 = {
14
+ id: string;
15
+ name: string;
16
+ kind: "application" | "api" | "worker" | "job" | "cli" | "sdk" | "database" | "storage" | "queue" | "cache" | "search" | "external-service";
17
+ boundary: "deployment" | "runtime" | "logical" | "external";
18
+ workspace: string | null;
19
+ technology: string[];
20
+ environment: "production" | "development" | "both";
21
+ optional: boolean;
22
+ confidence: "high" | "medium";
23
+ evidence: ArchitectureEvidenceV2[];
24
+ files: string[];
25
+ details: ArchitectureDetailV2[];
26
+ };
27
+ export type ArchitectureConnectionV2 = {
28
+ id: string;
29
+ from: string;
30
+ to: string;
31
+ label: string;
32
+ kind: "http" | "rpc" | "database" | "storage" | "queue" | "cache" | "sdk" | "webhook" | "runtime-call" | "build-time";
33
+ runtime: boolean;
34
+ optional: boolean;
35
+ confidence: "high" | "medium";
36
+ evidence: ArchitectureEvidenceV2[];
37
+ };
38
+ export type ArchitectureGraphV2 = {
39
+ schema_version: 2;
40
+ scanner_version: string;
41
+ components: ArchitectureComponentV2[];
42
+ connections: ArchitectureConnectionV2[];
43
+ inventory: {
44
+ dependencies: string[];
45
+ dev_dependencies: string[];
46
+ frameworks: string[];
47
+ routes: string[];
48
+ scripts: string[];
49
+ environment_variables: string[];
50
+ docker_services: string[];
51
+ };
52
+ coverage: {
53
+ languages: string[];
54
+ frameworks: string[];
55
+ skipped: string[];
56
+ unsupported: string[];
57
+ warnings: string[];
58
+ git_available: boolean;
59
+ truncated: boolean;
60
+ };
61
+ };
62
+ export type Graph = ArchitectureGraphV2;
63
+ export type Change = {
64
+ type: "added" | "removed" | "changed";
65
+ entity: "component" | "connection";
66
+ id: string;
67
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryjourney/architecture",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Deterministic, privacy-preserving architecture scanning for Journey",
5
5
  "keywords": [
6
6
  "journey",
@@ -28,14 +28,14 @@
28
28
  "url": "https://github.com/dharunvs/tryjourney.dev/issues"
29
29
  },
30
30
  "license": "MIT",
31
- "devDependencies": {
32
- "@types/node": "^24.0.0",
33
- "tsx": "^4.21.0",
34
- "typescript": "^5.9.0"
35
- },
36
31
  "scripts": {
37
32
  "build": "tsc",
38
33
  "typecheck": "tsc --noEmit",
39
34
  "test": "tsx --test test/*.test.ts"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^24.0.0",
38
+ "tsx": "^4.21.0",
39
+ "typescript": "^5.9.0"
40
40
  }
41
- }
41
+ }