@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 +39 -0
- package/README.md +12 -8
- package/llms-install.md +2 -0
- package/package.json +2 -2
- package/src/ai/generate-sections.js +75 -31
- package/src/analyzers/context-builder.js +73 -14
- package/src/analyzers/dependency-graph.js +69 -6
- package/src/analyzers/domain-inference.js +21 -1
- package/src/analyzers/flow-inference.js +36 -1
- package/src/analyzers/import-parsers.js +118 -24
- package/src/analyzers/maturity.js +151 -0
- package/src/cli.js +21 -17
- package/src/docs/generate-doc-set.js +7 -0
- package/src/init.js +2 -4
- package/src/mcp/tools.js +2 -1
- package/src/renderers/renderAnalysis.js +1 -1
- package/src/utils/ai-notice.js +20 -0
- package/src/utils/project-name.js +24 -0
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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": "
|
|
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
|
-
//
|
|
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
|
|
168
|
-
const
|
|
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
|
|
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}
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
if (
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
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
|
|
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
|
|
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
|
|
228
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
435
|
-
|
|
436
|
-
if (
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
info(
|
|
148
|
-
info(`${fmt.cyan("│")}
|
|
149
|
-
info(
|
|
150
|
-
info(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
info(
|
|
155
|
-
info(`${fmt.cyan("│")}
|
|
156
|
-
info(
|
|
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:
|
|
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 =
|
|
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:
|
|
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,
|
|
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
|
+
}
|