depgraph-core 1.0.2 → 1.0.3

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.
@@ -0,0 +1,218 @@
1
+ # Data Types Reference
2
+
3
+ > **File**: `src/types.ts`
4
+ > All shared TypeScript interfaces used across every stage of the pipeline.
5
+
6
+ ---
7
+
8
+ ## Data Flow Summary
9
+
10
+ ```
11
+ RawEntity ┐
12
+ RawImport ├──► ParsedFile ──► DepNode / DepEdge ──► DepGraph
13
+ ┘ │
14
+
15
+ AffectedNode ──► ImpactReport
16
+
17
+
18
+ OutputJSON (written to disk)
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Stage 1–2 Types (Parser Output)
24
+
25
+ ### `RawEntity`
26
+
27
+ A code construct extracted directly from source — before any cross-file resolution.
28
+
29
+ ```ts
30
+ interface RawEntity {
31
+ name: string; // e.g. "getUserById"
32
+ type: string; // "function" | "class" | "component" | "hook" | "api" | "interface" | "type"
33
+ line: number; // 1-indexed line number in the source file
34
+ complexity: string; // "low" | "medium" | "high"
35
+ }
36
+ ```
37
+
38
+ **Entity types by language:**
39
+
40
+ | Type | Languages | Example |
41
+ |---|---|---|
42
+ | `function` | JS/TS, Python | `function doSomething()` |
43
+ | `class` | JS/TS, Python | `class UserService` |
44
+ | `component` | JS/TS (React) | `const Button = () =>` |
45
+ | `hook` | JS/TS (React) | `const useAuth = () =>` |
46
+ | `interface` | TypeScript | `interface User` |
47
+ | `type` | TypeScript | `type UserId = string` |
48
+ | `api` | JS/TS (Express) | `app.get('/users', ...)` |
49
+
50
+ ---
51
+
52
+ ### `RawImport`
53
+
54
+ An import statement extracted from a source file.
55
+
56
+ ```ts
57
+ interface RawImport {
58
+ source: string; // the import path, e.g. "./userService" or "express"
59
+ names: string[]; // the names imported, e.g. ["getUserById", "createUser"]
60
+ isLocal: boolean; // true if source starts with "." (relative path)
61
+ }
62
+ ```
63
+
64
+ **Key distinction**: Only imports where `isLocal === true` become graph edges. External package imports (`"express"`, `"lodash"`, etc.) are ignored during graph construction.
65
+
66
+ ---
67
+
68
+ ### `ParsedFile`
69
+
70
+ The complete output for one source file after parsing.
71
+
72
+ ```ts
73
+ interface ParsedFile {
74
+ filePath: string; // absolute or relative path on disk
75
+ lang: string; // parser's lang id, e.g. "js" or "py"
76
+ lines: number; // total line count
77
+ entities: RawEntity[]; // all found code constructs
78
+ imports: RawImport[]; // all import statements
79
+ exports: string[]; // names of exported entities
80
+ }
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Stage 3–4 Types (Graph)
86
+
87
+ ### `DepNode`
88
+
89
+ A fully resolved node in the dependency graph. Built from a `RawEntity`, enriched with graph metrics.
90
+
91
+ ```ts
92
+ interface DepNode {
93
+ id: string; // composite ID: "entityName__fileBase"
94
+ name: string; // entity name, e.g. "getUserById"
95
+ type: string; // same as RawEntity.type
96
+ file: string; // source file path
97
+ line: number; // source line number
98
+ lang: string; // language id
99
+ complexity: string; // "low" | "medium" | "high"
100
+ inDegree: number; // how many other nodes import this one
101
+ outDegree: number; // how many nodes this one imports
102
+ centralityScore: number; // inDegree * 2 + outDegree
103
+ connections: string[]; // IDs of directly connected nodes (both directions)
104
+
105
+ // Optional — only present for specific entity types:
106
+ extends?: string; // parent class/interface name
107
+ method?: string; // HTTP method for API routes, e.g. "GET"
108
+ route?: string; // URL path for API routes, e.g. "/api/users"
109
+ }
110
+ ```
111
+
112
+ **Node ID format**: `getUserById__userService`
113
+ (entity name + `__` + file basename without extension, with non-alphanumeric chars replaced by `_`)
114
+
115
+ ---
116
+
117
+ ### `DepEdge`
118
+
119
+ A directed link from one node to another, representing an import relationship.
120
+
121
+ ```ts
122
+ interface DepEdge {
123
+ from: string; // source node ID (the importer)
124
+ to: string; // target node ID (the imported entity)
125
+ type: string; // currently always "imports"
126
+ description: string; // human-readable, e.g. "UserRouter imports getUserById from userService"
127
+ }
128
+ ```
129
+
130
+ **Direction convention**: `from → to` means "`from` depends on `to`".
131
+
132
+ ---
133
+
134
+ ### `DepGraph`
135
+
136
+ The complete graph.
137
+
138
+ ```ts
139
+ interface DepGraph {
140
+ nodes: Map<string, DepNode>; // keyed by node ID
141
+ edges: DepEdge[];
142
+ }
143
+ ```
144
+
145
+ The `nodes` Map is converted to an Array when writing to JSON output.
146
+
147
+ ---
148
+
149
+ ## Stage 5 Types (Impact)
150
+
151
+ ### `AffectedNode`
152
+
153
+ A downstream node discovered during BFS traversal from the impact target.
154
+
155
+ ```ts
156
+ interface AffectedNode {
157
+ nodeId: string; // graph node ID
158
+ name: string; // entity name
159
+ file: string; // source file path
160
+ depth: number; // BFS depth from target (1 = direct dependent)
161
+ impact: string; // "critical" | "high" | "medium" | "low"
162
+ reason: string; // why this node is affected
163
+ changeRequired: string; // what action needs to be taken
164
+ breakingChange: boolean; // true if depth <= 2
165
+ }
166
+ ```
167
+
168
+ ---
169
+
170
+ ### `ImpactReport`
171
+
172
+ The full simulation result returned by `simulateImpact`.
173
+
174
+ ```ts
175
+ interface ImpactReport {
176
+ targetNode: string; // the node ID of the changed entity
177
+ changeDescription: string; // description from --impact flag
178
+ riskScore: number; // 0–100
179
+ riskLevel: string; // "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"
180
+ affectedNodes: AffectedNode[]; // all downstream nodes
181
+ breakingChanges: AffectedNode[]; // subset where breakingChange === true
182
+ testingPlan: string[]; // recommended test actions
183
+ recommendations: string[]; // code review / deployment guidance
184
+ }
185
+ ```
186
+
187
+ **Risk score formula**: `C×30 + H×15 + M×7 + L×2 + targetInDegree×3` (capped at 100)
188
+
189
+ ---
190
+
191
+ ## Output Type
192
+
193
+ ### `OutputJSON`
194
+
195
+ The root schema of the generated `depgraph-output.json` file.
196
+
197
+ ```ts
198
+ interface OutputJSON {
199
+ meta: {
200
+ version: string; // tool version, e.g. "1.0.0"
201
+ timestamp: string; // ISO 8601, e.g. "2024-01-15T09:30:00.000Z"
202
+ totalFiles: number;
203
+ totalLines: number;
204
+ };
205
+ summary: {
206
+ totalNodes: number;
207
+ totalEdges: number;
208
+ entryPoints: string[]; // node IDs with inDegree=0 & outDegree>0
209
+ leafNodes: string[]; // node IDs with outDegree=0 & inDegree>0
210
+ isolatedNodes: string[]; // node IDs with both degrees = 0 (dead code)
211
+ criticalNodes: string[]; // node IDs with centralityScore > 20
212
+ };
213
+ nodes: DepNode[];
214
+ edges: DepEdge[];
215
+ files: ParsedFile[];
216
+ impact?: ImpactReport; // only present when --impact was used
217
+ }
218
+ ```
@@ -0,0 +1,205 @@
1
+ # Language Registry
2
+
3
+ > **File**: `src/languages/registry.ts`
4
+ > **Exports**: `LanguageParser` (interface), `registerParser`, `getLanguageParser`
5
+
6
+ ---
7
+
8
+ ## Overview
9
+
10
+ The language registry is a **simple plugin system**. It solves one problem: the parser stage needs to know *how* to parse a `.py` file differently from a `.ts` file, without hardcoding language logic into the stage itself.
11
+
12
+ Any file that imports `registerParser` and calls it at module load time becomes a **language plugin**. The parser stage just calls `getLanguageParser(ext)` and gets back the right handler — it doesn't know or care which language it's dealing with.
13
+
14
+ ---
15
+
16
+ ## The `LanguageParser` Interface
17
+
18
+ This is the contract every language plugin must fulfil:
19
+
20
+ ```ts
21
+ export interface LanguageParser {
22
+ /** A short unique identifier for the language, e.g. "js", "py". */
23
+ lang: string;
24
+
25
+ /** File extensions this parser handles, e.g. [".js", ".ts", ".tsx"]. */
26
+ extensions: string[];
27
+
28
+ /**
29
+ * Extract named code entities (functions, classes, routes, etc.)
30
+ * from clean source code (single-line comments already stripped).
31
+ */
32
+ extractEntities: (code: string, filePath: string) => RawEntity[];
33
+
34
+ /**
35
+ * Extract import statements from clean source code.
36
+ */
37
+ extractImports: (code: string) => RawImport[];
38
+
39
+ /**
40
+ * Extract the names of exported entities from clean source code.
41
+ */
42
+ extractExports: (code: string) => string[];
43
+ }
44
+ ```
45
+
46
+ ---
47
+
48
+ ## How Registration Works
49
+
50
+ The registry stores parsers in a private module-level array:
51
+
52
+ ```ts
53
+ const parsers: LanguageParser[] = [];
54
+
55
+ export function registerParser(parser: LanguageParser): void {
56
+ parsers.push(parser);
57
+ }
58
+
59
+ export function getLanguageParser(ext: string): LanguageParser | null {
60
+ return parsers.find(p => p.extensions.includes(ext)) ?? null;
61
+ }
62
+ ```
63
+
64
+ That's the entire registry — an array and two functions.
65
+
66
+ ### Lookup
67
+
68
+ `getLanguageParser(".ts")` returns the first registered parser whose `extensions` array includes `".ts"`. If none match, it returns `null` and the file is skipped by the parser stage.
69
+
70
+ ---
71
+
72
+ ## How Plugins Self-Register
73
+
74
+ Each language file (e.g. `src/languages/javascript.ts`) defines a parser object and calls `registerParser` at the **bottom of the file**, at module scope:
75
+
76
+ ```ts
77
+ const JavaScriptParser: LanguageParser = {
78
+ lang: 'js',
79
+ extensions: ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'],
80
+ extractEntities,
81
+ extractImports,
82
+ extractExports,
83
+ };
84
+
85
+ registerParser(JavaScriptParser);
86
+ ```
87
+
88
+ This code runs **once**, when the module is first imported.
89
+
90
+ ### Triggering registration
91
+
92
+ Plugins must be imported somewhere in the application for their side-effect (`registerParser(...)`) to run. In `src/main.ts`:
93
+
94
+ ```ts
95
+ import './languages/javascript';
96
+ import './languages/python';
97
+ ```
98
+
99
+ These are **side-effect imports** (no named export is consumed). They exist purely to execute the registration call.
100
+
101
+ > If you add a new language parser file but forget to import it in `main.ts`, it will never be registered and its extensions will never be recognised.
102
+
103
+ ---
104
+
105
+ ## Currently Registered Languages
106
+
107
+ | File | `lang` | Extensions |
108
+ |---|---|---|
109
+ | `javascript.ts` | `js` | `.js` `.jsx` `.mjs` `.cjs` `.ts` `.tsx` |
110
+ | `python.ts` | `py` | `.py` |
111
+
112
+ The following language files exist as stubs (imported but with minimal or no implementation yet):
113
+
114
+ - `java.ts`
115
+ - `csharp.ts`
116
+ - `go.ts`
117
+ - `kotlin.ts`
118
+ - `php.ts`
119
+ - `ruby.ts`
120
+ - `swift.ts`
121
+
122
+ ---
123
+
124
+ ## How to Add a New Language
125
+
126
+ Follow these steps to add full support for a new language.
127
+
128
+ ### 1. Create the parser file
129
+
130
+ Create `src/languages/mylang.ts`:
131
+
132
+ ```ts
133
+ import { RawEntity, RawImport } from '../types';
134
+ import { LanguageParser, registerParser } from './registry';
135
+
136
+ function extractEntities(code: string, filePath: string): RawEntity[] {
137
+ const entities: RawEntity[] = [];
138
+ // Use regex or any other technique to find functions, classes, etc.
139
+ // Populate and return the entities array.
140
+ return entities;
141
+ }
142
+
143
+ function extractImports(code: string): RawImport[] {
144
+ const imports: RawImport[] = [];
145
+ // Parse import/require/use statements.
146
+ // Set isLocal: true for relative imports ('./something').
147
+ return imports;
148
+ }
149
+
150
+ function extractExports(code: string): string[] {
151
+ // Return an array of exported entity names.
152
+ return [];
153
+ }
154
+
155
+ const MyLangParser: LanguageParser = {
156
+ lang: 'mylang',
157
+ extensions: ['.ml', '.mls'],
158
+ extractEntities,
159
+ extractImports,
160
+ extractExports,
161
+ };
162
+
163
+ registerParser(MyLangParser);
164
+ ```
165
+
166
+ ### 2. Import it in `main.ts`
167
+
168
+ ```ts
169
+ import './languages/mylang';
170
+ ```
171
+
172
+ Add this **before** the pipeline starts (with the other language imports at the top of the file).
173
+
174
+ ### 3. Ensure the extension is in `SUPPORTED_EXTS`
175
+
176
+ In `src/constants.ts`, add your extension to the set:
177
+
178
+ ```ts
179
+ export const SUPPORTED_EXTS = new Set([
180
+ // ... existing extensions
181
+ '.ml', '.mls',
182
+ ]);
183
+ ```
184
+
185
+ Without this, the collector will never hand files with that extension to the parser in the first place.
186
+
187
+ ### 4. Test it
188
+
189
+ ```bash
190
+ node depgraph.js ./path/to/mylang-project --verbose
191
+ ```
192
+
193
+ Look for your files appearing in the verbose output and entities showing up in the summary.
194
+
195
+ ---
196
+
197
+ ## Rules for Writing a Good Parser
198
+
199
+ | Rule | Why |
200
+ |---|---|
201
+ | Only use `code` (the cleaned version) — never read files | The stage already strips comments and passes clean code |
202
+ | Reset `regex.lastIndex = 0` before each `exec` loop | Global regexes maintain state; forgetting this causes skipped matches |
203
+ | Guard against duplicates (`entities.some(e => e.name === name)`) | The graph stage skips duplicates, but clean data is better upstream |
204
+ | Mark relative imports with `isLocal: true` | Only local imports become graph edges; external packages are ignored |
205
+ | Keep extraction logic regex-based or simple string parsing | The project has zero AST dependencies on purpose — keep it that way |
@@ -0,0 +1,109 @@
1
+ # Stage 1 — Collector
2
+
3
+ > **File**: `src/stages/collector.ts`
4
+ > **Exports**: `collectFiles(dir: string): string[]`
5
+
6
+ ---
7
+
8
+ ## What It Does
9
+
10
+ The collector is the very first thing that runs. Its job is simple: **walk a directory tree and return a flat list of file paths** that the rest of the pipeline should process.
11
+
12
+ It does **not** read file contents — it only looks at names, extensions, and file sizes.
13
+
14
+ ---
15
+
16
+ ## How It Works
17
+
18
+ ### 1. Recursive directory walk
19
+
20
+ `collectFiles` calls an internal `walk()` helper which:
21
+
22
+ 1. Reads the entries of the current directory with `fs.readdirSync`.
23
+ 2. For each entry:
24
+ - If it's a **directory**, check it against `IGNORE_DIRS`. If not ignored, recurse into it.
25
+ - If it's a **file**, check its extension against `SUPPORTED_EXTS` and its size against `MAX_FILE_SIZE`.
26
+
27
+ ```ts
28
+ if (stat.isDirectory()) {
29
+ if (!IGNORE_DIRS.has(entry)) walk(fullPath);
30
+ continue;
31
+ }
32
+
33
+ const ext = path.extname(entry);
34
+ if (SUPPORTED_EXTS.has(ext) && stat.size < MAX_FILE_SIZE) {
35
+ results.push(fullPath);
36
+ }
37
+ ```
38
+
39
+ ### 2. Ignored directories
40
+
41
+ Defined in `src/constants.ts`:
42
+
43
+ ```ts
44
+ export const IGNORE_DIRS = new Set([
45
+ 'node_modules', '.git', 'dist', 'build',
46
+ '.next', '__pycache__', 'vendor', 'venv',
47
+ 'target', 'out', 'coverage', '.cache'
48
+ ]);
49
+ ```
50
+
51
+ These are skipped entirely — the walker never descends into them.
52
+
53
+ ### 3. Supported extensions
54
+
55
+ Also from constants:
56
+
57
+ ```ts
58
+ export const SUPPORTED_EXTS = new Set([
59
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
60
+ '.py', '.go', '.java', '.cs', '.rb',
61
+ '.php', '.swift', '.kt', '.vue', '.svelte'
62
+ ]);
63
+ ```
64
+
65
+ > **Note**: Collecting a file with `.java` doesn't mean it will be *parsed* successfully. The collector just gathers eligible paths. If no language parser is registered for that extension, the parser stage silently drops it.
66
+
67
+ ### 4. Size guard
68
+
69
+ ```ts
70
+ export const MAX_FILE_SIZE = 300_000; // 300 KB
71
+ ```
72
+
73
+ Files over 300 KB are skipped. This prevents the scanner from choking on minified bundles, lock files accidentally using a supported extension, or generated code.
74
+
75
+ ---
76
+
77
+ ## Error Handling
78
+
79
+ The collector is fault-tolerant at the entry level:
80
+ - If a directory can't be read (permissions, broken symlink), it logs a warning and continues.
81
+ - If `fs.statSync` fails on an entry, it skips that entry and continues.
82
+
83
+ It **never throws** — it always returns whatever it managed to collect.
84
+
85
+ ---
86
+
87
+ ## Output
88
+
89
+ A flat `string[]` of absolute file paths, for example:
90
+
91
+ ```
92
+ [
93
+ "/project/src/main.ts",
94
+ "/project/src/utils/auth.ts",
95
+ "/project/src/routes/users.ts"
96
+ ]
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Configuration Quick Reference
102
+
103
+ | Constant | Default | Description |
104
+ |---|---|---|
105
+ | `IGNORE_DIRS` | See above | Directory names to skip entirely |
106
+ | `SUPPORTED_EXTS` | See above | File extensions that are eligible |
107
+ | `MAX_FILE_SIZE` | `300_000` (300 KB) | Maximum file size in bytes |
108
+
109
+ All constants live in [`src/constants.ts`](../src/constants.ts).
@@ -0,0 +1,157 @@
1
+ # Stage 3 — Graph Builder
2
+
3
+ > **File**: `src/stages/graph.ts`
4
+ > **Exports**: `buildGraph(parsedFiles: ParsedFile[]): DepGraph`
5
+
6
+ ---
7
+
8
+ ## What It Does
9
+
10
+ The graph builder takes the flat list of `ParsedFile` objects and turns it into a **directed dependency graph**: a collection of **nodes** (entities) connected by **edges** (import relationships).
11
+
12
+ After this stage, you know exactly which entity depends on which other entity, across the entire codebase.
13
+
14
+ ---
15
+
16
+ ## The Three-Step Build Process
17
+
18
+ ### Step 1 — Create a node for every entity
19
+
20
+ Every `RawEntity` found in every file becomes a `DepNode` in the graph:
21
+
22
+ ```ts
23
+ for (const file of parsedFiles) {
24
+ const fileBase = path.basename(file.filePath, path.extname(file.filePath));
25
+
26
+ for (const entity of file.entities) {
27
+ const id = makeId(entity.name, fileBase); // e.g. "getUserById__userService"
28
+
29
+ if (nodes.has(id)) continue; // skip duplicates
30
+
31
+ nodes.set(id, {
32
+ id,
33
+ name: entity.name,
34
+ type: entity.type,
35
+ file: file.filePath,
36
+ line: entity.line,
37
+ lang: file.lang,
38
+ complexity: entity.complexity,
39
+ inDegree: 0,
40
+ outDegree: 0,
41
+ centralityScore: 0,
42
+ connections: [],
43
+ });
44
+ }
45
+ }
46
+ ```
47
+
48
+ **Node IDs** are composite strings: `entityName__fileBasename`.
49
+ For example, the function `getUserById` in `src/services/userService.ts` gets ID `getUserById__userService`.
50
+
51
+ This scheme prevents collisions when two files have a function with the same name.
52
+
53
+ ### Step 2 — Build a file path lookup map
54
+
55
+ ```ts
56
+ const fileMap = new Map<string, ParsedFile>();
57
+ for (const file of parsedFiles) {
58
+ fileMap.set(file.filePath, file);
59
+ }
60
+ ```
61
+
62
+ This allows O(1) lookup from a resolved file path → its `ParsedFile` data.
63
+
64
+ ### Step 3 — Connect nodes through imports
65
+
66
+ This is where the real graph-building happens. For every file:
67
+
68
+ 1. Iterate over its `imports`.
69
+ 2. Skip **external** imports (packages like `express`, `lodash`) — only local imports matter.
70
+ 3. **Resolve** the import path to an actual file on disk (see path resolution below).
71
+ 4. For each imported name, find the matching entity in the target file.
72
+ 5. Create an edge from **every entity in the importing file** to the **matching target entity**.
73
+
74
+ ```ts
75
+ edges.push({
76
+ from: fromId,
77
+ to: toId,
78
+ type: 'imports',
79
+ description: `${fromEntity.name} imports ${importedName} from ${path.basename(resolvedPath)}`,
80
+ });
81
+ ```
82
+
83
+ Duplicate edges are guarded against before insertion.
84
+
85
+ ---
86
+
87
+ ## Path Resolution
88
+
89
+ Import paths like `"./userService"` don't include an extension. The `resolvePath` helper tries these candidates in priority order:
90
+
91
+ ```
92
+ ./userService
93
+ ./userService.ts
94
+ ./userService.tsx
95
+ ./userService.js
96
+ ./userService.jsx
97
+ ./userService/index.ts
98
+ ./userService/index.js
99
+ ```
100
+
101
+ It finds the first candidate that matches a known file in `parsedFiles` and returns that path. If nothing matches, the import is silently skipped.
102
+
103
+ ---
104
+
105
+ ## Output Shape — `DepGraph`
106
+
107
+ ```ts
108
+ interface DepGraph {
109
+ nodes: Map<string, DepNode>;
110
+ edges: DepEdge[];
111
+ }
112
+ ```
113
+
114
+ At this point, all `inDegree`, `outDegree`, and `centralityScore` fields on each node are still `0`. The **Metrics stage** fills these in (see [stage-metrics.md](./stage-metrics.md)).
115
+
116
+ ---
117
+
118
+ ## Edge Direction Convention
119
+
120
+ ```
121
+ FROM → TO
122
+ (importer) (imported)
123
+ ```
124
+
125
+ If `routes/users.ts` imports `getUserById` from `services/userService.ts`, the edge is:
126
+
127
+ ```
128
+ GET /api/users__users → getUserById__userService
129
+ ```
130
+
131
+ This forward-direction convention means: **following edges = following dependencies**.
132
+ For impact analysis (who depends on me?), edges are traversed **in reverse** (see [stage-impact.md](./stage-impact.md)).
133
+
134
+ ---
135
+
136
+ ## The `makeId` Helper
137
+
138
+ ```ts
139
+ function makeId(name: string, fileBase: string): string {
140
+ const cleanName = name.replace(/[^a-zA-Z0-9]/g, '_');
141
+ const cleanBase = fileBase.replace(/[^a-zA-Z0-9]/g, '_');
142
+ return `${cleanName}__${cleanBase}`;
143
+ }
144
+ ```
145
+
146
+ Non-alphanumeric characters (e.g. spaces, slashes in route paths like `GET /api/users`) are replaced with underscores. This keeps IDs safe for use as JSON keys and stable across runs.
147
+
148
+ ---
149
+
150
+ ## Common Gotchas
151
+
152
+ | Situation | Behaviour |
153
+ |---|---|
154
+ | Two files export the same function name | First one wins; second is skipped (duplicate `id` guard) |
155
+ | Import resolves to a file with no entities | The import is dropped — nothing to link to |
156
+ | Circular imports (A imports B, B imports A) | Both edges are created; no infinite loop — the graph is built in a single linear pass |
157
+ | External package imports | Skipped via the `isLocal` flag on `RawImport` |