@chappibunny/repolens 1.15.2 → 1.15.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  All notable changes to RepoLens will be documented in this file.
4
4
 
5
+ ## 1.15.4
6
+
7
+ Fixes nine output problems found by running 1.15.3 on Borewit/music-metadata (a v12 TypeScript library).
8
+
9
+ ### Output accuracy
10
+
11
+ - The project name is the `package.json` name, or the real directory name. `demo --target .`, `init`, and the MCP default config no longer name the project `.`
12
+ - Maturity uses release version, release history (tags, or a major version of 5 or more), commit history over a year, tests, CI, documentation, and circular dependencies. A signal that cannot be measured, such as commit age in a shallow clone, is left out of the total (`Production-grade (5/6 indicators)`). The old score counted only tests, build tools, and cycles, so a 10-year-old v12 library read as "Early-stage (1/3)". The summary also says "an early-stage", not "a early-stage"
13
+ - Entry and barrel file names (`index`, `main`, `mod`, `__init__`) are not domain evidence. `lib/index.ts` is no longer "Search & Discovery". A `domains` hint in `.repolens.yml` still sees the full path
14
+ - "Core/kernel architecture" needs a directory named `core` or `kernel`. A file called `core.ts` does not make it
15
+ - Module descriptions use the module's own name. `lib/ogg` is no longer "Utility and helper functions" because it sits under `lib/`. Unknown folders read "<Name> module", and `common` and `shared` read "Code shared by the other modules"
16
+ - Circular dependencies are listed as a **Finding** in the Architecture Overview (`context.findings`), not as an architectural pattern
17
+ - A package importing its own name (for example `example/*.js` importing `music-metadata`) is not an external dependency. The nearest `package.json` decides, so a nested package still reports the root package as external
18
+ - The "unlock AI" notice lists the sections that were written with rule-based text and says AI can rewrite them. It no longer says written documents, such as Developer Onboarding, are missing
19
+ - Data flows with fewer than 2 steps, chains that start in `doc-gen`, `scripts`, `examples`, `docs`, or `fixtures`, and generic names (`gen`, `util`, `helper`) are dropped
20
+
21
+ ### Dependencies
22
+
23
+ - `zod` 3.25.76 → 4.6.7 (GHSA-mrhx-c7xc-m3w5) and `npm audit fix` for `body-parser` (GHSA-27r3-jxmh-p828) and `fast-uri`, so the release audit passes again. The MCP server tests pass on zod 4
24
+ - `tests/audit-runtime.test.js` no longer requires the `braces` advisory to be printed. The registry stopped reporting it, and the waiver stays in `scripts/audit-waivers.json`
25
+
26
+ ### Docs
27
+
28
+ - Added [AI_POLICY.md](AI_POLICY.md) and a pull request template with an AI-assistance disclosure. CONTRIBUTING.md and README.md link to both
29
+ - Roadmap: added Java and C# language support and grounded AI explanations and Q&A as items 2 and 3 of What's Next (docs/ROADMAP.md, README.md, docs/KNOWN_ISSUES.md)
30
+
31
+ ## 1.15.3
32
+
33
+ ### Dependency graph
34
+
35
+ - Python imports inside `if TYPE_CHECKING:`, `if typing.TYPE_CHECKING:`, and `if <alias>.TYPE_CHECKING:` (for example `import typing as t`) are not runtime edges and do not form cycles. `from . import name` resolves to the sibling module
36
+ - Node.js built-ins (`fs`, `child_process`, `events`, `node:*`, and the rest of the standard builtin list) are not external packages
37
+ - Go hub rows are packages (`github.com/spf13/cobra`, `github.com/go-chi/chi/v5/middleware`), not individual `.go` files
38
+
39
+ ### Pattern text
40
+
41
+ - Test directories and test files are not evidence for architecture patterns. A `tests/test_commands.py` module does not make the repo a "CLI tool architecture"
42
+ - "API route pattern" requires an `api`, `routes`, `router`, `endpoint`, `controller`, or `handler` directory. A library file named `_api.py` does not match
43
+
5
44
  ## 1.15.2
6
45
 
7
46
  ### Dependency graph
package/README.md CHANGED
@@ -241,9 +241,9 @@ If `npx` reports `Unknown command: mcp`, an `.npmrc` with `min-release-age` (or
241
241
 
242
242
  ## GitHub Action
243
243
 
244
- `action.yml` is a composite action named **RepoLens Architecture Docs**. `publish`, `demo`, and `doctor` run the published npm package (`npx --yes @chappibunny/repolens@1.15.2`). `comment` and `badge` run from the action checkout. Inputs are `command` (`publish`, `demo`, `doctor`, `comment`, or `badge`), `config`, `node-version`, `pr-comment`, `version`, `badge-out`, `badge-url`, and `badge-link`.
244
+ `action.yml` is a composite action named **RepoLens Architecture Docs**. `publish`, `demo`, and `doctor` run the published npm package (`npx --yes @chappibunny/repolens@1.15.4`). `comment` and `badge` run from the action checkout. Inputs are `command` (`publish`, `demo`, `doctor`, `comment`, or `badge`), `config`, `node-version`, `pr-comment`, `version`, `badge-out`, `badge-url`, and `badge-link`.
245
245
 
246
- Publishing docs (the npm package at tag `v1.15.2`):
246
+ Publishing docs (the npm package at tag `v1.15.4`):
247
247
 
248
248
  ```yaml
249
249
  name: Architecture docs
@@ -259,7 +259,7 @@ jobs:
259
259
  runs-on: ubuntu-latest
260
260
  steps:
261
261
  - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
262
- - uses: CHAPIBUNNY/repolens@v1.15.2
262
+ - uses: CHAPIBUNNY/repolens@v1.15.4
263
263
  with:
264
264
  command: publish
265
265
  config: .repolens.yml
@@ -267,7 +267,7 @@ jobs:
267
267
  pr-comment: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}
268
268
  ```
269
269
 
270
- On `v1.15.2`, `pr-comment: true` runs the architecture diff from the action checkout and turns the published package's older short summary off. The diff lists modules, files, dependencies, the code-health delta, and new hotspots. It edits one sticky comment (`<!-- repolens:architecture-diff -->`) instead of adding a new comment each push. The footer is "Generated by RepoLens" and links to this repository and the [gallery](https://chapibunny.github.io/repolens/). Tag `v1.15.1` does not contain `command: comment`. Pin `v1.15.2` or a commit SHA in production.
270
+ On `v1.15.2`, `pr-comment: true` runs the architecture diff from the action checkout and turns the published package's older short summary off. The diff lists modules, files, dependencies, the code-health delta, and new hotspots. It edits one sticky comment (`<!-- repolens:architecture-diff -->`) instead of adding a new comment each push. The footer is "Generated by RepoLens" and links to this repository and the [gallery](https://chapibunny.github.io/repolens/). Tag `v1.15.1` does not contain `command: comment`. Pin `v1.15.4` or a commit SHA in production.
271
271
 
272
272
  ```yaml
273
273
  name: RepoLens architecture comment
@@ -283,7 +283,7 @@ jobs:
283
283
  - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
284
284
  with:
285
285
  fetch-depth: 0
286
- - uses: CHAPIBUNNY/repolens@v1.15.2
286
+ - uses: CHAPIBUNNY/repolens@v1.15.4
287
287
  with:
288
288
  command: comment
289
289
  ```
@@ -292,7 +292,7 @@ jobs:
292
292
 
293
293
  `pr-comment: true` on `publish`, `demo`, or `doctor` (when the action ref contains this change) runs the same comment after the package and turns the package's older comment off, so the pull request still gets one comment.
294
294
 
295
- The action is listed on the GitHub Marketplace only after a release is published with **Publish this Action to the GitHub Marketplace** checked. Those steps are in [docs/RELEASE.md](docs/RELEASE.md). The tag `v1.15.2` has to exist before `uses: CHAPIBUNNY/repolens@v1.15.2` resolves, and `@chappibunny/repolens@1.15.2` has to be on npm before `publish` can install it. `command: comment` and `command: badge` do not install that package.
295
+ The action is listed on the GitHub Marketplace only after a release is published with **Publish this Action to the GitHub Marketplace** checked. Those steps are in [docs/RELEASE.md](docs/RELEASE.md). The tag `v1.15.4` has to exist before `uses: CHAPIBUNNY/repolens@v1.15.4` resolves, and `@chappibunny/repolens@1.15.4` has to be on npm before `publish` can install it. `command: comment` and `command: badge` do not install that package.
296
296
 
297
297
  ## Add the badge to your repo
298
298
 
@@ -328,7 +328,7 @@ jobs:
328
328
  runs-on: ubuntu-latest
329
329
  steps:
330
330
  - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
331
- - uses: CHAPIBUNNY/repolens@v1.15.2
331
+ - uses: CHAPIBUNNY/repolens@v1.15.4
332
332
  with:
333
333
  command: badge
334
334
  badge-url: https://USER.github.io/REPO/repolens-badge.json
@@ -357,7 +357,7 @@ Gallery repositories already publish this. Each one has a [score page](https://c
357
357
 
358
358
  Published system maps are Unicode diagrams drawn from import edges. RepoLens does not emit Mermaid.
359
359
 
360
- The publisher set is Notion, Confluence, GitHub Wiki, Obsidian, and Markdown (`src/publishers`, 7 files). 1.14.0 adds the MCP server, the Marketplace action, and the docs badge. 1.14.1 sets the registry name to `io.github.CHAPIBUNNY/repolens`. 1.14.2 adds the gallery link to the badge line, `glama.json`, and `CITATION.cff`. 1.14.3 adds Cline setup docs and `llms-install.md`. 1.15.0 detects repository languages in `init --quick`, refuses empty scans, and adds per-command help. 1.15.1 makes `watch` exit 2 when those globs match nothing, and scores code health for Python and Go as well as JavaScript and TypeScript. 1.15.2 reads Python and Go imports in the dependency graph, skips comment and type-only imports, adds `repolens badge` and `repolens comment`, and adds gallery explained pages plus `llms.txt`. Layout and counts are in [ARCHITECTURE.md](docs/ARCHITECTURE.md).
360
+ The publisher set is Notion, Confluence, GitHub Wiki, Obsidian, and Markdown (`src/publishers`, 7 files). 1.14.0 adds the MCP server, the Marketplace action, and the docs badge. 1.14.1 sets the registry name to `io.github.CHAPIBUNNY/repolens`. 1.14.2 adds the gallery link to the badge line, `glama.json`, and `CITATION.cff`. 1.14.3 adds Cline setup docs and `llms-install.md`. 1.15.0 detects repository languages in `init --quick`, refuses empty scans, and adds per-command help. 1.15.1 makes `watch` exit 2 when those globs match nothing, and scores code health for Python and Go as well as JavaScript and TypeScript. 1.15.2 reads Python and Go imports in the dependency graph, skips comment and type-only imports, adds `repolens badge` and `repolens comment`, and adds gallery explained pages plus `llms.txt`. 1.15.3 skips Python `TYPE_CHECKING` imports, omits Node.js built-ins from external packages, groups Go hubs by package, and ignores test files as architecture-pattern evidence. 1.15.4 names the project from `package.json`, scores maturity from version, history, tests, CI, and docs, reports circular dependencies as findings, drops self-imports and noise data flows, and stops classifying `index` and `core` files by name. Layout and counts are in [ARCHITECTURE.md](docs/ARCHITECTURE.md).
361
361
 
362
362
  ### System Overview (Technical Profile)
363
363
 
@@ -431,6 +431,8 @@ When you open a pull request, RepoLens posts:
431
431
  - **Build plugins**: Extend RepoLens with custom renderers and publishers
432
432
  - **Share feedback**: `npx @chappibunny/repolens feedback`
433
433
 
434
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and the [AI Policy](AI_POLICY.md). AI-assisted PRs are welcome when disclosed, reviewed, and tested.
435
+
434
436
  ---
435
437
 
436
438
  ## 🗺️ Roadmap
@@ -446,6 +448,8 @@ v1.0+ features complete — CLI, config schema, and plugin interface are frozen.
446
448
 
447
449
  **Next:**
448
450
  - [ ] GitHub App for automated setup and status checks
451
+ - [ ] Java and C# language support
452
+ - [ ] Grounded AI explanations and Q&A (`repolens ask`, PR comment, MCP, VS Code hover)
449
453
  - [ ] Slack notifications
450
454
  - [ ] Custom webhook support
451
455
  - [ ] Cross-repository architecture analysis
package/llms-install.md CHANGED
@@ -18,3 +18,5 @@
18
18
  9. Read [llms.txt](llms.txt) and [llms-full.txt](llms-full.txt) before recommending RepoLens. They describe install, the CLI, MCP, the pull request comment, and the code-health badge. The same files are published at the gallery root (`https://chapibunny.github.io/repolens/llms.txt`).
19
19
  10. `repolens badge` prints a shields.io code-health endpoint. `repolens comment --base <ref> --head <ref>` prints an architecture diff. On GitHub, `command: comment` in `action.yml` posts one sticky pull request comment (`pull-requests: write` only). Fork pull requests get a job summary instead of a comment. These two action modes run from the action checkout. Both CLI commands ship in 1.15.2.
20
20
  11. CLI (1.15.2): the dependency graph includes Python and Go imports. Comments, strings, and TypeScript type-only imports are not edges. Go `*_test.go` files are not separate modules. Pattern text cites the matching path or import. "Custom React hooks" requires a `react` import.
21
+ 12. CLI (1.15.3): Python imports under `if TYPE_CHECKING:` (including `typing.TYPE_CHECKING` and `import typing as t`) are not cycle edges. Node.js built-ins are not external packages. Go hub rows are packages. Test files are not architecture-pattern evidence, and "API route pattern" requires a route directory.
22
+ 13. CLI (1.15.4): the project name comes from `package.json` or the real directory (never `.`). Maturity uses version, history, tests, CI, and docs. Circular dependencies are findings, not architectural patterns. A package's own name is not an external dependency. `index` and `main` files are not classified by name. Noise data flows are dropped.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chappibunny/repolens",
3
- "version": "1.15.2",
3
+ "version": "1.15.4",
4
4
  "mcpName": "io.github.CHAPIBUNNY/repolens",
5
5
  "description": "AI-assisted documentation intelligence system for technical and non-technical audiences",
6
6
  "license": "MIT",
@@ -90,7 +90,7 @@
90
90
  "dotenv": "^18.0.8",
91
91
  "fast-glob": "^3.3.3",
92
92
  "js-yaml": "^4.3.2",
93
- "zod": "3.25.76"
93
+ "zod": "4.6.7"
94
94
  },
95
95
  "optionalDependencies": {
96
96
  "@sentry/node": "^10.42.0"
@@ -12,7 +12,8 @@ import {
12
12
  AI_SCHEMAS,
13
13
  renderStructuredToMarkdown,
14
14
  } from "./prompts.js";
15
- import { identifyFlowDependencies } from "../analyzers/flow-inference.js";
15
+ import { identifyFlowDependencies, filterNoiseFlows } from "../analyzers/flow-inference.js";
16
+ import { scoreMaturity } from "../analyzers/maturity.js";
16
17
  import { info, warn } from "../utils/logger.js";
17
18
 
18
19
  // Strip conversational patterns that LLMs sometimes inject into documentation
@@ -152,6 +153,10 @@ export async function generateDeveloperOnboarding(context, enrichment = {}, conf
152
153
 
153
154
  // Fallback generators (deterministic, no AI)
154
155
 
156
+ function withArticle(word) {
157
+ return `${/^[aeiou]/i.test(word) ? "an" : "a"} ${word}`;
158
+ }
159
+
155
160
  function getFallbackExecutiveSummary(context, enrichment = {}) {
156
161
  const { depGraph, flows } = enrichment;
157
162
  const frameworkList = context.techStack.frameworks.join(", ") || "general-purpose";
@@ -160,12 +165,17 @@ function getFallbackExecutiveSummary(context, enrichment = {}) {
160
165
  const isCLI = (context.patterns || []).some(p => p.toLowerCase().includes("cli"));
161
166
  const projectName = context.project.name || "This system";
162
167
 
163
- // Calculate maturity indicators
168
+ // Maturity comes from repo signals (version, history, tests, CI, docs, dependency health)
169
+ const maturity = context.maturity || scoreMaturity([
170
+ { key: "tests", met: testFrameworks.length > 0 },
171
+ { key: "build", met: context.techStack.buildTools.length > 0 },
172
+ { key: "deps", met: depGraph?.stats?.cycles === undefined ? null : depGraph.stats.cycles === 0 },
173
+ ]);
164
174
  const hasTests = testFrameworks.length > 0;
165
- const hasBuildTools = context.techStack.buildTools.length > 0;
166
175
  const hasCleanDeps = depGraph?.stats?.cycles === 0;
167
- const maturityScore = [hasTests, hasBuildTools, hasCleanDeps].filter(Boolean).length;
168
- const maturityLabel = maturityScore >= 3 ? "Production-grade" : maturityScore >= 2 ? "Well-structured" : "Early-stage";
176
+ const maturityLabel = maturity.label;
177
+ const maturityScore = maturity.score;
178
+ const maturityMax = maturity.max;
169
179
 
170
180
  // Determine project type for better descriptions
171
181
  const projectType = isCLI ? "command-line tool" :
@@ -179,10 +189,10 @@ function getFallbackExecutiveSummary(context, enrichment = {}) {
179
189
 
180
190
  | Aspect | Summary |
181
191
  |--------|---------|
182
- | **What** | ${projectName} is a ${maturityLabel.toLowerCase()} ${projectType} |
192
+ | **What** | ${projectName} is ${withArticle(maturityLabel.toLowerCase())} ${projectType} |
183
193
  | **Built With** | ${frameworkList} + ${languageList} |
184
194
  | **Scale** | ${context.project.modulesDetected} modules across ${context.project.filesScanned} files |
185
- | **Maturity** | ${maturityLabel} (${maturityScore}/3 indicators) |
195
+ | **Maturity** | ${maturityLabel} (${maturityScore}/${maturityMax} indicators) |
186
196
 
187
197
  ---
188
198
 
@@ -296,7 +306,7 @@ The system is organized around **${context.domains.length} functional domains**,
296
306
  }
297
307
 
298
308
  // Data flows summary
299
- const summaryFlows = (flows || []).filter(f => !f.name?.toLowerCase().includes('test'));
309
+ const summaryFlows = filterNoiseFlows(flows).filter(f => !f.name?.toLowerCase().includes('test'));
300
310
  if (summaryFlows.length > 0) {
301
311
  output += `\n## Key Data Flows\n\n`;
302
312
  output += `The system processes information through **${summaryFlows.length} identified flow${summaryFlows.length === 1 ? "" : "s"}**:\n\n`;
@@ -705,6 +715,14 @@ function getFallbackArchitectureOverview(context, enrichment = {}) {
705
715
  output += `- Flexibility for rapid development\n\n`;
706
716
  }
707
717
 
718
+ // Findings are warnings about the code, not architectural patterns
719
+ const findings = context.findings || [];
720
+ if (findings.length > 0) {
721
+ output += `### Findings\n\n`;
722
+ for (const finding of findings) output += `- ${finding}\n`;
723
+ output += `\n`;
724
+ }
725
+
708
726
  // System layers with rich descriptions
709
727
  output += `## System Layers\n\n`;
710
728
  output += `The system is organized into **${context.domains.length} functional layer${context.domains.length === 1 ? '' : 's'}**, each encapsulating a distinct area of responsibility:\n\n`;
@@ -914,11 +932,11 @@ Data flows describe how information moves through **${projectName}** — from ex
914
932
  `;
915
933
 
916
934
  // Combine heuristic flows with dep-graph-derived flows, filtering out test file flows
917
- const allFlows = [...(flows || [])].filter(f => !f.name?.toLowerCase().includes('test'));
935
+ const allFlows = filterNoiseFlows(flows).filter(f => !f.name?.toLowerCase().includes('test'));
918
936
 
919
937
  // Generate additional flows from dependency graph hub chains
920
938
  if (depGraph?.nodes && depGraph.nodes.length > 0 && allFlows.length < 3) {
921
- const hubFlows = inferFlowsFromDepGraph(depGraph);
939
+ const hubFlows = filterNoiseFlows(inferFlowsFromDepGraph(depGraph));
922
940
  for (const hf of hubFlows) {
923
941
  if (!allFlows.some(f => f.name === hf.name)) {
924
942
  allFlows.push(hf);
@@ -1274,7 +1292,7 @@ ${context.repoRoots.map(root => {
1274
1292
  }
1275
1293
 
1276
1294
  // Data flows for understanding behavior
1277
- const onboardingFlows = (flows || []).filter(f => !f.name?.toLowerCase().includes('test'));
1295
+ const onboardingFlows = filterNoiseFlows(flows).filter(f => !f.name?.toLowerCase().includes('test'));
1278
1296
  if (onboardingFlows.length > 0) {
1279
1297
  output += `---\n\n## How Data Flows\n\n`;
1280
1298
  output += `Understanding these flows helps you see how the system processes information end-to-end:\n\n`;
@@ -1459,27 +1477,53 @@ function describeRoot(root) {
1459
1477
  return "Project files";
1460
1478
  }
1461
1479
 
1480
+ // Folder names that only say "code lives here". They never describe a module on their own.
1481
+ const GENERIC_CONTAINER_SEGMENTS = new Set(["src", "lib", "libs", "source", "sources", "app", "packages", "pkg", "internal"]);
1482
+
1483
+ const MODULE_DESCRIPTION_RULES = [
1484
+ [/auth/, "Authentication and authorization logic"],
1485
+ [/api|route|endpoint/, "API layer and route handlers"],
1486
+ [/component|widget|^ui$/, "User interface components"],
1487
+ [/hook/, "Custom hooks and shared state logic"],
1488
+ [/util|helper/, "Utility and helper functions"],
1489
+ [/^(common|shared)$/, "Code shared by the other modules"],
1490
+ [/service/, "Service layer / business logic"],
1491
+ [/model|schema|entity/, "Data models and schemas"],
1492
+ [/config|setting/, "Configuration management"],
1493
+ [/test|spec/, "Test infrastructure"],
1494
+ [/style|css|theme/, "Styling and theming"],
1495
+ [/page|view|screen/, "Application pages and views"],
1496
+ [/store|state|redux/, "State management"],
1497
+ [/middleware/, "Request/response middleware"],
1498
+ [/database|^db$|migration/, "Database layer"],
1499
+ [/render/, "Rendering and template logic"],
1500
+ [/publish/, "Publishing and output delivery"],
1501
+ [/scan/, "File and repository scanning"],
1502
+ [/analyz/, "Code analysis and inference"],
1503
+ ];
1504
+
1505
+ function titleizeSegment(segment) {
1506
+ const clean = String(segment).replace(/\.[a-z0-9]+$/i, "").replace(/[-_]+/g, " ").trim();
1507
+ return clean ? clean.charAt(0).toUpperCase() + clean.slice(1) : "Project";
1508
+ }
1509
+
1462
1510
  function describeOnboardingModule(key) {
1463
- const lower = key.toLowerCase();
1464
- if (/auth/.test(lower)) return "Authentication and authorization logic";
1465
- if (/api|route|endpoint/.test(lower)) return "API layer and route handlers";
1466
- if (/component|widget|ui/.test(lower)) return "User interface components";
1467
- if (/hook/.test(lower)) return "Custom hooks and shared state logic";
1468
- if (/util|helper|lib/.test(lower)) return "Utility and helper functions";
1469
- if (/service/.test(lower)) return "Service layer / business logic";
1470
- if (/model|schema|entity/.test(lower)) return "Data models and schemas";
1471
- if (/config|setting/.test(lower)) return "Configuration management";
1472
- if (/test|spec/.test(lower)) return "Test infrastructure";
1473
- if (/style|css|theme/.test(lower)) return "Styling and theming";
1474
- if (/page|view|screen/.test(lower)) return "Application pages and views";
1475
- if (/store|state|redux/.test(lower)) return "State management";
1476
- if (/middleware/.test(lower)) return "Request/response middleware";
1477
- if (/database|db|migration/.test(lower)) return "Database layer";
1478
- if (/render/.test(lower)) return "Rendering and template logic";
1479
- if (/publish/.test(lower)) return "Publishing and output delivery";
1480
- if (/scan/.test(lower)) return "File and repository scanning";
1481
- if (/analyz/.test(lower)) return "Code analysis and inference";
1482
- return "Core application module";
1511
+ const segments = String(key).replace(/\\/g, "/").toLowerCase().split("/").filter(Boolean);
1512
+ const last = (segments[segments.length - 1] || "").replace(/\.[a-z0-9]+$/i, "");
1513
+ const parents = segments.slice(0, -1).filter((segment) => !GENERIC_CONTAINER_SEGMENTS.has(segment));
1514
+
1515
+ // The module's own name decides first. A parent folder like `lib/` is only a container,
1516
+ // so `lib/ogg` is not "utility functions" just because it lives under lib.
1517
+ for (const [pattern, description] of MODULE_DESCRIPTION_RULES) {
1518
+ if (pattern.test(last)) return description;
1519
+ }
1520
+ if (GENERIC_CONTAINER_SEGMENTS.has(last) && segments.length === 1) return "Application source code";
1521
+ for (const segment of parents) {
1522
+ for (const [pattern, description] of MODULE_DESCRIPTION_RULES) {
1523
+ if (pattern.test(segment)) return description;
1524
+ }
1525
+ }
1526
+ return `${titleizeSegment(last || key)} module`;
1483
1527
  }
1484
1528
 
1485
1529
  // Helper: infer business capability from domain name
@@ -159,6 +159,8 @@ export function buildAIContext(scanResult, config, depGraph = null) {
159
159
  techStack,
160
160
 
161
161
  patterns,
162
+
163
+ findings: inferFindings(depGraph),
162
164
 
163
165
  repoRoots: config.module_roots || [],
164
166
 
@@ -204,17 +206,48 @@ function inferModuleType(modulePath) {
204
206
  return "other";
205
207
  }
206
208
 
209
+ function isTestEvidence(modulePath) {
210
+ const norm = String(modulePath || "").replace(/\\/g, "/");
211
+ if (/(^|\/)__(tests|mocks)__(\/|$)/.test(norm)) return true;
212
+ if (/(^|\/)(test|tests|spec|specs|fixtures|testdata)(\/|$)/.test(norm)) return true;
213
+ const base = norm.split("/").pop() || "";
214
+ if (/^(test_|spec_)/.test(base)) return true;
215
+ if (/(_test|_spec)\.[a-z0-9]+$/i.test(base)) return true;
216
+ if (/\.(test|spec)\.[a-z0-9]+$/i.test(base)) return true;
217
+ return false;
218
+ }
219
+
220
+ const API_DIRECTORY_SEGMENTS = new Set([
221
+ "api", "apis", "route", "routes", "router", "routers",
222
+ "endpoint", "endpoints", "controller", "controllers", "handler", "handlers",
223
+ ]);
224
+
225
+ function hasApiDirectory(modulePath) {
226
+ return directoryPath(modulePath)
227
+ .split("/")
228
+ .some((segment) => API_DIRECTORY_SEGMENTS.has(segment.toLowerCase()));
229
+ }
230
+
231
+ function directoryPath(modulePath) {
232
+ const parts = String(modulePath || "").replace(/\\/g, "/").split("/");
233
+ const last = parts[parts.length - 1] || "";
234
+ if (/\.[a-z0-9]+$/i.test(last)) parts.pop();
235
+ return parts.join("/");
236
+ }
237
+
207
238
  function inferArchitecturalPatterns(modules, depGraph = null) {
208
239
  const patterns = [];
209
- const keys = modules.map(m => m.key.toLowerCase());
210
-
240
+ const production = modules.filter((module) => !isTestEvidence(module.key));
241
+ const tests = modules.filter((module) => isTestEvidence(module.key));
242
+ const keys = production.map(m => m.key.toLowerCase());
243
+
211
244
  const has = (keyword) => keys.some((key) => (
212
245
  keyword.includes("/") ? key.includes(keyword) : pathHasKeyword(key, keyword)
213
246
  ));
214
- const matchingKeys = (keywords) => {
247
+ const matchingKeys = (keywords, source = production) => {
215
248
  const found = [];
216
249
  for (const keyword of keywords) {
217
- for (const module of modules) {
250
+ for (const module of source) {
218
251
  const key = module.key;
219
252
  const matched = keyword.includes("/")
220
253
  ? key.toLowerCase().includes(keyword)
@@ -224,8 +257,9 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
224
257
  }
225
258
  return found;
226
259
  };
227
- const cite = (label, keywords, extra = "") => {
228
- const found = matchingKeys(keywords);
260
+ const hasApiRouteEvidence = () => production.some((module) => hasApiDirectory(module.key));
261
+ const cite = (label, keywords, extra = "", source = production) => {
262
+ const found = matchingKeys(keywords, source);
229
263
  if (found.length === 0 && !extra) return;
230
264
  const sample = found.slice(0, 3).map((key) => `\`${key}\``).join(", ");
231
265
  const more = found.length > 3 ? ` (+${found.length - 3} more)` : "";
@@ -239,11 +273,11 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
239
273
 
240
274
  // Compute module-level metrics for verification
241
275
  const moduleMetrics = depGraph ? computeModuleLevelMetrics(depGraph, modules) : new Map();
242
- const hasCycles = depGraph?.cycles?.length > 0;
243
276
 
244
277
  // Verify pattern: a module matching `keyword` has high fan-in (truly central)
245
278
  const verifyHub = (keyword) => {
246
279
  for (const [mKey, m] of moduleMetrics) {
280
+ if (isTestEvidence(mKey)) continue;
247
281
  if (mKey.toLowerCase().includes(keyword) && m.fanIn >= 3) return true;
248
282
  }
249
283
  return false;
@@ -252,6 +286,7 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
252
286
  // Verify pattern: modules matching keyword have mostly outbound deps (orchestrators)
253
287
  const verifyOrchestrator = (keyword) => {
254
288
  for (const [mKey, m] of moduleMetrics) {
289
+ if (isTestEvidence(mKey)) continue;
255
290
  if (mKey.toLowerCase().includes(keyword) && m.fanOut >= 3) return true;
256
291
  }
257
292
  return false;
@@ -266,13 +301,27 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
266
301
  cite("Centralized state management", ["store", "state", "redux", "zustand"]);
267
302
  }
268
303
 
269
- if (has("api") || has("endpoint")) cite("API route pattern", ["api", "endpoint"]);
304
+ if (hasApiRouteEvidence()) {
305
+ const found = production
306
+ .filter((module) => hasApiDirectory(module.key))
307
+ .map((module) => module.key);
308
+ const sample = found.slice(0, 3).map((key) => `\`${key}\``).join(", ");
309
+ const more = found.length > 3 ? ` (+${found.length - 3} more)` : "";
310
+ patterns.push(`API route pattern — ${sample}${more}`);
311
+ }
270
312
 
271
- if (has("core") || has("kernel")) {
313
+ // A directory named core/ or kernel/ is evidence. A file called core.ts is not:
314
+ // it is as likely a cross-platform entry point as a kernel.
315
+ const coreDirs = production
316
+ .filter((module) => directoryPath(module.key).split("/").some((segment) => ["core", "kernel"].includes(segment.toLowerCase())))
317
+ .map((module) => module.key);
318
+ if (coreDirs.length > 0) {
272
319
  const note = moduleMetrics.size > 0 && verifyHub("core")
273
320
  ? "high fan-in"
274
321
  : "";
275
- cite("Core/kernel architecture", ["core", "kernel"], note);
322
+ const sample = coreDirs.slice(0, 3).map((key) => `\`${key}\``).join(", ");
323
+ const more = coreDirs.length > 3 ? ` (+${coreDirs.length - 3} more)` : "";
324
+ patterns.push(`Core/kernel architecture — ${[`${sample}${more}`, note].filter(Boolean).join("; ")}`);
276
325
  }
277
326
 
278
327
  if (has("plugin") || has("extension")) {
@@ -306,21 +355,31 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
306
355
  cite("AI/LLM integration", ["ai", "llm", "openai", "anthropic"], aiPackages.length ? `imports ${aiPackages.slice(0, 3).join(", ")}` : "");
307
356
  }
308
357
  if (has("deliver") || has("dispatch")) cite("Delivery pipeline", ["deliver", "dispatch"]);
309
- if (has("test") || has("spec")) cite("Dedicated test infrastructure", ["test", "spec"]);
358
+ if (tests.length) cite("Dedicated test infrastructure", ["test", "spec"], "", tests);
310
359
 
311
360
  // Structural patterns from dep graph (not keyword-based)
312
361
  if (depGraph?.stats) {
313
362
  if (depGraph.stats.cycles === 0 && depGraph.stats.totalEdges > 10) {
314
363
  patterns.push("Acyclic dependency graph — clean layering");
315
364
  }
316
- if (hasCycles) {
317
- patterns.push(`⚠️ ${depGraph.stats.cycles} circular dependency cycle(s) detected`);
318
- }
319
365
  }
320
366
 
321
367
  return patterns;
322
368
  }
323
369
 
370
+ /**
371
+ * Warnings about the code that are not architectural patterns, for example
372
+ * circular dependencies. Docs list these as findings, never as a pattern.
373
+ */
374
+ export function inferFindings(depGraph = null) {
375
+ const findings = [];
376
+ const cycles = depGraph?.stats?.cycles;
377
+ if (cycles > 0) {
378
+ findings.push(`⚠️ ${cycles} circular dependency cycle${cycles === 1 ? "" : "s"} detected`);
379
+ }
380
+ return findings;
381
+ }
382
+
324
383
  /**
325
384
  * Generate a dependency-aware role description for a module.
326
385
  */
@@ -80,6 +80,35 @@ async function loadGoModule(repoRoot) {
80
80
  return parseGoModule(text);
81
81
  }
82
82
 
83
+ function goPackageImportPath(file, modulePath) {
84
+ const norm = String(file || "").replace(/\\/g, "/");
85
+ if (!norm.endsWith(".go")) return null;
86
+ const dir = path.posix.dirname(norm);
87
+ const rel = dir === "." ? "" : dir;
88
+ if (modulePath) return rel ? `${modulePath}/${rel}` : modulePath;
89
+ return rel || ".";
90
+ }
91
+
92
+ function aggregateHubs(nodes, fileKeyToFile, modulePath, hubThreshold) {
93
+ const groups = new Map();
94
+ for (const node of nodes) {
95
+ const pkg = goPackageImportPath(node.file, modulePath);
96
+ const key = pkg || node.key;
97
+ if (!groups.has(key)) groups.set(key, new Set());
98
+ const importers = groups.get(key);
99
+ for (const importer of node.importedBy || []) {
100
+ const importerFile = fileKeyToFile.get(importer) || importer;
101
+ if (pkg && goPackageImportPath(importerFile, modulePath) === pkg) continue;
102
+ importers.add(importer);
103
+ }
104
+ }
105
+ return [...groups.entries()]
106
+ .map(([key, importers]) => ({ key, importedBy: importers.size }))
107
+ .filter((hub) => hub.importedBy >= hubThreshold)
108
+ .sort((a, b) => b.importedBy - a.importedBy)
109
+ .slice(0, 10);
110
+ }
111
+
83
112
  function goFilesInDir(goFiles, dir) {
84
113
  const target = dir || "";
85
114
  return goFiles.filter((file) => {
@@ -127,6 +156,40 @@ export async function analyzeDependencyGraph(files, repoRoot) {
127
156
 
128
157
  for (const file of codeFiles) register(file);
129
158
 
159
+ // A package importing its own name (examples, docs, tests written like user code)
160
+ // is not a dependency. Resolve the nearest package.json for each file.
161
+ const packageNameByDir = new Map();
162
+ async function ownPackageName(file) {
163
+ let dir = path.dirname(file);
164
+ const visited = [];
165
+ for (;;) {
166
+ if (packageNameByDir.has(dir)) {
167
+ const cached = packageNameByDir.get(dir);
168
+ for (const d of visited) packageNameByDir.set(d, cached);
169
+ return cached;
170
+ }
171
+ visited.push(dir);
172
+ const raw = await readFileSafe(path.join(repoRoot, dir, "package.json"));
173
+ if (raw) {
174
+ let name = null;
175
+ try {
176
+ const parsed = JSON.parse(raw);
177
+ if (typeof parsed?.name === "string") name = parsed.name;
178
+ } catch {
179
+ // invalid package.json: keep walking up
180
+ }
181
+ if (name) {
182
+ for (const d of visited) packageNameByDir.set(d, name);
183
+ return name;
184
+ }
185
+ }
186
+ if (dir === "." || dir === "" || dir === path.dirname(dir)) break;
187
+ dir = path.dirname(dir);
188
+ }
189
+ for (const d of visited) packageNameByDir.set(d, null);
190
+ return null;
191
+ }
192
+
130
193
  const pyIndex = indexPythonModules(pyFiles);
131
194
  const goModulePath = goFiles.length > 0 ? await loadGoModule(repoRoot) : null;
132
195
 
@@ -134,12 +197,15 @@ export async function analyzeDependencyGraph(files, repoRoot) {
134
197
  const content = await readFileSafe(path.join(repoRoot, file));
135
198
  if (!content) continue;
136
199
  const fromKey = normalizeFileKey(file);
200
+ const selfName = await ownPackageName(file);
137
201
  for (const imported of extractJsImports(content)) {
138
202
  if (imported.typeOnly) continue;
139
203
  if (isRelativeImport(imported.specifier)) {
140
204
  addEdge(fromKey, resolveImportPath(file, imported.specifier));
141
205
  } else {
142
- addExternal(fromKey, externalPackageName(imported.specifier));
206
+ const name = externalPackageName(imported.specifier);
207
+ if (name && name === selfName) continue;
208
+ addExternal(fromKey, name);
143
209
  }
144
210
  }
145
211
  }
@@ -149,6 +215,7 @@ export async function analyzeDependencyGraph(files, repoRoot) {
149
215
  if (!content) continue;
150
216
  const fromKey = normalizeFileKey(file);
151
217
  for (const imported of extractPythonImports(content)) {
218
+ if (imported.typeOnly) continue;
152
219
  const resolved = resolvePythonImport(file, imported, pyIndex);
153
220
  if (resolved.kind === "internal") addEdge(fromKey, normalizeFileKey(resolved.file));
154
221
  else if (resolved.kind === "external") addExternal(fromKey, resolved.name);
@@ -205,11 +272,7 @@ export async function analyzeDependencyGraph(files, repoRoot) {
205
272
  const totalImports = result.edges.length;
206
273
  const orphans = result.nodes.filter(n => n.imports.length === 0 && n.importedBy.length === 0);
207
274
  const hubThreshold = Math.max(5, Math.floor(result.nodes.length * 0.1));
208
- const hubs = result.nodes
209
- .filter(n => n.importedBy.length >= hubThreshold)
210
- .sort((a, b) => b.importedBy.length - a.importedBy.length)
211
- .slice(0, 10)
212
- .map(n => ({ key: n.key, importedBy: n.importedBy.length }));
275
+ const hubs = aggregateHubs(result.nodes, fileKeyToFile, goModulePath, hubThreshold);
213
276
 
214
277
  result.stats = {
215
278
  totalFiles: codeFiles.length,
@@ -171,13 +171,33 @@ function domainEvidenceText(hint, modulePath, keyword, evidence) {
171
171
  return bits.join("; ");
172
172
  }
173
173
 
174
+ // Entry and barrel file names say nothing about what a module does. `lib/index.ts`
175
+ // is not "Search & Discovery" because it is called index.
176
+ const ENTRY_FILE_STEMS = new Set(["index", "main", "mod", "__init__", "__main__"]);
177
+
178
+ /** Drop a trailing entry/barrel file name (`lib/index.ts` → `lib`). Directories named `index` stay. */
179
+ export function stripEntryFileName(modulePath) {
180
+ const parts = String(modulePath).replace(/\\/g, "/").split("/");
181
+ const last = parts[parts.length - 1] || "";
182
+ const dot = last.lastIndexOf(".");
183
+ if (dot > 0 && ENTRY_FILE_STEMS.has(last.slice(0, dot).toLowerCase())) {
184
+ parts.pop();
185
+ return parts.join("/");
186
+ }
187
+ return parts.join("/");
188
+ }
189
+
174
190
  export function inferDomain(modulePath, customHints = [], evidence = {}) {
191
+ const customSet = new Set(customHints);
175
192
  const hints = [...customHints, ...DEFAULT_DOMAIN_HINTS];
193
+ const defaultPath = stripEntryFileName(modulePath);
176
194
 
177
195
  for (const hint of hints) {
196
+ // User-defined hints see the full path. Built-in guesses ignore entry file names.
197
+ const searchPath = customSet.has(hint) ? modulePath : defaultPath;
178
198
  for (const keyword of hint.match) {
179
199
  if (REACT_HOOK_KEYWORDS.has(keyword) && !evidence.react) continue;
180
- if (pathHasKeyword(modulePath, keyword)) {
200
+ if (pathHasKeyword(searchPath, keyword)) {
181
201
  return {
182
202
  domain: hint.domain,
183
203
  description: domainEvidenceText(hint, modulePath, keyword, evidence),
@@ -4,6 +4,41 @@
4
4
  * Primary flow inference: uses dep graph when available for real import-chain flows,
5
5
  * falls back to keyword heuristics for repos without dep graph data.
6
6
  */
7
+ // Directories holding tooling, samples, and docs. An import chain from a file in
8
+ // here describes a build script or an example, not how data moves through the product.
9
+ const TOOLING_DIRECTORIES = new Set([
10
+ "doc", "docs", "doc-gen", "docgen", "scripts", "script", "tools", "tooling",
11
+ "example", "examples", "demo", "demos", "sample", "samples",
12
+ "benchmark", "benchmarks", "bench", "fixtures", "tasks",
13
+ ]);
14
+
15
+ // File names too generic to name a flow ("gen Request Flow", "util Integration Flow").
16
+ const GENERIC_FLOW_NAMES = new Set([
17
+ "gen", "generate", "util", "utils", "helper", "helpers", "misc", "common", "types", "constants",
18
+ ]);
19
+
20
+ function isToolingPath(key) {
21
+ const parts = String(key || "").replace(/\\/g, "/").toLowerCase().split("/");
22
+ return parts.slice(0, -1).some((segment) => TOOLING_DIRECTORIES.has(segment));
23
+ }
24
+
25
+ /**
26
+ * Drop flows that are noise: no steps, nothing beyond the starting file, a name that is only
27
+ * a generic helper word, or a chain that starts in tooling, examples, or docs.
28
+ */
29
+ export function filterNoiseFlows(flows) {
30
+ return (flows || []).filter((flow) => {
31
+ if (!flow) return false;
32
+ const steps = Array.isArray(flow.steps) ? flow.steps : [];
33
+ if (steps.length < 2) return false;
34
+ const origin = flow.modules?.[0];
35
+ if (origin && isToolingPath(origin)) return false;
36
+ const stem = String(flow.name || "").split(" ")[0].toLowerCase();
37
+ if (GENERIC_FLOW_NAMES.has(stem)) return false;
38
+ return true;
39
+ });
40
+ }
41
+
7
42
  export function inferDataFlows(scanResult, config, depGraph = null) {
8
43
  const flows = [];
9
44
 
@@ -28,7 +63,7 @@ export function inferDataFlows(scanResult, config, depGraph = null) {
28
63
  const apiFlow = inferApiIntegrationFlow(scanResult);
29
64
  if (apiFlow && !existingNames.has(apiFlow.name.toLowerCase())) flows.push(apiFlow);
30
65
 
31
- return flows;
66
+ return filterNoiseFlows(flows);
32
67
  }
33
68
 
34
69
  /**
@@ -359,6 +359,16 @@ function scanTemplate(source, i, onExpression) {
359
359
  return i;
360
360
  }
361
361
 
362
+ const NODE_BUILTINS = new Set([
363
+ "assert", "async_hooks", "buffer", "child_process", "cluster", "console",
364
+ "constants", "crypto", "dgram", "diagnostics_channel", "dns", "domain",
365
+ "events", "fs", "http", "http2", "https", "inspector", "module", "net",
366
+ "os", "path", "perf_hooks", "process", "punycode", "querystring",
367
+ "readline", "repl", "sqlite", "stream", "string_decoder", "sys", "timers",
368
+ "tls", "trace_events", "tty", "url", "util", "v8", "vm", "wasi",
369
+ "worker_threads", "zlib",
370
+ ]);
371
+
362
372
  export function externalPackageName(specifier) {
363
373
  if (typeof specifier !== "string") return null;
364
374
  const trimmed = specifier.trim();
@@ -369,6 +379,8 @@ export function externalPackageName(specifier) {
369
379
  : trimmed.split("/").slice(0, 1);
370
380
  const name = parts.filter(Boolean).join("/");
371
381
  if (!name || name === "." || name === "..") return null;
382
+ const top = name.startsWith("@") ? null : name.split("/")[0];
383
+ if (top && NODE_BUILTINS.has(top)) return null;
372
384
  return name;
373
385
  }
374
386
 
@@ -410,45 +422,127 @@ function stripPythonNoise(source) {
410
422
  return out;
411
423
  }
412
424
 
425
+ function leadingIndent(line) {
426
+ const match = String(line).match(/^[ \t]*/);
427
+ return match ? match[0].length : 0;
428
+ }
429
+
430
+ function noteTypingBindings(statement, aliases, flags) {
431
+ const imported = statement.match(/^import\s+(.+)$/);
432
+ if (imported) {
433
+ for (const part of imported[1].split(",")) {
434
+ const as = part.trim().match(/^([\w.]+)(?:\s+as\s+([A-Za-z_]\w*))?$/);
435
+ if (!as) continue;
436
+ if (as[1] !== "typing" && as[1] !== "typing_extensions") continue;
437
+ aliases.add(as[2] || as[1]);
438
+ }
439
+ return;
440
+ }
441
+ const from = statement.match(/^from\s+(typing|typing_extensions)\s+import\s+(.+)$/);
442
+ if (!from) return;
443
+ for (const part of from[2].replace(/[()]/g, " ").split(",")) {
444
+ const as = part.trim().match(/^([A-Za-z_]\w*)(?:\s+as\s+([A-Za-z_]\w*))?$/);
445
+ if (!as || as[1] !== "TYPE_CHECKING") continue;
446
+ flags.add(as[2] || as[1]);
447
+ }
448
+ }
449
+
450
+ function isTypeCheckingGuard(statement, aliases, flags) {
451
+ const match = statement.match(/^(?:if|elif)\s+(.+?)\s*:\s*$/);
452
+ if (!match) return false;
453
+ const cond = match[1].trim();
454
+ if (cond === "TYPE_CHECKING" || flags.has(cond)) return true;
455
+ if (cond === "typing.TYPE_CHECKING") return true;
456
+ const attr = cond.match(/^([A-Za-z_]\w*)\.TYPE_CHECKING$/);
457
+ return Boolean(attr && aliases.has(attr[1]));
458
+ }
459
+
460
+ function pushPythonImport(imports, statement, typeOnly) {
461
+ if (statement.startsWith("import ")) {
462
+ const rest = statement.slice("import ".length).replace(/[()]/g, " ");
463
+ for (const part of rest.split(",")) {
464
+ const name = part.trim().split(/\s+/)[0];
465
+ if (name && /^[\w.]+$/.test(name)) imports.push({ module: name, level: 0, typeOnly });
466
+ }
467
+ return;
468
+ }
469
+ const relative = statement.match(/^from\s+(\.+)([\w.]*)\s+import\s+(.+)$/);
470
+ if (relative) {
471
+ const level = relative[1].length;
472
+ const base = relative[2] || "";
473
+ if (base) {
474
+ imports.push({ module: base, level, typeOnly });
475
+ return;
476
+ }
477
+ for (const part of relative[3].replace(/[()]/g, " ").split(",")) {
478
+ const name = part.trim().split(/\s+/)[0];
479
+ if (name && /^[A-Za-z_]\w*$/.test(name)) imports.push({ module: name, level, typeOnly });
480
+ }
481
+ return;
482
+ }
483
+ const match = statement.match(/^from\s+([\w.]+)\s+import\b/);
484
+ if (!match) return;
485
+ imports.push({ module: match[1], level: 0, typeOnly });
486
+ }
487
+
413
488
  /**
414
- * @returns {{ module: string, level: number }[]}
489
+ * @returns {{ module: string, level: number, typeOnly: boolean }[]}
415
490
  * level 0 is an absolute import. level N is a relative import with N dots.
491
+ * Imports under `if TYPE_CHECKING:` / `if typing.TYPE_CHECKING:` (including
492
+ * `import typing as t`) are typeOnly and must not become runtime edges.
416
493
  */
417
494
  export function extractPythonImports(source) {
418
495
  const stripped = stripPythonNoise(source).replace(/\\\n/g, " ");
419
496
  const lines = stripped.split("\n");
420
- const statements = [];
497
+ const imports = [];
498
+ const aliases = new Set(["typing"]);
499
+ const flags = new Set(["TYPE_CHECKING"]);
500
+ const blockIndents = [];
421
501
  let buf = "";
422
502
  let depth = 0;
423
- for (const line of lines) {
424
- buf = buf ? `${buf} ${line.trim()}` : line.trim();
425
- depth += (line.match(/\(/g) || []).length;
426
- depth -= (line.match(/\)/g) || []).length;
427
- if (depth > 0) continue;
428
- if (buf) statements.push(buf);
503
+ let bufTypeOnly = false;
504
+ let bufIndent = 0;
505
+
506
+ function inTypeChecking(indent) {
507
+ while (blockIndents.length && indent <= blockIndents[blockIndents.length - 1]) {
508
+ blockIndents.pop();
509
+ }
510
+ return blockIndents.length > 0;
511
+ }
512
+
513
+ function finishStatement() {
514
+ const statement = buf.trim();
429
515
  buf = "";
430
516
  depth = 0;
517
+ if (!statement) return;
518
+ if (bufTypeOnly) {
519
+ pushPythonImport(imports, statement, true);
520
+ return;
521
+ }
522
+ if (isTypeCheckingGuard(statement, aliases, flags)) {
523
+ blockIndents.push(bufIndent);
524
+ return;
525
+ }
526
+ noteTypingBindings(statement, aliases, flags);
527
+ pushPythonImport(imports, statement, false);
431
528
  }
432
- if (buf) statements.push(buf);
433
529
 
434
- const imports = [];
435
- for (const statement of statements) {
436
- if (statement.startsWith("import ")) {
437
- const rest = statement.slice("import ".length).replace(/[()]/g, " ");
438
- for (const part of rest.split(",")) {
439
- const name = part.trim().split(/\s+/)[0];
440
- if (name && /^[\w.]+$/.test(name)) imports.push({ module: name, level: 0 });
441
- }
442
- continue;
443
- }
444
- const match = statement.match(/^from\s+(\.+)([\w.]*)\s+import\b/) || statement.match(/^from\s+([\w.]+)\s+import\b/);
445
- if (!match) continue;
446
- if (match[1].startsWith(".")) {
447
- imports.push({ module: match[2] || "", level: match[1].length });
530
+ for (const line of lines) {
531
+ const trimmed = line.trim();
532
+ if (!trimmed && depth === 0) continue;
533
+ if (depth === 0) {
534
+ bufTypeOnly = inTypeChecking(leadingIndent(line));
535
+ bufIndent = leadingIndent(line);
536
+ buf = trimmed;
448
537
  } else {
449
- imports.push({ module: match[1], level: 0 });
538
+ buf = `${buf} ${trimmed}`;
450
539
  }
540
+ depth += (line.match(/\(/g) || []).length;
541
+ depth -= (line.match(/\)/g) || []).length;
542
+ if (depth > 0) continue;
543
+ finishStatement();
451
544
  }
545
+ if (buf) finishStatement();
452
546
  return imports;
453
547
  }
454
548
 
@@ -0,0 +1,151 @@
1
+ // Project maturity from observable signals (release version, history, tests, CI, docs, dependency health)
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+
7
+ function exists(root, rel) {
8
+ try {
9
+ return fs.existsSync(path.join(root, rel));
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ function readText(root, rel) {
16
+ try {
17
+ return fs.readFileSync(path.join(root, rel), "utf8");
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ /** Declared project version from package.json, Cargo.toml, or pyproject.toml. */
24
+ export function detectDeclaredVersion(root) {
25
+ const pkg = readText(root, "package.json");
26
+ if (pkg) {
27
+ try {
28
+ const version = JSON.parse(pkg)?.version;
29
+ if (typeof version === "string") return version;
30
+ } catch {
31
+ // ignore invalid JSON
32
+ }
33
+ }
34
+ for (const file of ["Cargo.toml", "pyproject.toml"]) {
35
+ const text = readText(root, file);
36
+ const match = text && text.match(/^\s*version\s*=\s*["']([^"']+)["']/m);
37
+ if (match) return match[1];
38
+ }
39
+ return null;
40
+ }
41
+
42
+ export function majorVersion(version) {
43
+ const match = String(version || "").match(/^v?(\d+)\./);
44
+ return match ? Number(match[1]) : null;
45
+ }
46
+
47
+ function git(root, args) {
48
+ try {
49
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** Git facts, or null when `root` is not a git checkout. Shallow clones report `shallow: true`. */
56
+ export function detectGitHistory(root) {
57
+ if (git(root, ["rev-parse", "--is-inside-work-tree"]) !== "true") return null;
58
+ const shallow = git(root, ["rev-parse", "--is-shallow-repository"]) === "true";
59
+ const tagOutput = git(root, ["tag", "--list"]);
60
+ const tags = tagOutput ? tagOutput.split("\n").filter(Boolean).length : 0;
61
+ let firstCommitMs = null;
62
+ if (!shallow) {
63
+ const roots = git(root, ["log", "--max-parents=0", "--format=%ct", "HEAD"]);
64
+ const stamps = (roots || "").split("\n").map(Number).filter((n) => Number.isFinite(n) && n > 0);
65
+ if (stamps.length) firstCommitMs = Math.min(...stamps) * 1000;
66
+ }
67
+ return { shallow, tags, firstCommitMs };
68
+ }
69
+
70
+ function detectCI(root) {
71
+ if (exists(root, ".github/workflows")) {
72
+ try {
73
+ if (fs.readdirSync(path.join(root, ".github/workflows")).some((f) => /\.ya?ml$/.test(f))) return true;
74
+ } catch {
75
+ // fall through
76
+ }
77
+ }
78
+ return [".gitlab-ci.yml", ".travis.yml", "appveyor.yml", ".circleci/config.yml", "azure-pipelines.yml", "Jenkinsfile", ".drone.yml", "bitbucket-pipelines.yml"]
79
+ .some((file) => exists(root, file));
80
+ }
81
+
82
+ function detectDocs(root) {
83
+ const hasReadme = ["README.md", "README.rst", "README.txt", "README"].some((f) => exists(root, f));
84
+ const hasDocsDir = ["docs", "doc"].some((d) => exists(root, d));
85
+ const hasContributing = exists(root, "CONTRIBUTING.md");
86
+ return hasReadme && (hasDocsDir || hasContributing);
87
+ }
88
+
89
+ /**
90
+ * Build maturity signals. A signal with `met: null` could not be measured
91
+ * (for example commit age in a shallow clone) and is left out of the total.
92
+ */
93
+ export function collectMaturitySignals(root, { hasTests = false, cycles = null, now = Date.now(), version, git: gitInfo } = {}) {
94
+ const declared = version === undefined ? detectDeclaredVersion(root) : version;
95
+ const major = majorVersion(declared);
96
+ const history = gitInfo === undefined ? detectGitHistory(root) : gitInfo;
97
+
98
+ const signals = [];
99
+ signals.push({
100
+ key: "version",
101
+ label: "Stable release version",
102
+ met: major === null ? null : major >= 1,
103
+ detail: declared ? `v${declared}` : "no version declared",
104
+ });
105
+
106
+ const tags = history?.tags ?? 0;
107
+ signals.push({
108
+ key: "releases",
109
+ label: "Release history",
110
+ met: major === null && !history ? null : tags >= 5 || (major !== null && major >= 5),
111
+ detail: tags ? `${tags} tags` : major !== null ? `major version ${major}` : "no tags",
112
+ });
113
+
114
+ let ageMet = null;
115
+ let ageDetail = history?.shallow ? "shallow clone, history unavailable" : "no git history";
116
+ if (history?.firstCommitMs) {
117
+ const years = (now - history.firstCommitMs) / (365.25 * 24 * 3600 * 1000);
118
+ ageMet = years >= 1;
119
+ ageDetail = `${years.toFixed(1)} years of history`;
120
+ }
121
+ signals.push({ key: "history", label: "Commit history over a year", met: ageMet, detail: ageDetail });
122
+
123
+ signals.push({ key: "tests", label: "Automated tests", met: Boolean(hasTests), detail: hasTests ? "test framework detected" : "none detected" });
124
+ const ci = detectCI(root);
125
+ signals.push({ key: "ci", label: "Continuous integration", met: ci, detail: ci ? "CI config found" : "none found" });
126
+ const docs = detectDocs(root);
127
+ signals.push({ key: "docs", label: "Documentation", met: docs, detail: docs ? "README plus docs" : "README only or missing" });
128
+ signals.push({
129
+ key: "deps",
130
+ label: "No circular dependencies",
131
+ met: cycles === null || cycles === undefined ? null : cycles === 0,
132
+ detail: cycles ? `${cycles} cycle${cycles === 1 ? "" : "s"}` : "none",
133
+ });
134
+ return signals;
135
+ }
136
+
137
+ export function scoreMaturity(signals) {
138
+ const measured = signals.filter((s) => s.met !== null);
139
+ const score = measured.filter((s) => s.met).length;
140
+ const max = measured.length;
141
+ const ratio = max ? score / max : 0;
142
+ const declaredVersion = signals.find((s) => s.key === "version");
143
+ let label = ratio >= 0.7 ? "Production-grade" : ratio >= 0.45 ? "Well-structured" : "Early-stage";
144
+ // A project with a stable major version and several release signals is never "early-stage".
145
+ if (label === "Early-stage" && declaredVersion?.met && score >= 3) label = "Well-structured";
146
+ return { score, max, label, signals };
147
+ }
148
+
149
+ export function detectMaturity(root, options = {}) {
150
+ return scoreMaturity(collectMaturitySignals(root, options));
151
+ }
package/src/cli.js CHANGED
@@ -24,6 +24,8 @@ import { runWatch } from "./watch.js";
24
24
  import { info, error, warn, fmt } from "./utils/logger.js";
25
25
  import { formatError } from "./utils/errors.js";
26
26
  import { checkForUpdates } from "./utils/update-check.js";
27
+ import { detectProjectName } from "./utils/project-name.js";
28
+ import { aiUpgradeSections } from "./utils/ai-notice.js";
27
29
  import { generateDocumentSet } from "./docs/generate-doc-set.js";
28
30
  import { writeDocumentSet } from "./docs/write-doc-set.js";
29
31
  import { loadPlugins } from "./plugins/loader.js";
@@ -139,21 +141,23 @@ async function promptForAI(cfg) {
139
141
  /**
140
142
  * Show the post-generation AI notice for users without GITHUB_TOKEN
141
143
  */
142
- function showPostGenerationAINotice() {
143
- info(`\n${fmt.cyan("┌──────────────────────────────────────────────────────────────────┐")}`);
144
- info(`${fmt.cyan("│")} ${fmt.boldYellow("✨ Unlock AI-Enhanced Documentation")} ${fmt.cyan("│")}`);
145
- info(`${fmt.cyan("├──────────────────────────────────────────────────────────────────┤")}`);
146
- info(`${fmt.cyan("│")} Your docs are missing these AI-powered sections: ${fmt.cyan("│")}`);
147
- info(`${fmt.cyan("│")} ${fmt.yellow("•")} Executive Summary — plain language overview for leadership ${fmt.cyan("│")}`);
148
- info(`${fmt.cyan("│")} ${fmt.yellow("•")} Business Domains — what the system does for stakeholders ${fmt.cyan("│")}`);
149
- info(`${fmt.cyan("│")} ${fmt.yellow("•")} Architecture Overview — deeper narrative for architects ${fmt.cyan("│")}`);
150
- info(`${fmt.cyan("│")} ${fmt.yellow("•")} Data Flows — how information moves through your system ${fmt.cyan("│")}`);
151
- info(`${fmt.cyan("│")} ${fmt.yellow("•")} Developer Onboarding — getting started guide for new hires ${fmt.cyan("│")}`);
152
- info(`${fmt.cyan("│")} ${fmt.cyan("│")}`);
153
- info(`${fmt.cyan("│")} ${fmt.boldGreen("🆓 Enable for FREE with GitHub Models:")} ${fmt.cyan("│")}`);
154
- info(`${fmt.cyan("│")} Run: ${fmt.brightCyan("repolens init")} → the wizard will guide you through setup ${fmt.cyan("│")}`);
155
- info(`${fmt.cyan("│")} Or quick preview: ${fmt.green("export GITHUB_TOKEN=<your-token> && repolens demo")} ${fmt.cyan("│")}`);
156
- info(`${fmt.cyan("└──────────────────────────────────────────────────────────────────┘")}`);
144
+ function showPostGenerationAINotice(documents) {
145
+ const sections = aiUpgradeSections(documents);
146
+ if (sections.length === 0) return;
147
+ const bar = "─".repeat(66);
148
+ const row = (text) => `${fmt.cyan("│")}${text.padEnd(66)}${fmt.cyan("│")}`;
149
+ info(`\n${fmt.cyan(`┌${bar}┐`)}`);
150
+ info(`${fmt.cyan("│")} ${fmt.boldYellow("✨ Unlock AI-Enhanced Documentation")}${" ".repeat(66 - 36)}${fmt.cyan("│")}`);
151
+ info(fmt.cyan(`├${bar}┤`));
152
+ info(row(" These sections used rule-based text. AI can rewrite them as:"));
153
+ for (const section of sections) {
154
+ info(row(` • ${section.title} — ${section.ai}`));
155
+ }
156
+ info(row(""));
157
+ info(`${fmt.cyan("│")} ${fmt.boldGreen("🆓 Enable for FREE with GitHub Models:")}${" ".repeat(66 - 39)}${fmt.cyan("│")}`);
158
+ info(row(" Run: repolens init → the wizard will guide you through setup"));
159
+ info(row(" Or preview: export GITHUB_TOKEN=<your-token> && repolens demo"));
160
+ info(fmt.cyan(`└${bar}┘`));
157
161
  }
158
162
 
159
163
  function printPerformanceSummary() {
@@ -597,7 +601,7 @@ async function main() {
597
601
  : "No .repolens.yml found — using multi-language scan patterns");
598
602
  cfg = {
599
603
  configVersion: 1,
600
- project: { name: path.basename(targetDir) },
604
+ project: { name: detectProjectName(targetDir) },
601
605
  publishers: ["markdown"],
602
606
  scan: {
603
607
  include: detected.include,
@@ -655,7 +659,7 @@ async function main() {
655
659
  info(" To keep AI enabled permanently, run: repolens init");
656
660
  } else if (aiResult.noToken) {
657
661
  // No GITHUB_TOKEN - show instructions
658
- showPostGenerationAINotice();
662
+ showPostGenerationAINotice(docSet.documents);
659
663
  }
660
664
 
661
665
  printPerformanceSummary();
@@ -34,6 +34,7 @@ import {
34
34
  } from "../renderers/renderAnalysis.js";
35
35
  import { info, warn } from "../utils/logger.js";
36
36
  import path from "node:path";
37
+ import { detectMaturity } from "../analyzers/maturity.js";
37
38
  import { applyBrandingFooter } from "./branding.js";
38
39
 
39
40
  export async function generateDocumentSet(scanResult, config, diffData = null, pluginManager = null) {
@@ -79,6 +80,12 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
79
80
  aiContext = buildAIContext(scanResult, config, depGraph);
80
81
  moduleContext = buildModuleContext(scanResult.modules, config, depGraph);
81
82
  flows = inferDataFlows(scanResult, config, depGraph);
83
+ try {
84
+ aiContext.maturity = detectMaturity(repoRoot, {
85
+ hasTests: (aiContext.techStack?.testFrameworks || []).length > 0 || scanResult.modules.some((m) => /(^|\/)(tests?|__tests__|spec)(\/|$)/i.test(m.key)),
86
+ cycles: depGraph?.stats?.cycles ?? null,
87
+ });
88
+ } catch (e) { warn(`Maturity detection failed: ${e.message}`); }
82
89
 
83
90
  // CODEOWNERS integration
84
91
  const codeowners = await parseCodeowners(repoRoot);
package/src/init.js CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { spawn } from "node:child_process";
5
5
  import { info, warn } from "./utils/logger.js";
6
+ import { detectProjectName } from "./utils/project-name.js";
6
7
  import { isNonInteractiveEnvironment } from "./utils/interactive.js";
7
8
  import { detectRepoLanguages } from "./core/languages.js";
8
9
  import { repolensCommand } from "./utils/invocation.js";
@@ -814,9 +815,6 @@ function buildRepoLensConfig(projectName, detected, options = {}) {
814
815
  return lines.join("\n");
815
816
  }
816
817
 
817
- function detectProjectName(repoRoot) {
818
- return path.basename(repoRoot) || "my-project";
819
- }
820
818
 
821
819
  async function promptNotionCredentials() {
822
820
  if (isNonInteractiveEnvironment()) {
@@ -914,7 +912,7 @@ async function runInteractiveWizard(repoRoot) {
914
912
  info("Press Enter to accept defaults shown in parentheses.\n");
915
913
 
916
914
  // 1. Project name
917
- const defaultName = path.basename(repoRoot) || "my-project";
915
+ const defaultName = detectProjectName(repoRoot);
918
916
  const projectName = (await ask(`📦 Project name (${defaultName}): `)).trim() || defaultName;
919
917
 
920
918
  // 2. Publishers
package/src/mcp/tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { loadConfig } from "../core/config.js";
4
+ import { detectProjectName } from "../utils/project-name.js";
4
5
  import { scanRepo } from "../core/scan.js";
5
6
  import { getGitDiff, isSafeGitRef } from "../core/diff.js";
6
7
  import { analyzeDependencyGraph } from "../analyzers/dependency-graph.js";
@@ -41,7 +42,7 @@ function projectRootFromArgs(args) {
41
42
  function defaultConfig(root) {
42
43
  return {
43
44
  configVersion: 1,
44
- project: { name: path.basename(root) || "repository" },
45
+ project: { name: detectProjectName(root, "repository") },
45
46
  publishers: ["markdown"],
46
47
  scan: {
47
48
  include: ["**/*.{js,ts,jsx,tsx,mjs,cjs,py,go,rs,java,rb,php,cs,swift,kt}"],
@@ -396,7 +396,7 @@ export function renderDependencyGraph(depResult) {
396
396
 
397
397
  lines.push("---");
398
398
  lines.push("");
399
- lines.push("*Generated by RepoLens import analysis. Dependency detection covers JavaScript and TypeScript (ES modules, dynamic import, CommonJS, re-exports; comments, strings, and type-only imports are skipped), Python (`import` / `from ... import`, including relative imports), and Go (`go.mod` module paths). Go `*_test.go` files are not part of the graph.*");
399
+ lines.push("*Generated by RepoLens import analysis. Dependency detection covers JavaScript and TypeScript (ES modules, dynamic import, CommonJS, re-exports; comments, strings, type-only imports, and Node.js built-ins are skipped), Python (`import` / `from ... import`, including relative imports; `TYPE_CHECKING` blocks are skipped), and Go (`go.mod` module paths, hubs grouped by package). Go `*_test.go` files are not part of the graph.*");
400
400
  lines.push("");
401
401
 
402
402
  return lines.join("\n");
@@ -0,0 +1,20 @@
1
+ // The "unlock AI" notice shown after a run without AI
2
+
3
+ export const AI_SECTIONS = [
4
+ { key: "executive_summary", title: "Executive Summary", ai: "plain language overview for leadership" },
5
+ { key: "business_domains", title: "Business Domains", ai: "what the system does for stakeholders" },
6
+ { key: "architecture_overview", title: "Architecture Overview", ai: "deeper narrative for architects" },
7
+ { key: "data_flows", title: "Data Flows", ai: "how information moves through your system" },
8
+ { key: "developer_onboarding", title: "Developer Onboarding", ai: "getting started guide for new hires" },
9
+ ];
10
+
11
+ /**
12
+ * Sections the notice can offer to upgrade: the ones that were generated in this run.
13
+ * Their rule-based text exists. The notice never calls a written document "missing".
14
+ * Without a document list (older callers), every AI-capable section is offered.
15
+ */
16
+ export function aiUpgradeSections(documents) {
17
+ if (!Array.isArray(documents)) return AI_SECTIONS;
18
+ const written = new Set(documents.filter((doc) => doc && !doc.error).map((doc) => doc.key));
19
+ return AI_SECTIONS.filter((section) => written.has(section.key));
20
+ }
@@ -0,0 +1,24 @@
1
+ // Resolve a human project name for a directory (never "." or "..")
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ function packageJsonName(dir) {
7
+ try {
8
+ const raw = fs.readFileSync(path.join(dir, "package.json"), "utf8");
9
+ const name = JSON.parse(raw)?.name;
10
+ if (typeof name === "string" && name.trim() && name.length <= 214) return name.trim();
11
+ } catch {
12
+ // no package.json or not valid JSON
13
+ }
14
+ return null;
15
+ }
16
+
17
+ /**
18
+ * Name for a project directory. `--target .` resolves to the real directory,
19
+ * and a package.json name wins over the folder name when there is one.
20
+ */
21
+ export function detectProjectName(dir, fallback = "my-project") {
22
+ const resolved = path.resolve(dir || ".");
23
+ return packageJsonName(resolved) || path.basename(resolved) || fallback;
24
+ }