From bc51dc0a0d26871a115cfc3a38f11d859c979210 Mon Sep 17 00:00:00 2001 From: jiachen Date: Mon, 19 Oct 2026 05:11:05 +0800 Subject: [PATCH 1/4] =?UTF-8?q?Add=20=E4=BB=93=E5=BA=93=E9=80=8F=E8=A7=86?= =?UTF-8?q?=20Repo=20X-Ray=20(repo-xray)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Understand a codebase from its Git history: a code map you can drill into, hotspots and bug magnets, high-risk files, owners and knowledge loss, commit rhythm and firefighting, and the tech stack, for GitHub, GitLab, and local repositories. Analysis runs locally with the user's git: blob-less bare clones inside context.dataDir, network only to the repository hosts the user adds, tokens kept in the Node process, no Agent turns. --- README.md | 1 + README.zh-CN.md | 1 + .../repo-xray/.minimax-plugin/plugin.json | 14 + plugins/yujiachen-y/repo-xray/LICENSE | 21 + plugins/yujiachen-y/repo-xray/README.md | 148 + plugins/yujiachen-y/repo-xray/README.zh-CN.md | 64 + .../yujiachen-y/repo-xray/docs/preview.jpg | Bin 0 -> 226615 bytes plugins/yujiachen-y/repo-xray/icon.png | Bin 0 -> 148070 bytes .../repo-xray/miniapp/client/app.js | 2780 +++++++++++ .../repo-xray/miniapp/client/charts.js | 581 +++ .../repo-xray/miniapp/client/icon.svg | 85 + .../repo-xray/miniapp/client/index.html | 45 + .../repo-xray/miniapp/client/styles.css | 4150 +++++++++++++++++ .../repo-xray/miniapp/miniapp.json | 7 + .../repo-xray/miniapp/node/analyze.mjs | 626 +++ .../repo-xray/miniapp/node/codemap.mjs | 807 ++++ .../repo-xray/miniapp/node/git.mjs | 303 ++ .../repo-xray/miniapp/node/miniapp-api.ts | 397 ++ .../repo-xray/miniapp/node/server.mjs | 597 +++ .../repo-xray/miniapp/node/techstack.mjs | 362 ++ .../repo-xray/miniapp/node/util.mjs | 86 + plugins/yujiachen-y/repo-xray/package.json | 6 + 22 files changed, 11081 insertions(+) create mode 100644 plugins/yujiachen-y/repo-xray/.minimax-plugin/plugin.json create mode 100644 plugins/yujiachen-y/repo-xray/LICENSE create mode 100644 plugins/yujiachen-y/repo-xray/README.md create mode 100644 plugins/yujiachen-y/repo-xray/README.zh-CN.md create mode 100644 plugins/yujiachen-y/repo-xray/docs/preview.jpg create mode 100644 plugins/yujiachen-y/repo-xray/icon.png create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/client/app.js create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/client/charts.js create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/client/icon.svg create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/client/index.html create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/client/styles.css create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/miniapp.json create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/node/analyze.mjs create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/node/codemap.mjs create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/node/miniapp-api.ts create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/node/techstack.mjs create mode 100644 plugins/yujiachen-y/repo-xray/miniapp/node/util.mjs create mode 100644 plugins/yujiachen-y/repo-xray/package.json diff --git a/README.md b/README.md index 7bdf6c4..e007ddf 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [心屿 / Xinyu Island](plugins/yujiachen-y/xinyu-daily/) | A cozy daily-mood island that plays like an indie game: an inspiration card shaped by your answers, mood stickers, a joy jar, a decision slot machine, a Book of Answers, and 20 hidden secrets guarded by a grumpy crab. Optionally, Xiaoyu writes you a daily letter and chats with you using your model. Requires MiniMax Code 3.1.2 or later | [yujiachen-y](https://github.com/yujiachen-y) | | [MoMo Collab](plugins/amszuidas/momo-collab/) | A project steward that turns stray thoughts, screenshots, and links into ideas, writes clear ideas up as tasks, runs each task in its own Agent session with a full handoff, checks the result against your done criteria, and tracks everything on a board. Requires MiniMax Code 3.1.2 or later | [amszuidas](https://github.com/amszuidas) | | [Anatomy Gallery](plugins/zch0227/anatomy-gallery/) | A gallery for interactive isometric figures made with the anatomy skill: hang them in rooms you organize, view light and dark editions full screen, keep every version, and have an Agent create or edit figures from the gallery with a per-run process log. The anatomy skill is bundled. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | +| [仓库透视 / Repo X-Ray](plugins/yujiachen-y/repo-xray/) | Understand a codebase from its Git history: a code map you can drill into, hotspots and bug magnets, high-risk files, owners and knowledge loss, commit rhythm and firefighting, and the tech stack. Works with GitHub, GitLab, and local repositories; analysis runs on your machine | [yujiachen-y](https://github.com/yujiachen-y) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index dd0c744..6de095e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -76,6 +76,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [心屿](plugins/yujiachen-y/xinyu-daily/README.zh-CN.md) | 像独立小游戏的每日心情小岛:由你的回答决定的今日灵感卡、心情贴纸、小确幸罐子、纠结老虎机、答案之书,还有坏脾气螃蟹藏起来的 20 个秘密;可选开启小屿陪伴,借用你的模型每天写信、陪你聊天。需要 MiniMax Code 3.1.2 或更高版本 | [yujiachen-y](https://github.com/yujiachen-y) | | [协作 · MoMo](plugins/amszuidas/momo-collab/README.zh-CN.md) | 项目管家 MoMo:把随手的想法、截图和链接攒成想法,写清后变成任务,派 Agent 会话带着完整背景去执行,按验收标准核对结果后交你审核,进展在看板上一眼可见。需要 MiniMax Code 3.1.2 或更高版本 | [amszuidas](https://github.com/amszuidas) | | [Anatomy 画廊](plugins/zch0227/anatomy-gallery/README.zh-CN.md) | 集中展示 anatomy 技能做出的交互式等距图解:按自建展区挂墙,全屏查看明暗两版,保留全部历史版本;在画廊里让 Agent 新建或修改作品,每次运行都有过程记录。自带 anatomy 技能。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | +| [仓库透视](plugins/yujiachen-y/repo-xray/README.zh-CN.md) | 从 Git 历史看懂一个代码仓库:可逐层下钻的代码地图、热点与 Bug 磁铁、高风险文件、负责人与知识流失、提交节奏与救火记录、技术栈;支持 GitHub、GitLab 和本机仓库,全部在本机分析 | [yujiachen-y](https://github.com/yujiachen-y) |
预览:Token 用量看板 diff --git a/plugins/yujiachen-y/repo-xray/.minimax-plugin/plugin.json b/plugins/yujiachen-y/repo-xray/.minimax-plugin/plugin.json new file mode 100644 index 0000000..745ac83 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/.minimax-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "name": "repo-xray", + "displayName": "仓库透视", + "version": "1.0.0", + "description": "从 Git 历史看懂一个代码仓库:代码地图、热点与 bug 磁铁、高风险文件、负责人与知识流失、提交节奏和技术栈。支持 GitHub / GitLab 仓库和本地仓库,全部在本机计算。", + "author": "yujiachen-y", + "icon": "icon.png", + "category": "Code", + "exampleQueries": ["打开仓库透视", "打开仓库透视,看看这个仓库的代码地图和高风险文件", "用仓库透视看看这个仓库主要是谁在维护"], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/yujiachen-y/repo-xray/LICENSE b/plugins/yujiachen-y/repo-xray/LICENSE new file mode 100644 index 0000000..0ad9761 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 yujiachen-y + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/yujiachen-y/repo-xray/README.md b/plugins/yujiachen-y/repo-xray/README.md new file mode 100644 index 0000000..0717f8a --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/README.md @@ -0,0 +1,148 @@ +# 仓库透视 Repo X-Ray (`repo-xray`) + +English | [简体中文](README.zh-CN.md) + +Understand an unfamiliar codebase from its Git history before you read any code. Add a GitHub or +GitLab repository, or pick one on your machine, and Repo X-Ray turns the commit log into a code map +you can drill into, a short list of high-risk files, who maintains what, how the team ships, and +what the project is built with. + +Analysis runs locally with your own `git`. Nothing is uploaded, and the page loads no remote +resources. The metrics follow the rules of +[codebase-recon-skill](https://github.com/yujiachen-y/codebase-recon-skill), and the Markdown export +uses that skill's report format. The interface is in Chinese. + +![Repo X-Ray code map with synthetic data](docs/preview.jpg) + +*Preview uses synthetic data: a fictional monorepo generated for the screenshot.* + +## What you can do + +- **Overview**: key numbers, the main problems found by deterministic rules, recommendations, what + changed since the previous analysis, and a small code map that links into the full one. +- **Code map**: a treemap of the repository's directories that you can drill into. Colour it by + change heat, bug-fix share, owner, last change, knowledge loss (the share of a directory's history + written by people with no commits in the last 3 months), or file type. Size blocks by file count + or by changes, and limit the window to 3, 6, or 12 months or all history. Works with the keyboard. +- **High-risk files**: files that rank both as change hotspots and as bug magnets (most often + touched by commits whose message mentions fix, bug, or broken), with their owners. +- **Contributors**: ranking by non-merge commits, how concentrated the work is, who was active in + the last 3 months, and bots and token accounts listed separately. Identities with the same name or + email are merged by default; you can switch to one identity per author name. +- **Rhythm and firefighting**: monthly commit trend, a commit calendar, and revert, hotfix, + emergency, and rollback commits. +- **Tech stack**: languages, frameworks, and tools detected from manifests such as `package.json`, + `go.mod`, `Cargo.toml`, `pyproject.toml`, `pom.xml`, and `Gemfile`. +- Search repositories, every file in history, and contributors with ⌘K. Copy or download the report + as Markdown, or put a prepared prompt into the conversation input so the Agent reads the riskiest + files for you; you review the text and send it yourself. +- Suggestions of recently used local repositories, light and dark themes, keyboard shortcuts (press + `?`), and `prefers-reduced-motion` support. + +## Requirements + +- `git` on `PATH`. Remote repositories need Git 2.31 or later, because credentials and the proxy are + passed to git through `GIT_CONFIG_COUNT` environment variables. +- Private repositories: if the `gh` (GitHub) or `glab` (GitLab) CLI is installed and logged in, the + app reuses that login. Otherwise paste an access token for the host in Settings. + +## Install + +Copy this directory, including the hidden `.minimax-plugin/`, into the MiniMax Code plugins +directory as `repo-xray/` (`~/.minimax/plugins/repo-xray/` by default; see the root README's Install +section). Restart MiniMax Code, then open 仓库透视 from the Mini App entry in the right panel or ask +the Agent to "打开仓库透视". + +## Tested environment + +- MiniMax Code 3.1.3-preview.4675 on macOS 27 with Git 2.54. Installed through the Host publish flow + (also re-installed directly from this repository directory), opened in the right panel. +- Exercised: a public GitHub repository, a self-hosted GitLab repository using the `glab` login, and + local repositories, including re-analysis; every tab; every code-map colouring, size, and time + option, drilling in and out with the mouse and the keyboard; the ⌘K search; the Markdown export; + light and dark themes. No console errors or failed requests. +- Not exercised: popping the page out into its own window (`surface.window`, sized 1360×900); the + append into the conversation input, which asks the Host for confirmation; `prefers-reduced-motion`; + Windows and Linux. + +## Data & access + +- Files read: + - Repositories you add. A local repository is read in place with the read-only git commands + below; the app only looks at the history of `HEAD`, never fetches, and never writes into it. + - Local repository suggestions: lists directories under the scan roots (default `~/Workspace`, + `~/workspace`, `~/code`, `~/Code`, `~/src`, `~/Projects`, `~/projects`, `~/Developer`, `~/dev`, + `~/repos`, `~/GitHub`, `~/git`; up to 10 custom roots can be set in Settings), at most 4 levels + deep, 4000 directories, and 300 repositories, skipping hidden directories and folders such as + `node_modules`. For each directory it only checks for a `.git` entry and the modification times + of `.git/FETCH_HEAD`, `.git/index`, and `.git/HEAD`. + - The host names in `glab`'s config file (`$GLAB_CONFIG_DIR/config.yml`, + `~/.config/glab-cli/config.yml`, or `~/Library/Application Support/glab-cli/config.yml`), to know + which hosts `glab` is logged in to. Tokens are not read from this file. + - Whether `gh` and `glab` exist on `PATH`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, + `~/.local/bin`, or `~/.local/share/mise/shims`. +- Files written, all inside the Host-provided `context.dataDir` (atomic write through a temporary + file): + - `config.json`: the repository list and settings (scan roots, proxy, and any access tokens you + paste in Settings, stored in plain text in this file). + - `reports/.json`: the analysis result for each repository. + - `repos/.git`: a bare, blob-less clone of each remote repository. It holds commits and trees, + plus only the manifest and README-type files used for the tech stack and directory descriptions. + - Removing a repository deletes its report and, for a remote repository, its clone. Local + repositories are never modified. +- Network: only `git` goes online, over HTTPS, to the host of each remote repository you add: clone + and fetch, `ls-remote` to follow a renamed default branch, and fetching the few manifest and README + files above. `git@host:path` and `ssh://` addresses are converted to HTTPS URLs. If a proxy is set, + git connects through it; the proxy comes from Settings, then `HTTPS_PROXY` / `https_proxy` / + `HTTP_PROXY` / `http_proxy`, then the macOS system HTTPS proxy. The page loads only the app's own + same-origin files and `/api/` routes. Links to repository pages on GitHub or GitLab open in your + browser only when you click them. No telemetry. +- Processes, all started with an argument array and never through a shell. Addresses and paths you + enter are validated and passed as separate arguments, never in an option position: + - `git`: `clone --bare --filter=blob:none`, `fetch`, `ls-remote`, `log`, `ls-tree`, `cat-file`, + `rev-parse`, `for-each-ref`, `symbolic-ref`, and `config` (reads `remote.origin.url` of a local + repository to build web links; sets `remote.origin.fetch` and `HEAD` only inside the app's own + clones). + - `gh auth token --hostname ` for GitHub hosts and `glab config get token --host ` for + hosts in `glab`'s config, only when those CLIs are installed, to reuse your existing login. + - `/usr/sbin/scutil --proxy` on macOS, only when no proxy is set in Settings or the environment. +- Credentials: tokens from `gh`, `glab`, or Settings stay in the Node process. They reach git as an + `http.extraHeader` through `GIT_CONFIG_*` environment variables, never in command-line arguments, + repository config, the page, logs, or the event stream. Tokens from `gh` and `glab` are cached in + memory only. Git credential helpers and interactive prompts are turned off for these commands. +- Agent turns: none. No `agentAccess` and no MCP endpoints. "发到对话…", "让 Agent 讲解…", and + "让 Agent 读高风险文件…" call `window.mcode.miniapp.message.append` to place a prepared prompt (a + summary of the report and some file paths) in the conversation input after the Host asks you to + confirm; you decide whether to send it. Where that API is unavailable, the text is copied to the + clipboard instead. +- Clipboard: written only when you click a copy action (a SHA, a path, the Markdown report). +- Browser storage: `localStorage` keeps only the theme, the last opened repository, and the identity + merge preference. +- Reset: remove a repository with 移除 on its page; access tokens can be cleared in Settings. Deleting + the app's data directory removes everything it stored. + +## Files + +```text +.minimax-plugin/plugin.json Plugin manifest +package.json Mini App declaration +miniapp/miniapp.json Payload roots, Node entry, page route, pop-out window size +miniapp/client/index.html Page shell served at /dashboard +miniapp/client/app.js Page logic: routing, tabs, code map, dialogs (no dependencies) +miniapp/client/charts.js Hand-written SVG charts and the treemap layout +miniapp/client/styles.css Styles, light and dark themes +miniapp/client/icon.svg App icon used inside the page +miniapp/node/server.mjs Node entry: start(context) → { dispose }, routes, jobs, SSE +miniapp/node/git.mjs git runner, address parsing, clone and fetch, credentials and proxy +miniapp/node/analyze.mjs Reads the history and computes the report +miniapp/node/codemap.mjs Code map: directory tree, colourings, directory descriptions +miniapp/node/techstack.mjs Tech stack detection from manifests +miniapp/node/util.mjs Date helpers, JSON read and atomic write +miniapp/node/miniapp-api.ts Type declarations for the runtime context +icon.png Plugin icon +docs/preview.jpg README preview (synthetic data) +``` + +## License + +[MIT](./LICENSE) diff --git a/plugins/yujiachen-y/repo-xray/README.zh-CN.md b/plugins/yujiachen-y/repo-xray/README.zh-CN.md new file mode 100644 index 0000000..acd9173 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/README.zh-CN.md @@ -0,0 +1,64 @@ +# 仓库透视 (`repo-xray`) + +[English](README.md) | 简体中文 + +读代码之前,先从 Git 历史看懂一个陌生的代码仓库。添加一个 GitHub / GitLab 仓库,或者选一个本机仓库,仓库透视会把提交记录变成一张可以逐层下钻的代码地图、一份高风险文件清单,告诉你谁在维护哪里、团队怎么交付、项目用了哪些技术。 + +分析用你本机的 `git` 在本地完成:不上传任何内容,页面也不加载任何远程资源。指标口径沿用 [codebase-recon-skill](https://github.com/yujiachen-y/codebase-recon-skill),导出的 Markdown 也是这个 skill 的报告格式。界面为中文。 + +![仓库透视代码地图(虚构数据)](docs/preview.jpg) + +*预览图使用虚构数据:为截图专门生成的一个虚构 monorepo。* + +## 能做什么 + +- **概览**:关键数字、按确定性规则找出的主要问题、建议、与上次分析相比的变化,以及一张能点进完整地图的代码地图缩略图。 +- **代码地图**:可以逐层下钻的目录 treemap。着色可选修改热度、Bug 占比、负责人、最近修改、知识流失(这块的全部历史提交中,来自近 3 个月没有任何提交的人的比例)和文件类型;面积可按文件数或修改次数;时间范围可选近 3、6、12 个月或全部历史。支持键盘操作。 +- **风险文件**:同时出现在“修改热点”和“Bug 磁铁”(最常被提交说明里带 fix、bug、broken 的提交改动)两个榜单里的文件,附负责人。 +- **贡献者**:按非合并提交排名、工作集中度、近 3 个月的活跃情况,机器人和令牌账号单独列出。默认把同名或同邮箱的身份合并,也可以切换成按作者名逐一统计。 +- **节奏与救火**:月度提交趋势、提交日历,以及 revert、hotfix、emergency、rollback 类提交。 +- **技术栈**:从 `package.json`、`go.mod`、`Cargo.toml`、`pyproject.toml`、`pom.xml`、`Gemfile` 等清单文件识别语言、框架和工具。 +- ⌘K 搜索仓库、全部历史文件和贡献者;把报告复制或下载为 Markdown;或者把一段准备好的提示词放进对话输入框,让 Agent 帮你读最高风险的文件,内容由你确认后自己发送。 +- 推荐最近用过的本机仓库,支持浅色和深色主题、键盘快捷键(按 `?` 查看),以及系统的“减弱动态效果”设置。 + +## 运行要求 + +- `PATH` 里有 `git`。分析远程仓库需要 Git 2.31 或更高版本,因为凭据和代理通过 `GIT_CONFIG_COUNT` 环境变量传给 git。 +- 私有仓库:如果装了 `gh`(GitHub)或 `glab`(GitLab)并且已经登录,会直接复用登录状态;否则可以在设置里为对应主机填写访问令牌。 + +## 安装 + +把整个目录(包括隐藏的 `.minimax-plugin/`)复制到 MiniMax Code 的插件目录,命名为 `repo-xray/`(默认是 `~/.minimax/plugins/repo-xray/`,详见根目录 README 的安装说明)。重启 MiniMax Code,然后从右侧面板的 Mini App 入口打开仓库透视,或者对 Agent 说“打开仓库透视”。 + +## Tested environment + +- 在 macOS 27 上的 MiniMax Code 3.1.3-preview.4675 中测试,Git 版本 2.54:通过宿主的发布流程安装(也从本仓库目录重新安装过),在右侧面板打开。 +- 实测过:一个公开的 GitHub 仓库、一个通过 `glab` 登录访问的自建 GitLab 仓库和本机仓库,包括重新分析;所有分区;代码地图的全部着色、面积和时间范围选项,用鼠标和键盘逐层进入与返回;⌘K 搜索;Markdown 导出;浅色和深色主题。控制台没有报错,也没有失败的请求。 +- 未实测:把页面弹出到独立窗口(`surface.window`,尺寸 1360×900);追加到对话输入框(需要宿主弹窗确认);“减弱动态效果”;Windows 和 Linux。 + +## Data & access + +- 读取的文件: + - 你添加的仓库。本机仓库只用下文列出的只读 git 命令原地读取:只看 `HEAD` 的历史,不 fetch,也不写入任何内容。 + - 本机仓库推荐:在扫描目录下列出子目录(默认 `~/Workspace`、`~/workspace`、`~/code`、`~/Code`、`~/src`、`~/Projects`、`~/projects`、`~/Developer`、`~/dev`、`~/repos`、`~/GitHub`、`~/git`;可在设置里改成最多 10 个自定义目录),最深 4 层、最多 4000 个目录和 300 个仓库,跳过隐藏目录和 `node_modules` 等目录。每个目录只检查有没有 `.git`,以及 `.git/FETCH_HEAD`、`.git/index`、`.git/HEAD` 的修改时间。 + - `glab` 配置文件(`$GLAB_CONFIG_DIR/config.yml`、`~/.config/glab-cli/config.yml` 或 `~/Library/Application Support/glab-cli/config.yml`)里的主机名,用来判断 `glab` 登录了哪些主机;不从这个文件读取令牌。 + - 在 `PATH`、`/opt/homebrew/bin`、`/usr/local/bin`、`/usr/bin`、`~/.local/bin`、`~/.local/share/mise/shims` 里查找 `gh` 和 `glab` 是否存在。 +- 写入的文件,全部位于宿主提供的 `context.dataDir` 内(先写临时文件再原子替换): + - `config.json`:仓库列表和设置(扫描目录、代理,以及你在设置里填写的访问令牌;令牌以明文保存在这个文件里)。 + - `reports/.json`:每个仓库的分析结果。 + - `repos/.git`:远程仓库的 bare、blob-less 克隆,只包含提交和目录树,外加技术栈识别和目录说明用到的少量清单文件与 README 类文件。 + - 移除仓库时删除它的报告;如果是远程仓库,同时删除它的克隆。本机仓库从不改动。 +- 网络:只有 `git` 会联网,通过 HTTPS 访问你添加的远程仓库所在的主机:clone 和 fetch、用 `ls-remote` 跟进默认分支改名、拉取上面提到的少量清单与 README 文件。`git@host:path` 和 `ssh://` 形式的地址会转换成 HTTPS 地址。设置了代理时 git 经代理连接,代理依次取自设置、`HTTPS_PROXY` / `https_proxy` / `HTTP_PROXY` / `http_proxy` 环境变量、macOS 系统 HTTPS 代理。页面只加载本应用同源的文件和 `/api/` 路由。指向 GitHub / GitLab 页面的链接只有在你点击时才会在浏览器里打开。没有任何统计上报。 +- 进程:全部用参数数组启动,从不经过 shell。你输入的地址和路径都会先校验,并作为独立参数传入,不会出现在选项位置: + - `git`:`clone --bare --filter=blob:none`、`fetch`、`ls-remote`、`log`、`ls-tree`、`cat-file`、`rev-parse`、`for-each-ref`、`symbolic-ref`,以及 `config`(读取本机仓库的 `remote.origin.url` 来生成网页链接;只在应用自己的克隆里设置 `remote.origin.fetch` 和 `HEAD`)。 + - `gh auth token --hostname `(GitHub 主机)和 `glab config get token --host `(`glab` 配置里的主机),仅在装了这两个 CLI 时调用,用来复用已有登录。 + - macOS 上的 `/usr/sbin/scutil --proxy`,仅在设置和环境变量里都没有代理时调用。 +- 凭据:来自 `gh`、`glab` 或设置的令牌只留在 Node 进程里,通过 `GIT_CONFIG_*` 环境变量以 `http.extraHeader` 的形式交给 git,不会出现在命令行参数、仓库配置、页面、日志或事件流里。来自 `gh` 和 `glab` 的令牌只缓存在内存中。这些 git 命令会关闭凭据助手和交互式提示。 +- Agent:不运行 Agent,没有声明 `agentAccess`,也没有 MCP 端点。“发到对话…”“让 Agent 讲解…”“让 Agent 读高风险文件…”会调用 `window.mcode.miniapp.message.append`,在宿主弹窗确认后把一段准备好的提示词(报告摘要和部分文件路径)放进对话输入框,由你决定是否发送。环境不支持这个接口时,改为复制到剪贴板。 +- 剪贴板:只在你点击复制类操作(SHA、路径、Markdown 报告)时写入。 +- 浏览器存储:`localStorage` 只保存主题、上次打开的仓库和身份合并偏好。 +- 重置:在仓库页面点“移除”删除单个仓库;访问令牌可以在设置里清除。删除应用的数据目录即可清空它保存的全部内容。 + +## License + +[MIT](./LICENSE) diff --git a/plugins/yujiachen-y/repo-xray/docs/preview.jpg b/plugins/yujiachen-y/repo-xray/docs/preview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5308a97c41a10ce8be4f389e0d9dfd01803b9e8f GIT binary patch literal 226615 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

oq`FzEM~CGi_o7r+ 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 z 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 z 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�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
  • + ${ICON[r.provider] || ICON.other}${esc(r.name)}${sub}${badge}
  • `; + }) + .join('') + : '
  • 还没有分析过仓库
  • '; + $('.brand-mark')?.classList.toggle('busy', state.repos.some((r) => r.job?.running)); +} +function setLive(on) { + const el = $('#live'); + if (!el) return; + el.classList.toggle('off', !on); + $('#live-text').textContent = on ? '已连接 · 实时更新' : '连接中断,正在重连…'; +} + +// --------------------------------------------------------------------------- +// Welcome +// --------------------------------------------------------------------------- +function parsePreview(input) { + const v = input.trim(); + if (!v) return ''; + if (/^(\/|~|[A-Za-z]:[\\/])/.test(v)) return `本地仓库 · ${v}`; + let m; + if ((m = v.match(/^git@([^:]+):(.+?)(\.git)?$/))) return `${m[1]} · ${m[2]}`; + if (/^https?:\/\//i.test(v)) { + try { + const u = new URL(v); + return `${u.host} · ${u.pathname.replace(/^\/|\/$/g, '').replace(/\.git$/, '').split('/-/')[0]}`; + } catch { + return '地址格式不对'; + } + } + if ((m = v.match(/^([\w-]+)\/([\w.-]+)$/))) return `github.com · ${m[1]}/${m[2]}`; + return '无法识别:请输入 https 地址、owner/repo,或本地仓库的绝对路径'; +} +function analyzeBar(value = '') { + return ` +
    `; +} +const EXAMPLES = ['sindresorhus/got', 'https://github.com/expressjs/express', '~/Workspace/my-repo']; +const METRICS = [ + ['代码热点', '窗口内改得最多的文件', 'git log --name-only'], + ['Bug 磁铁', '修 bug 时最常碰的文件', '--grep="fix|bug|broken"'], + ['知识集中度', '一半提交出自几个人', 'git shortlog -sn'], + ['节奏与救火', '月度趋势、revert / hotfix', '--since · uniq -c'], +]; +function renderWelcome() { + const s = state.suggest; + main.innerHTML = `
    +
    + +

    读代码之前,先看一遍它的 git 历史

    +

    找出最常被改、最常出 bug 的文件,谁在维护它们,项目节奏是升是降,以及它用了哪些技术。指标定义和 codebase-recon skill 保持一致,全部在本机计算。

    +
    +
    + + ${analyzeBar()} +
    试试${EXAMPLES.map((x) => ``).join('')}
    +
    +
    ${METRICS.map(([t, d, c]) => `
    ${t}${d}
    ${esc(c)}
    `).join('')}
    +
    +

    本机仓库

    只读扫描常用目录下的 git 仓库,按最近活动排序;扫描目录可在设置里修改
    +
    + ${ + !s + ? '
    ' + : s.length + ? `
      ${s + .map( + (x, i) => `
    • `, + ) + .join('')}
    ` + : '

    没有在常用目录里找到 git 仓库。可以在设置里添加扫描目录,或直接在上面输入路径。

    ' + } +
    +
    `; + $('#target-input')?.focus(); +} +async function loadSuggest(refresh = false) { + try { + state.suggest = (await api(`/api/suggest${refresh ? '?refresh=1' : ''}`)).repos; + } catch { + state.suggest = []; + } + if (!route.repo) renderWelcome(); +} + +// --------------------------------------------------------------------------- +// Repo page +// --------------------------------------------------------------------------- +function headerHtml(r, rep) { + const running = r.job?.running; + const v = rep?.vitals; + const web = r.webUrl || rep?.web?.url; + const at = r.lastSync?.at; + return `
    +
    +

    ${ICON[r.provider] || ICON.other}${esc(r.name)}

    +
    + ${esc(r.display)} + ${rep ? `` : ''} + ${r.kind === 'local' ? '本地仓库 · 分析当前 HEAD,不 fetch' : ''} + ${running ? ' 正在分析…' : ` 上次分析 ${relTime(at)}`} +
    + ${v ? `
    ${v.first} → ${v.latest}${fmt(v.commits)} commits${fmt(v.branches)} 个分支${fmt(v.files)} 个文件${TIER[v.tier]} · 窗口 ${esc(v.window)} · Top ${v.N}
    ` : ''} +
    +
    + ${rep ? `` : ''} + ${web ? `${ICON.ext}打开网页` : ''} + + +
    +
    +
    `; +} +function tabsHtml(rep) { + const counts = rep ? { risk: rep.highRisk.length, people: rep.busFactor.total } : {}; + // Coming back to the map tab returns to where it was left for this repo. + const patch = (t) => ({ tab: t, filter: null, file: null, person: null, ...(t === 'map' && route.tab !== 'map' ? mapPlaceFor(route.repo) : {}) }); + return ``; +} +function renderRepo() { + const r = current(); + if (!r) return renderWelcome(); + const rep = state.report && state.reportKey === `${r.id}:${state.identity}` ? state.report : null; + let body; + if (state.error && !rep) body = ``; + else if (!rep) { + const err = r.lastSync?.error && !r.job?.running; + body = err + ? `
    ${ICON.alert}

    分析失败

    ${esc(r.lastSync.error.message)}

    ` + : skeletonFor(route.tab); + } else body = tabBody(rep) + statusLine(r, rep); + main.innerHTML = `${headerHtml(r, rep)}${tabsHtml(rep)}
    ${body}
    `; + afterRender(r, rep); + renderProgress(); +} + +// ---- post-render details: tab ink, title decode, count-up, code map ------- +let inkAt = null; +function placeInk() { + const bar = main.querySelector('.tabs-bar'); + const ink = bar?.querySelector('.tab-ink'); + const a = bar?.querySelector('a[aria-current="page"]'); + if (!ink || !a) return; + const to = { x: a.offsetLeft, w: a.offsetWidth }; + if (inkAt && !reducedMotion()) { + ink.style.transition = 'none'; + ink.style.transform = `translateX(${inkAt.x}px)`; + ink.style.width = `${inkAt.w}px`; + void ink.offsetWidth; + ink.style.transition = ''; + } + ink.style.transform = `translateX(${to.x}px)`; + ink.style.width = `${to.w}px`; + inkAt = to; +} +let decodedFor = null; +function decodeTitle(el, text) { + if (!el || reducedMotion()) return; + const glyphs = 'ABCDEFGHJKLMNPQRSTUVWXYZ0123456789/<>_-=+*'; + const chars = [...text]; + const t0 = performance.now(); + const dur = Math.min(700, 260 + chars.length * 22); + const step = (t) => { + if (!el.isConnected) return; + const k = (t - t0) / dur; + if (k >= 1) return void (el.textContent = text); + el.textContent = chars.map((c, i) => (i / chars.length < k || !/[\x21-\x7e]/.test(c) ? c : glyphs[(Math.random() * glyphs.length) | 0])).join(''); + requestAnimationFrame(step); + }; + requestAnimationFrame(step); +} +let countedFor = null; +function countUp(root) { + const els = root.querySelectorAll('[data-count]'); + if (!els.length || reducedMotion()) return; + const t0 = performance.now(); + const ease = (k) => 1 - (1 - k) ** 3; + const step = (t) => { + const k = Math.min(1, (t - t0) / 900); + for (const el of els) { + if (!el.isConnected) return; + const v = Number(el.dataset.count) * ease(k); + el.textContent = el.dataset.fmt === 'pct1' ? `${v.toFixed(1)}%` : fmt(Math.round(v)); + } + if (k < 1) requestAnimationFrame(step); + }; + requestAnimationFrame(step); +} +function afterRender(r, rep) { + placeInk(); + if (decodedFor !== r.id) { + decodedFor = r.id; + decodeTitle(main.querySelector('.scramble'), r.name); + } + if (rep && route.tab === 'overview' && countedFor !== state.reportKey + rep.head) { + countedFor = state.reportKey + rep.head; + countUp(main); + } + if (rep && route.tab === 'overview') mountThumb(rep); + else thumbRO?.disconnect(); + if (rep && route.tab === 'map') mountMap(); + else { + mapRO?.disconnect(); + mapRO = null; + } +} +function statusLine(r, rep) { + const steps = r.lastSync?.head === rep.head ? r.lastSync?.steps || [] : []; + const an = steps.find((x) => x.stage === 'analyze'); + return `
    + HEAD ${esc(rep.head.slice(0, 8))} + 算法 v${rep.algo} + 口径 ${rep.identity === 'raw' ? '原始' : '合并身份'} + 解析 ${fmt(rep.vitals.commits)} commits${an ? ` · 用时 ${secs(an.ms)}` : ''}${r.lastSync?.ms && r.lastSync?.head === rep.head ? ` · 全程 ${secs(r.lastSync.ms)}` : ''} + 生成于 ${relTime(rep.generatedAt)} + ⌘K 搜索 · ? 快捷键 +
    `; +} +function skeletonFor(tab) { + if (tab === 'overview') return `
    ${'
    '.repeat(6)}
    `; + if (tab === 'map') return '
    '; + return '
    '; +} +function tabBody(rep) { + switch (route.tab) { + case 'map': + return mapTab(rep); + case 'risk': + return riskTab(rep); + case 'people': + return peopleTab(rep); + case 'momentum': + return momentumTab(rep); + case 'tech': + return techTab(rep); + default: + return overviewTab(rep); + } +} + +// ---- explain popovers ----------------------------------------------------- +function explainFor(key, rep) { + const v = rep.vitals; + const since = v.window === 'all time' ? '' : ` --since="${v.window}"`; + const mo = mapOpts(rep); + const mSince = mo.range === 'all' ? '' : ` --since="${mo.range} months ago"`; + const cp = mp.view?.couple || { maxChangeset: 30, minRevs: 5, minShared: 5, minDegree: 0.3 }; + const E = { + window: ['按 skill 的规则给仓库分档:<500 个 commit 看全部历史,500–10k 看 1 年,>10k 看 6 个月。', `git rev-list --count HEAD\ngit log --oneline${since} | wc -l`], + risk: [`同时出现在“代码热点 Top ${v.N}”和“Bug 磁铁 Top ${v.N}”里的文件。`, `git log --format=format: --name-only${since} | sort | uniq -c | sort -nr | head -${v.N}\ngit log -i -E --grep="fix|bug|broken" --name-only --format=''${since} | sort | uniq -c | sort -nr | head -${v.N}`], + active: [`近 3 个月有非合并提交的人数 / 历史总人数,低于 30% 时告警。${rep.identity === 'merged' ? '当前为“合并身份”口径:大小写不同的同名账号与同邮箱账号视为一人,机器人不计入。' : '当前为原始口径,与 shortlog 完全一致。'}`, 'git shortlog -sn --no-merges --since="3 months ago" HEAD | wc -l\ngit shortlog -sn --no-merges HEAD | wc -l'], + half: ['按非合并提交数从高到低累加,达到总数一半时的人数。数字越小,知识越集中。', 'git shortlog -sn --no-merges HEAD'], + trend: ['比较最近 3 个完整月和之前 3 个月的月均提交数:高 20% 以上为上升,低 20% 以上为下降;环比平均波动超过 50% 为波动。当月未结束不计入。', "git log --format='%ad' --date=format:'%Y-%m' | sort | uniq -c"], + fire: ['窗口内标题含 revert / hotfix / emergency / rollback 的提交占比,≥5% 判为偏高(skill 未给阈值)。', `git log --oneline${since} | grep -iE 'revert|hotfix|emergency|rollback'`], + map: ['缩略图只画第一层:面积是 HEAD 中的文件数,颜色是窗口内的修改次数(与“代码热点”同一口径),按分位数分成 5 档。大目录逐层展开,过小的子项合并成“其他 N 项”。点任意一块,会在“代码地图”分区里打开并定位到那里。', `git ls-tree -r --name-only HEAD | cut -d/ -f1 | sort | uniq -c | sort -nr\ngit log --format=format: --name-only${since} | sort | uniq -c | sort -nr`], + mapview: ['把 HEAD 的目录树画成方块。面积默认是文件数,可以切换成所选时间范围内的修改次数;颜色按上方选择的视角。大目录逐层展开,小于 0.5% 的子项合并成“其他 N 项”。已删除文件的修改,算到它最深的、仍然存在的目录上。时间范围从今天往前算;仓库已经停更时,从最后一次提交往前算。', `git ls-tree -r --name-only HEAD\ngit log -M100% --name-status --format='%H %aN %ct %s'${mSince} HEAD`], + couple: [`“A 常和 B 一起改”指改了 A 的提交里,同时也改了 B 的比例。这个比例有方向,A→B 和 B→A 可能不同(code-maat 用两边提交数的平均值做分母)。只统计所选时间范围内的非合并提交,超过 ${cp.maxChangeset} 个文件的大提交不计。门槛沿用 code-maat 的默认值:两边各至少 ${cp.minRevs} 个提交、一起改过至少 ${cp.minShared} 次、比例 ≥${Math.round(cp.minDegree * 100)}%。changelog、lock 文件这类几乎随每次修改一起改的流程文件单独列出,不画线;测试目录和同一个包里的代码一起改,标为“测试同步”。`, `git log --no-merges --numstat --date=short --pretty=format:'--%h--%ad--%aN' --no-renames${mSince} HEAD > logfile.log\njava -jar code-maat.jar -l logfile.log -c git2 -a coupling -s ${cp.maxChangeset} -n ${cp.minRevs} -m ${cp.minShared} -i ${Math.round(cp.minDegree * 100)}`], + cal: ['近 365 天每天的提交数,按作者日期统计,含合并提交(与“提交节奏”同一口径)。', "git log --since='1 year ago' --date=short --format='%ad' | sort | uniq -c"], + }[key]; + if (!E) return ''; + return ``; +} +function kpi({ label, value, sub, accent, i, key, rep, viz = '', tip = '' }) { + return `
    ${label}${key ? explainFor(key, rep) : ''}
    ${value}
    ${sub}
    ${viz}
    `; +} +const num = (n, f) => `${f === 'pct1' ? `${Number(n).toFixed(1)}%` : fmt(n)}`; +function kpisHtml(rep) { + const v = rep.vitals; + const bf = rep.busFactor; + const mo = rep.momentum; + const ff = rep.firefighting; + const months = mo.months.slice(-12).map((x) => x.n); + const done = mo.months.filter((x) => x.m !== mo.partialMonth).slice(-6); + const riskRanks = new Set(rep.highRisk.map((h) => h.hotRank)); + const top = rep.contributors.slice(0, 8); + const shares = top.map((c) => ({ value: c.share, color: C.memberColor(c.name) })); + shares.push({ value: Math.max(0, 1 - top.reduce((s2, c) => s2 + c.share, 0)), color: 'var(--k-other)' }); + const ffMax = Math.max(0.1, ff.rate * 1.4); + return `
    ${[ + kpi({ i: 0, rep, key: 'window', label: '窗口内提交', value: num(v.windowCommits), sub: `${WINDOW_ZH[v.window] || v.window} · 全部 ${fmt(v.commits)}`, accent: 'var(--k-code)', viz: C.sparkline(months, { w: 120, h: 26, stretch: true, color: 'var(--k-code)' }), tip: `近 12 个月每月提交数\n${mo.months.slice(-12).map((x) => `${x.m} ${fmt(x.n)}`).join('\n')}` }), + kpi({ i: 1, rep, key: 'risk', label: '高风险文件', value: num(rep.highRisk.length), sub: `热点 Top ${v.N} 中有 ${rep.highRisk.length} 个也是 Bug 磁铁`, accent: 'var(--mcode-danger)', viz: C.dots(v.N, riskRanks) }), + kpi({ i: 2, rep, key: 'active', label: '活跃贡献者', value: `${num(bf.active3m)}/ ${fmt(bf.total)}`, sub: `近 3 个月 · ${pct(bf.ratio)}${bf.warn ? ' · 低于 30%' : ''}`, accent: 'var(--k-merge)', viz: C.meter({ value: bf.ratio, threshold: 0.3, tick: '30%', color: bf.warn ? 'var(--mcode-danger)' : 'var(--k-merge)' }) }), + kpi({ i: 3, rep, key: 'half', label: '一半提交来自', value: `${num(bf.halfCount)}人`, sub: `第一名占 ${pct(bf.topShare)}`, accent: 'var(--k-mr)', viz: C.stackMeter(shares, { mark: 0.5, tick: '50%' }) }), + kpi({ i: 4, rep, key: 'trend', label: '提交节奏', value: `${TREND[mo.trend]} ${TREND_GLYPH[mo.trend]}`, sub: mo.trend === 'insufficient' ? '历史不足 6 个月' : `月均 ${fmt(Math.round(mo.lastAvg))} ← ${fmt(Math.round(mo.prevAvg))}`, accent: 'var(--k-review)', viz: C.miniBars(done.map((x) => x.n), { hi: 3, color: TREND_COLOR[mo.trend] }), tip: done.map((x) => `${x.m} ${fmt(x.n)}`).join('\n') }), + kpi({ i: 5, rep, key: 'fire', label: '救火提交', value: num((ff.rate * 100).toFixed(1), 'pct1'), sub: `${fmt(ff.count)} / ${fmt(ff.total)}${ff.rate >= 0.05 ? ' · 偏高' : ''}`, accent: 'var(--k-comment)', viz: C.meter({ value: ff.rate, max: ffMax, threshold: 0.05, tick: '5%', color: ff.rate >= 0.05 ? 'var(--mcode-warning)' : 'var(--k-comment)' }) }), + ].join('')}
    `; +} +function deltaHtml(rep) { + const d = rep.delta; + if (!d) return ''; + const parts = [`${d.commits >= 0 ? '+' : ''}${fmt(d.commits)} 个 commit`]; + if (d.riskAdded?.length) parts.push(`高风险新增 ${d.riskAdded.slice(0, 3).map((p) => pathHtml(p)).join('、')}${d.riskAdded.length > 3 ? ` 等 ${d.riskAdded.length} 个` : ''}`); + if (d.riskRemoved?.length) parts.push(`移出 ${d.riskRemoved.length} 个`); + if (d.trendFrom && d.trendFrom !== d.trendTo) parts.push(`节奏 ${TREND[d.trendFrom]} → ${TREND[d.trendTo]}`); + return `
    ${ICON.bolt}自上次分析(${esc(d.fromHead.slice(0, 8))},${relTime(d.fromAt)})以来:${parts.join(' · ')}
    `; +} +const seeAll = (tab, label = '查看全部') => `${label}${ICON.arrow}`; + +// ---- overview ------------------------------------------------------------- +function overviewTab(rep) { + const mo = rep.momentum; + const cm = liteMap(rep); + return `${deltaHtml(rep)}${kpisHtml(rep)} +
    +
    +

    主要问题

    由提交历史上的确定性规则得出,点击进入对应分区
    + ${problemsHtml(rep)} +
    +
    +

    建议

    对应 skill 报告的 Recommendations
    + ${recHtml(rep)} +
    +
    +

    代码地图 ${explainFor('map', rep)}

    ${cm ? `面积是 HEAD 中的文件数,颜色是窗口内的修改次数。点任意一块,进入代码地图并定位到那里` : '这份报告来自旧版本,缩略图会在后台重新分析完成后出现;代码地图分区现在就能用'}
    ${cm ? C.heatLegend() : ''}${seeAll('map', '打开代码地图')}
    + ${cm ? `
    ${cm.blocks.some((b) => b.risk) ? '含高风险文件' : ''}${cm.blocks.some((b) => b.kind === 'other') ? '合并在一起的小项' : ''}${fmt(cm.totals.files)} 个文件 · 窗口内修改 ${fmt(cm.totals.churn)} 次 · ${fmt(cm.blocks.length)} 块
    ` : ''} +
    +
    +

    高风险文件

    前 5 个,按热点排名
    ${seeAll('risk', `全部 ${rep.highRisk.length} 个`)}
    + ${rep.highRisk.length ? `
      ${rep.highRisk.slice(0, 5).map((h, i) => `
    1. ${i + 1}${pathHtml(h.path)}${h.owner ? esc(h.owner.name) : '–'}${fmt(h.bugCount)}/${fmt(h.hotCount)}
    2. `).join('')}
    ` : `

    ${ICON.ok} 没有文件同时出现在两个榜单里。

    `} +
    +
    +

    主要贡献者

    非合并提交占比
    ${seeAll('people', `全部 ${fmt(rep.busFactor.total)} 人`)}
    + ${shareOfContributors(rep, 8)} +
      ${rep.contributors.slice(0, 5).map((c) => `
    1. ${c.rank}${avatar(c.name)}${personLink(c.name)}${fmt(c.commits)}${pct(c.share)}
    2. `).join('')}
    +
    +
    +

    提交节奏 ${TREND[mo.trend]}

    近 12 个月
    ${seeAll('momentum')}
    + ${momentumChart(rep, 12, 180)} +
    +
    +

    技术栈

    ${rep.tech ? '语言构成与主要框架' : '正在识别…'}
    ${rep.tech ? seeAll('tech') : ''}
    + ${rep.tech ? techSummary(rep.tech) : '
    '} +
    +
    `; +} +function problemsHtml(rep) { + const icon = { warn: ICON.alert, good: ICON.ok, info: ICON.info }; + const lvl = { warn: '提醒', good: '良好', info: '信息' }; + return `
      ${rep.problems + .map((p) => { + const tab = TAB_OF_ANCHOR[p.anchor] || 'overview'; + const inner = `${icon[p.level]}
      ${esc(p.title)}
      ${esc(p.detail)}
      ${tab !== 'overview' ? `` : ''}`; + return `
    • ${tab !== 'overview' ? `${inner}` : `
      ${inner}
      `}
    • `; + }) + .join('')}
    `; +} +function recHtml(rep) { + const r = rep.recommendations; + return `
      +
    • 先读${r.startReading.length ? r.startReading.map((p) => `${pathHtml(p)}${fileActions(p, { compact: true })}`).join('') : '暂无'}
    • +
    • 找谁聊${r.talkTo ? `${avatar(r.talkTo.name)}${personLink(r.talkTo.name)}${pct(r.talkTo.share)} 的修改负责 ${pathHtml(r.talkTo.file)}` : '暂无'}
    • +
    • 注意${r.watchOut.length ? r.watchOut.map((w) => `${esc(w)}`).join('') : '没有明显的趋势风险'}
    • +
    • 继续
    • +
    `; +} +function fileActions(path, { compact = false, exists = true } = {}) { + const web = exists ? webLink('file', path) : null; + const ed = exists ? editorLink(path) : null; + const cls = compact ? 'icon-btn btn-ghost btn-sm mini' : 'btn-sm'; + return ` + + ${web ? `${ICON.ext}${compact ? '' : '网页'}` : ''} + ${ed ? `${ICON.code}${compact ? '' : 'VS Code'}` : ''} + `; +} +function shareOfContributors(rep, n) { + const total = rep.contributors[0] ? Math.round(rep.contributors[0].commits / rep.contributors[0].share) : 0; + const top = rep.contributors.slice(0, n); + return C.shareBar([...top.map((c) => ({ label: c.name, value: c.commits, color: C.memberColor(c.name) })), { label: '其他贡献者', value: Math.max(0, total - top.reduce((s, c) => s + c.commits, 0)), color: 'var(--k-other)' }], { height: 12, label: '提交占比' }); +} +function techSummary(t) { + const items = langItems(t); + const groups = t.groups.filter((g) => !['语言与运行时', '工程实践', '代码质量'].includes(g.category)).slice(0, 4); + return `${C.shareBar(items, { height: 12, label: '语言构成' })} +
      ${items.slice(0, 4).map((i) => `
    • ${esc(i.label)}${pct(i.value / items.reduce((s, x) => s + x.value, 0))}
    • `).join('')}
    +
    ${groups.map((g) => `
    ${esc(g.category)}
    ${g.items.slice(0, 5).map((i) => esc(i.name)).join(' · ')}
    `).join('')}
    `; +} + +// ---- risk tab ------------------------------------------------------------- +function riskTab(rep) { + const f = route.filter; + let list = rep.highRisk; + if (f === 'solo') list = list.filter((h) => h.exists && h.owner && h.owner.share >= 0.8); + if (f === 'deleted') list = rep.hotspots.filter((h) => !h.exists).map((h) => rep.highRisk.find((x) => x.path === h.path) || { path: h.path, hotRank: h.rank, hotCount: h.count, exists: false, owners: [] }); + const soloN = rep.highRisk.filter((h) => h.exists && h.owner && h.owner.share >= 0.8).length; + const deletedN = rep.hotspots.filter((h) => !h.exists).length; + const chips = [ + [null, `全部 ${rep.highRisk.length}`], + ['solo', `单人维护 ${soloN}`], + ['deleted', `已删除的热点 ${deletedN}`], + ]; + return `
    +

    高风险文件 ${explainFor('risk', rep)}

    同时出现在代码热点和 bug 磁铁里的文件;负责人按全部历史中改动它的非合并提交计算${rep.identity === 'merged' ? ',不含机器人' : ''}
    +
    ${chips.map(([k, l]) => `${l}`).join('')}
    + ${riskTable(list, rep)} +
    +
    +

    代码热点

    窗口内被修改次数最多的文件,红底表示同时是 bug 磁铁
    ${barListHtml(rep.hotspots, rep, 'var(--k-code)')}
    +

    Bug 磁铁

    提交说明含 fix / bug / broken 的提交最常改动的文件
    ${barListHtml(rep.bugMagnets, rep, 'var(--mcode-danger)')}
    +

    新增文件

    窗口内新增 ${fmt(rep.recentlyAdded.total)} 个文件,按目录看哪里在长新代码
    ${dirBars(rep.recentlyAdded.dirs)}
    +
    `; +} +function riskTable(list, rep) { + if (!list.length) return route.filter ? '

    没有符合筛选条件的文件。

    ' : `
    ${ICON.ok}

    没有文件同时出现在两个榜单里

    No files appear in both lists — good sign.

    `; + const maxHot = Math.max(...list.map((h) => h.hotCount || 0), 1); + return `
    + ${list + .map( + (h) => ` + + + + + + + `, + ) + .join('')}
    高风险文件
    文件热点修改次数磁铁修 bug主要负责人其他维护者操作
    ${pathHtml(h.path)}${h.exists ? '' : ' 已删除'}#${h.hotRank}
    ${fmt(h.hotCount)}
    ${h.bugRank ? `#${h.bugRank}` : '–'}${h.bugCount != null ? `${fmt(h.bugCount)} ${pct(h.bugCount / h.hotCount)}` : '–'}${h.owner ? `
    ${avatar(h.owner.name)}
    ${personLink(h.owner.name)}
    ${pct(h.owner.share)} 的修改${h.owner.share >= 0.8 ? ' · 单人维护' : ''}
    ` : '–'}
    ${(h.owners || []).slice(1, 4).map((o) => `${esc(o.name)} ${pct(o.share)}`).join('、') || '–'}${fileActions(h.path, { compact: true, exists: h.exists })}
    `; +} +function barListHtml(list, rep, color) { + if (!list.length) return '

    窗口内没有数据。

    '; + const risk = new Set(rep.highRisk.map((h) => h.path)); + const max = list[0].count; + return `
      ${list.map((x) => `
    1. ${x.rank}${pathHtml(x.path)}
      ${fmt(x.count)}
    2. `).join('')}
    `; +} +function dirBars(dirs) { + if (!dirs.length) return '

    窗口内没有新增文件。

    '; + const max = dirs[0][1]; + return `
      ${dirs.map(([d, n], i) => `
    1. ${i + 1}${esc(d)}
      ${fmt(n)}
    2. `).join('')}
    `; +} + +// ---- people tab ----------------------------------------------------------- +function peopleTab(rep) { + const bf = rep.busFactor; + return `
    +

    贡献者排名 ${explainFor('half', rep)}

    按全部历史的非合并提交数排序,共 ${fmt(bf.total)} 人;近 3 个月活跃 ${bf.active3m} 人${explainFor('active', rep)}
    +
    +
    + + +
    +
    + ${shareOfContributors(rep, 8)} +
    ${contribHtml(rep)}
    +
    + ${ + rep.identity === 'merged' && rep.bots.length + ? `

    机器人与令牌账号

    按名字和邮箱识别(如 [bot]、project_*_bot、agent 账号),不参与排名、活跃比例和负责人计算;切到“原始口径”可看 skill 的原始结果
    +
    ${rep.bots.map((b) => `${esc(b.name)}${fmt(b.commits)}`).join('')}
    ` + : '' + }`; +} +function contribHtml(rep) { + const q = state.contribFilter.trim().toLowerCase(); + const matched = rep.contributors.filter((c) => !q || [c.name, ...(c.aliases || [])].some((n) => n.toLowerCase().includes(q))); + const list = state.contribAll || q ? matched : matched.slice(0, 20); + const max = rep.contributors[0]?.commits || 1; + return ` + ${ + list + .map( + (c) => ` + + + + + + + `, + ) + .join('') || `` + }
    贡献者排名
    #贡献者提交数占比窗口内修 bug近 12 个月首次 → 最近状态主要目录
    ${c.rank}
    ${avatar(c.name)}
    ${personLink(c.name)}${c.aliases?.length ? `
    也作 ${esc(c.aliases.join('、'))}
    ` : ''}
    ${fmt(c.commits)}
    ${pct(c.share, 1)}${fmt(c.window)}${fmt(c.fixes)}${C.sparkline(c.monthly, { color: C.memberColor(c.name) })}${c.first} → ${c.last}${c.active ? '活跃' : '近 3 月无提交'}${c.dirs.slice(0, 2).map(([d]) => `${esc(d)}`).join('、')}
    没有匹配的贡献者
    ${!q && matched.length > 20 ? `
    ` : ''}`; +} + +// ---- momentum tab --------------------------------------------------------- +function momentumChart(rep, last, height = 220) { + const mo = rep.momentum; + const months = last ? mo.months.slice(-last) : mo.months; + const done = mo.months.filter((x) => x.m !== mo.partialMonth); + const last3 = done.slice(-3); + const prev3 = done.slice(-6, -3); + const bands = []; + if (prev3.length === 3) bands.push({ from: prev3[0].m, to: prev3[2].m, value: mo.prevAvg, label: `之前月均 ${fmt(Math.round(mo.prevAvg))}`, color: 'var(--k-other)' }); + if (last3.length) bands.push({ from: last3[0].m, to: last3[last3.length - 1].m, value: mo.lastAvg, label: `近 3 月 ${fmt(Math.round(mo.lastAvg))}`, color: mo.trend === 'declining' ? 'var(--mcode-danger)' : mo.trend === 'rising' ? 'var(--mcode-success)' : 'var(--k-review)' }); + return C.monthBars({ months, partial: mo.partialMonth, bands, height }); +} +function calStats(a) { + const days = []; + for (let d = a.start; d <= a.end; d = C.addDays(d, 1)) days.push([d, a.daily[d] || 0]); + let streak = 0; + let best = 0; + let bestEnd = null; + let peak = [null, 0]; + let total = 0; + let weekend = 0; + for (const [d, n] of days) { + total += n; + if (n > peak[1]) peak = [d, n]; + const wd = C.parseYmd(d).getDay(); + if (wd === 0 || wd === 6) weekend += n; + streak = n ? streak + 1 : 0; + if (streak > best) { + best = streak; + bestEnd = d; + } + } + return { active: days.filter(([, n]) => n).length, span: days.length, best, bestEnd, peak, total, weekend: total ? weekend / total : 0 }; +} +function calendarCard(rep) { + const a = rep.activity; + if (!a) return ''; + const st = calStats(a); + return `
    +

    提交日历 ${explainFor('cal', rep)}

    近 365 天,共 ${fmt(st.total)} 个 commit;颜色越深当天提交越多
    ${C.heatLegend()}
    +
    ${C.calendar({ daily: a.daily, start: a.start, end: a.end, cell: 13, gap: 3, label: `提交日历:近 365 天有 ${st.active} 天有提交`, unit: '个 commit' })}
    +
    +
    ${st.active} / ${st.span}
    有提交的天数
    +
    ${st.best} 天
    最长连续${st.bestEnd ? `,止于 ${st.bestEnd.slice(5)}` : ''}
    +
    ${fmt(st.peak[1])}
    单日最多${st.peak[0] ? `(${st.peak[0]})` : ''}
    +
    ${pct(st.weekend)}
    周末提交占比
    +
    +
    `; +} +function momentumTab(rep) { + const mo = rep.momentum; + const ff = rep.firefighting; + return `${calendarCard(rep)}
    +

    提交节奏 ${TREND[mo.trend]} ${explainFor('trend', rep)}

    每月提交数(含合并提交)。虚线是近 3 个月和之前 3 个月的月均${mo.partialMonth ? `;${mo.partialMonth} 尚未结束,浅色显示` : ''}
    +
    +
    ${momentumChart(rep, state.momentumAll ? 0 : 24)}
    +
    +
    +

    救火记录 ${explainFor('fire', rep)}

    窗口内 ${fmt(ff.count)} 次,占 ${pct(ff.rate, 1)}${ff.count > ff.list.length ? `,显示最近 ${ff.list.length} 条` : ''}
    +
    ${ff.kinds.map(([k, n]) => `${esc(k)} ${fmt(n)}`).join('')}
    + ${ff.list.length ? `
      ${ff.list.map((c) => commitRow(c)).join('')}
    ` : '

    窗口内没有救火类提交。

    '} +
    `; +} +function commitRow(c, extra = '') { + const link = webLink('commit', c.sha); + const subj = link ? `${esc(c.subj)}` : `${esc(c.subj)}`; + return `
  • ${esc(c.date)}${extra}${subj}${c.author ? ` · ${esc(c.author)}` : ''}
  • `; +} + +// ---- tech tab ------------------------------------------------------------- +function langItems(t) { + const langs = t.languages.slice(0, 6); + const rest = t.languages.slice(6).reduce((s, l) => s + l.files, 0); + return [...langs.map((l, i) => ({ label: l.name, value: l.files, color: LANG_COLORS[i] })), ...(rest ? [{ label: '其他', value: rest, color: 'var(--heat-1)' }] : [])]; +} +function techTab(rep) { + const t = rep.tech; + if (!t) return '

    正在识别技术栈…

    '; + const items = langItems(t); + const total = items.reduce((s, x) => s + x.value, 0) || 1; + const pms = t.packageManagers.length ? `

    包管理

    ${t.packageManagers.map((p) => `${esc(p.name)}`).join('')}
    ` : ''; + const other = t.otherDeps.length ? `

    其他常用依赖(未归类,按被多少个 manifest 引用排序)

    ${t.otherDeps.map((d) => `${esc(d.name)}${d.n}`).join('')}
    ` : ''; + return `
    +

    技术栈

    语言按 HEAD 中的文件数统计;框架和工具来自 ${fmt(t.manifests.reduce((s, m) => s + m.n, 0))} 个 manifest 与配置文件,悬停或聚焦可看依据
    + ${items.length ? C.shareBar(items, { height: 14, label: '语言构成' }) : '

    没有识别到代码文件。

    '} +
      ${items.map((i) => `
    • ${esc(i.label)}${fmt(i.value)} · ${pct(i.value / total)}
    • `).join('')}
    +
    ${t.groups + .map((g) => `

    ${esc(g.category)}

    ${g.items.slice(0, 14).map((i) => `${esc(i.name)}${i.count > 1 ? `${i.count}` : ''}`).join('')}
    `) + .join('')}${pms}${other}
    + ${t.packages > 1 ? `

    共识别到 ${t.packages} 个 package.json。

    ` : ''} +
    `; +} + +// --------------------------------------------------------------------------- +// Code map tab +// --------------------------------------------------------------------------- +const LENSES = [ + ['heat', '热度', '所选时间范围内,这块里的文件一共被修改了多少次(与“代码热点”同一口径)。'], + ['bug', 'Bug 占比', '这些修改里,提交说明含 fix / bug / broken 的比例(与“Bug 磁铁”同一口径)。修改少于 5 次的块不着色。'], + ['owner', '负责人', '所选时间范围内在这块提交最多的人(非合并提交,不含机器人)。颜色越淡越是多人共有,第一名不到 25% 的标为“多人共有”。'], + ['age', '最近修改', '这块里最近一次提交距离仓库最新提交多久,看哪里还在演进、哪里已经稳定。不受时间范围影响。'], + ['loss', '知识流失', '这块全部历史提交中,来自近 3 个月没有任何提交的人的比例。比例高的地方,最熟悉它的人可能已经不在了。不受时间范围影响。'], + ['type', '文件类型', '按 HEAD 中的扩展名归类:颜色是占比最多的一类,块底部的细条是构成。没有说明的目录,可以靠它判断是代码、文档还是资源。'], +]; +const PLAYABLE = new Set(['heat', 'bug', 'owner']); +const RANGES = [['3', '近 3 个月'], ['6', '近 6 个月'], ['12', '近 12 个月'], ['all', '全部历史']]; +const TYPE_META = { + ts: ['TypeScript', 'hsl(210 65% 52%)'], + js: ['JavaScript', 'hsl(45 85% 50%)'], + py: ['Python', 'hsl(168 55% 40%)'], + go: ['Go', 'hsl(190 70% 46%)'], + rs: ['Rust', 'hsl(18 62% 46%)'], + jvm: ['Java / Kotlin', 'hsl(32 50% 50%)'], + c: ['C / C++', 'hsl(222 45% 58%)'], + swift: ['Swift', 'hsl(8 80% 58%)'], + rb: ['Ruby', 'hsl(352 62% 52%)'], + php: ['PHP', 'hsl(200 38% 40%)'], + dotnet: ['.NET', 'hsl(140 45% 42%)'], + style: ['样式', 'hsl(340 65% 62%)'], + markup: ['HTML / 模板', 'hsl(26 85% 58%)'], + shell: ['脚本', 'hsl(95 40% 44%)'], + doc: ['文档', 'hsl(205 18% 58%)'], + config: ['配置 / 数据', 'hsl(48 24% 52%)'], + asset: ['图片 / 字体 / 二进制', 'hsl(0 0% 50%)'], + other: ['其他', 'hsl(0 0% 36%)'], +}; +const OWNER_COLORS = ['#2f9e8f', '#f08a4b', '#3b8fd9', '#e5b72f', '#5bb85b', '#e5576f', '#22b8cf', '#b8875a']; +const AGE_LABELS = ['1 周内', '1 个月内', '3 个月内', '1 年内', '更早']; +const LOSS_LABELS = ['<20%', '20–40%', '40–60%', '60–80%', '≥80%']; +const NEUTRAL = 'var(--mcode-surface-muted)'; +const DAY_S = 86_400; + +const mp = { key: null, rep: null, place: null, max: 0, w: 0, view: null, error: null, loading: false, layout: null, frame: null, playing: false, timer: null, autoplay: false, sort: { key: 'size', dir: -1 } }; +const defaultRange = (rep) => ({ 'all time': 'all', '1 year ago': '12', '6 months ago': '6' })[rep?.vitals?.window] || '12'; +function mapOpts(rep = state.report) { + return { + focus: route.focus || '', + sel: route.sel || null, + lens: LENSES.some(([k]) => k === route.lens) ? route.lens : 'heat', + size: route.size === 'churn' ? 'churn' : 'files', + range: RANGES.some(([k]) => k === route.range) ? route.range : defaultRange(rep), + }; +} +function rememberMapPlace() { + const r = current(); + if (r && route.tab === 'map') state.mapPlace[r.id] = { focus: route.focus || null, sel: route.sel || null, lens: route.lens || null, size: route.size || null, range: route.range || null }; +} +const mapPlaceFor = (id) => ({ focus: null, sel: null, lens: null, size: null, range: null, ...state.mapPlace[id] }); +const mapHeight = (w) => Math.round(Math.max(340, Math.min(620, w * 0.62))); +const maxBlocksFor = (w, h) => Math.max(10, Math.min(44, Math.round((w * h) / 6500))); +const parentOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : ''); +const rangeText = (v) => (v.range === 'custom' ? '分析窗口内' : { 3: '近 3 个月', 6: '近 6 个月', 12: '近 12 个月', all: '全部历史上' }[v.range] || ''); +const ymdOfSec = (sec) => C.ymd(new Date(sec * 1000)); +function agoText(days) { + if (days < 1) return '当天'; + if (days < 30) return `${Math.round(days)} 天前`; + if (days < 365) return `${Math.max(1, Math.round(days / 30))} 个月前`; + return `${(days / 365).toFixed(1)} 年前`; +} +function quantiles(values) { + const s = values.filter((x) => x > 0).sort((a, b) => a - b); + const at = (p) => (s.length ? s[Math.min(s.length - 1, Math.floor(p * (s.length - 1)))] : 0); + return [at(0.3), at(0.6), at(0.85)]; +} +const levelOf = (q, n) => (!n ? 0 : n <= q[0] ? 1 : n <= q[1] ? 2 : n <= q[2] ? 3 : 4); +function webTree(path) { + const w = state.report?.web; + if (!w) return null; + const sep = w.provider === 'github' ? '' : '/-'; + return `${w.url}${sep}/tree/${state.report.head}/${path.split('/').map(encodeURIComponent).join('/')}`; +} + +// ---- colour helpers: pick dark or light label ink from the actual fill ---- +function rgbOf(c) { + if (colorCache.has(c)) return colorCache.get(c); + let s = String(c).trim(); + const v = s.match(/^var\((--[\w-]+)\)$/); + if (v) s = getComputedStyle(document.documentElement).getPropertyValue(v[1]).trim(); + let rgb = [128, 128, 128]; + let x; + if ((x = s.match(/^#([0-9a-f]{3,8})$/i))) { + let h = x[1]; + if (h.length <= 4) h = [...h.slice(0, 3)].map((ch) => ch + ch).join(''); + rgb = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); + } else if ((x = s.match(/^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/i))) { + const [h, sat, l] = [+x[1], +x[2] / 100, +x[3] / 100]; + const k = (n) => (n + h / 30) % 12; + const a = sat * Math.min(l, 1 - l); + rgb = [0, 8, 4].map((n) => Math.round((l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))) * 255)); + } else if ((x = s.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i))) rgb = [+x[1], +x[2], +x[3]]; + colorCache.set(c, rgb); + return rgb; +} +function inkFor(fill, opacity = 1) { + let rgb = rgbOf(fill); + if (opacity < 1) { + const bg = rgbOf('var(--mcode-surface)'); + rgb = rgb.map((v, i) => v * opacity + bg[i] * (1 - opacity)); + } + const t = (v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * t(rgb[0]) + 0.7152 * t(rgb[1]) + 0.0722 * t(rgb[2]) > 0.36 ? 'dark' : 'light'; +} + +// ---- names ---------------------------------------------------------------- +function blockTitle(v, b) { + if (b.kind === 'dir') return `${b.path}/`; + if (b.kind === 'other') return `${b.path ? `${b.path}/` : ''}${b.name}`; + return b.path; +} +function blockLabels(focus, b) { + if (b.kind === 'other') { + const n = b.name.match(/\d+/)?.[0] || ''; + return [b.name, `${n} 项`, `+${n}`]; + } + if (b.kind === 'file') return [b.name, b.name.replace(/\.[^.]+$/, '')]; + const rel = b.group && b.path.startsWith(`${b.group}/`) ? b.path.slice(b.group.length + 1) : focus && b.path.startsWith(`${focus}/`) ? b.path.slice(focus.length + 1) : b.path; + return C.dirLabels(rel); +} +const groupLabel = (focus, k) => (k === '' ? (focus ? '本层' : '根目录') : `${focus && k.startsWith(`${focus}/`) ? k.slice(focus.length + 1) : k}/`); +// Selection key in the URL: the path, except merged blocks ("其他 N 项"), whose path is their parent's +// (possibly the root, ''), so they get "/*". +const selKey = (b) => (b.kind === 'other' ? `${b.path ? `${b.path}/` : ''}*` : b.path); +const within = (p, q) => q === p || q.startsWith(`${p}/`); // q is p or inside it +function selIndex(v, sel) { + if (!v || !sel) return -1; + const i = v.blocks.findIndex((b) => selKey(b) === sel); + if (i >= 0) return i; + // A path inside a block (coming back up a level, or a file picked on the overview): select its block. + const j = v.blocks.findIndex((b) => b.kind === 'dir' && sel.startsWith(`${b.path}/`)); + if (j >= 0) return j; + // The path is split into several blocks on this level (typically the directory just left with + // Backspace): no single block stands for it, so select none rather than a neighbour. + if (v.blocks.some((b) => b.path && within(sel, b.path))) return -1; + // Otherwise it was folded into a merged block: the deepest one whose level holds it, provided the + // child on the way to it has no blocks of its own there. + let best = -1; + v.blocks.forEach((b, k) => { + if (b.kind !== 'other' || (b.path && !sel.startsWith(`${b.path}/`))) return; + const child = b.path ? `${b.path}/${sel.slice(b.path.length + 1).split('/')[0]}` : sel.split('/')[0]; + if (v.blocks.some((o) => o.path && within(child, o.path))) return; + if (best < 0 || b.path.length > v.blocks[best].path.length) best = k; + }); + return best; +} + +// ---- tab chrome ------------------------------------------------------------- +function crumbsHtml(focus) { + const r = current(); + const segs = focus ? focus.split('/') : []; + const items = [['', r ? r.name.split('/').pop() : '根目录'], ...segs.map((s, i) => [segs.slice(0, i + 1).join('/'), s])]; + return items + .map(([p, label], i) => + i === items.length - 1 + ? `${esc(label)}` + : `${esc(label)}`, + ) + .join(''); +} +const segBtns = (action, items, cur, label) => `
    ${items.map(([k, l]) => ``).join('')}
    `; +function mapTab(rep) { + const o = mapOpts(rep); + return `
    +
    +

    代码地图 ${explainFor('mapview', rep)}

    +
    ${segBtns('map-size', [['files', '按文件数'], ['churn', '按修改次数']], o.size, '面积代表')}${segBtns('map-range', [['3', '3 个月'], ['6', '6 个月'], ['12', '12 个月'], ['all', '全部']], o.range, '时间范围')}
    +
    +
    ${LENSES.map(([k, l]) => ``).join('')}
    +
    +
    +
    +
    +
    +

    +
    + +
    +
    +

    区域列表

    与地图同一批区域,可排序;点击区域名在地图上选中
    +
    +
    +
    `; +} +function syncMapControls() { + const o = mapOpts(); + for (const [action, cur] of [['map-size', o.size], ['map-range', o.range], ['map-lens', o.lens]]) { + for (const b of document.querySelectorAll(`[data-action="${action}"]`)) b.setAttribute('aria-pressed', String(b.dataset.v === cur)); + } + // The explain popover's command embeds the time range. + const h = $('#h-maptab'); + if (h && state.report && !h.querySelector('[aria-expanded="true"]')) h.innerHTML = `代码地图 ${explainFor('mapview', state.report)}`; +} +// Links rendered with the map's settings in their URL (the current tab, breadcrumbs) would bring +// back old settings after an in-place change, so rebuild them from the current route. +function syncMapLinks() { + const tab = main.querySelector('.tabs-bar a[aria-current="page"]'); + if (tab) tab.setAttribute('href', href({ tab: 'map', filter: null, file: null, person: null })); + const crumbs = $('nav.crumbs'); + if (crumbs) crumbs.innerHTML = crumbsHtml(route.focus); +} + +// ---- data ------------------------------------------------------------------- +let mapRO = null; +function observeMap(box) { + if (mapRO?._box === box) return; + mapRO?.disconnect(); + mapRO = new ResizeObserver(() => + requestAnimationFrame(() => { + if (box.isConnected && Math.abs(Math.round(box.clientWidth) - mp.w) >= 24) mountMap(); + }), + ); + mapRO._box = box; + mapRO.observe(box); +} +function prepareView(v) { + v.qHeat = quantiles(v.blocks.map((b) => b.churn)); + v.qHeatM = quantiles(v.blocks.flatMap((b) => b.m)); + const score = new Map(); + for (const b of v.blocks) { + if (b.owners[0]) score.set(b.owners[0][0], (score.get(b.owners[0][0]) || 0) + 4); + for (const a of b.mo) if (a >= 0) score.set(a, (score.get(a) || 0) + 1); + } + v.palette = new Map([...score].sort((a, b) => b[1] - a[1]).slice(0, OWNER_COLORS.length).map(([a], i) => [a, OWNER_COLORS[i]])); +} +async function mountMap() { + const box = $('#map-canvas'); + const r = current(); + const rep = state.report; + if (!box || !r || !rep) return; + observeMap(box); + const o = mapOpts(rep); + const w = Math.round(box.clientWidth); + const h = mapHeight(w); + box.style.height = `${h}px`; + const max = maxBlocksFor(w, h); + const key = [r.id, state.identity, rep.head, rep.generatedAt, o.focus, o.size, o.range].join('|'); + const place = [r.id, state.identity, o.focus].join('|'); + // A reloaded report object (e.g. directory descriptions filled in by the tech stage after the + // history report was published) always refetches, even when head and generatedAt are unchanged. + if (mp.key === key && mp.rep === rep && Math.abs(max - mp.max) < 6 && (mp.view || mp.error || mp.loading)) return drawMap(); + if (mp.key !== key) { + stopPlay(); + mp.frame = null; + } + // Same place, new numbers (area, range, canvas size): keep the old map on screen until the new one lands. + const keep = Boolean(mp.view && mp.place === place && box.querySelector('svg')); + mp.key = key; + mp.rep = rep; + mp.max = max; + mp.error = null; + mp.loading = true; + if (!keep) mp.view = null; + drawMap(); + let v = null; + let err = null; + try { + const q = new URLSearchParams({ focus: o.focus, size: o.size, range: o.range, identity: state.identity, max: String(max) }); + v = await api(`/api/repos/${encodeURIComponent(r.id)}/map?${q}`); + } catch (e) { + err = e; + } + if (mp.key !== key || mp.max !== max || mp.rep !== rep) return; + mp.loading = false; + if (v) { + prepareView(v); + mp.view = v; + mp.place = place; + } else { + mp.view = null; + mp.error = err; + } + drawMap(); + if (mp.autoplay && v) startPlay(); + mp.autoplay = false; +} + +// ---- drawing ---------------------------------------------------------------- +function drawMap() { + const box = $('#map-canvas'); + if (!box) return; + const v = mp.view; + mp.w = Math.round(box.clientWidth); + const busy = mp.loading && Boolean(v); + box.closest('.map-main')?.classList.toggle('is-loading', busy); + box.setAttribute('aria-busy', String(mp.loading)); + if (mp.error) { + mp.refocus = false; + box.innerHTML = `
    ${ICON.alert}

    代码地图加载失败

    ${esc(mp.error.message)}

    ${route.focus ? `回到根目录` : ''}

    `; + for (const id of ['#map-legend', '#map-time', '#map-note', '#map-table']) if ($(id)) $(id).innerHTML = ''; + $('#map-detail').innerHTML = '

    没有可显示的区域。

    '; + return; + } + if (!v) { + box.innerHTML = '
    '; + return; + } + mp.layout = null; + if (!v.blocks.length) { + box.innerHTML = `
    ${ICON.map}

    ${v.size === 'churn' ? `${rangeText(v)}这里没有修改` : '这里没有文件'}

    ${v.size === 'churn' ? '面积按修改次数时,没有修改的区域不会出现。可以切换到“按文件数”或更长的时间范围。' : ''}

    `; + } else { + mp.layout = C.mapLayout(v.blocks, { width: mp.w, height: mapHeight(mp.w), value: (b) => (v.size === 'churn' ? b.churn : b.files) }); + mp.cellOf = new Map(mp.layout.cells.map((c) => [c.i, c])); + box.innerHTML = C.mapSvg(mp.layout, { blocks: v.blocks, groupLabel: (k) => groupLabel(v.focus, k), groupValue: (n) => `${C.fmtK(n)} ${v.size === 'churn' ? '次' : '个文件'}`, nameOf: (b) => blockLabels(v.focus, b), label: `代码地图:${v.focus ? `${v.focus}/` : '仓库根目录'},${v.blocks.length} 个区域。方向键在区域间移动,空格或单击选中,回车或双击进入` }); + } + paintMap(); + renderMapLegend(); + renderMapTime(); + renderMapDetail(); + renderMapTable(); + renderMapNote(); + // After drilling in or out from the map, keep the keyboard on the map. + if (mp.refocus && !mp.loading) { + mp.refocus = false; + box.querySelector('.tm-cell[tabindex="0"]')?.focus({ preventScroll: true }); + } +} +function lensPaint(v, b, lens, f) { + const range = f == null ? rangeText(v) : `${v.months[f]} `; + const none = (val, tip) => ({ fill: NEUTRAL, ink: inkFor(NEUTRAL), val, tip: [tip], aria: tip }); + switch (lens) { + case 'bug': { + const n = f == null ? b.churn : b.m[f]; + const k = f == null ? b.bug : b.mb[f]; + if (n < (f == null ? 5 : 3)) return none(n ? '样本少' : '无修改', `${range}修改 ${fmt(n)} 次,太少,不计算占比`); + const ratio = k / n; + const fill = `var(--risk-${C.riskLevel(ratio)})`; + return { fill, ink: inkFor(fill), val: pct(ratio), tip: [`${range}修改 ${fmt(n)} 次,其中修 bug ${fmt(k)} 次(${pct(ratio)})`], aria: `修 bug 占 ${pct(ratio)}` }; + } + case 'owner': { + if (f != null) { + const a = b.mo[f]; + if (a < 0) return none('无人修改', `${range}没有人修改`); + const fill = v.palette.get(a) || 'var(--k-other)'; + return { fill, opacity: 0.92, ink: inkFor(fill, 0.92), val: v.authors[a], tip: [`${range}提交最多:${v.authors[a]}`], aria: `本月提交最多 ${v.authors[a]}` }; + } + const top = b.owners[0]; + if (!top) return none('无人修改', `${range}没有人修改`); + const share = top[1] / b.ownerSum; + const list = b.owners.slice(0, 3).map(([a, n]) => `${v.authors[a]} ${pct(n / b.ownerSum)}`).join('、'); + if (share < 0.25) return { ...none('多人共有', `${range}共 ${fmt(b.ownerSum)} 次提交,没有人超过 25%:${list}`), aria: '多人共有' }; + const fill = v.palette.get(top[0]) || 'var(--k-other)'; + const opacity = Math.min(1, 0.4 + share); + return { fill, opacity, ink: inkFor(fill, opacity), val: `${v.authors[top[0]]} ${pct(share)}`, tip: [`${range}的提交:${list}`], aria: `主要负责人 ${v.authors[top[0]]},占 ${pct(share)}` }; + } + case 'age': { + if (!b.last) return none('无记录', '历史里没有这块的提交'); + const days = Math.max(0, (v.latest - b.last) / DAY_S); + const fill = `var(--age-${days <= 7 ? 0 : days <= 30 ? 1 : days <= 90 ? 2 : days <= 365 ? 3 : 4})`; + return { fill, ink: inkFor(fill), val: agoText(days), tip: [`最近一次修改:${ymdOfSec(b.last)}(相对仓库最新提交 ${agoText(days)})`], aria: `最近一次修改在 ${agoText(days)}` }; + } + case 'loss': { + if (b.loss == null) return none('无记录', '没有可统计的提交'); + const fill = `var(--loss-${b.loss < 0.2 ? 0 : b.loss < 0.4 ? 1 : b.loss < 0.6 ? 2 : b.loss < 0.8 ? 3 : 4})`; + const who = b.lossTop.map((a) => v.authors[a]).join('、'); + return { fill, ink: inkFor(fill), val: pct(b.loss), tip: [`${pct(b.loss)} 的历史提交来自近 3 个月没有提交的人${who ? `(主要是 ${who})` : ''}`], aria: `知识流失 ${pct(b.loss)}` }; + } + case 'type': { + const t = b.types[0]; + if (!t) return none('—', '没有文件'); + const [label, fill] = TYPE_META[t[0]] || TYPE_META.other; + const total = b.types.reduce((s, x) => s + x[1], 0); + return { fill, ink: inkFor(fill), val: `${label} ${pct(t[1] / total)}`, tip: [`构成:${b.types.slice(0, 4).map(([k, n]) => `${(TYPE_META[k] || TYPE_META.other)[0]} ${pct(n / total)}`).join('、')}`], aria: `主要是 ${label}` }; + } + default: { + const n = f == null ? b.churn : b.m[f]; + const fill = `var(--heat-${levelOf(f == null ? v.qHeat : v.qHeatM, n)})`; + const avg = f == null && n && b.files > 1 ? `,平均每个文件 ${(n / b.files).toFixed(1)} 次` : ''; + return { fill, ink: inkFor(fill), val: n ? `${C.fmtK(n)} 次` : '无修改', tip: [`${range}修改 ${fmt(n)} 次${avg}`], aria: n ? `修改 ${n} 次` : '没有修改' }; + } + } +} +function paintMap() { + const v = mp.view; + const svg = $('#map-canvas svg'); + if (!v || !svg) return; + const o = mapOpts(); + const f = PLAYABLE.has(o.lens) ? mp.frame : null; + const selIdx = selIndex(v, o.sel); + const partners = new Map(); + if (selIdx >= 0) for (const c of v.blocks[selIdx].couple) if (c.block >= 0) partners.set(c.block, c); + for (const g of svg.querySelectorAll('.tm-cell')) { + const i = Number(g.dataset.i); + const b = v.blocks[i]; + const L = lensPaint(v, b, o.lens, f); + const rect = g.querySelector('.tm-r'); + rect.style.fill = L.fill; + rect.style.fillOpacity = L.opacity == null ? '' : String(L.opacity); + g.classList.toggle('ink-light', L.ink === 'light'); + g.classList.toggle('sel', i === selIdx); + g.classList.toggle('partner', partners.has(i)); + g.classList.toggle('dim', selIdx >= 0 && partners.size > 0 && i !== selIdx && !partners.has(i)); + if (i === selIdx) g.setAttribute('aria-current', 'true'); + else g.removeAttribute('aria-current'); + const val = g.querySelector('.tm-val'); + if (val) val.textContent = C.fitLabel([L.val], Number(val.dataset.w) || 0, 10); + const mix = g.querySelector('.tm-mix'); + const cell = mp.cellOf?.get(i); + if (mix) mix.innerHTML = o.lens === 'type' && cell && cell.w > 24 && cell.h > 18 ? typeStrip(b, cell) : ''; + const p = partners.get(i); + const head = [blockTitle(v, b), b.kind === 'file' ? '' : `${fmt(b.files)} 个文件`, b.desc ? b.desc.text.slice(0, 60) + (b.desc.text.length > 60 ? '…' : '') : '', b.risk ? `含 ${b.risk} 个高风险文件` : ''].filter(Boolean); + g.dataset.tip = [...head, ...L.tip, p ? `改选中区域的提交里 ${pct(p.degree)} 也改了这里(${p.shared} 次)` : '', b.kind === 'file' ? '双击或回车:查看文件历史' : b.zoom ? '双击或回车:进入这块' : ''].filter(Boolean).join('\n'); + g.setAttribute('aria-label', `${blockTitle(v, b)},${b.kind === 'file' ? '' : `${b.files} 个文件,`}${L.aria}${b.risk ? `,含 ${b.risk} 个高风险文件` : ''}`); + } + drawLines(svg, v, selIdx); + // Tab lands on the selected tile unless the keyboard is already inside the map; with a selection + // that is split on this level (see selIndex), on its largest piece, so Backspace keeps your place. + const land = selIdx >= 0 ? selIdx : o.sel ? v.blocks.findIndex((b) => b.path && within(o.sel, b.path)) : -1; + if (land >= 0 && !svg.contains(document.activeElement)) setRoving(svg, svg.querySelector(`.tm-cell[data-i="${land}"]`)); +} +function typeStrip(b, c) { + const total = b.types.reduce((s, x) => s + x[1], 0) || 1; + let x = c.x + 1; + const w = c.w - 2; + return b.types + .slice(0, 5) + .map(([k, n]) => { + const ww = (w * n) / total; + const r = ``; + x += ww; + return r; + }) + .join(''); +} +function drawLines(svg, v, selIdx) { + const layer = svg.querySelector('.tm-lines'); + if (!layer) return; + const a = selIdx >= 0 ? mp.cellOf?.get(selIdx) : null; + if (!a) { + layer.innerHTML = ''; + return; + } + const ax = a.x + a.w / 2; + const ay = a.y + a.h / 2; + let out = ''; + for (const c of v.blocks[selIdx].couple.slice(0, 6)) { + const b = c.block >= 0 ? mp.cellOf.get(c.block) : null; + if (!b) continue; + const bx = b.x + b.w / 2; + const by = b.y + b.h / 2; + const len = Math.hypot(bx - ax, by - ay) || 1; + const bend = Math.min(60, len * 0.18); + const cx = (ax + bx) / 2 - ((by - ay) / len) * bend; + const cy = (ay + by) / 2 + ((bx - ax) / len) * bend; + out += `${Math.round(c.degree * 100)}%`; + } + layer.innerHTML = `${out}`; +} + +// ---- keyboard: one Tab stop per map, arrow keys move between tiles ------------ +function setRoving(svg, cell) { + if (!svg || !cell) return; + for (const g of svg.querySelectorAll('.tm-cell[tabindex="0"]')) if (g !== cell) g.setAttribute('tabindex', '-1'); + cell.setAttribute('tabindex', '0'); +} +function cellBox(g) { + const r = g.querySelector('.tm-r'); + const [x, y, w, h] = ['x', 'y', 'width', 'height'].map((k) => Number(r.getAttribute(k))); + return { x, y, w, h }; +} +/** Nearest tile in the arrow's direction: tiles sharing an edge span first, then the closest one. */ +function moveCellFocus(cell, key) { + const svg = cell.closest('svg'); + const cells = [...svg.querySelectorAll('.tm-cell')]; + let next = key === 'Home' ? cells[0] : key === 'End' ? cells.at(-1) : null; + if (!next) { + const a = cellBox(cell); + const horiz = key === 'ArrowLeft' || key === 'ArrowRight'; + const fwd = key === 'ArrowRight' || key === 'ArrowDown'; + let best = Infinity; + for (const g of cells) { + if (g === cell) continue; + const b = cellBox(g); + const gap = horiz ? (fwd ? b.x - (a.x + a.w) : a.x - (b.x + b.w)) : fwd ? b.y - (a.y + a.h) : a.y - (b.y + b.h); + if (gap < -2) continue; + const overlap = horiz ? Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) : Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x); + const cross = horiz ? Math.abs(b.y + b.h / 2 - (a.y + a.h / 2)) : Math.abs(b.x + b.w / 2 - (a.x + a.w / 2)); + const score = (overlap > 0 ? 0 : 1e6 - overlap * 10) + Math.max(0, gap) * 10 + cross; + if (score < best) { + best = score; + next = g; + } + } + } + if (!next) return; + setRoving(svg, next); + next.focus(); +} +document.addEventListener('focusin', (e) => { + const g = e.target.closest?.('.tm-cell'); + if (g) setRoving(g.closest('svg'), g); +}); + +// ---- legend, timeline, note -------------------------------------------------- +function renderMapLegend() { + const el = $('#map-legend'); + const v = mp.view; + if (!el) return; + if (!v) return void (el.innerHTML = ''); + const o = mapOpts(); + const f = PLAYABLE.has(o.lens) ? mp.frame : null; + const key = (fill, label) => `${label}`; + let keys = ''; + if (o.lens === 'heat') { + const q = f == null ? v.qHeat : v.qHeatM; + keys = `${f == null ? '少' : '当月少'} ${[0, 1, 2, 3, 4].map((l) => ``).join('')} 多${q[2] ? `≤${q[0]} / ≤${q[1]} / ≤${q[2]} / >${q[2]} 次` : ''}`; + } else if (o.lens === 'bug') keys = C.riskLegend() + key(NEUTRAL, '修改太少'); + else if (o.lens === 'owner') keys = [...v.palette].map(([a, c]) => key(c, `${esc(v.authors[a])}`)).join('') + key('var(--k-other)', '其他人') + key(NEUTRAL, '多人共有 / 无人修改'); + else if (o.lens === 'age') keys = AGE_LABELS.map((l, i) => key(`var(--age-${i})`, l)).join(''); + else if (o.lens === 'loss') keys = LOSS_LABELS.map((l, i) => key(`var(--loss-${i})`, l)).join(''); + else { + const present = new Map(); + for (const b of v.blocks) for (const [k, n] of b.types) present.set(k, (present.get(k) || 0) + n); + keys = [...present].sort((a, b) => b[1] - a[1]).slice(0, 9).map(([k]) => key((TYPE_META[k] || TYPE_META.other)[1], (TYPE_META[k] || TYPE_META.other)[0])).join(''); + } + const extra = `${v.blocks.some((b) => b.risk) ? '含高风险文件' : ''}${v.blocks.some((b) => b.kind === 'other') ? '合并的小目录' : ''}`; + el.innerHTML = `

    ${esc(LENSES.find(([k]) => k === o.lens)[2])}

    `; +} +function renderMapTime() { + const el = $('#map-time'); + const v = mp.view; + if (!el) return; + if (!v || !v.blocks.length) return void (el.innerHTML = ''); + const o = mapOpts(); + const can = PLAYABLE.has(o.lens) && v.months.length > 1; + const tot = v.focusInfo.m; + const max = Math.max(1, ...tot); + const part = tot.map((_, i) => Boolean(frameSpan(v, i))); + const hint = !PLAYABLE.has(o.lens) ? '逐月播放适用于热度、Bug 占比、负责人三种着色' : v.size === 'churn' ? '播放时会改为按文件数排布,让布局保持不动' : `${v.months.length} 个月,结构不动、颜色逐月变化`; + el.innerHTML = ` +

    XIj~b*iZ#yNNCJe%j#<BSPl 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 literal 0 HcmV?d00001 diff --git a/plugins/yujiachen-y/repo-xray/miniapp/client/app.js b/plugins/yujiachen-y/repo-xray/miniapp/client/app.js new file mode 100644 index 0000000..e8ef81a --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/client/app.js @@ -0,0 +1,2780 @@ +import * as C from './charts.js'; + +const { esc, fmt } = C; +const $ = (s, r = document) => r.querySelector(s); +const main = $('#main'); + +const ICON = { + github: '', + gitlab: '', + other: '', + local: '', + sync: '', + ext: '', + copy: '', + code: '', + close: '', + alert: '', + ok: '', + info: '', + search: '', + file: '', + user: '', + bolt: '', + chat: '', + download: '', + arrow: '', + map: '', + cal: '', + up: '', + play: '', + pause: '', +}; +const TREND = { rising: '上升', declining: '下降', erratic: '波动', stable: '平稳', insufficient: '数据不足' }; +const WINDOW_ZH = { '6 months ago': '近 6 个月', '1 year ago': '近 1 年', 'all time': '全部历史' }; +const TREND_GLYPH = { rising: '↗', declining: '↘', erratic: '↯', stable: '→', insufficient: '·' }; +const TREND_COLOR = { rising: 'var(--mcode-success)', declining: 'var(--mcode-danger)', erratic: 'var(--mcode-warning)', stable: 'var(--k-code)', insufficient: 'var(--k-other)' }; +const reducedMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches; +const TIER = { small: '小型', medium: '中型', large: '大型' }; +const STAGE = { queued: '排队', sync: '同步仓库', analyze: '分析提交历史', tech: '识别技术栈与目录说明' }; +const LANG_COLORS = ['var(--k-code)', 'var(--k-mr)', 'var(--k-merge)', 'var(--k-review)', 'var(--k-comment)', 'var(--k-issue)', 'var(--k-other)']; +const TABS = [ + ['overview', '概览'], + ['map', '代码地图'], + ['risk', '风险文件'], + ['people', '贡献者'], + ['momentum', '节奏与救火'], + ['tech', '技术栈'], +]; +const TAB_OF_ANCHOR = { risk: 'risk', people: 'people', momentum: 'momentum', fire: 'momentum', vitals: 'overview' }; + +const state = { + repos: [], + settings: {}, + report: null, + reportKey: null, + error: null, + momentumAll: false, + contribFilter: '', + contribAll: false, + identity: localStorage.getItem('rr-identity') === 'raw' ? 'raw' : 'merged', + suggest: null, + pendingRemoval: new Map(), + mapPlace: {}, +}; +const colorCache = new Map(); +let route = readRoute(); + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- +async function api(path, opts = {}) { + const res = await fetch(path, { method: opts.method || 'GET', headers: opts.body ? { 'content-type': 'application/json' } : {}, body: opts.body ? JSON.stringify(opts.body) : undefined }); + let data = null; + try { + data = await res.json(); + } catch {} + if (!res.ok) throw Object.assign(new Error(data?.error?.message || (res.status === 401 ? '页面会话已过期,请刷新页面' : `请求失败(${res.status})`)), { code: data?.error?.code, status: res.status }); + return data; +} +const repoById = (id) => state.repos.find((r) => r.id === id) || null; +const current = () => (route.repo ? repoById(route.repo) : null); +const dtf = new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +function relTime(ts) { + if (!ts) return '从未'; + const d = Date.now() - ts; + if (d < 60_000) return '刚刚'; + if (d < 3_600_000) return `${Math.floor(d / 60_000)} 分钟前`; + if (d < 86_400_000) return `${Math.floor(d / 3_600_000)} 小时前`; + if (d < 30 * 86_400_000) return `${Math.floor(d / 86_400_000)} 天前`; + return dtf.format(ts); +} +function relDay(ymd) { + if (!ymd) return '–'; + const d = Math.round((new Date().setHours(0, 0, 0, 0) - C.parseYmd(ymd)) / 86_400_000); + if (d <= 0) return '今天'; + if (d === 1) return '昨天'; + if (d < 30) return `${d} 天前`; + if (d < 365) return `${Math.floor(d / 30)} 个月前`; + return `${Math.floor(d / 365)} 年前`; +} +const pct = (x, d = 0) => `${(x * 100).toFixed(d)}%`; +const secs = (ms) => (ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`); + +function toast(msg, action, { ok = false } = {}) { + const t = $('#toast'); + t.innerHTML = `${ok ? `` : ''}${esc(msg)}${action ? `` : ''}`; + t.hidden = false; + t.style.animation = 'none'; + void t.offsetWidth; + t.style.animation = ''; + if (action) t.querySelector('button').onclick = () => { + t.hidden = true; + action.run(); + }; + clearTimeout(toast.t); + toast.t = setTimeout(() => (t.hidden = true), action?.ms || 2800); +} +function flashCopied(el) { + if (!el?.isConnected) return; + const svg = el.querySelector('svg'); + if (svg && !el._icon) el._icon = svg.outerHTML; + if (svg) svg.outerHTML = ICON.ok.replace(' { + el.classList.remove('copied'); + const cur = el.querySelector('svg'); + if (cur && el._icon) cur.outerHTML = el._icon; + el._icon = null; + }, 1300); +} +async function copyText(text, label = '已复制', el = null) { + try { + await navigator.clipboard.writeText(text); + } catch { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.append(ta); + ta.select(); + document.execCommand('copy'); + ta.remove(); + } + flashCopied(el); + toast(label, null, { ok: true }); +} +function pathHtml(p, { link = true, baseFirst = true } = {}) { + const i = p.lastIndexOf('/'); + const dir = esc(i >= 0 ? p.slice(0, i + 1) : ''); + const base = esc(i >= 0 ? p.slice(i + 1) : p); + const inner = baseFirst + ? `${base} ${dir}` + : `${dir}${base}`; + return link ? `${inner}` : inner; +} +function personLink(name, { bot = false } = {}) { + return `${esc(name)}${bot ? ' 机器人' : ''}`; +} +function avatar(name, cls = '', color = null) { + const initial = /^[\x00-\x7f]/.test(name) ? name.slice(0, 2).toUpperCase() : name.slice(0, 1); + return ``; +} +function webLink(kind, ref) { + const w = state.report?.web; + if (!w) return null; + const sep = w.provider === 'github' ? '' : '/-'; + if (kind === 'commit') return `${w.url}${sep}/commit/${ref}`; + return `${w.url}${sep}/blob/${state.report.head}/${ref.split('/').map(encodeURIComponent).join('/')}`; +} +function editorLink(path) { + const r = current(); + if (!r?.localDir) return null; + return `vscode://file${encodeURI(`${r.localDir}/${path}`)}`; +} + +// --------------------------------------------------------------------------- +// Routing: the URL is the state +// --------------------------------------------------------------------------- +function readRoute() { + const q = new URLSearchParams(location.search); + const tab = q.get('tab'); + return { + repo: q.get('repo') || null, + tab: TABS.some(([t]) => t === tab) ? tab : 'overview', + file: q.get('file') || null, + person: q.get('person') || null, + filter: q.get('filter') || null, + focus: q.get('focus') || null, + sel: q.get('sel') || null, + lens: q.get('lens') || null, + size: q.get('size') || null, + range: q.get('range') || null, + }; +} +function href(patch) { + const next = { ...route, ...patch }; + const q = new URLSearchParams(); + if (next.repo) q.set('repo', next.repo); + if (next.repo && next.tab && next.tab !== 'overview') q.set('tab', next.tab); + if (next.filter) q.set('filter', next.filter); + if (next.tab === 'map') for (const k of ['focus', 'sel', 'lens', 'size', 'range']) if (next[k]) q.set(k, next[k]); + if (next.file) q.set('file', next.file); + if (next.person) q.set('person', next.person); + const s = q.toString(); + return `/dashboard${s ? `?${s}` : ''}`; +} +function go(patch, { replace = false } = {}) { + const url = href(patch); + if (url === location.pathname + location.search) return applyRoute(); + history[replace ? 'replaceState' : 'pushState']({ drawer: Boolean(patch.file || patch.person) }, '', url); + applyRoute(); +} +window.addEventListener('popstate', () => applyRoute()); + +let lastRepo = undefined; +async function applyRoute() { + const prev = route; + route = readRoute(); + if (route.repo && !repoById(route.repo) && state.repos.length) route = { ...route, repo: null }; + // The element under a visible tooltip is about to be replaced; don't leave its tip behind. + if (prev.repo !== route.repo || prev.tab !== route.tab || prev.focus !== route.focus) hideTip(); + if (route.repo) localStorage.setItem('rr-repo', route.repo); + renderSidebar(); + if (route.repo !== lastRepo) { + lastRepo = route.repo; + state.contribFilter = ''; + state.contribAll = false; + await loadReport(); + } else if (prev.tab !== route.tab || prev.filter !== route.filter || !$('#report')) { + if (prev.tab === 'map' && route.tab !== 'map') stopPlay(); + renderRepo(); + if (prev.tab !== route.tab) window.scrollTo({ top: 0 }); + } else if (route.tab === 'map' && prev.focus !== route.focus) { + renderRepo(); + } else if (route.tab === 'map' && ['size', 'range', 'lens', 'sel'].some((k) => prev[k] !== route[k])) { + // Same place, other settings (also back / forward between them): update in place; the + // old map stays on screen until the new numbers land. + syncMapControls(); + syncMapLinks(); + if (prev.size !== route.size || prev.range !== route.range) mountMap(); + else { + paintMap(); + renderMapLegend(); + renderMapTime(); + renderMapDetail(); + markTableSel(); + } + } + rememberMapPlace(); + syncDrawer(); + updateTitle(); +} +function updateTitle() { + const r = current(); + const tab = TABS.find(([t]) => t === route.tab)?.[1]; + const where = route.tab === 'map' && route.focus ? ` · ${route.focus}/` : ''; + document.title = r ? `${r.job?.running ? '分析中… ' : ''}${r.name}${route.tab !== 'overview' ? ` · ${tab}` : ''}${where} · 仓库透视` : '仓库透视'; +} + +// --------------------------------------------------------------------------- +// Theme +// --------------------------------------------------------------------------- +const THEMES = ['system', 'light', 'dark']; +function applyTheme() { + const pref = localStorage.getItem('rr-theme') || 'system'; + const dark = pref === 'dark' || (pref === 'system' && matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.dataset.theme = dark ? 'dark' : 'light'; + $('meta[name="theme-color"]')?.setAttribute('content', dark ? '#0a0a0b' : '#fafafa'); + const b = $('#theme-btn'); + if (b) b.lastChild.textContent = { system: ' 主题:跟随系统', light: ' 主题:浅色', dark: ' 主题:深色' }[pref]; + colorCache.clear(); +} +function repaintForTheme() { + applyTheme(); + // Label ink on the maps is picked from the resolved fill colour, so repaint them. + if (current() && $('#report')) { + if ($('#map-canvas svg')) paintMap(); + if ($('#codemap svg')) mountThumb(state.report); + } +} +function cycleTheme() { + const cur = localStorage.getItem('rr-theme') || 'system'; + localStorage.setItem('rr-theme', THEMES[(THEMES.indexOf(cur) + 1) % 3]); + repaintForTheme(); +} +matchMedia('(prefers-color-scheme: dark)').addEventListener('change', repaintForTheme); + +// --------------------------------------------------------------------------- +// Sidebar +// --------------------------------------------------------------------------- +const visibleRepos = () => state.repos.filter((r) => !state.pendingRemoval.has(r.id)); +function renderSidebar() { + const list = visibleRepos(); + $('#repo-list').innerHTML = list.length + ? list + .map((r) => { + const running = r.job?.running; + const err = !running && r.lastSync?.error && (!r.lastSync.at || r.lastSync.errorAt > r.lastSync.at); + const s = r.summary; + const spark = s?.spark ? `${C.sparkline(s.spark, { w: 44, h: 12, color: TREND_COLOR[s.trend] || 'var(--k-code)' })}` : ''; + const sub = running + ? `分析中` + : err + ? '分析失败' + : s + ? `${spark}${relDay(s.latest)}` + : '尚未分析'; + const badge = !running && !err && s ? `高风险文件 ${s.risk}` : ''; + return `

    +
    +
    + + ${hint}${part.some(Boolean) ? ' · 虚线框的柱子是不完整的月份' : ''}`; + syncMapTime(); +} +// The first frame usually starts mid-month (the range is counted back from today, or the history +// begins mid-month) and the last one is still running, so their lower bars are not a slowdown; name +// the days they actually cover. +function frameSpan(v, f) { + const parts = []; + const start = v.start ?? v.since; + if (f === 0 && start != null) { + const s = ymdOfSec(start); + if (s.slice(0, 7) === v.months[0] && !s.endsWith('-01')) parts.push(`${s.slice(5)} 起`); + } + if (f === v.months.length - 1 && v.latest) { + const d = new Date(v.latest * 1000); + if (d.getDate() < new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()) parts.push(`截至 ${ymdOfSec(v.latest).slice(5)}`); + } + return parts.join(','); +} +function syncMapTime() { + const v = mp.view; + const btn = $('#map-time .play-btn'); + if (!v || !btn) return; + const o = mapOpts(); + const f = PLAYABLE.has(o.lens) ? mp.frame : null; + const M = v.months.length; + btn.innerHTML = `${mp.playing ? ICON.pause : ICON.play}${mp.playing ? '暂停' : f != null && f < M - 1 ? '继续播放' : '逐月播放'}`; + btn.setAttribute('aria-pressed', String(mp.playing)); + const input = $('#map-month'); + const span = f == null ? '' : frameSpan(v, f); + input.value = String(f ?? M - 1); + input.setAttribute('aria-valuetext', f == null ? `${rangeText(v)}汇总` : `${v.months[f]}${span ? `(${span})` : ''}`); + $('#mt-month').textContent = f == null ? `${v.months[0]} → ${v.months[M - 1]}` : v.months[f]; + $('#mt-sub').textContent = f == null ? `${rangeText(v)}汇总 · ${fmt(v.totals.churn)} 次修改` : `${span ? `${span} · ` : '当月 '}${fmt(v.focusInfo.m[f])} 次修改`; + $('#mt-clear').hidden = f == null; + $('#map-time').classList.toggle('framed', f != null); + [...$('#map-time .mt-bars').children].forEach((bar, i) => bar.classList.toggle('on', i === f)); +} +function renderMapNote() { + const el = $('#map-note'); + const v = mp.view; + if (!el || !v) return; + const parts = [`${v.size === 'churn' ? `面积:${rangeText(v)}的修改次数` : '面积:HEAD 中的文件数'} · ${v.blocks.length} 个区域`]; + if (v.totals.delChurn) parts.push(`另有 ${fmt(v.totals.delChurn)} 次修改落在已删除、且所在目录也不在了的路径上,没有画进图里`); + if (v.process.length) parts.push(`流程文件 ${v.process.map(esc).join('、')} 几乎随每次修改一起改,不参与连线`); + parts.push('单击选中 · 双击或回车进入 · Backspace 返回上一层 · 方向键移动'); + el.innerHTML = parts.join(' · '); +} + +// ---- playback --------------------------------------------------------------- +function startPlay() { + const v = mp.view; + if (!v || v.months.length < 2) return; + if (!PLAYABLE.has(mapOpts().lens)) setLens('heat'); + if (mapOpts().size === 'churn') { + // Playback keeps the structure still: lay out by files, then play. + mp.autoplay = true; + return go({ size: null }, { replace: true }); + } + if (mp.frame == null || mp.frame >= v.months.length - 1) mp.frame = 0; + mp.playing = true; + const M = v.months.length; + clearInterval(mp.timer); + paintMap(); + renderMapLegend(); + syncMapTime(); + syncDetailFrame(); + mp.timer = setInterval( + () => { + if (!$('#map-canvas svg') || !mp.view) return stopPlay(); + if (mp.frame >= mp.view.months.length - 1) return stopPlay(); + mp.frame++; + paintMap(); + syncMapTime(); + syncDetailFrame(); + }, + M > 60 ? 220 : M > 24 ? 380 : 650, + ); +} +function stopPlay() { + clearInterval(mp.timer); + mp.timer = null; + if (!mp.playing) return; + mp.playing = false; + syncMapTime(); +} +function setFrame(f) { + stopPlay(); + const was = mp.frame; + mp.frame = f; + paintMap(); + if ((was == null) !== (f == null)) renderMapLegend(); + syncMapTime(); + syncDetailFrame(); +} + +// ---- selection, lens ----------------------------------------------------------- +function setSel(path) { + if ((route.sel || null) === (path || null)) return; + route = { ...route, sel: path || null }; + history.replaceState(history.state, '', href({})); + rememberMapPlace(); + paintMap(); + renderMapDetail(); + markTableSel(); +} +function setLens(k) { + if (mapOpts().lens === k) return; + route = { ...route, lens: k === 'heat' ? null : k }; + history.replaceState(history.state, '', href({})); + rememberMapPlace(); + if (!PLAYABLE.has(k)) { + stopPlay(); + mp.frame = null; + } + syncMapControls(); + paintMap(); + renderMapLegend(); + renderMapTime(); + renderMapDetail(); +} +function zoomOrOpen(i) { + const b = mp.view?.blocks[i]; + if (!b) return; + if (b.kind === 'file') return go({ file: b.path, person: null }); + if (b.zoom) { + mp.refocus = true; + return go({ focus: b.zoom, sel: null }); + } + setSel(selKey(b)); +} + +// ---- details ------------------------------------------------------------------- +function inferDesc(b) { + const total = b.types.reduce((s, x) => s + x[1], 0); + if (!total) return ''; + const [k1, n1] = b.types[0]; + const second = b.types[1] && b.types[1][1] / total >= 0.15 ? `,其次是${(TYPE_META[b.types[1][0]] || TYPE_META.other)[0]}(${pct(b.types[1][1] / total)})` : ''; + return `主要是${(TYPE_META[k1] || TYPE_META.other)[0]}(${pct(n1 / total)})${second}。仓库里没有找到这块的说明`; +} +function summaryOf(v, b, isFocus) { + const range = rangeText(v); + const parts = []; + if (b.churn) { + const share = !isFocus && v.totals.churn ? b.churn / v.totals.churn : null; + parts.push(`${range}修改 ${fmt(b.churn)} 次${share != null ? `,占${v.focus ? '这一层' : '全仓'}的 ${pct(share)}` : ''}`); + if (b.churn >= 5) parts.push(`其中 ${pct(b.bug / b.churn)} 是修 bug`); + } else parts.push(`${range}没有修改`); + if (b.owners.length) parts.push(`主要由 ${b.owners.slice(0, 2).map(([a, n]) => `${v.authors[a]}(${pct(n / b.ownerSum)})`).join('、')} 维护`); + if (b.last) { + const days = (v.latest - b.last) / DAY_S; + parts.push(days <= 30 ? `${agoText(days)}还有人改` : `最近一次修改在 ${ymdOfSec(b.last)}`); + } + if (b.loss != null && b.loss >= 0.4) parts.push(`${pct(b.loss)} 的历史提交来自近 3 个月不再提交的人`); + return `${parts.join(';')}。`; +} +/** Split two titles at their shared directory prefix: ('a/b/test/', 'a/b/src/') → ['a/b/', 'test/', 'src/']. */ +function splitCommon(a, b) { + const A = a.split('/'); + const Bs = b.split('/'); + let k = 0; + while (k < A.length - 1 && k < Bs.length - 1 && A[k] === Bs[k]) k++; + const pre = A.slice(0, k).join('/'); + return [pre ? `${pre}/` : '', A.slice(k).join('/'), Bs.slice(k).join('/')]; +} +function coItem(v, c, base) { + const t = c.block >= 0 ? v.blocks[c.block] : null; + const name = t ? blockTitle(v, t) : `${c.path}${c.target === 'dir' ? '/' : ''}`; + const [pre, , short] = splitCommon(base, name); + const attrs = `translate="no" title="${esc(name)}" aria-label="${esc(name)}"`; + const link = t + ? `` + : `${esc(short)}`; + return `
  • ${Math.round(c.degree * 100)}%${link}${pre ? `` : ''}${fmt(c.shared)} 次${t ? '' : ' · 视图外'}${c.kind === 'test' ? ' · 测试同步' : ''}
  • `; +} +function pairItem(v, p) { + const a = blockTitle(v, v.blocks[p.from]); + const b = blockTitle(v, v.blocks[p.to]); + const [pre, sa, sb] = splitCommon(a, b); + const btn = (i, full, short) => ``; + return `
  • ${Math.round(p.degree * 100)}%${btn(p.from, a, sa)}→${btn(p.to, b, sb)}${pre ? `` : ''}${fmt(p.shared)} 次${p.kind === 'test' ? ' · 测试同步' : ''}
  • `; +} +function syncDetailFrame() { + const el = $('#md-frame'); + const v = mp.view; + if (!el || !v) return; + const o = mapOpts(); + const f = PLAYABLE.has(o.lens) ? mp.frame : null; + const i = selIndex(v, o.sel); + const b = i >= 0 ? v.blocks[i] : v.focusInfo; + el.hidden = f == null; + if (f == null) return; + const owner = b.mo[f] >= 0 ? `,提交最多的是 ${v.authors[b.mo[f]]}` : ''; + el.textContent = `${v.months[f]}:修改 ${fmt(b.m[f])} 次,修 bug ${fmt(b.mb[f])} 次${owner}`; +} +function renderMapDetail() { + const el = $('#map-detail'); + const v = mp.view; + if (!el) return; + if (!v) { + if (!mp.error) el.innerHTML = '
    '; + return; + } + const r = current(); + const o = mapOpts(); + const i = selIndex(v, o.sel); + const isFocus = i < 0; + const b = isFocus ? v.focusInfo : v.blocks[i]; + const range = rangeText(v); + const path = isFocus ? v.focus : b.path; + const title = isFocus ? (v.focus ? `${v.focus}/` : r.name) : blockTitle(v, b); + const kicker = isFocus ? (v.focus ? '当前目录' : '整个仓库') : { dir: '目录', file: '文件', other: '合并的小项' }[b.kind]; + const icon = b.kind === 'file' ? ICON.file : ICON.map; + const desc = b.desc ? `${esc(b.desc.text)} — ${esc(b.desc.source)}` : b.kind === 'file' ? '' : inferDesc(b); + const stat = (k, val, sub = '') => `
    ${val}
    ${k}${sub}
    `; + const days = b.last ? (v.latest - b.last) / DAY_S : null; + // Within the map one person is one colour: avatar and bar use the owner lens palette. + const owners = b.owners.length + ? `
      ${b.owners.map(([a, n]) => `
    • ${avatar(v.authors[a], '', v.palette.get(a) || 'var(--k-other)')}${personLink(v.authors[a])}${fmt(n)} · ${pct(n / b.ownerSum)}
    • `).join('')}
    ` + : `

    ${range}没有人修改。

    `; + const lossNote = b.loss != null && b.loss >= 0.3 && b.lossTop.length ? `

    知识流失 ${pct(b.loss)}:${b.lossTop.map((a) => `${esc(v.authors[a])}`).join('、')} 写过这里的大量提交,但近 3 个月没有任何提交。

    ` : ''; + const tops = b.top.length + ? `
      ${b.top.map(([p, n, k, ex], j) => `
    1. ${j + 1}${pathHtml(p)}${ex ? '' : '已删除'}${fmt(n)}${k ? ` · ${pct(k / n)}` : ''}
    2. `).join('')}
    ` + : `

    ${range}没有修改。

    `; + let co; + if (isFocus && !b.couple.length && v.pairs.length) { + co = `

    这一层里最强的关系(改左边的提交里,有多少也改了右边):

      ${v.pairs + .slice(0, 6) + .map((p) => pairItem(v, p)) + .join('')}
    `; + } else if (b.couple.length) co = `

    改${isFocus ? '这一层' : '它'}的 ${fmt(b.revs)} 个提交里(不含超过 ${v.couple.maxChangeset} 个文件的大提交):

      ${b.couple.map((c) => coItem(v, c, title)).join('')}
    `; + else co = `

    ${b.revs < v.couple.minRevs ? `${range}只有 ${fmt(b.revs)} 个相关提交,还看不出稳定的关系。` : `没有达到门槛的关系(至少一起改过 ${v.couple.minShared} 次、占比 ≥${pct(v.couple.minDegree)})。`}

    `; + const proc = b.processWith.length ? `

    另外 ${b.processWith.map((p) => `${esc(p.path)}`).join('、')} 几乎随每次修改一起改(流程文件),没有列出。

    ` : ''; + const items = b.kind === 'other' && b.items?.length ? `

    包含的 ${b.items.length} 项

    ` : ''; + const acts = []; + if (!isFocus && b.zoom) acts.push(`${ICON.arrow}进入这块`); + if (!isFocus && b.kind === 'file') acts.push(`${ICON.file}查看文件历史`); + if (isFocus && v.focus) acts.push(`${ICON.up}返回上一层`); + if (b.kind !== 'other') acts.push(``); + if (path) acts.push(``); + const web = path ? (b.kind === 'file' ? webLink('file', path) : webTree(path)) : null; + if (web) acts.push(`${ICON.ext}网页`); + if (b.kind === 'file' && editorLink(path)) acts.push(`${ICON.code}VS Code`); + el.innerHTML = `
    ${kicker}${b.test ? ' · 测试' : ''}${b.process ? ' · 流程文件' : ''}

    ${esc(title)}

    ${isFocus ? '' : ``}
    + ${desc ? `

    ${desc}

    ` : ''} +

    ${esc(summaryOf(v, b, isFocus))}

    + +
    ${stat('文件', fmt(b.files))}${stat('修改', fmt(b.churn), `
    ${range.replace(/上$/, '')}`)}${stat('修 bug', b.churn >= 5 ? pct(b.bug / b.churn) : '–')}${stat('最近修改', days == null ? '–' : agoText(days))}
    + ${items} +

    负责人 ${range},按提交数

    ${owners}${lossNote}
    +

    最常改的文件 ${range}

    ${tops}
    +

    常和${isFocus ? '这一层' : '它'}一起改 ${explainFor('couple', state.report)}

    ${co}${proc}
    +
    ${acts.join('')}
    `; + syncDetailFrame(); +} + +// ---- table -------------------------------------------------------------------- +function renderMapTable() { + const el = $('#map-table'); + const v = mp.view; + if (!el) return; + if (!v || !v.blocks.length) return void (el.innerHTML = v ? '

    没有区域。

    ' : ''); + const cols = [ + ['name', '区域'], + ['size', v.size === 'churn' ? '修改' : '文件'], + ['churn', v.size === 'churn' ? '文件' : '修改'], + ['bug', '修 bug'], + ['owner', '负责人'], + ['last', '最近修改'], + ['loss', '知识流失'], + ['type', '主要类型'], + ]; + const val = { + name: (b) => blockTitle(v, b), + size: (b) => (v.size === 'churn' ? b.churn : b.files), + churn: (b) => (v.size === 'churn' ? b.files : b.churn), + bug: (b) => (b.churn >= 5 ? b.bug / b.churn : -1), + owner: (b) => (b.owners[0] ? v.authors[b.owners[0][0]] : ''), + last: (b) => b.last || 0, + loss: (b) => b.loss ?? -1, + type: (b) => (b.types[0] ? (TYPE_META[b.types[0][0]] || TYPE_META.other)[0] : ''), + }; + const { key, dir } = mp.sort; + const rows = v.blocks.map((b, i) => [b, i]).sort(([a], [b]) => { + const x = val[key](a); + const y = val[key](b); + return (typeof x === 'string' ? x.localeCompare(y) : x - y) * dir; + }); + const sel = selIndex(v, mapOpts().sel); + el.innerHTML = `${cols.map(([k, l]) => ``).join('')}${rows + .map(([b, i]) => { + const own = b.owners[0]; + return ` + + + `; + }) + .join('')}
    代码地图的区域列表
    ${b.risk ? ` 高风险 ${b.risk}` : ''}${b.desc ? `
    ${esc(b.desc.text)}
    ` : ''}
    ${fmt(val.size(b))}${fmt(val.churn(b))}${b.churn >= 5 ? pct(b.bug / b.churn) : '–'}${own ? `${esc(v.authors[own[0]])} ${pct(own[1] / b.ownerSum)}` : '–'}${b.last ? agoText((v.latest - b.last) / DAY_S) : '–'}${b.loss == null ? '–' : pct(b.loss)}${val.type(b) || '–'}
    `; +} +function markTableSel() { + const sel = selIndex(mp.view, mapOpts().sel); + for (const tr of document.querySelectorAll('#map-table tbody tr')) { + const on = Number(tr.dataset.i) === sel; + tr.classList.toggle('is-sel', on); + if (on) tr.setAttribute('aria-current', 'true'); + else tr.removeAttribute('aria-current'); + } +} + +// ---- agent ---------------------------------------------------------------------- +function mapAgentPrompt() { + const v = mp.view; + const r = current(); + const rep = state.report; + const i = selIndex(v, mapOpts().sel); + const isFocus = i < 0; + const b = isFocus ? v.focusInfo : v.blocks[i]; + const path = isFocus ? v.focus : b.path; + const title = isFocus ? (v.focus ? `${v.focus}/` : '整个仓库') : blockTitle(v, b); + const web = path ? (b.kind === 'file' ? webLink('file', path) : webTree(path)) : r.webUrl; + const where = r.kind === 'local' ? `本地路径:${r.localDir}${path ? `/${path}` : ''}` : `网页:${web || r.webUrl}(若本地没有 checkout,请先 clone ${r.webUrl})`; + const range = rangeText(v); + const owners = b.owners.slice(0, 3).map(([a, n]) => `${v.authors[a]}(${pct(n / b.ownerSum)})`).join('、') || '无'; + const top = b.top.slice(0, 5).map(([p, n]) => `${p}(${n} 次)`).join('、') || '无'; + const co = b.couple.slice(0, 5).map((c) => `${c.path}(改这里的提交里 ${pct(c.degree)} 也改了它)`).join('、') || '无'; + return [ + `请帮我讲解仓库 ${r.display} 里的 \`${title}\`${b.kind === 'file' ? '' : `(${fmt(b.files)} 个文件)`}是做什么的。`, + where, + '', + `仓库透视的数据(${range},HEAD ${rep.head.slice(0, 12)}):`, + `- 修改 ${fmt(b.churn)} 次,其中修 bug ${b.churn ? pct(b.bug / b.churn) : '0%'};最近一次修改 ${b.last ? ymdOfSec(b.last) : '–'}`, + `- 主要负责人:${owners}`, + `- 最常改的文件:${top}`, + `- 常一起改:${co}`, + ...(b.desc ? [`- 仓库自带的说明(${b.desc.source}):${b.desc.text}`] : []), + '', + b.kind === 'file' + ? '请阅读这个文件,说明它负责什么、对外提供什么、被谁调用,以及为什么常和上面那些文件一起改。只读分析,不要修改代码。' + : '请先读这个目录的 README(如果有)和入口文件,说明:它负责什么、由哪几个主要部分组成、对外提供什么接口,以及和上面那些“常一起改”的区域是什么关系。只读分析,不要修改代码。', + ].join('\n'); +} + +// ---- overview thumbnail ---------------------------------------------------------- +// Reports from before ALGO 7 carry an older codemap shape; only the lite view (with totals) is drawn. +const liteMap = (rep) => (rep?.codemap?.totals && Array.isArray(rep.codemap.blocks) ? rep.codemap : null); +let thumbRO = null; +function mountThumb(rep) { + thumbRO?.disconnect(); + const box = $('#codemap'); + const cm = liteMap(rep); + if (!box || !cm?.blocks.length) return; + const q = quantiles(cm.blocks.map((b) => b.churn)); + let lastW = 0; + const draw = () => { + const w = Math.round(box.clientWidth); + if (!w || Math.abs(w - lastW) < 24) return; + lastW = w; + const layout = C.mapLayout(cm.blocks, { width: w, height: Math.round(Math.max(240, Math.min(380, w * 0.36))), value: (b) => b.files }); + box.innerHTML = C.mapSvg(layout, { blocks: cm.blocks, groupLabel: (k) => groupLabel('', k), groupValue: (n) => `${C.fmtK(n)} 个文件`, nameOf: (b) => blockLabels('', b), action: 'thumb-open', label: '代码地图缩略图:点任意一块,在代码地图里打开' }); + for (const g of box.querySelectorAll('.tm-cell')) { + const b = cm.blocks[g.dataset.i]; + const fill = `var(--heat-${levelOf(q, b.churn)})`; + g.querySelector('.tm-r').style.fill = fill; + g.classList.toggle('ink-light', inkFor(fill) === 'light'); + const val = g.querySelector('.tm-val'); + if (val) val.textContent = C.fitLabel([`${C.fmtK(b.files)} 个文件`], Number(val.dataset.w) || 0, 10); + g.dataset.tip = `${blockTitle({}, b)}\n${fmt(b.files)} 个文件 · 窗口内修改 ${fmt(b.churn)} 次${b.risk ? `\n含 ${b.risk} 个高风险文件` : ''}\n点击:在代码地图中打开`; + g.setAttribute('aria-label', `${blockTitle({}, b)},${b.files} 个文件,窗口内修改 ${b.churn} 次,打开代码地图`); + } + }; + draw(); + thumbRO = new ResizeObserver(() => requestAnimationFrame(draw)); + thumbRO.observe(box); +} +function openThumb(i) { + const b = liteMap(state.report)?.blocks[i]; + if (!b) return; + // A folder opens as the new level; a file or a merged block opens its parent with it selected. + const at = b.kind === 'dir' ? { focus: b.path, sel: null } : b.kind === 'other' ? (b.zoom ? { focus: b.zoom, sel: null } : { focus: null, sel: selKey(b) }) : { focus: parentOf(b.path) || null, sel: b.path }; + go({ tab: 'map', ...at, lens: null, size: null, range: null, file: null, person: null, filter: null }); +} + +// --------------------------------------------------------------------------- +// Progress (deployment-log style) +// --------------------------------------------------------------------------- +const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'; +const pg = { key: null, timer: null, stage: null, f: 0 }; +function stopProgress() { + clearInterval(pg.timer); + pg.timer = null; + pg.key = null; + pg.stage = null; +} +function renderProgress() { + const el = $('#progress'); + const r = current(); + if (!el || !r) return stopProgress(); + const j = r.job; + main.classList.toggle('scanning', Boolean(j?.running && state.report && state.reportKey?.startsWith(`${r.id}:`))); + if (!j?.running) { + stopProgress(); + const showErr = j && j.stage === 'error' && Date.now() - (j.finishedAt || 0) < 60_000 && state.report; + el.className = ''; + el.innerHTML = showErr ? `` : ''; + return; + } + const key = `${r.id}:${j.startedAt}`; + if (pg.key !== key || !el.querySelector('#pg-body')) { + pg.key = key; + el.className = 'progress-log term'; + el.innerHTML = `
    xray ${esc(r.kind === 'local' ? r.display : r.name)}
    `; + } + if (pg.stage !== j.stage) { + pg.stage = j.stage; + const sr = $('#pg-sr'); + if (sr) sr.textContent = j.stage === 'queued' ? '排队等待分析' : `正在${STAGE[j.stage] || '分析'}`; + } + drawProgress(); + if (!pg.timer) pg.timer = setInterval(drawProgress, reducedMotion() ? 1000 : 90); +} +function drawProgress() { + const r = current(); + const j = r?.job; + const body = $('#pg-body'); + if (!j?.running || !body) return stopProgress(); + pg.f++; + const order = r.kind === 'local' ? ['analyze', 'tech'] : ['sync', 'analyze', 'tech']; + const done = new Map((j.steps || []).map((x) => [x.stage, x.ms])); + const spin = reducedMotion() ? '›' : SPIN[pg.f % SPIN.length]; + body.innerHTML = + (j.stage === 'queued' ? `
    ${spin}等待空闲的网络通道…${secs(Date.now() - j.startedAt)}
    ` : '') + + order + .map((st) => { + const ms = done.get(st); + const isCur = j.stage === st; + const icon = ms != null ? '✓' : isCur ? `${spin}` : '·'; + const time = ms != null ? secs(ms) : isCur ? secs(Date.now() - (j.stageStartedAt || Date.now())) : ''; + let detail = ''; + if (isCur && st === 'sync' && j.detail) detail = ` ${esc(j.detail)}${j.pct || 0}%`; + else if (isCur && j.detail) detail = ` · ${esc(j.detail)}`; + if (st === 'tech' && isCur && j.reportHead) detail += ' · 报告已可查看'; + return `
    ${icon}${STAGE[st]}${detail}${isCur ? '' : ''}${time}
    `; + }) + .join(''); + const total = $('#pg-total'); + if (total) total.textContent = secs(Date.now() - j.startedAt); +} + +// --------------------------------------------------------------------------- +// Data flow +// --------------------------------------------------------------------------- +async function loadState() { + const s = await api('/api/state'); + state.repos = s.repos; + state.settings = s.settings; + renderSidebar(); +} +let seq = 0; +async function loadReport({ quiet = false } = {}) { + const r = current(); + if (!r) { + state.report = null; + renderWelcome(); + if (!state.suggest) loadSuggest(); + return; + } + const my = ++seq; + const key = `${r.id}:${state.identity}`; + state.error = null; + if (!quiet || state.reportKey?.split(':')[0] !== r.id) { + state.report = null; + renderRepo(); + } + if (!r.lastSync?.at) return renderRepo(); + try { + const rep = await api(`/api/repos/${encodeURIComponent(r.id)}/report?identity=${state.identity}`); + if (my !== seq) return; + state.report = rep; + state.reportKey = key; + // Older algorithm: keep showing it and recompute quietly; the progress log explains what is happening. + if (rep.stale && !r.job?.running) api(`/api/repos/${encodeURIComponent(r.id)}/sync`, { method: 'POST' }).catch(() => {}); + } catch (e) { + if (my !== seq) return; + if (e.code === 'no_report' && !r.job?.running) { + // Report format changed since the last run: re-analyze transparently. + api(`/api/repos/${encodeURIComponent(r.id)}/sync`, { method: 'POST' }).catch(() => {}); + } else if (e.code !== 'no_report') state.error = e.message; + } + const y = window.scrollY; + renderRepo(); + if (quiet) window.scrollTo({ top: y }); +} +async function analyze(input) { + const v = input.trim(); + if (!v) return toast('请输入仓库地址或本地路径'); + const btn = $('#analyze-form [type=submit]'); + if (btn) { + btn.disabled = true; + btn.firstChild.outerHTML = ''; + } + try { + const { repo } = await api('/api/repos', { method: 'POST', body: { input: v } }); + await loadState(); + go({ repo: repo.id, tab: 'overview', file: null, person: null, filter: null }); + } catch (e) { + const hint = $('#parse-hint'); + if (hint) { + hint.textContent = e.message; + hint.className = 'parse-hint err'; + } else toast(e.message); + $('#target-input')?.focus(); + } finally { + if (btn?.isConnected) { + btn.disabled = false; + btn.firstChild.outerHTML = ICON.search; + } + } +} +function connect() { + const es = new EventSource('/api/stream'); + es.addEventListener('open', () => setLive(true)); + es.addEventListener('error', () => setLive(es.readyState === EventSource.OPEN)); + es.addEventListener('job', async (ev) => { + let j; + try { + j = JSON.parse(ev.data); + } catch { + return; + } + const r = repoById(j.repoId); + if (!r) return; + const prev = r.job; + r.job = j; + const isCur = j.repoId === route.repo; + if (!j.running && prev?.running !== false) { + await loadState().catch(() => {}); + if (isCur) { + await loadReport({ quiet: true }); + updateTitle(); + const took = j.finishedAt && j.startedAt ? ` · 用时 ${secs(j.finishedAt - j.startedAt)}` : ''; + toast(j.stage === 'done' ? `分析完成${took}` : j.stage === 'cancelled' ? '已取消' : j.error?.message || '分析失败', null, { ok: j.stage === 'done' }); + } + return; + } + renderSidebar(); + if (!isCur) return; + updateTitle(); + if (j.reportHead && j.reportHead !== prev?.reportHead && state.report?.head !== j.reportHead) { + await loadState().catch(() => {}); + return loadReport({ quiet: true }); + } + if (!$('#progress')) renderRepo(); + else renderProgress(); + const b = main.querySelector('[data-action="sync"]'); + if (b) b.disabled = true; + }); +} + +// --------------------------------------------------------------------------- +// Drawers (file / person), driven by the URL +// --------------------------------------------------------------------------- +let drawerKey = null; +function syncDrawer() { + const dlg = $('#drawer'); + const key = route.file ? `f:${route.file}` : route.person ? `p:${route.person}` : null; + if (!key) { + drawerKey = null; + if (dlg.open) dlg.close(); + return; + } + if (key === drawerKey && dlg.open) return; + drawerKey = key; + if (route.file) openFile(route.file); + else openPerson(route.person); +} +function closeDrawer() { + if (history.state?.drawer) history.back(); + else go({ file: null, person: null }, { replace: true }); +} +function drawerShell(title, sub) { + return `
    ${title}${sub ? `
    ${sub}
    ` : ''}
    `; +} +function fileBadges(path) { + const rep = state.report; + if (!rep) return ''; + const hot = rep.hotspots.find((h) => h.path === path); + const bug = rep.bugMagnets.find((h) => h.path === path); + const risk = rep.highRisk.some((h) => h.path === path); + const b = []; + if (risk) b.push('高风险'); + if (hot) b.push(`热点 #${hot.rank} / ${rep.vitals.N}`); + if (bug) b.push(`Bug 磁铁 #${bug.rank}`); + if (!b.length) b.push('不在窗口内的 Top 榜单'); + return `
    ${b.join('')}
    `; +} +function personBadges(name) { + const rep = state.report; + const c = rep?.contributors.find((x) => x.name === name); + if (!c) return ''; + return `
    排名 #${c.rank} / ${fmt(rep.busFactor.total)}占 ${pct(c.share, 1)}${c.active ? '近 3 个月活跃' : '近 3 个月无提交'}
    `; +} +/** File-level co-change, all history (code-maat thresholds; weak candidates only when nothing passes). */ +function couplingHtml(c) { + if (!c) return ''; + const strong = c.strong.length > 0; + const list = strong ? c.strong : c.weak; + const head = `

    常一起改的文件 全部历史上改它的 ${fmt(c.revs)} 个提交里,同时也改了这些文件的比例(不含合并提交和超过 ${c.maxChangeset} 个文件的大提交)

    `; + if (!list.length) return `
    ${head}

    ${c.revs < c.minRevs ? `只有 ${fmt(c.revs)} 个相关提交,还看不出稳定的关系。` : '没有经常和它一起改的文件。'}

    `; + const note = strong ? '' : `

    没有达到门槛(一起改过至少 ${c.minShared} 次、占比 ≥${pct(c.minDegree)})的文件。下面是一起改过 3 次以上的,仅供参考:

    `; + return `
    ${head}${note}
      ${list + .map((x) => `
    • ${Math.round(x.degree * 100)}%${pathHtml(x.path)}${fmt(x.shared)} 次${x.kind === 'test' ? ' · 测试同步' : x.kind === 'process' ? ' · 流程文件' : ''}${x.exists ? '' : ' · 已删除'}
    • `) + .join('')}
    `; +} +async function openFile(path) { + const r = current(); + const dlg = $('#drawer'); + if (!r) return; + dlg.innerHTML = `${drawerShell(`

    ${pathHtml(path, { link: false, baseFirst: false })}

    `)}
    `; + dlg.setAttribute('aria-labelledby', 'drawer-title'); + if (!dlg.open) dlg.showModal(); + try { + const d = await api(`/api/repos/${encodeURIComponent(r.id)}/file?path=${encodeURIComponent(path)}&identity=${state.identity}`); + if (drawerKey !== `f:${path}`) return; + const max = d.owners[0]?.commits || 1; + dlg.innerHTML = `${drawerShell(`

    ${pathHtml(path, { link: false, baseFirst: false })}

    `, `${d.exists ? '在 HEAD 中存在' : '已在 HEAD 中删除或改名'}${d.firstAdd ? ` · 首次加入 ${d.firstAdd}` : ''}${fileBadges(path)}`)} +
    +
    ${fileActions(path, { exists: d.exists })}
    +
    +
    全部修改
    ${fmt(d.total)}
    +
    修 bug 的提交
    ${fmt(d.fixes)}
    +
    维护者
    ${fmt(d.ownerCount)}
    +
    +

    每月修改次数

    ${C.monthBars({ months: d.months.slice(-24).map(([m, n]) => ({ m, n })), height: 170, color: 'var(--k-mr)' })}
    +

    维护者 等同 git shortlog -sn -- <file>${state.identity === 'merged' ? ',不含机器人' : ''}

    +
      ${d.owners.map((o) => `
    • ${avatar(o.name)}${personLink(o.name)}
      ${fmt(o.commits)} · ${pct(o.share)}
    • `).join('')}
    + ${couplingHtml(d.coupling)} +

    最近 ${d.commits.length} 次修改

      ${d.commits.map((c) => commitRow(c, `${c.fix ? 'fix ' : ''}${c.fire ? '救火 ' : ''}${c.status === 'A' ? '新增 ' : c.status === 'D' ? '删除 ' : ''}`)).join('')}
    +
    `; + } catch (e) { + dlg.querySelector('.dlg-body').innerHTML = ``; + } +} +async function openPerson(name) { + const r = current(); + const dlg = $('#drawer'); + if (!r) return; + dlg.innerHTML = `${drawerShell(`

    ${esc(name)}

    `)}
    `; + dlg.setAttribute('aria-labelledby', 'drawer-title'); + if (!dlg.open) dlg.showModal(); + try { + const d = await api(`/api/repos/${encodeURIComponent(r.id)}/person?name=${encodeURIComponent(name)}&identity=${state.identity}`); + if (drawerKey !== `p:${name}`) return; + const maxF = d.files[0]?.count || 1; + dlg.innerHTML = `${drawerShell(`
    ${avatar(d.name, 'lg')}

    ${esc(d.name)}${d.bot ? ' 机器人' : ''}

    `, `${d.first || '–'} → ${d.last || '–'}${d.aliases.length ? ` · 也作 ${esc(d.aliases.join('、'))}` : ''}${d.emails.length ? ` · ${esc(d.emails.join(', '))}` : ''}${personBadges(d.name)}`)} +
    +
    +
    非合并提交
    ${fmt(d.commits)}
    +
    修 bug
    ${fmt(d.fixes)}
    +
    合并提交
    ${fmt(d.merges)}
    +
    +

    每月提交

    ${C.monthBars({ months: d.months.slice(-24).map(([m, n]) => ({ m, n })), height: 170, color: C.memberColor(d.name) })}
    +

    改得最多的文件

      ${d.files.map((f, i) => `
    1. ${i + 1}${pathHtml(f.path)}
      ${fmt(f.count)}
    2. `).join('')}
    +

    最近提交

      ${d.recent.map((c) => commitRow(c, `${c.fix ? 'fix ' : ''}${c.fire ? '救火 ' : ''}`)).join('')}
    +
    `; + } catch (e) { + dlg.querySelector('.dlg-body').innerHTML = ``; + } +} + +// --------------------------------------------------------------------------- +// Export & chat +// --------------------------------------------------------------------------- +function buildMarkdown(rep, r) { + const v = rep.vitals; + const L = []; + L.push(`# Codebase Recon Report — ${r.display}`, ''); + L.push(`> 由仓库透视(Repo X-Ray)生成于 ${new Date().toLocaleString('zh-CN')},HEAD \`${rep.head.slice(0, 12)}\`(${rep.branch || 'HEAD'}),身份口径:${rep.identity === 'raw' ? '原始(与 shortlog 一致)' : '合并身份,机器人除外'}`, ''); + L.push('## Repo Vitals', `Age: ${v.first} to ${v.latest} | Commits: ${v.commits} | Branches: ${v.branches} | Analysis window: ${v.window}`, ''); + L.push('## 1. Code Hotspots', ...rep.hotspots.map((h) => `- ${h.count} \`${h.path}\``), ''); + L.push('## 2. Bug Magnets', ...rep.bugMagnets.map((h) => `- ${h.count} \`${h.path}\``), ''); + L.push('## 3. High-Risk Files'); + if (rep.highRisk.length) L.push(...rep.highRisk.map((h) => `- \`${h.path}\` — hotspot rank #${h.hotRank}, bug magnet rank #${h.bugRank}, primary owner: ${h.owner ? `${h.owner.name} (${pct(h.owner.share)})` : '–'}`)); + else L.push('No files appear in both lists — good sign.'); + L.push('', '## 4. Bus Factor', ...rep.contributors.slice(0, 10).map((c) => `- ${c.commits} ${c.name}`), `Active (last 3 months): ${rep.busFactor.active3m} of ${rep.busFactor.total} total contributors`); + if (rep.busFactor.warn) L.push('Warning: low active contributor ratio — knowledge concentration risk'); + L.push('', '## 5. Team Momentum', ...rep.momentum.months.slice(-12).map((m) => `- ${m.m}: ${m.n}`), `Trend: ${rep.momentum.trend}`, ''); + L.push('## 6. Firefighting Frequency', ...(rep.firefighting.list.length ? rep.firefighting.list.slice(0, 20).map((c) => `- ${c.sha.slice(0, 7)} ${c.subj}`) : ['None found']), `Rate: ${rep.firefighting.count} emergency commits out of ${rep.firefighting.total} total in window (${pct(rep.firefighting.rate, 1)})`, ''); + L.push('## 7. Recently Added Files', ...rep.recentlyAdded.files.map((f) => `- ${f.count} \`${f.path}\``), ''); + if (rep.tech) { + L.push('## Tech Stack', `Languages: ${rep.tech.languages.slice(0, 6).map((l) => `${l.name} ${pct(l.share)}`).join(', ')}`); + for (const g of rep.tech.groups) L.push(`- ${g.category}: ${g.items.map((i) => i.name).join(', ')}`); + L.push(''); + } + const rec = rep.recommendations; + L.push('## 8. Recommendations', `- Start reading: ${rec.startReading.map((p) => `\`${p}\``).join(', ') || '–'}`, `- Talk to: ${rec.talkTo ? `${rec.talkTo.name} (owner of \`${rec.talkTo.file}\`)` : '–'}`, `- Watch out: ${rec.watchOut.join('; ') || 'nothing notable'}`, ''); + return L.join('\n'); +} +function chatPrompt(rep, r) { + const loc = r.kind === 'local' ? `本地仓库 ${r.localDir}` : `${r.webUrl}(若本地没有 checkout,请先 clone)`; + const risk = rep.highRisk.slice(0, 5).map((h, i) => `${i + 1}. ${h.path}(热点 #${h.hotRank},修 bug ${h.bugCount}/${h.hotCount} 次,主要负责人 ${h.owner?.name || '–'})`); + return [ + `请基于下面这份仓库透视报告,帮我深入看一下 ${loc}。`, + '', + `HEAD ${rep.head.slice(0, 12)};${rep.vitals.commits} 个 commit;节奏${TREND[rep.momentum.trend]};救火占比 ${pct(rep.firefighting.rate, 1)};近 3 个月活跃 ${rep.busFactor.active3m}/${rep.busFactor.total} 人。`, + '', + '高风险文件(热点 ∩ bug 磁铁):', + ...(risk.length ? risk : ['(无)']), + '', + '请先阅读前 3 个高风险文件,回答:它们为什么反复出 bug(结构、耦合、职责、测试覆盖),有哪些具体可落地的拆分或加固建议。只读分析,不要修改代码。', + ].join('\n'); +} +async function sendToChat(btn, custom = null) { + const r = current(); + const rep = state.report; + if (!r || !rep) return; + const message = window.mcode?.miniapp?.message; + const text = custom ?? chatPrompt(rep, r); + if (!message?.append) { + await copyText(text, '当前环境不支持直接发到对话,已复制到剪贴板'); + return; + } + if (btn) btn.disabled = true; + try { + await message.append(text); + toast('已追加到对话输入框,确认后按发送即可'); + } catch (e) { + const code = e?.message; + toast(code === 'user_cancelled' ? '已取消,没有追加内容' : code === 'result_unknown' ? '结果未知,请先检查对话输入框再重试' : code === 'target_unavailable' ? '找不到可追加的对话,请从对话里打开这个 Mini App' : '追加失败,请稍后重试'); + } finally { + if (btn?.isConnected) btn.disabled = false; + } +} +function download(name, text) { + const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown;charset=utf-8' })); + const a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 2000); +} +function openExportMenu(btn) { + closeMenus(); + const menu = document.createElement('div'); + menu.className = 'menu'; + menu.setAttribute('role', 'menu'); + menu.innerHTML = ` + + `; + document.body.append(menu); + const b = btn.getBoundingClientRect(); + menu.style.top = `${b.bottom + 6}px`; + menu.style.left = `${Math.min(b.left, innerWidth - 240)}px`; + btn.setAttribute('aria-expanded', 'true'); + menu.querySelector('button').focus(); + menu._owner = btn; +} +function closeMenus() { + document.querySelectorAll('.menu').forEach((m) => { + m._owner?.setAttribute('aria-expanded', 'false'); + m.remove(); + }); +} + +// --------------------------------------------------------------------------- +// Explain popover +// --------------------------------------------------------------------------- +function toggleExplain(btn) { + const open = btn.getAttribute('aria-expanded') === 'true'; + closePopovers(); + if (open) return; + const tpl = btn.parentElement.querySelector(`template[data-explain="${btn.dataset.key}"]`) || btn.nextElementSibling; + if (!tpl) return; + const pop = document.createElement('div'); + pop.className = 'popover'; + pop.setAttribute('role', 'dialog'); + pop.setAttribute('aria-label', '指标说明'); + pop.append(tpl.content.cloneNode(true)); + document.body.append(pop); + const b = btn.getBoundingClientRect(); + const w = pop.getBoundingClientRect().width; + pop.style.top = `${b.bottom + 8}px`; + pop.style.left = `${Math.max(8, Math.min(b.left - 16, innerWidth - w - 8))}px`; + btn.setAttribute('aria-expanded', 'true'); + pop._owner = btn; +} +function closePopovers() { + document.querySelectorAll('.popover').forEach((p) => { + p._owner?.setAttribute('aria-expanded', 'false'); + p.remove(); + }); +} + +// --------------------------------------------------------------------------- +// ⌘K command palette +// --------------------------------------------------------------------------- +const pal = { items: [], active: 0, seq: 0, timer: null }; +function openPalette(prefill = '') { + const dlg = $('#palette'); + dlg.innerHTML = `
    +
    ${ICON.search}Esc
    +
      +
      ↑↓ 选择↵ 打开Esc 关闭
      `; + if (!dlg.open) dlg.showModal(); + const q = $('#pal-q'); + q.value = prefill; + pal.active = 0; + palQuery(prefill); + q.focus(); +} +function hl(text, needle) { + const t = String(text); + const i = needle ? t.toLowerCase().indexOf(needle) : -1; + if (i < 0) return esc(t); + return `${esc(t.slice(0, i))}${esc(t.slice(i, i + needle.length))}${esc(t.slice(i + needle.length))}`; +} +function palActions() { + const r = current(); + const A = [{ kind: 'action', label: '分析新仓库…', icon: ICON.search, run: () => go({ repo: null, tab: 'overview', file: null, person: null, filter: null }) }]; + if (r) { + A.push({ kind: 'action', label: r.kind === 'local' ? '重新分析当前仓库' : '同步并分析当前仓库', icon: ICON.sync, run: () => syncCurrent() }); + if (state.report) { + A.push({ kind: 'action', label: '复制 Markdown 报告', icon: ICON.copy, run: () => copyText(buildMarkdown(state.report, r), '报告已复制') }); + A.push({ kind: 'action', label: '发到对话…', icon: ICON.chat, run: () => sendToChat() }); + } + for (const [t, l] of TABS) A.push({ kind: 'action', label: `转到:${l}`, icon: ICON.arrow, run: () => go({ tab: t, filter: null, file: null, person: null }) }); + } + A.push({ kind: 'action', label: '切换主题', icon: '', run: cycleTheme }); + A.push({ kind: 'action', label: '打开设置', icon: ICON.info, run: openSettings }); + return A; +} +function palQuery(q) { + const needle = q.trim().toLowerCase(); + const repos = visibleRepos() + .filter((r) => !needle || r.name.toLowerCase().includes(needle) || r.display.toLowerCase().includes(needle)) + .slice(0, 6) + .map((r) => ({ kind: 'repo', label: r.name, sub: r.display, icon: ICON[r.provider] || ICON.other, run: () => go({ repo: r.id, tab: 'overview', file: null, person: null, filter: null }) })); + const actions = palActions().filter((a) => !needle || a.label.toLowerCase().includes(needle)); + pal.base = needle ? [...repos, ...actions] : [...actions.slice(0, 5), ...repos]; + pal.remote = []; + renderPal(); + clearTimeout(pal.timer); + const r = current(); + if (!needle || !r?.lastSync?.at) return; + const my = ++pal.seq; + pal.loading = true; + renderPal(); + pal.timer = setTimeout(async () => { + try { + const res = await api(`/api/repos/${encodeURIComponent(r.id)}/search?q=${encodeURIComponent(needle)}&identity=${state.identity}`); + if (my !== pal.seq) return; + pal.remote = [ + ...res.files.map((f) => ({ kind: 'file', label: f.path, count: f.count, exists: f.exists, icon: ICON.file, run: () => go({ file: f.path, person: null }) })), + ...res.people.map((p) => ({ kind: 'person', label: p.name, count: p.commits, bot: p.bot, icon: ICON.user, run: () => go({ person: p.name, file: null }) })), + ]; + } catch {} + pal.loading = false; + if (my === pal.seq) renderPal(); + }, 120); +} +function renderPal() { + const list = $('#pal-list'); + if (!list) return; + pal.items = [...pal.base.filter((x) => x.kind !== 'action' || !pal.remote.length), ...pal.remote, ...(pal.remote.length ? pal.base.filter((x) => x.kind === 'action') : [])]; + pal.active = Math.min(pal.active, Math.max(0, pal.items.length - 1)); + const GROUP = { action: '操作', repo: '仓库', file: '文件', person: '贡献者' }; + const needle = ($('#pal-q')?.value || '').trim().toLowerCase(); + let last = ''; + list.innerHTML = + pal.items + .map((it, i) => { + const head = it.kind !== last ? `` : ''; + last = it.kind; + let label; + if (it.kind === 'file') { + const k = it.label.lastIndexOf('/'); + label = `${hl(it.label.slice(k + 1), needle)} ${hl(it.label.slice(0, k + 1), needle)}`; + } else label = `${hl(it.label, needle)}`; + const meta = it.kind === 'file' ? `${fmt(it.count)} 次修改${it.exists ? '' : ' · 已删除'}` : it.kind === 'person' ? `${fmt(it.count)} 个提交${it.bot ? ' · 机器人' : ''}` : it.sub ? esc(it.sub) : ''; + return `${head}
    • ${it.icon}${label}${meta}
    • `; + }) + .join('') + (pal.loading ? '' : !pal.items.length ? '' : ''); + $('#pal-q')?.setAttribute('aria-activedescendant', pal.items.length ? `pal-${pal.active}` : ''); + const cnt = $('#pal-count'); + if (cnt) cnt.textContent = pal.items.length ? `${pal.items.length} 项` : ''; + $(`#pal-${pal.active}`)?.scrollIntoView({ block: 'nearest' }); +} +function palRun(i) { + const it = pal.items[i]; + if (!it) return; + $('#palette').close(); + it.run(); +} + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- +function openSettings() { + const s = state.settings; + const src = { gh: 'gh 登录', glab: 'glab 登录', token: '手动令牌', none: '未找到凭据(按公开仓库访问)' }; + const dlg = $('#settings'); + dlg.innerHTML = `

      设置

      +
      +

      凭据

      默认复用本机 gh / glab 的登录;令牌只保存在这个 Mini App 的数据目录里,不会发送到页面,也不会写进仓库配置。

      +
        ${(s.hosts || []).map((h) => `
      • ${esc(h.host)}
        ${src[h.source] || h.source}
        ${s.tokenHosts?.includes(h.host) ? `` : ''}
      • `).join('') || '
      • 分析远程仓库后,这里会列出用到的主机。
      • '}
      +
      +

      为某个主机填写访问令牌

      +
      +
      +
      +
      +

      本机仓库扫描目录

      +
      +
      +
      +
      +

      网络代理

      +
      + 当前:${{ settings: '使用这里的设置', env: '使用环境变量里的代理', system: '使用系统代理', none: '直连' }[s.proxyActive] || '直连'}
      +
      +
      +
      `; + dlg.setAttribute('aria-labelledby', 'set-title'); + if (!dlg.open) dlg.showModal(); +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- +async function syncCurrent() { + const r = current(); + if (!r) return; + try { + await api(`/api/repos/${encodeURIComponent(r.id)}/sync`, { method: 'POST' }); + } catch (e) { + toast(e.message); + } +} +function removeCurrent() { + const r = current(); + if (!r) return; + state.pendingRemoval.set(r.id, true); + const next = visibleRepos()[0]; + go({ repo: next?.id || null, tab: 'overview', file: null, person: null, filter: null }); + const timer = setTimeout(async () => { + state.pendingRemoval.delete(r.id); + try { + await api(`/api/repos/${encodeURIComponent(r.id)}`, { method: 'DELETE' }); + await loadState(); + } catch (e) { + toast(e.message); + await loadState().catch(() => {}); + } + }, 8000); + toast(r.kind === 'local' ? `已移除 ${r.name}(不会动本地仓库)` : `已移除 ${r.name},8 秒后删除缓存的 clone`, { + label: '撤销', + ms: 8000, + run: () => { + clearTimeout(timer); + state.pendingRemoval.delete(r.id); + go({ repo: r.id, tab: 'overview', file: null, person: null, filter: null }); + }, + }); +} +async function setIdentity(v) { + if (state.identity === v) return; + state.identity = v; + localStorage.setItem('rr-identity', v); + await loadReport({ quiet: true }); + if (route.file || route.person) { + drawerKey = null; + syncDrawer(); + } +} + +function openKeys() { + const dlg = $('#keys'); + const row = (keys, text) => `
      ${keys}
      ${text}
      `; + dlg.innerHTML = `

      键盘快捷键

      +
      + ${row('⌘K', '搜索仓库、全部历史文件、贡献者和操作')} + ${row('/', '聚焦贡献者筛选框或仓库地址输入框')} + ${row(`1–${TABS.length}`, '切换报告分区')} + ${row('[]', '切换到上一个 / 下一个仓库')} + ${row('Esc', '关闭抽屉、弹层和菜单;在代码地图上取消选择')} + ${row('?', '显示这份列表')} +
      +

      代码地图

      +
      + ${row('←↑→↓', '在方块之间移动')} + ${row('Space / 单击', '选中,查看详情和“常一起改”的连线')} + ${row('↵ / 双击', '进入目录,或打开文件历史')} + ${row('⌫', '返回上一层(浏览器后退也可以)')} +
      `; + if (!dlg.open) dlg.showModal(); +} +document.addEventListener('click', async (e) => { + // Popovers and menus close on outside click. + if (!e.target.closest('.popover,[data-action="explain"]')) closePopovers(); + if (!e.target.closest('.menu,[data-action="export-menu"]')) closeMenus(); + + const nav = e.target.closest('a[data-nav]'); + if (nav && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && e.button === 0) { + e.preventDefault(); + const u = new URL(nav.href, location.href); + history.pushState({ drawer: u.searchParams.has('file') || u.searchParams.has('person') }, '', u.pathname + u.search); + closePopovers(); + closeMenus(); + if ($('#palette').open) $('#palette').close(); + return applyRoute(); + } + const opt = e.target.closest('#pal-list [role=option]'); + if (opt) return palRun(Number(opt.dataset.i)); + + const el = e.target.closest('[data-action]'); + if (!el) { + if (e.target.tagName === 'DIALOG') e.target.id === 'drawer' ? closeDrawer() : e.target.close(); + return; + } + const r = current(); + switch (el.dataset.action) { + case 'new': + return go({ repo: null, tab: 'overview', file: null, person: null, filter: null }); + case 'analyze-path': + return analyze(el.dataset.path); + case 'rescan': + state.suggest = null; + renderWelcome(); + return loadSuggest(true); + case 'sync': + return syncCurrent(); + case 'cancel': + return r && api(`/api/repos/${encodeURIComponent(r.id)}/sync`, { method: 'DELETE' }).catch((err) => toast(err.message)); + case 'remove': + return removeCurrent(); + case 'mo-range': + state.momentumAll = el.dataset.all === '1'; + for (const b of el.parentElement.children) b.setAttribute('aria-pressed', String(b === el)); + $('#momentum').innerHTML = momentumChart(state.report, state.momentumAll ? 0 : 24); + return; + case 'contrib-all': + state.contribAll = !state.contribAll; + $('#contrib').innerHTML = contribHtml(state.report); + return; + case 'identity': + return setIdentity(el.dataset.v); + case 'explain': + return toggleExplain(el); + case 'copy': + return copyText(el.dataset.text, '已复制', el); + case 'map-cell': + return mp.view?.blocks[el.dataset.i] && setSel(selKey(mp.view.blocks[el.dataset.i])); + case 'thumb-open': + return openThumb(Number(el.dataset.i)); + case 'map-select': { + const b = mp.view?.blocks[el.dataset.i]; + if (!b) return; + setSel(selKey(b)); + // Picked from the list below the map: bring the map back into view. + if (el.closest('#map-table')) $('#map-canvas')?.scrollIntoView({ block: 'nearest', behavior: reducedMotion() ? 'auto' : 'smooth' }); + return; + } + case 'map-sel-clear': + setSel(null); + return $(`#map-canvas .tm-cell[tabindex="0"]`)?.focus(); + case 'map-lens': + return setLens(el.dataset.v); + case 'map-size': + return go({ size: el.dataset.v === 'files' ? null : el.dataset.v }, { replace: true }); + case 'map-range': + return go({ range: el.dataset.v === defaultRange(state.report) ? null : el.dataset.v }, { replace: true }); + case 'map-play': + return mp.playing ? stopPlay() : startPlay(); + case 'map-frame-clear': + return setFrame(null); + case 'map-retry': + mp.key = null; + return mountMap(); + case 'map-sort': { + const k = el.dataset.k; + mp.sort = mp.sort.key === k ? { key: k, dir: -mp.sort.dir } : { key: k, dir: ['name', 'owner', 'type'].includes(k) ? 1 : -1 }; + renderMapTable(); + return $(`#map-table [data-action="map-sort"][data-k="${k}"]`)?.focus(); + } + case 'map-agent': + return mp.view && sendToChat(el, mapAgentPrompt()); + case 'example': { + const i = $('#target-input'); + if (!i) return; + i.value = el.dataset.v; + i.dispatchEvent(new Event('input', { bubbles: true })); + i.focus(); + return; + } + case 'keys': + return openKeys(); + case 'export-menu': + return el.getAttribute('aria-expanded') === 'true' ? closeMenus() : openExportMenu(el); + case 'export-copy': + closeMenus(); + return state.report && copyText(buildMarkdown(state.report, r), 'Markdown 报告已复制'); + case 'export-download': + closeMenus(); + if (!state.report) return; + download(`${r.name.replace(/[^\w.-]+/g, '-')}-xray-${state.report.head.slice(0, 8)}.md`, buildMarkdown(state.report, r)); + return toast('已开始下载'); + case 'to-chat': + closeMenus(); + return sendToChat(el); + case 'close-drawer': + return closeDrawer(); + case 'close-dialog': + return el.closest('dialog')?.close(); + case 'settings': + await loadState().catch(() => {}); + return openSettings(); + case 'palette': + return openPalette(); + case 'clear-token': + try { + await api('/api/settings', { method: 'POST', body: { token: { host: el.dataset.host, value: '' } } }); + await loadState(); + openSettings(); + } catch (err) { + toast(err.message); + } + return; + case 'theme': + return cycleTheme(); + } +}); +$('#drawer').addEventListener('cancel', (e) => { + e.preventDefault(); + closeDrawer(); +}); +$('#drawer').addEventListener('close', () => { + if (route.file || route.person) closeDrawer(); +}); + +document.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + return $('#palette').open ? $('#palette').close() : openPalette(); + } + if (e.key === 'Escape') { + if (document.querySelector('.popover,.menu')) { + const owner = document.querySelector('.popover,.menu')._owner; + closePopovers(); + closeMenus(); + owner?.focus(); + e.preventDefault(); + } else if (route.tab === 'map' && route.sel && !document.querySelector('dialog[open]')) { + e.preventDefault(); + return setSel(null); + } + } + if (e.target.id === 'pal-q') { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const n = pal.items.length; + if (!n) return; + pal.active = (pal.active + (e.key === 'ArrowDown' ? 1 : n - 1)) % n; + renderPal(); + } else if (e.key === 'Enter') { + e.preventDefault(); + palRun(pal.active); + } + } + const menu = document.querySelector('.menu'); + if (menu && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + const items = [...menu.querySelectorAll('[role=menuitem]')]; + const i = items.indexOf(document.activeElement); + items[(i + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length]?.focus(); + e.preventDefault(); + } + const cell = e.target.classList?.contains('tm-cell') ? e.target : null; + if (cell && !e.metaKey && !e.ctrlKey && !e.altKey) { + const i = Number(cell.dataset.i); + const thumb = cell.dataset.action === 'thumb-open'; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (thumb) return openThumb(i); + return e.key === 'Enter' ? zoomOrOpen(i) : mp.view?.blocks[i] && setSel(selKey(mp.view.blocks[i])); + } + if (/^(Arrow(Left|Right|Up|Down)|Home|End)$/.test(e.key)) { + e.preventDefault(); + return moveCellFocus(cell, e.key); + } + } + const typing = /INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || '') || document.activeElement?.isContentEditable; + if (!typing && !e.metaKey && !e.ctrlKey && !e.altKey) { + if (e.key === '?') { + e.preventDefault(); + return $('#keys').open ? $('#keys').close() : openKeys(); + } + if (!document.querySelector('dialog[open]')) { + if (/^[1-9]$/.test(e.key) && Number(e.key) <= TABS.length && current()) { + e.preventDefault(); + const t = TABS[Number(e.key) - 1][0]; + return go({ tab: t, filter: null, file: null, person: null, ...(t === 'map' && route.tab !== 'map' ? mapPlaceFor(route.repo) : {}) }); + } + if (e.key === 'Backspace' && route.tab === 'map' && route.focus) { + e.preventDefault(); + mp.refocus = true; + return go({ focus: parentOf(route.focus) || null, sel: route.focus }); + } + if ((e.key === '[' || e.key === ']') && visibleRepos().length > 1) { + e.preventDefault(); + const list = visibleRepos(); + const i = list.findIndex((r) => r.id === route.repo); + const next = list[(i + (e.key === ']' ? 1 : list.length - 1) + list.length) % list.length]; + return go({ repo: next.id, tab: route.tab, file: null, person: null, filter: null, ...mapPlaceFor(next.id) }); + } + } + } + if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '') && !document.querySelector('dialog[open]')) { + const i = $('#contrib-filter') || $('#target-input'); + if (i) { + e.preventDefault(); + i.focus(); + } + } +}); +document.addEventListener('input', (e) => { + if (e.target.id === 'contrib-filter') { + state.contribFilter = e.target.value; + $('#contrib').innerHTML = contribHtml(state.report); + } else if (e.target.id === 'target-input') { + const h = $('#parse-hint'); + const p = parsePreview(e.target.value); + h.textContent = p ? `将分析:${p}` : ''; + h.className = `parse-hint${p.startsWith('无法识别') || p.startsWith('地址格式') ? ' err' : ''}`; + } else if (e.target.id === 'pal-q') { + pal.active = 0; + palQuery(e.target.value); + } else if (e.target.id === 'map-month') { + setFrame(Number(e.target.value)); + } +}); +// Single click selects (so the details and co-change lines show); double click goes in. +document.addEventListener('dblclick', (e) => { + const el = e.target.closest?.('[data-action="map-cell"]'); + if (!el) return; + e.preventDefault(); + zoomOrOpen(Number(el.dataset.i)); +}); +document.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = e.target; + if (f.id === 'analyze-form') return analyze($('#target-input').value); + const msg = (id, text, ok) => { + const m = $(id); + m.textContent = text; + m.className = `form-msg ${ok ? 'ok' : 'err'}`; + }; + const save = async (body, id) => { + try { + await api('/api/settings', { method: 'POST', body }); + await loadState(); + openSettings(); + msg(id, '已保存', true); + return true; + } catch (err) { + msg(id, err.message); + f.querySelector('input,textarea')?.focus(); + return false; + } + }; + if (f.id === 'token-form') await save({ token: { host: $('#tk-host').value.trim(), value: $('#tk-val').value.trim() } }, '#tk-msg'); + if (f.id === 'proxy-form') await save({ proxy: $('#pf-proxy').value.trim() }, '#pf-msg'); + if (f.id === 'scan-form' && (await save({ scanRoots: $('#sc-roots').value.split('\n').map((x) => x.trim()).filter(Boolean) }, '#sc-msg'))) { + state.suggest = null; + loadSuggest(true); + } +}); + +// Spotlight border: cards track the pointer (one rAF per frame, only the hovered card). +let spotEl = null; +let spotRaf = 0; +document.addEventListener( + 'pointermove', + (e) => { + const el = e.target.closest?.('.card,.kpi,.suggest'); + if (spotEl && spotEl !== el) { + spotEl.style.removeProperty('--mx'); + spotEl.style.removeProperty('--my'); + } + spotEl = el; + if (!el || spotRaf) return; + spotRaf = requestAnimationFrame(() => { + spotRaf = 0; + const b = el.getBoundingClientRect(); + el.style.setProperty('--mx', `${e.clientX - b.left}px`); + el.style.setProperty('--my', `${e.clientY - b.top}px`); + }); + }, + { passive: true }, +); +addEventListener('resize', () => placeInk(), { passive: true }); + +// Tooltips: pointer and keyboard; first one delayed, peers instant. +const tip = $('#tooltip'); +let tipWarmUntil = 0; +let tipTimer = null; +function showTip(el, x, y) { + tip.textContent = el.getAttribute('data-tip'); + tip.classList.add('show'); + tipWarmUntil = Date.now() + 600; + const b = tip.getBoundingClientRect(); + if (x + 14 + b.width > innerWidth - 8) x = x - b.width - 28; + if (y + 14 + b.height > innerHeight - 8) y = y - b.height - 28; + tip.style.left = `${x + 14}px`; + tip.style.top = `${y + 14}px`; +} +function hideTip() { + clearTimeout(tipTimer); + if (tip.classList.contains('show')) tipWarmUntil = Date.now() + 600; + tip.classList.remove('show'); +} +document.addEventListener('pointermove', (e) => { + const el = e.target.closest?.('[data-tip]'); + if (!el) return hideTip(); + clearTimeout(tipTimer); + if (tip.classList.contains('show') || Date.now() < tipWarmUntil) showTip(el, e.clientX, e.clientY); + else tipTimer = setTimeout(() => showTip(el, e.clientX, e.clientY), 350); +}); +document.addEventListener('focusin', (e) => { + const el = e.target.closest?.('[data-tip]'); + if (!el) return hideTip(); + const b = el.getBoundingClientRect(); + showTip(el, b.left, b.bottom); +}); +window.addEventListener('scroll', hideTip, { passive: true }); + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- +applyTheme(); +(async () => { + try { + await loadState(); + connect(); + if (!route.repo && !location.search) { + const last = localStorage.getItem('rr-repo'); + if (last && repoById(last)) history.replaceState(null, '', href({ repo: last })); + } + await applyRoute(); + } catch (e) { + main.innerHTML = ``; + } +})(); diff --git a/plugins/yujiachen-y/repo-xray/miniapp/client/charts.js b/plugins/yujiachen-y/repo-xray/miniapp/client/charts.js new file mode 100644 index 0000000..c30a45d --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/client/charts.js @@ -0,0 +1,581 @@ +// Hand-written SVG charts. Every function returns markup; tooltips use data-tip. + +export const esc = (s) => + String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + +const pad = (n) => String(n).padStart(2, '0'); +export const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +export const parseYmd = (s) => { + const [y, m, d] = String(s).split('-').map(Number); + return new Date(y, m - 1, d); +}; +export const addDays = (s, n) => { + const d = parseYmd(s); + return ymd(new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)); +}; +export const dayDiff = (a, b) => Math.round((parseYmd(b) - parseYmd(a)) / 86_400_000); +export const mondayOf = (s) => addDays(s, -((parseYmd(s).getDay() + 6) % 7)); +export const WD = ['日', '一', '二', '三', '四', '五', '六']; +export const fmt = (n) => (n == null ? '–' : Number(n).toLocaleString('zh-CN')); + +let uid = 0; +const nextId = (p) => `${p}${++uid}`; + +export function memberColor(name) { + let h = 0; + for (const c of String(name)) h = (h * 31 + c.charCodeAt(0)) >>> 0; + // Skip the violet/purple/magenta band (245°–335°): spread the rest of the wheel instead. + let hue = h % 270; + if (hue >= 245) hue += 90; + return `hsl(${hue} 58% 52%)`; +} + +function quantiles(values) { + const v = values.filter((x) => x > 0).sort((a, b) => a - b); + const q = (p) => (v.length ? v[Math.min(v.length - 1, Math.floor(p * (v.length - 1)))] : 0); + return [q(0.3), q(0.6), q(0.85)]; +} +const levelOf = (t, v) => (!v ? 0 : v <= t[0] ? 1 : v <= t[1] ? 2 : v <= t[2] ? 3 : 4); + +export function heatLegend() { + return ``; +} + +/** GitHub-style calendar, Monday-first rows. */ +export function calendar({ daily, start, end, cell = 12, gap = 3, label = '活动热力图', unit = '条活动' }) { + const startMon = mondayOf(start); + const nWeeks = Math.floor(dayDiff(startMon, end) / 7) + 1; + const t = quantiles(Object.values(daily)); + const left = 22; + const top = 16; + const cw = Math.min(cell * 3.2, Math.max(cell, Math.floor((53 * (cell + gap)) / nWeeks) - gap)); + const stepX = cw + gap; + const step = cell + gap; + const W = left + nWeeks * stepX; + const H = top + 7 * step; + let cells = ''; + let months = ''; + let lastMonth = -1; + let lastLabelW = -9; + for (let w = 0; w < nWeeks; w++) { + const weekStart = addDays(startMon, w * 7); + for (let d = 0; d < 7; d++) { + const date = addDays(startMon, w * 7 + d); + if (date > end) break; + const inRange = date >= start; + const v = daily[date] || 0; + const lv = inRange ? levelOf(t, v) : 0; + cells += ``; + } + const mDate = parseYmd(weekStart); + if (mDate.getMonth() !== lastMonth && (w - lastLabelW) * stepX >= 3 * step) { + months += `${mDate.getMonth() + 1}月`; + lastLabelW = w; + } + lastMonth = mDate.getMonth(); + } + const days = [1, 3, 5].map((d) => `${WD[d]}`).join(''); + return `${months}${days}${cells}`; +} + +/** Monotone cubic interpolation (Fritsch–Carlson) for evenly or unevenly spaced x. */ +function monotone(points) { + const n = points.length; + if (n === 0) return ''; + if (n === 1) return `L${points[0][0]},${points[0][1]}`; + const dx = []; + const m = []; + for (let i = 0; i < n - 1; i++) { + dx.push(points[i + 1][0] - points[i][0]); + m.push((points[i + 1][1] - points[i][1]) / (dx[i] || 1)); + } + const tan = [m[0]]; + for (let i = 1; i < n - 1; i++) tan.push(m[i - 1] * m[i] <= 0 ? 0 : (m[i - 1] + m[i]) / 2); + tan.push(m[n - 2]); + for (let i = 0; i < n - 1; i++) { + if (m[i] === 0) { + tan[i] = 0; + tan[i + 1] = 0; + continue; + } + const a = tan[i] / m[i]; + const b = tan[i + 1] / m[i]; + const s = a * a + b * b; + if (s > 9) { + const k = 3 / Math.sqrt(s); + tan[i] = k * a * m[i]; + tan[i + 1] = k * b * m[i]; + } + } + let d = ''; + for (let i = 0; i < n - 1; i++) { + const [x0, y0] = points[i]; + const [x1, y1] = points[i + 1]; + const h = dx[i] / 3; + d += `C${(x0 + h).toFixed(1)},${(y0 + tan[i] * h).toFixed(1)} ${(x1 - h).toFixed(1)},${(y1 - tan[i + 1] * h).toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`; + } + return d; +} + +function niceMax(v) { + if (v <= 0) return 4; + const p = 10 ** Math.floor(Math.log10(v)); + for (const k of [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]) if (k * p >= v) return k * p; + return 10 * p; +} + +/** Smooth stacked area by week. series[w][k]. */ +export function stackedArea({ weeks, series, kinds, labels, colors, hidden, end, height = 230 }) { + const W = 760; + const H = height; + const L = 40; + const R = 10; + const T = 10; + const B = 24; + const vis = kinds.map((k, i) => [k, i]).filter(([k]) => !hidden.has(k)); + const totals = series.map((row) => vis.reduce((s, [, i]) => s + row[i], 0)); + const max = niceMax(Math.max(1, ...totals)); + const n = weeks.length; + const x = (i) => L + (n <= 1 ? 0 : (i * (W - L - R)) / (n - 1)); + const y = (v) => T + (H - T - B) * (1 - v / max); + const id = nextId('sa'); + let defs = ''; + let layers = ''; + const base = new Array(n).fill(0); + for (const [k, i] of vis) { + const lower = base.map((v, w) => [x(w), y(v)]); + for (let w = 0; w < n; w++) base[w] += series[w][i]; + const upper = base.map((v, w) => [x(w), y(v)]); + const gid = `${id}-${k}`; + defs += ``; + const rev = [...lower].reverse(); + layers += ``; + } + const top = totals.map((v, w) => [x(w), y(v)]); + const line = n ? `` : ''; + let grid = ''; + for (let g = 0; g <= 4; g++) { + const v = (max * g) / 4; + grid += `${fmt(Math.round(v))}`; + } + let xl = ''; + let lastM = -1; + let lastX = -99; + weeks.forEach((wk, i) => { + const m = parseYmd(wk).getMonth(); + if (m !== lastM && x(i) - lastX > 34) { + xl += `${m + 1}月`; + lastX = x(i); + } + lastM = m; + }); + const colW = n > 1 ? (W - L - R) / (n - 1) : W - L - R; + const hits = weeks + .map((wk, i) => { + const wEnd = addDays(wk, 6) > end ? end : addDays(wk, 6); + const lines = vis.filter(([, k]) => series[i][k] > 0).map(([k, ki]) => `${labels[k]} ${fmt(series[i][ki])}`); + const tip = `${wk.slice(5)} – ${wEnd.slice(5)} 共 ${fmt(totals[i])}${lines.length ? ' ' + lines.join(' ') : ''}`; + return ``; + }) + .join(''); + const peak = totals.indexOf(Math.max(...totals)); + const peakMark = totals[peak] > 0 ? `峰值 ${fmt(totals[peak])}` : ''; + return `${defs}${grid}${layers}${line}${peakMark}${xl}${hits}`; +} + +export function donut({ items, center, sub }) { + const total = items.reduce((s, i) => s + i.value, 0); + const R = 62; + const r = 44; + let a0 = -Math.PI / 2; + let paths = ''; + for (const it of items) { + if (!it.value) continue; + const frac = it.value / total; + const a1 = a0 + frac * Math.PI * 2 - (frac < 1 ? 0.012 : 0); + const large = a1 - a0 > Math.PI ? 1 : 0; + const p = (a, rad) => `${(75 + rad * Math.cos(a)).toFixed(2)},${(75 + rad * Math.sin(a)).toFixed(2)}`; + paths += + frac >= 0.999 + ? `` + : ``; + a0 += frac * Math.PI * 2; + } + if (!total) paths = ``; + return `${paths}${esc(center)}${esc(sub)}`; +} + +/** Members × weeks matrix. rows: [{ username, label, weekly }] */ +export function swimlane({ rows, weeks, end }) { + const labelW = 96; + const ch = 11; + const gap = 2; + const rowH = 16; + const n = weeks.length; + const cell = Math.min(40, Math.max(ch, Math.floor((53 * (ch + gap)) / n) - gap)); + const W = labelW + n * (cell + gap); + const H = 16 + rows.length * rowH; + const t = quantiles(rows.flatMap((r) => r.weekly)); + let out = ''; + let lastM = -1; + let lastX = -99; + weeks.forEach((wk, i) => { + const m = parseYmd(wk).getMonth(); + const xx = labelW + i * (cell + gap); + if (m !== lastM && xx - lastX > 30) { + out += `${m + 1}月`; + lastX = xx; + } + lastM = m; + }); + rows.forEach((r, ri) => { + const yy = 16 + ri * rowH; + out += `${esc(r.label.length > 9 ? r.label.slice(0, 8) + '…' : r.label)}`; + r.weekly.forEach((v, i) => { + const wEnd = addDays(weeks[i], 6) > end ? end : addDays(weeks[i], 6); + out += ``; + }); + out += ''; + }); + return `${out}`; +} + +/** 7 × 24 punch card, Monday-first rows. */ +export function punchcard({ punch, color = 'var(--k-code)', label = '作息打卡图' }) { + const L = 22; + const T = 6; + const step = 26; + const rowH = 22; + const W = L + 24 * step; + const H = T + 7 * rowH + 16; + const max = Math.max(1, ...punch); + let out = ''; + for (let d = 0; d < 7; d++) { + const yy = T + d * rowH + rowH / 2; + out += `${WD[(d + 1) % 7]}`; + for (let h = 0; h < 24; h++) { + const v = punch[d * 24 + h] || 0; + const r = v ? 2 + Math.sqrt(v / max) * 8.5 : 1.2; + const night = h >= 22 || h < 6; + out += ``; + } + } + for (let h = 0; h < 24; h += 3) out += `${h}时`; + return `${out}`; +} + +/** Build vs collaborate scatter with median quadrants and collision-aware labels. */ +export function scatter({ points }) { + const W = 580; + const H = 340; + const L = 34; + const R = 14; + const T = 26; + const B = 34; + if (!points.length) return ''; + const sx = Math.sqrt(Math.max(1, ...points.map((p) => p.x))); + const sy = Math.sqrt(Math.max(1, ...points.map((p) => p.y))); + const X = (v) => L + (W - L - R) * (Math.sqrt(v) / sx); + const Y = (v) => T + (H - T - B) * (1 - Math.sqrt(v) / sy); + const med = (a) => { + const v = [...a].sort((p, q) => p - q); + return v.length ? v[Math.floor(v.length / 2)] : 0; + }; + const mx = med(points.map((p) => p.x)); + const my = med(points.map((p) => p.y)); + const maxR = Math.max(1, ...points.map((p) => p.r)); + const qx = X(mx); + const qy = Y(my); + let out = ``; + out += ``; + const q = (x, y, anchor, t) => `${t}`; + out += q(W - R, T - 10, 'end', '全能型 ↗') + q(L, T - 10, 'start', '协作型') + q(W - R, H - 4, 'end', '产出型') + q(L, H - 4, 'start', '轻量参与'); + out += `产出(推送 + 发起 + 合入)→协作(Review + 评论)→`; + const placed = []; + const nodes = [...points].sort((a, b) => b.r - a.r).map((p) => ({ ...p, cx: X(p.x), cy: Y(p.y), rad: 4 + 12 * Math.sqrt(p.r / maxR) })); + let circles = ''; + for (const p of nodes) { + circles += ``; + } + let labels = ''; + const byImportance = [...nodes].sort((a, b) => b.x + b.y - (a.x + a.y)); + for (const p of byImportance) { + const text = p.label.length > 6 ? p.label.slice(0, 6) : p.label; + const w = [...text].reduce((s, c) => s + (/[\x00-\x7f]/.test(c) ? 5.6 : 10), 0); + const h = 11; + const cands = [ + [p.cx, p.cy - p.rad - 3, 'middle'], + [p.cx + p.rad + 3, p.cy + 3.5, 'start'], + [p.cx - p.rad - 3, p.cy + 3.5, 'end'], + [p.cx, p.cy + p.rad + 10, 'middle'], + ]; + for (const [x, y, anchor] of cands) { + const x0 = anchor === 'middle' ? x - w / 2 : anchor === 'end' ? x - w : x; + const box = [x0 - 1, y - h + 1, x0 + w + 1, y + 2]; + if (box[0] < L || box[2] > W - R || box[1] < T - 4 || box[3] > H - B) continue; + if (placed.some((b) => !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]))) continue; + placed.push(box); + labels += `${esc(text)}`; + break; + } + } + return `${out}${circles}${labels}`; +} + +export function sparkline(values, { w = 96, h = 26, color = 'var(--k-code)', stretch = false } = {}) { + if (!values.length) return ''; + const max = Math.max(1, ...values); + const n = values.length; + const pts = values.map((v, i) => [n <= 1 ? 0 : (i * w) / (n - 1), h - 2 - ((h - 4) * v) / max]); + const id = nextId('sp'); + const d = `M${pts[0][0]},${pts[0][1]}${monotone(pts)}`; + const size = stretch ? 'preserveAspectRatio="none" class="spark-fill"' : `width="${w}" height="${h}"`; + return ``; +} + +/** Monthly bars with optional averaged bands. months: [{m:'YYYY-MM', n}] */ +export function monthBars({ months, partial, bands = [], height = 220, color = 'var(--k-code)' }) { + const W = 760; + const H = height; + const L = 40; + const R = 8; + const T = 18; + const B = 24; + const n = months.length; + if (!n) return ''; + const peak = Math.max(1, ...months.map((x) => x.n)); + const raw = peak / 4; + const p10 = 10 ** Math.floor(Math.log10(raw)); + const step = Math.max(1, [1, 2, 2.5, 5, 10].map((k) => k * p10).find((k) => k >= raw)); + const ticks = Math.ceil(peak / step); + const max = step * ticks; + const bw = (W - L - R) / n; + const y = (v) => T + (H - T - B) * (1 - v / max); + const gid = nextId('mb'); + let out = ``; + for (let g = 0; g <= ticks; g++) { + const v = step * g; + out += `${fmt(Math.round(v))}`; + } + months.forEach((x, i) => { + const h = y(0) - y(x.n); + const isPartial = x.m === partial; + const gap = Math.min(3, bw * 0.25); + out += ``; + }); + const placed = []; + for (const b of bands) { + const i0 = months.findIndex((x) => x.m === b.from); + const i1 = months.findIndex((x) => x.m === b.to); + if (i0 < 0 || i1 < 0) continue; + const yy = y(b.value); + // Label sits above its line; if it would collide with an earlier label, drop it below the line. + let ly = yy - 6; + if (placed.some((p) => Math.abs(p - ly) < 13)) ly = yy + 14; + if (ly < T + 9) ly = yy + 14; + placed.push(ly); + out += `${esc(b.label)}`; + } + const every = Math.ceil(n / 12); + months.forEach((x, i) => { + if (i % every && i !== n - 1) return; + const [yy, mm] = x.m.split('-'); + out += `${mm === '01' || i === 0 ? `${yy.slice(2)}/` : ''}${Number(mm)}月`; + }); + return `${out}`; +} + +export function niceMaxPublic(v) { + if (v <= 0) return 4; + const p = 10 ** Math.floor(Math.log10(v)); + for (const k of [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]) if (k * p >= v) return k * p; + return 10 * p; +} + +/** Stacked horizontal share bar. items: [{label, value, color}] */ +export function shareBar(items, { height = 14, label = '占比' } = {}) { + const total = items.reduce((s, i) => s + i.value, 0) || 1; + return ``; +} + +// --------------------------------------------------------------------------- +// Code map (nested squarified treemap) and KPI instruments +// --------------------------------------------------------------------------- +export const fmtK = (n) => (n >= 10_000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)); +export const textW = (s, px = 11) => [...s].reduce((w, c) => w + (/[\x00-\x7f]/.test(c) ? px * 0.62 : px), 0); +/** First candidate that fits; otherwise the last one shortened at the end, or '' when too little would survive. */ +export function fitLabel(cands, maxW, px = 11) { + for (const c of cands) if (textW(c, px) <= maxW) return c; + const out = clip(cands[cands.length - 1], maxW, px); + return [...out].length >= 4 ? out : ''; +} +function clip(s, maxW, px = 11) { + if (textW(s, px) <= maxW) return s; + const chars = [...s]; + while (chars.length > 1 && textW(`${chars.join('')}…`, px) > maxW) chars.pop(); + return `${chars.join('')}…`; +} +const GENERIC_DIRS = new Set(['src', 'lib', 'test', 'tests', 'unit', 'e2e', 'spec', '__tests__', 'app', 'pkg', 'internal', 'components', 'utils', 'assets', 'docs', 'scripts', 'source', 'main', 'resources', 'public', 'static']); +/** Directory label candidates, most to least specific; the last one is the fallback. */ +export function dirLabels(rel) { + const segs = rel.split('/'); + const last = segs.at(-1); + const key = segs.length >= 2 && GENERIC_DIRS.has(last.toLowerCase()) ? segs[0] : last; + const out = [`${rel}/`]; + if (segs.length >= 3) out.push(`${segs[0]}/…/${last}/`); + out.push(`${key}/`, key); + return out; +} + +/** Squarified layout (Bruls, Huizing & van Wijk). items: [{ value > 0 }] sorted descending. */ +export function squarify(items, x, y, w, h) { + const out = []; + const total = items.reduce((s, i) => s + i.value, 0); + if (!total || w <= 0 || h <= 0) return out; + const scale = (w * h) / total; + let rest = items.map((item) => ({ item, area: item.value * scale })); + const worst = (row, side) => { + let sum = 0; + let mx = 0; + let mn = Infinity; + for (const r of row) { + sum += r.area; + mx = Math.max(mx, r.area); + mn = Math.min(mn, r.area); + } + const s2 = side * side; + return Math.max((s2 * mx) / (sum * sum), (sum * sum) / (s2 * mn)); + }; + while (rest.length) { + const side = Math.min(w, h); + const row = [rest[0]]; + let cur = worst(row, side); + for (let i = 1; i < rest.length; i++) { + const next = worst([...row, rest[i]], side); + if (next > cur) break; + row.push(rest[i]); + cur = next; + } + const sum = row.reduce((s, r) => s + r.area, 0); + if (w >= h) { + const sw = sum / h; + let cy = y; + for (const r of row) { + out.push({ item: r.item, x, y: cy, w: sw, h: r.area / sw }); + cy += r.area / sw; + } + x += sw; + w -= sw; + } else { + const sh = sum / w; + let cx = x; + for (const r of row) { + out.push({ item: r.item, x: cx, y, w: r.area / sh, h: sh }); + cx += r.area / sh; + } + y += sh; + h -= sh; + } + rest = rest.slice(row.length); + } + return out; +} + +const RISK_BINS = [0.1, 0.2, 0.3, 0.4]; +const RISK_LABELS = ['<10%', '10–20%', '20–30%', '30–40%', '≥40%']; +export const riskLevel = (ratio) => RISK_BINS.filter((b) => ratio >= b).length; +export function riskLegend() { + return ``; +} + +/** + * Nested layout: blocks grouped by `group` (the focus' direct child that holds them), groups + * squarified by total value, blocks squarified inside each group frame. + * @returns {{ width: number, height: number, groups: any[], cells: { i: number, x: number, y: number, w: number, h: number }[] }} + */ +export function mapLayout(blocks, { width, height, value }) { + const groups = new Map(); + blocks.forEach((b, i) => { + const v = value(b); + if (!(v > 0)) return; + const g = groups.get(b.group) || { key: b.group, value: 0, items: [] }; + g.value += v; + g.items.push({ i, value: v }); + groups.set(b.group, g); + }); + const PAD = 3; + const HEAD = 19; + const out = { width, height, groups: [], cells: [] }; + for (const g of squarify([...groups.values()].sort((a, b) => b.value - a.value), 0, 0, width, height)) { + const G = g.item; + const head = g.w > 64 && g.h > 52; + out.groups.push({ key: G.key, x: g.x, y: g.y, w: g.w, h: g.h, head, value: G.value }); + const top = head ? HEAD : PAD; + for (const c of squarify(G.items.sort((a, b) => b.value - a.value), g.x + PAD, g.y + top, g.w - 2 * PAD, g.h - top - PAD)) { + out.cells.push({ i: c.item.i, x: c.x + 1, y: c.y + 1, w: Math.max(0, c.w - 2), h: Math.max(0, c.h - 2) }); + } + } + return out; +} + +/** Structure only (frames, tiles, labels); colours, values and tooltips are painted by the caller. */ +export function mapSvg(layout, { blocks, groupLabel, groupValue = fmtK, nameOf, action = 'map-cell', label = '代码地图' }) { + const id = nextId('mp'); + let frames = ''; + const heads = new Set(); + for (const g of layout.groups) { + frames += ``; + if (!g.head) continue; + heads.add(g.key); + const name = groupLabel(g.key); + // The header carries the area measure with its unit, so it never reads as the tiles' lens value. + const val = groupValue(g.value); + const both = textW(name, 10.5) + textW(val, 10.5) + 24 <= g.w; + frames += `${esc(both ? name : clip(name, g.w - 16, 10.5))}${both ? `${esc(val)}` : ''}`; + } + let cells = ''; + for (const c of layout.cells) { + const b = blocks[c.i]; + const { x, y, w, h } = c; + const whole = b.kind === 'dir' && b.path === b.group && heads.has(b.group); // the frame header already names it + const px = w >= 130 ? 11 : 10; + const name = !whole && w >= 40 && h >= 20 ? fitLabel(nameOf(b), w - (b.risk ? 22 : 12), px) : ''; + const showVal = whole ? w >= 54 && h >= 20 : w >= 54 && h >= 36; + cells += + `` + + `` + + (b.kind === 'other' ? `` : '') + + (name ? `${esc(name)}` : '') + + (showVal ? `` : '') + + '' + + (b.risk && w >= 16 && h >= 14 ? `` : '') + + ''; + } + return `${frames}${cells}`; +} + +/** Horizontal instrument: filled share with an optional threshold tick. */ +export function meter({ value, max = 1, threshold = null, tick = '', color = 'var(--k-code)' }) { + const p = Math.max(0, Math.min(1, max ? value / max : 0)); + const t = threshold == null ? null : Math.max(0, Math.min(1, threshold / max)); + return ``; +} + +/** Segmented share bar with a marker (e.g. the 50% line). items: [{ value, color }] as fractions of 1. */ +export function stackMeter(items, { mark = 0.5, tick = '' } = {}) { + return ``; +} + +/** One cell per slot (e.g. Top-N hotspot ranks), lit where the predicate holds. */ +export function dots(n, lit) { + return ``; +} + +/** Tiny bars, last `hi` of them emphasised. */ +export function miniBars(values, { hi = 3, color = 'var(--k-review)' } = {}) { + const max = Math.max(1, ...values); + return ``; +} diff --git a/plugins/yujiachen-y/repo-xray/miniapp/client/icon.svg b/plugins/yujiachen-y/repo-xray/miniapp/client/icon.svg new file mode 100644 index 0000000..5650934 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/client/icon.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/yujiachen-y/repo-xray/miniapp/client/index.html b/plugins/yujiachen-y/repo-xray/miniapp/client/index.html new file mode 100644 index 0000000..c2360d3 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/client/index.html @@ -0,0 +1,45 @@ + + + + + + 仓库透视 + + + + + + +
      + +
      +
      + + + + + + + + + diff --git a/plugins/yujiachen-y/repo-xray/miniapp/client/styles.css b/plugins/yujiachen-y/repo-xray/miniapp/client/styles.css new file mode 100644 index 0000000..95c6107 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/client/styles.css @@ -0,0 +1,4150 @@ +:root, +[data-theme='light'] { + --mcode-bg: #fafafa; + --mcode-bg-secondary: #f4f4f5; + --mcode-surface: #ffffff; + --mcode-surface-muted: #f6f6f7; + --mcode-text: #171717; + --mcode-text-muted: #666666; + --mcode-text-subtle: #a1a1a1; + --mcode-border: #0a0a0a14; + --mcode-border-strong: #0a0a0a59; + --mcode-accent: #0094fc; + --mcode-success: #04b54b; + --mcode-warning: #f56811; + --mcode-danger: #f73646; + --mcode-primary-action-bg: #171717; + --mcode-primary-action-text: #ffffff; + + --heat-0: #eceef2; + --heat-1: #c9e9f6; + --heat-2: #7fcbea; + --heat-3: #2d9fd6; + --heat-4: #0f5f99; + --risk-0: #eceef2; + --risk-1: #f9e1e2; + --risk-2: #f4bcc0; + --risk-3: #ea8790; + --risk-4: #d9364a; + /* Most recent change: fresh green fading to grey (dormant). */ + --age-0: #0f9d6b; + --age-1: #4cbb8a; + --age-2: #97d5b6; + --age-3: #cfe5d9; + --age-4: #e6e7ea; + /* Knowledge loss: neutral to amber to red-orange. */ + --loss-0: #eceef2; + --loss-1: #fbe5c2; + --loss-2: #f6c27a; + --loss-3: #ee8a47; + --loss-4: #d4472a; + --k-code: #2f7cf6; + --k-mr: #06a8c4; + --k-merge: #12a594; + --k-review: #e79a10; + --k-comment: #f2685a; + --k-issue: #2fb35a; + --k-other: #a3abb8; + --glow: rgb(47 124 246 / 10%); + --shadow: 0 0 20px rgb(10 10 10 / 8%); + --grid-dot: rgb(10 10 10 / 9%); + --spot: rgb(0 148 252 / 6%); + --spot-border: rgb(0 148 252 / 50%); + --term-bg: #fcfcfd; + color-scheme: light; +} + +[data-theme='dark'] { + --mcode-bg: #0a0a0b; + --mcode-bg-secondary: #0d0d0f; + --mcode-surface: #111113; + --mcode-surface-muted: #18181b; + --mcode-text: #ededed; + --mcode-text-muted: #9b9ba3; + --mcode-text-subtle: #5f5f68; + --mcode-border: #ffffff14; + --mcode-border-strong: #ffffff59; + --mcode-accent: #3aa3ff; + --mcode-success: #23c160; + --mcode-warning: #f0782a; + --mcode-danger: #f04458; + --mcode-primary-action-bg: #ffffff; + --mcode-primary-action-text: #171717; + + --heat-0: #1c1d22; + --heat-1: #0f3446; + --heat-2: #155f80; + --heat-3: #2a9fd0; + --heat-4: #8fdcff; + --risk-0: #1c1d22; + --risk-1: #2f1f24; + --risk-2: #4d2229; + --risk-3: #7a2633; + --risk-4: #c43345; + --age-0: #2fd39a; + --age-1: #1f9e72; + --age-2: #176a50; + --age-3: #1c3a31; + --age-4: #222327; + --loss-0: #1c1d22; + --loss-1: #3a2c17; + --loss-2: #6b4a1a; + --loss-3: #b5651f; + --loss-4: #f0783a; + --k-code: #5b9cff; + --k-mr: #22c7e0; + --k-merge: #2cc3b0; + --k-review: #f2b13c; + --k-comment: #ff8070; + --k-issue: #49c972; + --k-other: #7c8494; + --glow: rgb(91 156 255 / 14%); + --shadow: 0 0 24px rgb(0 0 0 / 40%); + --grid-dot: rgb(255 255 255 / 7%); + --spot: rgb(90 150 255 / 8%); + --spot-border: rgb(130 170 255 / 60%); + --term-bg: #0c0c0e; + color-scheme: dark; +} +:root { + --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', SFMono-Regular, Menlo, monospace; + --brand: linear-gradient(135deg, #0ea5e9 0%, #06b6d4 45%, #10b981 100%); + --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1); +} + +* { + box-sizing: border-box; +} +[hidden] { + display: none !important; +} +html, +body { + margin: 0; + background: var(--mcode-bg); + color: var(--mcode-text); + font: 14px/1.55 ui-sans-serif, -apple-system, system-ui, 'Segoe UI', Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; +} +/* Keep focused or scrolled-to elements clear of the sticky tab bar (about 45px tall). */ +html { + scroll-padding-top: 56px; +} +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} +.mono { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} +:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; + border-radius: 6px; +} + +/* ---------- shell ---------- */ +.shell { + display: flex; + min-height: 100vh; +} +.sidebar { + width: 232px; + flex: none; + border-right: 1px solid var(--mcode-border); + background: var(--mcode-bg-secondary); + display: flex; + flex-direction: column; + padding: 16px 12px; + gap: 16px; + position: sticky; + top: 0; + height: 100vh; +} +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 8px; +} +.brand-mark { + width: 30px; + height: 30px; + flex: none; + border-radius: 22%; + overflow: hidden; +} +.brand-mark img { + display: block; + width: 100%; + height: 100%; +} +.brand-name { + font-weight: 600; + font-size: 15px; +} +.brand-sub { + font-size: 12px; + color: var(--mcode-text-muted); +} +.nav-title { + font-size: 12px; + color: var(--mcode-text-muted); + padding: 0 8px; + display: flex; + justify-content: space-between; + align-items: center; +} +.team-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + flex: 1; +} +.team-item { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + border: 0; + background: transparent; + text-align: left; + border-radius: 8px; + padding: 7px 8px; + cursor: pointer; + transition: background 150ms ease-out; +} +.team-item:hover { + background: var(--mcode-surface-muted); +} +.team-item[aria-current='page'] { + background: var(--mcode-surface); + box-shadow: inset 0 0 0 1px var(--mcode-border); +} +.team-item .ti-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.team-item .ti-meta { + font-size: 12px; + color: var(--mcode-text-muted); +} +.kind-dot { + width: 18px; + height: 18px; + flex: none; + color: var(--mcode-text-muted); +} +.sidebar-foot { + display: flex; + flex-direction: column; + gap: 4px; + border-top: 1px solid var(--mcode-border); + padding-top: 12px; +} + +main { + flex: 1; + min-width: 0; + padding: 24px 28px 48px; +} + +/* ---------- buttons & inputs ---------- */ +.btn { + height: 34px; + padding: 0 14px; + border-radius: 8px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + transition: background 150ms ease-out, opacity 150ms ease-out, border-color 150ms ease-out; +} +.btn:hover { + background: var(--mcode-surface-muted); +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.btn svg { + width: 16px; + height: 16px; +} +.btn-primary { + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + border-color: transparent; +} +.btn-primary:hover { + background: var(--mcode-primary-action-bg); + opacity: 0.86; +} +.btn-ghost { + background: transparent; + border-color: transparent; +} +.btn-ghost:hover { + background: var(--mcode-surface-muted); +} +.btn-danger { + color: var(--mcode-danger); +} +.btn-sm { + height: 28px; + padding: 0 10px; + font-size: 13px; +} +.btn-block { + width: 100%; + justify-content: flex-start; +} +.icon-btn { + width: 30px; + height: 30px; + padding: 0; + justify-content: center; +} +.input, +select.input, +textarea.input { + height: 36px; + border-radius: 8px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface-muted); + padding: 0 10px; + width: 100%; + transition: border-color 150ms ease-out; +} +textarea.input { + height: auto; + min-height: 110px; + padding: 8px 10px; + resize: vertical; +} +.input:focus { + outline: none; + border-color: var(--mcode-accent); + box-shadow: 0 0 0 3px var(--glow); +} +.input::placeholder { + color: var(--mcode-text-subtle); +} +.field { + display: flex; + flex-direction: column; + gap: 6px; +} +.field > label, +.field-label { + font-size: 13px; + color: var(--mcode-text-muted); +} +.hint { + font-size: 12px; + color: var(--mcode-text-muted); +} +.segmented { + display: inline-flex; + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); + border-radius: 9px; + padding: 2px; +} +.segmented button { + border: 0; + background: transparent; + height: 28px; + padding: 0 11px; + border-radius: 7px; + cursor: pointer; + color: var(--mcode-text-muted); + transition: background 150ms ease-out, color 150ms ease-out; +} +.segmented button[aria-pressed='true'] { + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: 0 1px 2px rgb(0 0 0 / 8%), inset 0 0 0 1px var(--mcode-border); +} + +/* ---------- page header ---------- */ +.page-head { + display: flex; + gap: 16px; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + margin-bottom: 20px; +} +.page-head h1 { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; +} +.page-meta { + margin-top: 4px; + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + font-size: 13px; + color: var(--mcode-text-muted); + align-items: center; +} +.page-meta .chip svg { + width: 14px; + height: 14px; +} +.chip { + display: inline-flex; + align-items: center; + gap: 5px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface-muted); + color: var(--mcode-text-muted); + white-space: nowrap; +} +.head-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--mcode-success); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--mcode-success) 22%, transparent); +} + +.progress { + margin: -6px 0 18px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + padding: 10px 14px; + background: var(--mcode-surface); + display: flex; + gap: 14px; + align-items: center; + font-size: 13px; +} +.progress[hidden] { + display: none; +} +.progress-bar { + flex: 1; + height: 6px; + border-radius: 999px; + background: var(--mcode-surface-muted); + overflow: hidden; + min-width: 120px; +} +.progress-bar > span { + display: block; + height: 100%; + width: 0; + border-radius: inherit; + background: linear-gradient(90deg, var(--k-code), var(--k-mr)); + transition: width 300ms ease-out; +} +.spinner { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid var(--mcode-border); + border-top-color: var(--k-code); + animation: spin 0.8s linear infinite; + flex: none; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ---------- cards & grid ---------- */ +.kpis { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} +.kpi { + position: relative; + border: 1px solid var(--mcode-border); + border-radius: 12px; + padding: 14px 16px 14px; + background-color: var(--mcode-surface); + box-shadow: inset 0 2px 0 0 var(--kpi-accent, var(--k-code)); + overflow: hidden; + display: flex; + flex-direction: column; +} +.kpi-label { + font-size: 12px; + color: var(--mcode-text-muted); + padding-right: 22px; +} +.kpi .info-btn { + position: absolute; + top: 10px; + right: 10px; + margin: 0; +} +.kpi-value { + font-size: 24px; + line-height: 34px; + font-weight: 600; + letter-spacing: -0.03em; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums slashed-zero; + white-space: nowrap; +} +.kpi-value .unit { + font-size: 12px; + font-weight: 500; + letter-spacing: 0; + margin-left: 3px; + color: var(--mcode-text-muted); + font-family: ui-sans-serif, -apple-system, system-ui, sans-serif; +} +.kpi-sub { + font-size: 12px; + color: var(--mcode-text-muted); + display: flex; + flex-wrap: wrap; + gap: 2px 6px; + align-items: center; + min-height: 18px; + position: relative; + z-index: 1; +} +.kpi.has-spark .kpi-sub { + padding-right: 64px; +} +.delta { + white-space: nowrap; +} +.kpi-spark { + opacity: 0.55 !important; +} +.kpi-spark { + position: absolute; + right: 12px; + bottom: 10px; + width: 84px; + height: 30px; + opacity: 0.9; +} +.delta { + font-weight: 500; + font-variant-numeric: tabular-nums; +} +.delta.up { + color: var(--mcode-success); +} +.delta.down { + color: var(--mcode-danger); +} + +.grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 16px; +} +.span-12 { + grid-column: span 12; +} +.span-8 { + grid-column: span 8; +} +.span-7 { + grid-column: span 7; +} +.span-6 { + grid-column: span 6; +} +.span-5 { + grid-column: span 5; +} +.span-4 { + grid-column: span 4; +} +.card { + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + padding: 16px 18px; + min-width: 0; +} +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} +.card h2 { + margin: 0; + font-size: 15px; + line-height: 22px; + font-weight: 600; +} +.card-desc { + font-size: 12px; + color: var(--mcode-text-muted); + margin-top: 2px; +} +.chart { + width: 100%; + display: block; + overflow: visible; +} +.chart text { + fill: var(--mcode-text-muted); + font-size: 10px; +} +.chart .grid-line { + stroke: var(--mcode-border); +} +.legend { + display: flex; + flex-wrap: wrap; + gap: 4px 6px; +} +.legend button, +.legend span.lg { + display: inline-flex; + align-items: center; + gap: 6px; + border: 0; + background: transparent; + padding: 3px 6px; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + color: var(--mcode-text-muted); +} +.legend button:hover { + background: var(--mcode-surface-muted); +} +.legend button[aria-pressed='false'] { + opacity: 0.4; + text-decoration: line-through; +} +.swatch { + width: 10px; + height: 10px; + border-radius: 3px; + flex: none; +} +.heat-legend { + display: inline-flex; + gap: 3px; + align-items: center; + font-size: 11px; + color: var(--mcode-text-muted); +} +.heat-legend i { + width: 11px; + height: 11px; + border-radius: 3px; + display: inline-block; +} +.heat-cell { + transition: opacity 150ms ease-out; +} +.chart-scroll { + overflow-x: auto; + padding-bottom: 4px; +} +.anim-in { + animation: rise 420ms ease-out both; + animation-delay: calc(var(--i, 0) * 40ms); +} +@keyframes rise { + from { + opacity: 0; + transform: translateY(6px); + } +} +.cell-in { + animation: fadein 500ms ease-out both; + animation-delay: calc(var(--i, 0) * 4ms); +} +@keyframes fadein { + from { + opacity: 0; + } +} +.draw-in { + stroke-dasharray: 3000; + stroke-dashoffset: 3000; + animation: draw 1.1s ease-out forwards; +} +@keyframes draw { + to { + stroke-dashoffset: 0; + } +} + +/* ---------- donut ---------- */ +.mix { + display: flex; + gap: 18px; + align-items: center; + flex-wrap: wrap; +} +.mix svg { + width: 150px; + height: 150px; + flex: none; +} +.mix-list { + list-style: none; + margin: 0; + padding: 0; + flex: 1; + min-width: 150px; + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} +.mix-list li { + display: flex; + align-items: center; + gap: 8px; +} +.mix-list .v { + margin-left: auto; + font-variant-numeric: tabular-nums; + color: var(--mcode-text-muted); +} +.donut-center { + font-size: 20px !important; + font-weight: 600; + fill: var(--mcode-text) !important; +} + +/* ---------- insights ---------- */ +.insights { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + max-height: 360px; + overflow-y: auto; +} +.insight { + display: flex; + gap: 10px; + padding: 10px 12px; + border-radius: 10px; + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); +} +.insight-icon { + width: 22px; + height: 22px; + border-radius: 7px; + display: grid; + place-items: center; + flex: none; + color: #fff; +} +.insight-icon svg { + width: 14px; + height: 14px; +} +.insight.warn .insight-icon { + background: var(--mcode-warning); +} +.insight.good .insight-icon { + background: var(--mcode-success); +} +.insight.info .insight-icon { + background: var(--k-code); +} +.insight-title { + font-weight: 500; +} +.insight-detail { + font-size: 12px; + color: var(--mcode-text-muted); + overflow-wrap: anywhere; +} + +/* ---------- table ---------- */ +.toolbar { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.search { + width: 200px; + height: 30px; +} +.table-wrap { + overflow-x: auto; + margin: 0 -18px; + padding: 0 18px; +} +table.board { + width: 100%; + border-collapse: collapse; + font-size: 13px; + min-width: 980px; +} +table.board th { + text-align: left; + font-weight: 500; + color: var(--mcode-text-muted); + font-size: 12px; + padding: 8px 8px; + border-bottom: 1px solid var(--mcode-border); + white-space: nowrap; +} +table.board th button { + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + padding: 2px 4px; + margin: -2px -4px; + border-radius: 5px; + display: inline-flex; + align-items: center; + gap: 3px; +} +table.board th button:hover { + color: var(--mcode-text); +} +table.board th[aria-sort] button { + color: var(--mcode-text); +} +table.board td { + padding: 8px 8px; + border-bottom: 1px solid var(--mcode-border); + vertical-align: middle; + white-space: nowrap; +} +table.board tbody tr { + cursor: pointer; + transition: background 150ms ease-out; +} +table.board tbody tr:hover, +table.board tbody tr:focus-visible { + background: var(--mcode-surface-muted); +} +.num { + text-align: right !important; + font-variant-numeric: tabular-nums; +} +.who { + display: flex; + align-items: center; + gap: 10px; + min-width: 170px; +} +.who-name { + font-weight: 500; +} +.who-user { + font-size: 12px; + color: var(--mcode-text-muted); +} +.avatar { + width: 30px; + height: 30px; + border-radius: 50%; + flex: none; + display: grid; + place-items: center; + font-size: 12px; + font-weight: 600; + color: #fff; + overflow: hidden; + position: relative; +} +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + inset: 0; +} +.avatar.lg { + width: 52px; + height: 52px; + font-size: 18px; +} +.rank { + width: 20px; + color: var(--mcode-text-subtle); + font-variant-numeric: tabular-nums; + font-size: 12px; +} +.bar-cell { + display: flex; + align-items: center; + gap: 8px; + min-width: 120px; +} +.bar-track { + flex: 1; + height: 6px; + border-radius: 999px; + background: var(--mcode-surface-muted); + overflow: hidden; + min-width: 50px; +} +.bar-track > span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--k-code), var(--k-mr)); +} +.mixbar { + display: flex; + height: 8px; + width: 120px; + border-radius: 999px; + overflow: hidden; + background: var(--mcode-surface-muted); +} +.mixbar > span { + height: 100%; +} +.status { + display: inline-flex; + align-items: center; + gap: 5px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + border: 1px solid var(--mcode-border); + white-space: nowrap; +} +.status::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +.status.up, +.status.back { + color: var(--mcode-success); +} +.status.down, +.status.error { + color: var(--mcode-danger); +} +.status.silent { + color: var(--mcode-warning); +} +.status.new { + color: var(--k-mr); +} +.status.steady { + color: var(--k-code); +} +.status.idle { + color: var(--mcode-text-subtle); +} +.muted { + color: var(--mcode-text-muted); +} +.subtle { + color: var(--mcode-text-subtle); +} + +/* ---------- repos ---------- */ +.repo-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.repo-row { + display: grid; + grid-template-columns: minmax(140px, 260px) 1fr 60px; + gap: 12px; + align-items: center; + font-size: 13px; +} +.repo-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.repo-bar { + display: flex; + height: 14px; + border-radius: 5px; + overflow: hidden; + background: var(--mcode-surface-muted); +} +.repo-bar > span { + height: 100%; + border-right: 1px solid var(--mcode-surface); + transform-origin: left; + animation: grow 700ms ease-out both; +} +@keyframes grow { + from { + transform: scaleX(0); + } +} + +/* ---------- empty / error ---------- */ +.empty { + border: 1px dashed var(--mcode-border); + border-radius: 12px; + padding: 36px 24px; + text-align: center; + color: var(--mcode-text-muted); +} +.empty h2 { + color: var(--mcode-text); + font-size: 16px; + margin: 10px 0 6px; +} +.empty svg { + width: 32px; + height: 32px; + color: var(--mcode-text-subtle); +} +.alert { + border: 1px solid color-mix(in srgb, var(--mcode-danger) 40%, transparent); + background: color-mix(in srgb, var(--mcode-danger) 7%, transparent); + color: var(--mcode-text); + border-radius: 10px; + padding: 10px 14px; + font-size: 13px; + margin-bottom: 16px; +} +.welcome { + max-width: 760px; + margin: 40px auto; +} +.welcome h1 { + font-size: 22px; + margin: 14px 0 6px; +} +.steps { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin: 24px 0; +} +.step { + border: 1px solid var(--mcode-border); + border-radius: 12px; + padding: 14px 16px; + background: var(--mcode-surface); +} +.step b { + display: block; + margin-bottom: 4px; +} +.step .n { + width: 22px; + height: 22px; + border-radius: 7px; + display: grid; + place-items: center; + font-size: 12px; + font-weight: 600; + color: #fff; + background: linear-gradient(135deg, var(--k-code), var(--k-mr)); + margin-bottom: 8px; +} +.skeleton { + border-radius: 12px; + background: linear-gradient(90deg, var(--mcode-surface-muted), var(--mcode-bg-secondary), var(--mcode-surface-muted)); + background-size: 200% 100%; + animation: shimmer 1.4s ease-in-out infinite; + border: 1px solid var(--mcode-border); +} +@keyframes shimmer { + to { + background-position: -200% 0; + } +} + +/* ---------- dialogs ---------- */ +dialog { + border: 1px solid var(--mcode-border); + border-radius: 14px; + background: var(--mcode-surface); + color: var(--mcode-text); + padding: 0; + box-shadow: var(--shadow); + width: min(720px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); +} +dialog::backdrop { + background: rgb(0 0 0 / 35%); +} +dialog[open] { + animation: pop 200ms ease-out; +} +@keyframes pop { + from { + opacity: 0; + transform: translateY(8px); + } +} +.dlg { + display: flex; + flex-direction: column; + max-height: calc(100vh - 50px); +} +.dlg-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px 12px; + border-bottom: 1px solid var(--mcode-border); +} +.dlg-head h2 { + margin: 0; + font-size: 16px; +} +.dlg-body { + padding: 16px 20px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; +} +.dlg-foot { + display: flex; + gap: 8px; + justify-content: flex-end; + align-items: center; + padding: 12px 20px; + border-top: 1px solid var(--mcode-border); +} +.dlg-foot .spacer { + flex: 1; +} +.row { + display: flex; + gap: 12px; + align-items: flex-end; +} +.row > * { + flex: 1; +} +.tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--mcode-border); +} +.tabs button { + border: 0; + background: transparent; + padding: 8px 10px; + cursor: pointer; + color: var(--mcode-text-muted); + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} +.tabs button[aria-selected='true'] { + color: var(--mcode-text); + border-bottom-color: var(--mcode-text); +} +.pick-list { + list-style: none; + margin: 0; + padding: 4px; + border: 1px solid var(--mcode-border); + border-radius: 10px; + max-height: 220px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1px; +} +.pick-list label, +.pick-list button { + display: flex; + gap: 10px; + align-items: center; + padding: 6px 8px; + border-radius: 7px; + cursor: pointer; + width: 100%; + border: 0; + background: transparent; + text-align: left; +} +.pick-list label:hover, +.pick-list button:hover { + background: var(--mcode-surface-muted); +} +.pick-list button[aria-pressed='true'] { + background: var(--glow); +} +.pick-list .meta { + margin-left: auto; + font-size: 12px; + color: var(--mcode-text-muted); +} +.member-edit { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 6px; + max-height: 240px; + overflow-y: auto; +} +.member-edit li { + display: flex; + align-items: center; + gap: 6px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + padding: 4px 4px 4px 8px; + background: var(--mcode-surface-muted); +} +.member-edit .u { + font-size: 13px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.member-edit input { + width: 72px; + height: 26px; + border-radius: 6px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface); + padding: 0 6px; + font-size: 12px; +} +.src-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.src-item { + display: flex; + gap: 12px; + align-items: center; + border: 1px solid var(--mcode-border); + border-radius: 10px; + padding: 10px 12px; +} +.src-item svg.k { + width: 22px; + height: 22px; + flex: none; +} +.src-item .grow { + flex: 1; + min-width: 0; +} +.form-msg { + font-size: 13px; + min-height: 18px; +} +.form-msg.err { + color: var(--mcode-danger); +} +.form-msg.ok { + color: var(--mcode-success); +} + +/* ---------- drawer ---------- */ +dialog.drawer { + margin: 0 0 0 auto; + height: 100vh; + max-height: 100vh; + width: min(640px, 100vw); + border-radius: 14px 0 0 14px; + border-right: 0; +} +dialog.drawer[open] { + animation: slide 240ms ease-out; +} +@keyframes slide { + from { + transform: translateX(40px); + opacity: 0; + } +} +.drawer .dlg { + max-height: 100vh; + height: 100%; +} +.person { + display: flex; + gap: 14px; + align-items: center; +} +.person h2 { + font-size: 18px; + margin: 0; +} +.mini-kpis { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} +.mini-kpi { + border: 1px solid var(--mcode-border); + border-radius: 10px; + padding: 8px 10px; +} +.mini-kpi .kpi-value { + font-size: 18px; + line-height: 26px; +} +.sub-h { + font-size: 13px; + font-weight: 600; + margin: 0 0 8px; +} +.recent { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.recent li { + display: grid; + grid-template-columns: 10px 1fr auto; + gap: 10px; + align-items: baseline; + padding: 7px 0; + border-bottom: 1px solid var(--mcode-border); + font-size: 13px; +} +.recent li:last-child { + border-bottom: 0; +} +.recent .t { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.recent .d { + font-size: 12px; + color: var(--mcode-text-muted); + white-space: nowrap; +} + +.tooltip { + position: fixed; + z-index: 50; + pointer-events: none; + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + font-size: 12px; + line-height: 1.5; + padding: 6px 9px; + border-radius: 8px; + white-space: pre; + box-shadow: var(--shadow); + opacity: 0; + transition: opacity 120ms ease-out; + max-width: 320px; +} +.tooltip.show { + opacity: 1; +} +.toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 60; + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + padding: 8px 14px; + border-radius: 10px; + font-size: 13px; + box-shadow: var(--shadow); +} +.toast[hidden] { + display: none; +} + +@media (max-width: 1280px) { + .kpis { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (max-width: 1100px) { + .span-8, + .span-7, + .span-6, + .span-5, + .span-4 { + grid-column: span 12; + } +} +@media (max-width: 768px) { + .kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .shell { + flex-direction: column; + } + .sidebar { + width: auto; + height: auto; + position: static; + border-right: 0; + border-bottom: 1px solid var(--mcode-border); + } + .team-list { + flex-direction: row; + overflow-x: auto; + } + main { + padding: 16px; + } + .steps { + grid-template-columns: 1fr; + } + .mini-kpis { + grid-template-columns: repeat(2, 1fr); + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } + .draw-in { + stroke-dashoffset: 0; + } +} + +.card.stretch { + display: flex; + flex-direction: column; +} +.card.stretch > .fill { + flex: 1; + display: flex; + align-items: center; +} +.card.stretch > .fill > * { + width: 100%; +} + +/* ---------- repository report ---------- */ +.grow-y { + transform-box: fill-box; + transform-origin: bottom; + animation: growy 600ms ease-out both; +} +@keyframes growy { + from { + transform: scaleY(0); + } +} +.share-bar { + display: flex; + border-radius: 6px; + overflow: hidden; + background: var(--mcode-surface-muted); +} +.share-bar > span { + height: 100%; + border-right: 1px solid var(--mcode-surface); +} +.analyze-bar { + display: flex; + gap: 8px; + margin-bottom: 20px; +} +.analyze-bar .input { + height: 40px; + font-size: 14px; +} +.analyze-bar .btn { + height: 40px; +} +.examples { + display: flex; + gap: 6px; + flex-wrap: wrap; + align-items: center; + font-size: 12px; + color: var(--mcode-text-muted); + margin: -10px 0 20px; +} +.examples button { + border: 1px dashed var(--mcode-border); + background: transparent; + border-radius: 999px; + padding: 2px 10px; + cursor: pointer; + color: var(--mcode-text-muted); + font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; + font-size: 12px; +} +.examples button:hover { + color: var(--mcode-text); + border-style: solid; +} +.path { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12.5px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; +} +.path .dir { + color: var(--mcode-text-muted); +} +.path .base { + color: var(--mcode-text); + font-weight: 500; +} +.link-btn { + border: 0; + background: transparent; + padding: 0; + cursor: pointer; + text-align: left; + min-width: 0; + max-width: 100%; +} +.link-btn:hover .base { + color: var(--mcode-accent); + text-decoration: underline; +} +.bar-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.bar-list li { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) 70px 44px; + gap: 8px; + align-items: center; + padding: 4px 6px; + border-radius: 7px; + font-size: 13px; +} +.bar-list li:hover { + background: var(--mcode-surface-muted); +} +.bar-list li.hl { + background: color-mix(in srgb, var(--mcode-danger) 7%, transparent); +} +.bar-list .n { + text-align: right; + font-variant-numeric: tabular-nums; +} +.mini-bar { + height: 6px; + border-radius: 999px; + background: var(--mcode-surface-muted); + overflow: hidden; +} +.mini-bar > span { + display: block; + height: 100%; + border-radius: inherit; +} +.tag { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 7px; + border-radius: 999px; + font-size: 11px; + border: 1px solid var(--mcode-border); + color: var(--mcode-text-muted); + white-space: nowrap; +} +.tag.danger { + color: var(--mcode-danger); + border-color: color-mix(in srgb, var(--mcode-danger) 40%, transparent); +} +.tag.warn { + color: var(--mcode-warning); + border-color: color-mix(in srgb, var(--mcode-warning) 40%, transparent); +} +.tag.good { + color: var(--mcode-success); + border-color: color-mix(in srgb, var(--mcode-success) 40%, transparent); +} +.tag.accent { + color: var(--k-mr); + border-color: color-mix(in srgb, var(--k-mr) 40%, transparent); +} +.rec-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 12px; +} +.rec-list > li { + display: grid; + grid-template-columns: 64px 1fr; + gap: 10px; + align-items: start; +} +.rec-list .k { + font-size: 12px; + color: var(--mcode-text-muted); + padding-top: 2px; +} +.rec-list .v { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.tech-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 10px; +} +.tech-group { + border: 1px solid var(--mcode-border); + border-radius: 10px; + padding: 10px 12px; + background: var(--mcode-surface-muted); +} +.tech-group h3 { + margin: 0 0 8px; + font-size: 12px; + color: var(--mcode-text-muted); + font-weight: 500; +} +.chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.tech-chip { + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + padding: 0 9px; + border-radius: 7px; + font-size: 12.5px; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + cursor: default; +} +.tech-chip .c { + font-size: 11px; + color: var(--mcode-text-subtle); +} +.lang-list { + list-style: none; + margin: 10px 0 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 6px 20px; + font-size: 13px; +} +.lang-list li { + display: flex; + align-items: center; + gap: 8px; +} +.lang-list .v { + margin-left: auto; + white-space: nowrap; + color: var(--mcode-text-muted); + font-variant-numeric: tabular-nums; +} +.fire-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 320px; + overflow-y: auto; +} +.fire-list li { + display: grid; + grid-template-columns: 74px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid var(--mcode-border); + font-size: 13px; +} +.fire-list .s { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.fire-list a, +.recent a { + color: inherit; + text-decoration: none; +} +.fire-list a:hover, +.recent a:hover { + color: var(--mcode-accent); + text-decoration: underline; +} +.footnote { + font-size: 12px; + color: var(--mcode-text-muted); + margin-top: 20px; + line-height: 1.7; +} +.footnote code { + font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; + font-size: 11.5px; + background: var(--mcode-surface-muted); + padding: 1px 4px; + border-radius: 4px; +} +.trend { + font-weight: 600; +} +.trend.rising { + color: var(--mcode-success); +} +.trend.declining { + color: var(--mcode-danger); +} +.trend.erratic { + color: var(--mcode-warning); +} +.repo-item .ti-meta.warn { + color: var(--mcode-danger); +} +.cb { + min-width: 860px; +} + +a.btn { + text-decoration: none; + color: inherit; +} + +.path .base + .dir { + font-size: 11.5px; + color: var(--mcode-text-subtle); +} + +/* ---------- UX round 2 ---------- */ +.skip-link { + position: absolute; + left: 12px; + top: -40px; + z-index: 100; + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + padding: 6px 12px; + border-radius: 8px; + text-decoration: none; +} +.skip-link:focus { + top: 12px; +} +main:focus { + outline: none; +} +h1, +h2, +section, +#report { + scroll-margin-top: 16px; +} +dialog { + overscroll-behavior: contain; +} +select.input { + background-color: var(--mcode-surface-muted); + color: var(--mcode-text); +} +.text-link { + color: var(--mcode-accent); +} +.nowrap { + white-space: nowrap; +} +a.team-item { + text-decoration: none; + color: inherit; +} +.search-trigger { + display: flex; + align-items: center; + gap: 8px; + height: 34px; + padding: 0 8px 0 10px; + border-radius: 8px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface); + color: var(--mcode-text-muted); + cursor: pointer; + transition: border-color 150ms ease-out, color 150ms ease-out; +} +.search-trigger:hover { + color: var(--mcode-text); + border-color: var(--mcode-border-strong); +} +.search-trigger svg { + width: 15px; + height: 15px; +} +.search-trigger span { + flex: 1; + text-align: left; +} +kbd { + font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; + font-size: 11px; + padding: 1px 5px; + border-radius: 5px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface-muted); + color: var(--mcode-text-muted); + white-space: nowrap; +} +.parse-hint { + font-size: 12px; + color: var(--mcode-text-muted); + min-height: 18px; + margin-top: 6px; + font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; +} +.parse-hint.err { + color: var(--mcode-danger); + font-family: inherit; +} +.analyze-bar { + align-items: flex-start; +} +.suggest-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 6px; + max-height: 360px; + overflow-y: auto; +} +.suggest { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface-muted); + border-radius: 9px; + padding: 8px 10px; + cursor: pointer; + text-align: left; + transition: border-color 150ms ease-out, background 150ms ease-out; +} +.suggest:hover { + border-color: var(--mcode-border-strong); + background: var(--mcode-surface); +} +.suggest .grow { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} +.tabs-bar { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--mcode-border); + margin: 0 0 18px; + position: sticky; + top: 0; + z-index: 5; + background: var(--mcode-bg); + overflow-x: auto; +} +.tabs-bar a { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 12px; + color: var(--mcode-text-muted); + text-decoration: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + white-space: nowrap; + transition: color 150ms ease-out, border-color 150ms ease-out; +} +.tabs-bar a:hover { + color: var(--mcode-text); +} +.tabs-bar a[aria-current='page'] { + color: var(--mcode-text); + border-bottom-color: var(--mcode-text); + font-weight: 500; +} +.tabs-bar .count { + font-size: 11px; + padding: 0 6px; + border-radius: 999px; + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); + font-variant-numeric: tabular-nums; +} +.segmented a.seg { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 11px; + border-radius: 7px; + color: var(--mcode-text-muted); + text-decoration: none; + white-space: nowrap; +} +.segmented a.seg[aria-current='true'] { + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: 0 1px 2px rgb(0 0 0 / 8%), inset 0 0 0 1px var(--mcode-border); +} +.see-all { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 13px; + color: var(--mcode-text-muted); + text-decoration: none; + white-space: nowrap; +} +.see-all:hover { + color: var(--mcode-accent); +} +.see-all svg { + width: 14px; + height: 14px; +} +.insight-link { + text-decoration: none; + color: inherit; + transition: border-color 150ms ease-out; +} +.insight-link:hover { + border-color: var(--mcode-border-strong); +} +.insight .go { + align-self: center; + color: var(--mcode-text-subtle); + display: grid; +} +.insight .go svg { + width: 16px; + height: 16px; +} +.insights > li { + list-style: none; +} +.bar-list.compact li { + grid-template-columns: 22px minmax(0, 1fr) auto 64px; +} +.rec-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.rec-row .link-btn { + min-width: 0; + flex: 1; +} +.actions { + display: inline-flex; + gap: 4px; + align-items: center; + flex-wrap: wrap; +} +.btn.mini { + width: 26px; + height: 26px; + padding: 0; + justify-content: center; + color: var(--mcode-text-muted); +} +.btn.mini:hover { + color: var(--mcode-text); +} +.btn.mini svg { + width: 14px; + height: 14px; +} +a.link-btn { + text-decoration: none; + color: inherit; + display: block; + min-width: 0; +} +a.person-link { + display: inline; +} +a.person-link:hover .who-name { + color: var(--mcode-accent); + text-decoration: underline; +} +.sha { + border: 0; + background: transparent; + color: inherit; + font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; + font-size: 11.5px; + cursor: pointer; + padding: 0; +} +.sha:hover { + color: var(--mcode-accent); +} +.info-btn { + display: inline-grid; + place-items: center; + width: 18px; + height: 18px; + margin-left: 4px; + vertical-align: -3px; + border-radius: 50%; + border: 1px solid var(--mcode-border); + background: transparent; + color: var(--mcode-text-subtle); + cursor: pointer; + position: relative; +} +.info-btn::after { + content: ''; + position: absolute; + inset: -4px; +} +.info-btn:hover, +.info-btn[aria-expanded='true'] { + color: var(--mcode-text); + border-color: var(--mcode-border-strong); +} +.info-btn svg { + width: 11px; + height: 11px; +} +.popover { + position: fixed; + z-index: 70; + width: min(420px, calc(100vw - 16px)); + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: 12px; + box-shadow: 0 1px 2px rgb(0 0 0 / 6%), 0 8px 28px rgb(0 0 0 / 14%); + padding: 12px 14px; + font-size: 13px; + animation: pop 160ms ease-out; +} +.popover p { + margin: 0 0 8px; + color: var(--mcode-text); + line-height: 1.6; +} +.popover pre { + margin: 0 0 8px; + padding: 8px 10px; + border-radius: 8px; + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); + font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; + font-size: 11.5px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.menu { + position: fixed; + z-index: 70; + min-width: 220px; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: 12px; + box-shadow: 0 1px 2px rgb(0 0 0 / 6%), 0 8px 28px rgb(0 0 0 / 14%); + padding: 4px; + display: flex; + flex-direction: column; + animation: pop 140ms ease-out; +} +.menu button { + display: flex; + align-items: center; + gap: 8px; + height: 34px; + padding: 0 10px; + border: 0; + background: transparent; + border-radius: 8px; + text-align: left; + cursor: pointer; +} +.menu button:hover, +.menu button:focus-visible { + background: var(--mcode-surface-muted); + outline: none; +} +.menu svg { + width: 15px; + height: 15px; + color: var(--mcode-text-muted); +} +.btn span:empty { + display: none; +} +.delta-banner { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 10px 14px; + margin-bottom: 14px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--k-code) 35%, transparent); + background: color-mix(in srgb, var(--k-code) 7%, transparent); + font-size: 13px; +} +.delta-banner svg { + width: 16px; + height: 16px; + flex: none; + margin-top: 2px; + color: var(--k-code); +} +.delta-banner .link-btn { + display: inline; +} +.progress-log { + display: flex; + gap: 12px; + align-items: flex-start; + border: 1px solid var(--mcode-border); + border-radius: 12px; + padding: 10px 14px; + margin: -4px 0 16px; + background: var(--mcode-surface); +} +.steps-log { + list-style: none; + margin: 0; + padding: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; +} +.steps-log li { + display: grid; + grid-template-columns: 18px 1fr auto; + gap: 10px; + align-items: center; + color: var(--mcode-text-muted); +} +.steps-log li.cur { + color: var(--mcode-text); + font-weight: 500; +} +.steps-log li.done { + color: var(--mcode-text); +} +.steps-log .st { + width: 14px; + height: 14px; + border-radius: 50%; + display: grid; + place-items: center; +} +.steps-log .st.ok { + background: var(--mcode-success); + color: #fff; +} +.steps-log .st.ok svg { + width: 10px; + height: 10px; +} +.steps-log .st.idle { + border: 1.5px dashed var(--mcode-border-strong); + opacity: 0.4; +} +.tech-dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 12px; + margin: 12px 0 0; + font-size: 13px; +} +.tech-dl dt { + color: var(--mcode-text-muted); +} +.tech-dl dd { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +a.tech-chip { + text-decoration: none; + color: inherit; + cursor: pointer; +} +.tech-chip:focus-visible { + outline: 2px solid var(--mcode-accent); +} +table.board tbody tr { + cursor: default; +} +.toast { + display: flex; + gap: 12px; + align-items: center; +} +.toast-btn { + border: 0; + background: transparent; + color: inherit; + font-weight: 600; + text-decoration: underline; + cursor: pointer; + padding: 0; +} +dialog.palette { + width: min(640px, calc(100vw - 32px)); + margin-top: 12vh; + padding: 0; +} +.pal { + display: flex; + flex-direction: column; + max-height: 64vh; +} +.pal-input { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid var(--mcode-border); +} +.pal-input svg { + width: 18px; + height: 18px; + color: var(--mcode-text-muted); +} +.pal-input input { + flex: 1; + border: 0; + background: transparent; + font-size: 15px; + outline: none; + min-width: 0; +} +#pal-list { + list-style: none; + margin: 0; + padding: 6px; + overflow-y: auto; + flex: 1; +} +#pal-list [role='option'] { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; +} +#pal-list [role='option'][aria-selected='true'] { + background: var(--mcode-surface-muted); + box-shadow: inset 0 0 0 1px var(--mcode-border); +} +#pal-list .pi svg { + width: 16px; + height: 16px; + color: var(--mcode-text-muted); +} +#pal-list .pl { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +#pal-list .pm { + font-size: 12px; + white-space: nowrap; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; +} +.pal-group { + font-size: 11px; + color: var(--mcode-text-subtle); + padding: 8px 10px 4px; +} +.pal-empty { + padding: 16px 10px; +} +.pal-foot { + display: flex; + gap: 14px; + padding: 8px 14px; + border-top: 1px solid var(--mcode-border); + font-size: 12px; +} +.pal-foot kbd { + margin-right: 2px; +} +@media (pointer: fine) { + html { + overscroll-behavior: none; + } +} + +/* ========================================================================= + Tech layer (round 3): instrument-panel visuals. Every effect below is + decorative only — state is always also carried by text, icons or ARIA. + ========================================================================= */ +body { + font-feature-settings: 'cv11', 'ss01'; +} +.mono, +.path, +kbd, +.sha, +.examples button, +.parse-hint, +.footnote code, +.popover pre { + font-family: var(--font-mono); +} +::selection { + background: color-mix(in srgb, var(--mcode-accent) 28%, transparent); +} +* { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--mcode-text-subtle) 55%, transparent) transparent; +} + +/* ---- canvas: dot grid + one soft aurora at the top ---- */ +main { + position: relative; + isolation: isolate; +} +main::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 520px; + z-index: -1; + pointer-events: none; + background-image: radial-gradient(var(--grid-dot) 1px, transparent 1px); + background-size: 22px 22px; + -webkit-mask-image: linear-gradient(to bottom, #000 0%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, transparent 100%); +} +main::after { + content: ''; + position: absolute; + top: -180px; + left: 50%; + width: min(980px, 90%); + height: 360px; + transform: translateX(-50%); + z-index: -1; + pointer-events: none; + background: radial-gradient(closest-side, color-mix(in srgb, var(--k-code) 13%, transparent), transparent); + filter: blur(8px); +} +[data-theme='light'] main::after { + opacity: 0.55; +} + +/* ---- sidebar ---- */ +.sidebar { + background: color-mix(in srgb, var(--mcode-bg-secondary) 86%, transparent); +} +.brand-mark { + position: relative; + box-shadow: 0 6px 18px -6px rgb(6 182 212 / 55%); +} +/* The icon's scan line sweeps while a repository is being analyzed (and on hover). */ +.brand-mark .scan { + position: absolute; + inset: 0; + background: linear-gradient(180deg, transparent 42%, rgb(207 250 254 / 80%) 50%, transparent 58%); + animation: xray 1.6s ease-in-out infinite alternate; + opacity: 0; + transition: opacity 300ms ease-out; +} +.brand-mark.busy .scan, +.brand:hover .brand-mark .scan { + opacity: 1; +} +@keyframes xray { + from { + transform: translateY(-36%); + } + to { + transform: translateY(36%); + } +} +.brand-name { + letter-spacing: -0.01em; +} +.brand-sub { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.02em; +} +.team-item { + position: relative; + padding: 6px 8px; +} +.team-item[aria-current='page']::before { + content: ''; + position: absolute; + left: -12px; + top: 8px; + bottom: 8px; + width: 2px; + border-radius: 2px; + background: var(--mcode-accent); + box-shadow: 0 0 10px var(--mcode-accent); +} +.ti-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} +.ti-sub { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--mcode-text-subtle); + font-family: var(--font-mono); + min-width: 0; +} +.ti-sub .spark-mini { + width: 44px; + height: 12px; + flex: none; + opacity: 0.85; +} +.ti-risk { + font-family: var(--font-mono); + font-size: 11px; + min-width: 20px; + height: 18px; + padding: 0 5px; + border-radius: 6px; + display: inline-grid; + place-items: center; + border: 1px solid var(--mcode-border); + color: var(--mcode-text-muted); +} +.ti-risk.warn { + color: var(--mcode-danger); + border-color: color-mix(in srgb, var(--mcode-danger) 35%, transparent); + background: color-mix(in srgb, var(--mcode-danger) 8%, transparent); +} +.sidebar-status { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 8px 0; + font-family: var(--font-mono); + font-size: 11px; + color: var(--mcode-text-subtle); +} +.sidebar-status .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--mcode-success); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--mcode-success) 60%, transparent); + animation: beacon 2.4s ease-out infinite; +} +.sidebar-status.off .dot { + background: var(--mcode-text-subtle); + animation: none; +} +@keyframes beacon { + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--mcode-success) 55%, transparent); + } + 70%, + 100% { + box-shadow: 0 0 0 6px transparent; + } +} +.kbd-hint { + margin-left: auto; + opacity: 0.8; +} + +/* ---- cards: spotlight border that follows the pointer ---- */ +.card, +.kpi, +.suggest, +.term { + --mx: -400px; + --my: -400px; +} +.card, +.kpi { + background-image: radial-gradient(420px circle at var(--mx) var(--my), var(--spot), transparent 42%); + transition: border-color 200ms ease-out; +} +.card::after, +.kpi::after, +.suggest::after { + content: ''; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + pointer-events: none; + background: radial-gradient(260px circle at var(--mx) var(--my), var(--spot-border), transparent 45%); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); + opacity: 0; + transition: opacity 250ms ease-out; +} +.card, +.suggest { + position: relative; +} +.card:hover::after, +.kpi:hover::after, +.suggest:hover::after { + opacity: 1; +} +.card h2 { + letter-spacing: -0.01em; +} +.card-eyebrow { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--mcode-text-subtle); + margin-bottom: 2px; +} + +/* ---- KPI instruments ---- */ +.kpi-sub { + margin-top: 2px; +} +.kpi-viz { + margin-top: auto; + padding-top: 10px; + min-height: 24px; + display: flex; + align-items: flex-end; +} +.kpi-viz > * { + width: 100%; +} +.spark-fill { + width: 100%; + height: 26px; + display: block; +} +.meter { + position: relative; + height: 6px; + border-radius: 999px; + background: var(--mcode-surface-muted); + box-shadow: inset 0 0 0 1px var(--mcode-border); + margin-top: 14px; +} +.meter-fill, +.meter-segs { + position: absolute; + inset: 0 auto 0 0; + height: 100%; + border-radius: inherit; + transform-origin: left; + animation: grow 900ms var(--ease-out) both; +} +.meter-segs { + right: 0; + display: flex; + overflow: hidden; +} +.meter-segs > span { + height: 100%; + border-right: 1px solid var(--mcode-surface); +} +.meter-tick { + position: absolute; + top: -4px; + bottom: -4px; + width: 1.5px; + margin-left: -0.75px; + background: var(--mcode-text); + opacity: 0.7; +} +.meter-tick b { + position: absolute; + bottom: calc(100% + 1px); + left: 50%; + transform: translateX(-50%); + font: 500 10px/1 var(--font-mono); + color: var(--mcode-text-muted); + white-space: nowrap; +} +.dots { + display: grid; + grid-template-columns: repeat(var(--cols), 1fr); + gap: 3px; +} +.dots i { + height: 7px; + border-radius: 2px; + background: var(--mcode-surface-muted); + box-shadow: inset 0 0 0 1px var(--mcode-border); +} +.dots i.on { + background: var(--mcode-danger); + box-shadow: 0 0 6px color-mix(in srgb, var(--mcode-danger) 55%, transparent); + animation: fadein 400ms ease-out both; +} +.mini-bars { + display: flex; + align-items: flex-end; + gap: 2px; + height: 26px; +} +.mini-bars i { + flex: 1; + border-radius: 2px 2px 0 0; + background: color-mix(in srgb, var(--mcode-text-subtle) 45%, transparent); + transform-origin: bottom; + animation: growy 700ms var(--ease-out) both; +} + +/* ---- page head ---- */ +.page-head h1 { + font-size: 22px; + letter-spacing: -0.02em; +} +.scramble { + font-variant-ligatures: none; +} +.head-sha { + font-family: var(--font-mono); + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 8px; + height: 22px; + border-radius: 6px; + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); + color: var(--mcode-text); + cursor: pointer; +} +.head-sha:hover { + border-color: var(--mcode-border-strong); +} +.head-sha .br { + color: var(--mcode-text-muted); +} +.vitals-line { + font-family: var(--font-mono); + font-size: 12px; + color: var(--mcode-text-muted); + gap: 4px 0; +} +.vitals-line > span:not(:last-child)::after { + content: '/'; + margin: 0 10px; + color: var(--mcode-text-subtle); +} +.vitals-line b { + color: var(--mcode-text); + font-weight: 500; +} + +/* ---- tabs: underline indicator glides between tabs ---- */ +.tabs-bar { + background: color-mix(in srgb, var(--mcode-bg) 82%, transparent); + -webkit-backdrop-filter: saturate(1.4) blur(10px); + backdrop-filter: saturate(1.4) blur(10px); + margin-left: -28px; + margin-right: -28px; + padding: 0 28px; +} +.tabs-bar a[aria-current='page'] { + border-bottom-color: transparent; +} +.tab-ink { + position: absolute; + bottom: 0; + left: 0; + height: 2px; + border-radius: 2px; + background: var(--mcode-text); + transition: transform 260ms var(--ease-out), width 260ms var(--ease-out); + pointer-events: none; +} +.tabs-bar .count { + font-family: var(--font-mono); +} +.tabs-bar kbd { + font-size: 10px; + padding: 0 4px; + opacity: 0; + transition: opacity 150ms ease-out; +} +.tabs-bar:hover kbd, +.tabs-bar:focus-within kbd { + opacity: 0.8; +} + +/* ---- code map: tiles (shared by the overview thumbnail and the map tab) ---- */ +.treemap { + display: block; + width: 100%; + height: auto; + user-select: none; + -webkit-user-select: none; +} +.tm-frame { + fill: color-mix(in srgb, var(--mcode-surface-muted) 70%, transparent); + stroke: var(--mcode-border); +} +.chart .tm-group { + font: 600 10.5px var(--font-mono); + fill: var(--mcode-text-muted); + letter-spacing: 0.01em; +} +.chart .tm-gval { + font-weight: 400; + fill: var(--mcode-text-subtle); +} +.tm-cell { + cursor: pointer; + outline: none; +} +.tm-r { + stroke: var(--mcode-border); + transition: fill 240ms ease-out, opacity 160ms ease-out, stroke 150ms ease-out, filter 150ms ease-out; +} +.tm-cell:hover .tm-r { + stroke: var(--mcode-text); + stroke-width: 1.5; + filter: brightness(1.08); +} +/* Label ink follows the resolved fill (see inkFor), not the theme. */ +.chart .tm-name { + font: 500 11px var(--font-mono); + fill: #141416; + pointer-events: none; +} +.chart .tm-val { + font: 400 10px var(--font-mono); + fill: rgb(20 20 22 / 68%); + pointer-events: none; +} +.tm-cell.ink-light .tm-name { + fill: #fff; +} +.tm-cell.ink-light .tm-val { + fill: rgb(255 255 255 / 76%); +} +.tm-hatch { + stroke: color-mix(in srgb, var(--mcode-text) 9%, transparent); +} +.tm-risk { + fill: #fff; + stroke: var(--mcode-danger); + stroke-width: 2; + pointer-events: none; +} +/* Selection: shape cues (thick outline, connector dots) in addition to colour. */ +.tm-cell.sel .tm-r { + stroke: var(--mcode-text); + stroke-width: 2.5; +} +.tm-cell.partner .tm-r { + stroke: var(--k-mr); + stroke-width: 2; +} +.tm-cell.dim .tm-r { + opacity: 0.32; +} +.tm-cell.dim text { + opacity: 0.55; +} +.tm-cell:focus-visible .tm-r { + stroke: var(--mcode-accent); + stroke-width: 3; +} +.tm-lines { + pointer-events: none; +} +.cp-line { + fill: none; + stroke: var(--k-mr); + stroke-linecap: round; + opacity: 0.9; + filter: drop-shadow(0 0 3px color-mix(in srgb, var(--k-mr) 55%, transparent)); + animation: cp-in 260ms var(--ease-out) both; +} +.cp-line.cp-test { + stroke: var(--k-merge); + stroke-dasharray: 5 4; + filter: none; +} +.cp-end { + fill: var(--mcode-surface); + stroke: var(--k-mr); + stroke-width: 2; +} +.cp-src { + fill: var(--k-mr); + stroke: var(--mcode-surface); + stroke-width: 2; +} +.chart .cp-pct { + font: 600 10.5px var(--font-mono); + fill: var(--mcode-text); + paint-order: stroke; + stroke: var(--mcode-surface); + stroke-width: 3.5px; + stroke-linejoin: round; + animation: cp-in 260ms var(--ease-out) both; +} +@keyframes cp-in { + from { + opacity: 0; + } +} +.tm-foot { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + align-items: center; + margin-top: 10px; + font-size: 12px; + color: var(--mcode-text-muted); +} +.tm-foot .mono { + margin-left: auto; +} +.tm-foot .key { + display: inline-flex; + align-items: center; + gap: 6px; +} +.ring { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 50%; + background: #fff; + box-shadow: inset 0 0 0 2px var(--mcode-danger); + flex: none; +} +i.hatch { + display: inline-block; + width: 11px; + height: 11px; + border-radius: 3px; + flex: none; + background: repeating-linear-gradient(45deg, var(--mcode-surface-muted) 0 3px, color-mix(in srgb, var(--mcode-text) 22%, transparent) 3px 4.5px) !important; + box-shadow: inset 0 0 0 1px var(--mcode-border); +} +.card-tools { + display: flex; + align-items: center; + gap: 8px 16px; + flex-wrap: wrap; +} + +/* ---- code map tab ---- */ +.map-tab { + display: flex; + flex-direction: column; + gap: 12px; + container-type: inline-size; +} +.map-head { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + align-items: flex-end; + justify-content: space-between; +} +.map-title { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.map-title h2 { + margin: 0; + font-size: 15px; + line-height: 22px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; +} +.crumbs { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; + font: 500 13px/22px var(--font-mono); + min-width: 0; +} +.crumb { + color: var(--mcode-text-muted); + text-decoration: none; + padding: 0 5px; + border-radius: 5px; + max-width: 32ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +a.crumb:hover { + color: var(--mcode-accent); + background: var(--mcode-surface-muted); +} +.crumb.cur { + color: var(--mcode-text); + font-weight: 600; +} +.crumb-sep { + color: var(--mcode-text-subtle); +} +.map-ctl { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.lens-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.lens { + display: inline-flex; + align-items: center; + gap: 8px; + height: 30px; + padding: 0 12px 0 10px; + border-radius: 999px; + border: 1px solid var(--mcode-border); + background: var(--mcode-surface); + color: var(--mcode-text-muted); + cursor: pointer; + font-size: 13px; + transition: color 150ms ease-out, border-color 150ms ease-out, background 150ms ease-out, box-shadow 150ms ease-out; +} +.lens:hover { + color: var(--mcode-text); + border-color: var(--mcode-border-strong); +} +.lens[aria-pressed='true'] { + color: var(--mcode-text); + font-weight: 500; + border-color: color-mix(in srgb, var(--mcode-accent) 60%, transparent); + background: color-mix(in srgb, var(--mcode-accent) 9%, var(--mcode-surface)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--mcode-accent) 13%, transparent); +} +.lens i { + width: 16px; + height: 10px; + border-radius: 3px; + flex: none; + box-shadow: inset 0 0 0 1px var(--mcode-border); +} +.lens-heat i { + background: linear-gradient(90deg, var(--heat-1), var(--heat-4)); +} +.lens-bug i { + background: linear-gradient(90deg, var(--risk-1), var(--risk-4)); +} +.lens-owner i { + background: linear-gradient(90deg, #2f9e8f 0 34%, #f08a4b 34% 67%, #3b8fd9 67%); +} +.lens-age i { + background: linear-gradient(90deg, var(--age-0), var(--age-4)); +} +.lens-loss i { + background: linear-gradient(90deg, var(--loss-1), var(--loss-4)); +} +.lens-type i { + background: linear-gradient(90deg, hsl(210 65% 52%) 0 40%, hsl(45 85% 50%) 40% 70%, hsl(205 18% 58%) 70%); +} +.map-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); + gap: 12px; + align-items: start; +} +@container (max-width: 900px) { + .map-layout { + grid-template-columns: minmax(0, 1fr); + } +} +.map-main { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 16px; + position: relative; + min-width: 0; +} +.map-canvas { + position: relative; + min-height: 340px; + transition: opacity 150ms ease-out; +} +.map-main.is-loading .map-canvas { + opacity: 0.5; +} +.map-main.is-loading .map-canvas::before { + content: ''; + position: absolute; + z-index: 1; + inset: 0 0 auto; + height: 2px; + background: linear-gradient(90deg, transparent, var(--mcode-accent), transparent); + background-size: 40% 100%; + background-repeat: no-repeat; + animation: map-load 1s linear infinite; +} +@keyframes map-load { + from { + background-position: -40% 0; + } + to { + background-position: 140% 0; + } +} +.map-empty { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +.map-empty p { + margin: 4px 0 0; + max-width: 46ch; +} +.map-empty .actions { + display: flex; + gap: 8px; + justify-content: center; + margin-top: 12px; +} +.map-legend { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 40px; +} +.lg-desc { + margin: 0; + font-size: 12.5px; + color: var(--mcode-text-muted); +} +.lg-keys { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + align-items: center; + font-size: 12px; + color: var(--mcode-text-muted); +} +.lg-key { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 22ch; + min-width: 0; + white-space: nowrap; +} +.lg-key > span:not(.ring) { + overflow: hidden; + text-overflow: ellipsis; +} +.lg-key i { + width: 11px; + height: 11px; + border-radius: 3px; + flex: none; + box-shadow: inset 0 0 0 1px var(--mcode-border); +} +.lg-ramp { + display: inline-flex; + align-items: center; + gap: 3px; +} +.lg-ramp i { + width: 14px; + height: 11px; + border-radius: 3px; +} +.map-time { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding-top: 10px; + border-top: 1px solid var(--mcode-border); +} +.play-btn { + min-width: 104px; + justify-content: center; +} +.play-btn svg { + width: 12px; + height: 12px; +} +.mt-track { + position: relative; + flex: 1 1 220px; + height: 36px; + min-width: 0; +} +.mt-bars { + position: absolute; + inset: 0 8px 14px; + display: flex; + align-items: flex-end; + gap: 1px; + pointer-events: none; +} +.mt-bars i { + flex: 1; + min-width: 0; + border-radius: 1.5px 1.5px 0 0; + background: color-mix(in srgb, var(--mcode-text) 16%, transparent); + transition: background 150ms ease-out; +} +.mt-bars i.on { + background: var(--mcode-accent); +} +/* Months the range only partly covers (see frameSpan); the hint names the convention. */ +.mt-bars i.part { + background: color-mix(in srgb, var(--mcode-text) 7%, transparent); + outline: 1px dashed color-mix(in srgb, var(--mcode-text) 22%, transparent); + outline-offset: -1px; +} +.mt-bars i.part.on { + background: color-mix(in srgb, var(--mcode-accent) 55%, transparent); + outline-color: var(--mcode-accent); +} +#map-month { + position: absolute; + inset: auto 0 0; + width: 100%; + height: 14px; + margin: 0; + accent-color: var(--mcode-accent); + cursor: pointer; +} +#map-month:disabled { + cursor: default; + opacity: 0.5; +} +.mt-label { + display: flex; + flex-direction: column; + line-height: 1.3; + min-width: 136px; +} +.mt-label b { + font-size: 13px; +} +.mt-hint { + flex-basis: 100%; + font-size: 11.5px; +} +.map-time.framed .mt-label b { + color: var(--mcode-accent); +} +.map-note { + margin: 0; + font-size: 12px; + line-height: 1.6; +} +.map-detail { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + min-width: 0; +} +.map-detail > .hint { + margin: 0; +} +.md-head { + display: flex; + align-items: flex-start; + gap: 10px; +} +.md-icon { + width: 32px; + height: 32px; + border-radius: 8px; + display: grid; + place-items: center; + flex: none; + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); + color: var(--mcode-text-muted); +} +.md-icon svg { + width: 16px; + height: 16px; +} +.md-titles { + flex: 1; + min-width: 0; +} +.md-kicker { + font-size: 11.5px; + color: var(--mcode-text-muted); +} +.md-title { + margin: 0; + font: 600 14px/20px var(--font-mono); + overflow-wrap: anywhere; +} +.md-desc { + margin: 0; + font-size: 13px; + line-height: 1.6; +} +.md-src { + color: var(--mcode-text-subtle); + font-size: 12px; +} +.md-summary { + margin: 0; + font-size: 12.5px; + line-height: 1.65; + color: var(--mcode-text-muted); +} +.md-frame { + margin: 0; + font-size: 12px; + padding: 6px 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--mcode-accent) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--mcode-accent) 30%, transparent); +} +.md-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; +} +.md-stat { + border: 1px solid var(--mcode-border); + border-radius: 8px; + padding: 6px 8px; + background: var(--mcode-surface-muted); + min-width: 0; +} +.md-stat .v { + font: 600 14px/20px var(--font-mono); + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.md-stat .k { + font-size: 11px; + line-height: 1.3; + color: var(--mcode-text-muted); +} +.md-sec h4 { + margin: 0 0 6px; + font-size: 12.5px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.md-sec h4 .hint { + font-weight: 400; + font-size: 11.5px; +} +.md-sec > .hint { + margin: 0; +} +.md-owners, +.md-top, +.md-items, +.co-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.md-owners li { + display: grid; + grid-template-columns: minmax(0, 1fr) 56px auto; + gap: 8px; + align-items: center; + padding: 3px 0; + font-size: 12.5px; +} +.md-owners .avatar { + width: 22px; + height: 22px; + font-size: 9.5px; +} +.md-owners .who { + min-width: 0; +} +.md-owners .person-link { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.md-owners .n, +.md-top .n { + font-size: 11.5px; + color: var(--mcode-text-muted); + white-space: nowrap; +} +.md-loss { + margin: 6px 0 0; +} +.md-top li { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto auto; + gap: 6px; + align-items: center; + padding: 3px 0; +} +.md-top .rank { + width: auto; +} +.bugc { + color: var(--mcode-danger); +} +.md-co-head { + margin: 0 0 4px; +} +.co-list li { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto; + grid-template-rows: auto 3px; + column-gap: 8px; + row-gap: 3px; + align-items: center; + padding: 4px 0; + font-size: 12.5px; +} +.co-pct { + font-weight: 600; + font-size: 12px; + text-align: right; +} +.co-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + color: inherit; + font: 500 12.5px var(--font-mono); + text-decoration: none; +} +.co-name:hover { + color: var(--mcode-accent); + text-decoration: underline; +} +.co-meta { + white-space: nowrap; + font-size: 11.5px; +} +.co-bar { + grid-column: 2 / -1; + height: 3px; + border-radius: 99px; + background: var(--mcode-surface-muted); + overflow: hidden; +} +.co-bar > span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--k-mr); +} +.co-list.weak .co-bar > span { + background: var(--k-other); +} +.co-pair { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} +.co-names { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} +.co-names .co-name { + flex: 0 1 auto; +} +.co-names .arrow { + flex: none; + color: var(--mcode-text-subtle); +} +.co-ctx { + font-size: 11px; + line-height: 1.3; + color: var(--mcode-text-subtle); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.md-items li { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 2px 0; + font-size: 12.5px; +} +.md-items .link-btn { + font: 500 12.5px var(--font-mono); + color: inherit; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.md-items .link-btn:hover { + color: var(--mcode-accent); +} +.md-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding-top: 12px; + border-top: 1px solid var(--mcode-border); +} +.map-table-card .table-wrap { + margin-bottom: -4px; +} +table.map-list { + min-width: 860px; +} +table.map-list tbody tr { + cursor: default; +} +table.map-list th[scope='row'] { + max-width: 340px; + white-space: normal; + font: 400 13px/1.45 ui-sans-serif, -apple-system, system-ui, 'Segoe UI', Helvetica, Arial, sans-serif; + letter-spacing: normal; + text-transform: none; + color: var(--mcode-text); + border-bottom: 1px solid var(--mcode-border); +} +table.map-list th[scope='row'] .link-btn { + display: inline-block; + max-width: 100%; + vertical-align: middle; +} +.ml-desc { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11.5px; + margin-top: 1px; +} +table.map-list tr.is-sel { + background: color-mix(in srgb, var(--mcode-accent) 9%, transparent); +} +table.map-list tr.is-sel > th { + box-shadow: inset 3px 0 0 var(--mcode-accent); +} +.map-detail { + container-type: inline-size; +} +@container (max-width: 420px) { + .md-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +.keys + .sub-h { + margin: 18px 0 10px; +} + +/* ---- activity calendar ---- */ +.cal-wrap { + overflow-x: auto; +} +.cal-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} +.cal-stat { + border: 1px solid var(--mcode-border); + border-radius: 10px; + padding: 8px 10px; + background: var(--mcode-surface-muted); +} +.cal-stat .v { + font: 600 16px/24px var(--font-mono); + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} +.cal-stat .k { + font-size: 11.5px; + color: var(--mcode-text-muted); +} +.heat-cell:hover { + stroke: var(--mcode-text); + stroke-width: 1; +} + +/* ---- month bars ---- */ +.chart .mb { + transition: filter 150ms ease-out; +} +.chart .mb:hover { + filter: brightness(1.25) drop-shadow(0 0 6px var(--glow)); +} + +/* ---- terminal (welcome input + progress log) ---- */ +.term { + position: relative; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--term-bg); + box-shadow: var(--shadow), 0 0 0 4px color-mix(in srgb, var(--mcode-surface-muted) 60%, transparent); + overflow: hidden; +} +.term-bar { + display: flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-bottom: 1px solid var(--mcode-border); + font: 11px var(--font-mono); + color: var(--mcode-text-subtle); +} +.term-bar i { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--mcode-border); + box-shadow: inset 0 0 0 1px var(--mcode-border); +} +.term-bar .t { + margin-left: 8px; +} +.term-bar .r { + margin-left: auto; +} +.term .analyze-bar { + margin: 0; + padding: 12px 12px 10px 14px; + align-items: center; +} +.term .prompt { + font: 600 15px var(--font-mono); + color: var(--mcode-success); + user-select: none; +} +.term .analyze-bar .input { + border: 0; + background: transparent; + box-shadow: none; + height: 36px; + font-size: 14.5px; + padding: 0 4px; + caret-color: var(--mcode-accent); +} +.term .analyze-bar .input:focus { + box-shadow: none; +} +.term:focus-within { + border-color: color-mix(in srgb, var(--mcode-accent) 55%, transparent); + box-shadow: var(--shadow), 0 0 0 4px var(--glow); +} +.term .parse-hint { + padding: 0 14px 10px 34px; + margin: 0; +} +.term .examples { + margin: 0; + padding: 10px 14px 12px; + border-top: 1px dashed var(--mcode-border); +} +.welcome-hero { + text-align: left; + margin-bottom: 18px; +} +.welcome-hero h1 { + font-size: 30px; + line-height: 1.2; + letter-spacing: -0.035em; + margin: 18px 0 8px; + font-weight: 650; +} +.welcome-hero h1 .grad { + background: linear-gradient(92deg, var(--mcode-text) 10%, color-mix(in srgb, var(--mcode-text) 55%, var(--k-code)) 60%, var(--k-code)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.welcome-hero p { + max-width: 640px; + margin: 0; +} +.metric-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin: 18px 0 22px; +} +.metric-strip > div { + border: 1px solid var(--mcode-border); + border-radius: 10px; + padding: 10px 12px; + background: color-mix(in srgb, var(--mcode-surface) 80%, transparent); + font-size: 12.5px; + color: var(--mcode-text-muted); +} +.metric-strip b { + display: block; + color: var(--mcode-text); + font-size: 13px; + margin-bottom: 2px; +} +.metric-strip code { + font: 11px var(--font-mono); + color: var(--mcode-text-subtle); +} +.suggest { + background: var(--mcode-surface); +} +.suggest .kind-dot { + color: var(--mcode-text-subtle); +} +.suggest:hover .kind-dot { + color: var(--mcode-accent); +} +.suggest .go { + color: var(--mcode-text-subtle); + display: grid; + opacity: 0; + transform: translateX(-4px); + transition: opacity 150ms ease-out, transform 150ms ease-out; +} +.suggest .go svg { + width: 14px; + height: 14px; +} +.suggest:hover .go, +.suggest:focus-visible .go { + opacity: 1; + transform: none; +} + +.progress-log.term { + display: block; + padding: 0; + margin: -4px 0 16px; + background: var(--term-bg); +} +.progress-log .term-bar .r { + display: flex; + align-items: center; + gap: 10px; +} +.term-body { + padding: 10px 14px 12px; + font: 12.5px/1.7 var(--font-mono); +} +.term-body .ln { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + color: var(--mcode-text-subtle); +} +.term-body .ln.done { + color: var(--mcode-text-muted); +} +.term-body .ln.cur { + color: var(--mcode-text); +} +.term-body .ln .ok { + color: var(--mcode-success); +} +.term-body .ln .ms { + font-variant-numeric: tabular-nums; + color: var(--mcode-text-subtle); +} +.term-body .ln.cur .ms { + color: var(--mcode-text); +} +.term-body .ln .detail { + color: var(--mcode-text-muted); +} +.term-body .bar { + display: inline-block; + width: 120px; + height: 4px; + border-radius: 2px; + margin: 0 8px 2px; + vertical-align: middle; + background: var(--mcode-surface-muted); + overflow: hidden; +} +.term-body .bar > span { + display: block; + height: 100%; + background: var(--mcode-accent); + transition: width 300ms ease-out; +} +.braille { + color: var(--mcode-accent); + display: inline-block; + width: 1ch; +} +.cursor { + display: inline-block; + width: 7px; + height: 14px; + margin-left: 3px; + vertical-align: -2px; + background: var(--mcode-accent); + animation: blink 1.05s steps(1) infinite; +} +@keyframes blink { + 50% { + opacity: 0; + } +} + +/* ---- analysis scan line over the report while a job runs ---- */ +#report { + position: relative; +} +.scanning #report::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 0; + height: 120px; + z-index: 3; + pointer-events: none; + background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--mcode-accent) 7%, transparent) 85%, color-mix(in srgb, var(--mcode-accent) 45%, transparent) 99%, transparent); + animation: scan 2.6s var(--ease-out) infinite; +} +@keyframes scan { + from { + transform: translateY(-120px); + opacity: 0; + } + 15% { + opacity: 1; + } + to { + transform: translateY(calc(100vh - 120px)); + opacity: 0; + } +} + +/* ---- skeleton becomes a sweeping highlight ---- */ +.skeleton { + background: linear-gradient(100deg, var(--mcode-surface-muted) 30%, color-mix(in srgb, var(--mcode-surface-muted) 40%, var(--mcode-surface)) 50%, var(--mcode-surface-muted) 70%); + background-size: 300% 100%; + animation: shimmer 1.6s ease-in-out infinite; +} + +/* ---- insights: severity rail ---- */ +.insight { + position: relative; + overflow: hidden; + background: var(--mcode-surface-muted); +} +.insight::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: var(--k-code); +} +.insight.warn::before { + background: var(--mcode-warning); +} +.insight.good::before { + background: var(--mcode-success); +} +.insight-link:hover .go { + color: var(--mcode-text); + transform: translateX(2px); +} +.insight .go { + transition: transform 150ms ease-out, color 150ms ease-out; +} +.sev { + font: 600 10px/16px var(--font-mono); + letter-spacing: 0.06em; + padding: 0 5px; + border-radius: 4px; + margin-right: 6px; + vertical-align: 1px; + border: 1px solid currentColor; +} +.insight.warn .sev { + color: var(--mcode-warning); +} +.insight.good .sev { + color: var(--mcode-success); +} +.insight.info .sev { + color: var(--k-code); +} + +/* ---- tables & lists ---- */ +table.board th { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--mcode-text-subtle); +} +table.board td.num, +.bar-list .n, +.rank { + font-family: var(--font-mono); +} +table.board tbody tr { + position: relative; +} +table.board tbody tr:hover td:first-child { + box-shadow: inset 2px 0 0 var(--mcode-accent); +} +.bar-track > span, +.mini-bar > span { + transform-origin: left; + animation: grow 800ms var(--ease-out) both; +} +.rank-badge { + font: 600 11px/18px var(--font-mono); + padding: 0 6px; + border-radius: 5px; + border: 1px solid var(--mcode-border); + color: var(--mcode-text-muted); + white-space: nowrap; +} +.rank-badge.danger { + color: var(--mcode-danger); + border-color: color-mix(in srgb, var(--mcode-danger) 35%, transparent); + background: color-mix(in srgb, var(--mcode-danger) 8%, transparent); +} + +/* ---- palette ---- */ +dialog.palette { + border-color: color-mix(in srgb, var(--mcode-border-strong) 40%, transparent); + box-shadow: 0 0 0 1px var(--mcode-border), 0 24px 60px -12px rgb(0 0 0 / 35%), 0 0 0 6px color-mix(in srgb, var(--mcode-surface-muted) 50%, transparent); +} +dialog.palette::backdrop, +dialog.drawer::backdrop, +dialog.sheet::backdrop, +dialog#settings::backdrop { + background: rgb(0 0 0 / 38%); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); +} +#pal-list mark { + background: transparent; + color: var(--mcode-accent); + font-weight: 600; +} +#pal-list [role='option'][aria-selected='true'] { + background: color-mix(in srgb, var(--mcode-accent) 9%, var(--mcode-surface-muted)); + box-shadow: inset 2px 0 0 var(--mcode-accent); +} +#pal-list [role='option'][aria-selected='true'] .pi svg { + color: var(--mcode-accent); +} +.pal-group { + font-family: var(--font-mono); + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 10.5px; +} +.pal-foot { + justify-content: flex-start; +} +.pal-foot .r { + margin-left: auto; + font-family: var(--font-mono); +} + +/* ---- drawer ---- */ +.drawer .dlg-head { + position: relative; +} +.drawer .dlg-head::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 1px; + background: linear-gradient(90deg, transparent, var(--mcode-accent), transparent); + opacity: 0.55; +} +.drawer-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +/* ---- toast: terminal-ish ---- */ +.toast { + font-family: var(--font-mono); + font-size: 12.5px; + padding: 9px 14px; + border-radius: 10px; + animation: toast-in 220ms var(--ease-out); +} +.toast .ok { + color: var(--mcode-success); + display: grid; +} +.toast .ok svg { + width: 14px; + height: 14px; +} +[data-theme='light'] .toast .ok { + color: #3ddc84; +} +@keyframes toast-in { + from { + opacity: 0; + transform: translate(-50%, 8px); + } +} + +/* ---- copied flash on copy buttons ---- */ +.copied { + color: var(--mcode-success) !important; +} +.btn.copied { + border-color: color-mix(in srgb, var(--mcode-success) 45%, transparent); +} + +/* ---- delta banner ---- */ +.delta-banner { + font-family: inherit; + border-color: color-mix(in srgb, var(--mcode-accent) 35%, transparent); + background: linear-gradient(90deg, color-mix(in srgb, var(--mcode-accent) 10%, transparent), transparent 70%); +} +.delta-banner svg { + color: var(--mcode-accent); +} + +/* ---- footer status line ---- */ +.statusline { + margin-top: 28px; + padding-top: 12px; + border-top: 1px dashed var(--mcode-border); + display: flex; + flex-wrap: wrap; + gap: 4px 18px; + font: 11px/1.6 var(--font-mono); + color: var(--mcode-text-subtle); +} +.statusline b { + color: var(--mcode-text-muted); + font-weight: 500; +} +.statusline .r { + margin-left: auto; +} + +/* ---- shortcuts sheet ---- */ +.keys { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 16px; + align-items: center; + margin: 0; + font-size: 13px; +} +.keys dt { + display: flex; + gap: 4px; + justify-content: flex-end; +} +.keys dd { + margin: 0; + color: var(--mcode-text-muted); +} +dialog.sheet { + width: min(460px, calc(100vw - 32px)); +} + +@media (max-width: 1100px) { + .metric-strip, + .cal-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 768px) { + .tabs-bar { + margin-left: -16px; + margin-right: -16px; + padding: 0 16px; + } + .team-item[aria-current='page']::before { + display: none; + } +} +@media (prefers-reduced-motion: reduce) { + .scanning #report::before, + .brand-mark .scan, + .cursor { + display: none; + } + .sidebar-status .dot { + animation: none; + } +} + +/* round 3 fixes */ +.tooltip { + white-space: pre-wrap; + overflow-wrap: anywhere; + max-width: 360px; +} +.scanning #report { + overflow: clip; + overflow-clip-margin: 8px; +} +.codemap { + min-height: 300px; +} +.kpi-value .trend { + font-family: ui-sans-serif, -apple-system, system-ui, sans-serif; + letter-spacing: -0.01em; +} +.head-sha svg { + width: 12px; + height: 12px; + flex: none; +} +.chart .band-label { + font-weight: 600; + font-size: 10.5px; + paint-order: stroke; + stroke: var(--mcode-surface); + stroke-width: 4px; + stroke-linejoin: round; +} + +.segmented .count { + font: 500 10.5px/16px var(--font-mono); + margin-left: 5px; + padding: 0 5px; + border-radius: 999px; + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); + color: var(--mcode-text-muted); +} diff --git a/plugins/yujiachen-y/repo-xray/miniapp/miniapp.json b/plugins/yujiachen-y/repo-xray/miniapp/miniapp.json new file mode 100644 index 0000000..4fe89b8 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/miniapp.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + "artifacts": { "client": ["./miniapp/client"], "node": ["./miniapp/node"] }, + "runtime": { "kind": "process", "entry": "./miniapp/node/server.mjs", "lifecycle": "on-demand" }, + "surface": { "path": "/dashboard", "window": { "width": 1360, "height": 900, "minWidth": 720, "minHeight": 520, "resizable": true } }, + "mcpEndpoints": [] +} diff --git a/plugins/yujiachen-y/repo-xray/miniapp/node/analyze.mjs b/plugins/yujiachen-y/repo-xray/miniapp/node/analyze.mjs new file mode 100644 index 0000000..7ab4b63 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/analyze.mjs @@ -0,0 +1,626 @@ +// @ts-check +import { COUPLE, coKind, mapView } from './codemap.mjs'; +import { git } from './git.mjs'; + +export const ALGO_VERSION = 8; +const FIX_RE = 'fix|bug|broken'; // skill 2c (git --grep, case-insensitive, full message) +const FIRE_RE = /revert|hotfix|emergency|rollback/i; // skill 2e (oneline subject) +const SEP = '\x1f'; + +/** + * Build the in-memory history model with one streaming `git log` pass. + * @param {string} dir + * @param {{ signal?: AbortSignal, onProgress?: (n: number) => void }} opts + */ +export async function buildModel(dir, { signal, onProgress } = {}) { + const head = (await git(['-C', dir, 'rev-parse', 'HEAD'], { signal, timeout: 20_000 })).trim(); + let branch = ''; + try { + branch = (await git(['-C', dir, 'symbolic-ref', '--short', '-q', 'HEAD'], { signal, timeout: 20_000 })).trim(); + } catch {} + const branches = (await git(['-C', dir, 'for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes'], { signal, timeout: 60_000 })) + .split('\n') + .filter((r) => r && !r.endsWith('/HEAD')).length; + + const authors = []; + const authorIdx = new Map(); + const paths = []; + const pathIdx = new Map(); + const commits = []; + let cur = null; + let rest = ''; + const internAuthor = (name, email) => { + const key = `${name}\x00${email}`; + let i = authorIdx.get(key); + if (i === undefined) { + i = authors.length; + authors.push({ name, email }); + authorIdx.set(key, i); + } + return i; + }; + const internPath = (p) => { + let i = pathIdx.get(p); + if (i === undefined) { + i = paths.length; + paths.push(p); + pathIdx.set(p, i); + } + return i; + }; + const handleLine = (line) => { + if (line.charCodeAt(0) === 0x1e) { + const f = line.slice(1).split(SEP); + cur = { sha: f[0], a: internAuthor(f[1] || '(unknown)', (f[2] || '').toLowerCase()), ad: f[3], at: Number(f[4]), ct: Number(f[5]), merge: (f[6] || '').trim().includes(' '), subj: f.slice(7).join(SEP), files: [] }; + commits.push(cur); + if (commits.length % 2000 === 0) onProgress?.(commits.length); + return; + } + if (!cur || !line) return; + const tab = line.indexOf('\t'); + if (tab < 0) return; + const status = line[0]; + let p = line.slice(tab + 1); + if (status === 'R' || status === 'C') p = p.split('\t').pop() || p; + cur.files.push(internPath(p), status.charCodeAt(0)); + }; + await git( + ['-C', dir, '-c', 'core.quotePath=false', 'log', '-M100%', '--date=format:%Y-%m-%d', `--format=%x1e%H${SEP}%aN${SEP}%aE${SEP}%ad${SEP}%at${SEP}%ct${SEP}%P${SEP}%s`, '--name-status', 'HEAD'], + { + signal, + env: { GIT_NO_LAZY_FETCH: '1' }, + timeout: 10 * 60_000, + onStdout: (chunk) => { + const text = rest + chunk.toString('utf8'); + const lines = text.split('\n'); + rest = lines.pop() || ''; + for (const l of lines) handleLine(l); + }, + }, + ); + if (rest) handleLine(rest); + if (!commits.length) throw Object.assign(new Error('仓库还没有任何提交'), { code: 'empty' }); + + const fixOut = await git(['-C', dir, 'log', '-i', '-E', `--grep=${FIX_RE}`, '--format=%H', 'HEAD'], { signal, timeout: 5 * 60_000 }); + const fix = new Set(fixOut.split('\n').filter(Boolean)); + for (const c of commits) { + c.fix = fix.has(c.sha); + c.fire = FIRE_RE.test(c.subj); + c.files = Int32Array.from(c.files); + } + + // Tree at HEAD (for "still exists" and tech stack). + const tree = []; + const treeOut = await git(['-C', dir, '-c', 'core.quotePath=false', 'ls-tree', '-r', '--full-tree', 'HEAD'], { signal, timeout: 120_000 }); + for (const line of treeOut.split('\n')) { + const tab = line.indexOf('\t'); + if (tab < 0) continue; + const [mode, type, oid] = line.slice(0, tab).split(' '); + if (type !== 'blob') continue; + tree.push({ path: line.slice(tab + 1), oid, mode }); + } + const pathCount = new Int32Array(paths.length); + for (const c of commits) for (let i = 0; i < c.files.length; i += 2) pathCount[c.files[i]]++; + const model = { head, branch, branches, authors, paths, pathCount, pathIdx, commits, tree, treeSet: new Set(tree.map((t) => t.path)), builtAt: Date.now() }; + model.ids = { raw: rawIdentities(authors), merged: mergedIdentities(authors, commits) }; + return model; +} + +// --------------------------------------------------------------------------- +// Identities +// raw = skill parity: one identity per %aN (mailmap-aware, case-sensitive) +// merged = union by case-insensitive name and by email; bot-like identities +// (project tokens, [bot], agent accounts) form their own groups +// --------------------------------------------------------------------------- + +const BOT_EMAIL = /(^|[._+-])bot[s]?([._+-]|@)|\[bot\]|project_\d+_bot|group_\d+_bot|^(agent|ci|build|robot|automation|deploy)@|^agent@/i; +const BOT_NAME = /\[bot\]|(^|[\s_-])bot$|^bot[\s_-]|^(dependabot|renovate|github-actions|gitlab-ci|semantic-release|greenkeeper|snyk-bot|mergify|codecov|pre-commit-ci)\b|(^|\s)agent$|^test[\s_-]?agent$/i; +export function isBot(a) { + if (/\[bot\]@users\.noreply\.github\.com$/.test(a.email)) return true; + const ghUser = /@users\.noreply\.github\.com$/.test(a.email); + return (!ghUser && BOT_EMAIL.test(a.email)) || BOT_NAME.test(a.name); +} + +function rawIdentities(authors) { + const byName = new Map(); + const names = []; + const idOf = new Int32Array(authors.length); + authors.forEach((a, i) => { + let id = byName.get(a.name); + if (id === undefined) { + id = names.length; + names.push(a.name); + byName.set(a.name, id); + } + idOf[i] = id; + }); + return { idOf, names, bot: names.map(() => false), mode: 'raw' }; +} + +function mergedIdentities(authors, commits) { + const parent = authors.map((_, i) => i); + const find = (x) => (parent[x] === x ? x : (parent[x] = find(parent[x]))); + const union = (a, b) => { + a = find(a); + b = find(b); + if (a !== b) parent[b] = a; + }; + const bot = authors.map(isBot); + const firstByKey = new Map(); + authors.forEach((a, i) => { + const keys = [`${bot[i] ? 'b' : 'h'}:n:${a.name.trim().toLowerCase()}`]; + if (a.email && !bot[i] && /@/.test(a.email)) keys.push(`h:e:${a.email}`); + for (const k of keys) { + const j = firstByKey.get(k); + if (j === undefined) firstByKey.set(k, i); + else union(j, i); + } + }); + const counts = new Int32Array(authors.length); + for (const c of commits) counts[c.a]++; + const groups = new Map(); + authors.forEach((_, i) => { + const r = find(i); + if (!groups.has(r)) groups.set(r, []); + groups.get(r).push(i); + }); + const names = []; + const botFlags = []; + const aliases = []; + const idOf = new Int32Array(authors.length); + for (const members of groups.values()) { + const id = names.length; + const byName = new Map(); + for (const m of members) byName.set(authors[m].name, (byName.get(authors[m].name) || 0) + counts[m]); + const sorted = [...byName.entries()].sort((a, b) => b[1] - a[1]); + names.push(sorted[0][0]); + aliases.push(sorted.slice(1).map(([n]) => n)); + botFlags.push(members.every((m) => bot[m])); + for (const m of members) idOf[m] = id; + } + const humanNames = new Set(names.filter((_, i) => !botFlags[i]).map((n) => n.toLowerCase())); + names.forEach((n, i) => { + if (botFlags[i] && humanNames.has(n.toLowerCase())) names[i] = `${n}(令牌)`; + }); + return { idOf, names, bot: botFlags, aliases, mode: 'merged' }; +} + +// --------------------------------------------------------------------------- +// Report (mirrors codebase-recon SKILL.md) +// --------------------------------------------------------------------------- + +const DAY = 86_400; + +function minusMonths(sec, months) { + const d = new Date(sec * 1000); + d.setMonth(d.getMonth() - months); + return Math.floor(d.getTime() / 1000); +} +const monthOf = (ad) => ad.slice(0, 7); +function topDir(p) { + const parts = p.split('/'); + if (parts.length <= 1) return '(根目录)'; + return parts.length > 2 ? `${parts[0]}/${parts[1]}` : parts[0]; +} + +const ymdOf = (sec) => { + const d = new Date(sec * 1000); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; + +/** Daily commit counts (author date, merges included like momentum) for the 365 days ending at anchor. */ +function buildActivity(commits, anchor) { + const end = ymdOf(anchor); + const start = ymdOf(anchor - 364 * DAY); + /** @type {Record} */ + const daily = {}; + for (const c of commits) if (c.ad >= start && c.ad <= end) daily[c.ad] = (daily[c.ad] || 0) + 1; + return { start, end, daily }; +} + +export function buildReport(model, { now = Math.floor(Date.now() / 1000), identity = 'merged' } = {}) { + const { commits, paths } = model; + const ident = model.ids[identity] || model.ids.merged; + const authors = ident.names; + const who = (c) => ident.idOf[c.a]; + const human = (id) => !ident.bot[id]; + const total = commits.length; + + // Phase 1: Probe + const latest = commits[0].ad; + const first = commits[commits.length - 1].ad; + const tier = total < 500 ? 'small' : total <= 10_000 ? 'medium' : 'large'; + const N = { small: 10, medium: 20, large: 30 }[tier]; + const windowMonths = { small: null, medium: 12, large: 6 }[tier]; + let anchor = now; + let anchorFallback = false; + let since = windowMonths ? minusMonths(now, windowMonths) : -Infinity; + if (windowMonths && !commits.some((c) => c.ct >= since)) { + anchor = commits[0].ct; + since = minusMonths(anchor, windowMonths); + anchorFallback = true; + } + const windowLabel = windowMonths ? (windowMonths === 12 ? '1 year ago' : '6 months ago') : 'all time'; + const inWin = (c) => c.ct >= since; + + // 2a hotspots / 2c bug magnets / 2f recently added + const hot = new Map(); + const bug = new Map(); + const added = new Map(); + let windowCommits = 0; + for (const c of commits) { + if (!inWin(c)) continue; + windowCommits++; + const f = c.files; + for (let i = 0; i < f.length; i += 2) { + const p = f[i]; + hot.set(p, (hot.get(p) || 0) + 1); + if (c.fix) bug.set(p, (bug.get(p) || 0) + 1); + if (f[i + 1] === 65 /* A */) added.set(p, (added.get(p) || 0) + 1); + } + } + const rank = (m) => [...m.entries()].sort((a, b) => b[1] - a[1] || (paths[a[0]] < paths[b[0]] ? -1 : 1)); + const asList = (ranked, n) => ranked.slice(0, n).map(([p, count], i) => ({ path: paths[p], count, rank: i + 1, exists: model.treeSet.has(paths[p]) })); + const hotspots = asList(rank(hot), N); + const bugMagnets = asList(rank(bug), N); + const recentFiles = asList(rank(added), N); + const addedDirs = new Map(); + for (const [p, n] of added) addedDirs.set(topDir(paths[p]), (addedDirs.get(topDir(paths[p])) || 0) + n); + + // Per-file all-time ownership (non-merge), needed for risk ownership. + const ownersOf = (pathStr) => { + const pi = model.pathIdx.get(pathStr) ?? -1; + const counts = new Map(); + for (const c of commits) { + if (c.merge || !human(who(c))) continue; + const f = c.files; + for (let i = 0; i < f.length; i += 2) if (f[i] === pi) { + counts.set(who(c), (counts.get(who(c)) || 0) + 1); + break; + } + } + const sum = [...counts.values()].reduce((s, n) => s + n, 0); + return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([a, n]) => ({ name: authors[a], commits: n, share: sum ? n / sum : 0 })); + }; + + // Cross-ref 1+2: high-risk files + const bugRank = new Map(bugMagnets.map((b) => [b.path, b])); + const highRisk = hotspots + .filter((h) => bugRank.has(h.path)) + .map((h) => { + const b = bugRank.get(h.path); + const owners = ownersOf(h.path); + return { path: h.path, hotRank: h.rank, bugRank: b.rank, hotCount: h.count, bugCount: b.count, exists: h.exists, owner: owners[0] || null, owners }; + }); + + // 2b bus factor + 2g active contributors (3B: ranked by person) + const per = new Map(); + const cutoff3m = minusMonths(now, 3); + const months12 = []; + { + const d = new Date(anchor * 1000); + for (let i = 11; i >= 0; i--) { + const x = new Date(d.getFullYear(), d.getMonth() - i, 1); + months12.push(`${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, '0')}`); + } + } + const m12Index = new Map(months12.map((m, i) => [m, i])); + const botCommits = new Map(); + for (const c of commits) { + if (c.merge) continue; + if (!human(who(c))) { + botCommits.set(who(c), (botCommits.get(who(c)) || 0) + 1); + continue; + } + let p = per.get(who(c)); + if (!p) { + p = { a: who(c), commits: 0, first: c.ad, last: c.ad, lastCt: c.ct, active: false, window: 0, fixes: 0, dirs: new Map(), monthly: new Array(12).fill(0) }; + per.set(who(c), p); + } + p.commits++; + if (c.ad < p.first) p.first = c.ad; + if (c.ad > p.last) p.last = c.ad; + if (c.ct > p.lastCt) p.lastCt = c.ct; + if (c.ct >= cutoff3m) p.active = true; + if (inWin(c)) p.window++; + if (c.fix) p.fixes++; + const mi = m12Index.get(monthOf(c.ad)); + if (mi !== undefined) p.monthly[mi]++; + const f = c.files; + const seen = new Set(); + for (let i = 0; i < f.length; i += 2) { + const d = topDir(paths[f[i]]); + if (seen.has(d)) continue; + seen.add(d); + p.dirs.set(d, (p.dirs.get(d) || 0) + 1); + } + } + const nonMerge = [...per.values()].reduce((s, p) => s + p.commits, 0); + const ranked = [...per.values()].sort((a, b) => b.commits - a.commits || (authors[a.a] < authors[b.a] ? -1 : 1)); + const contributors = ranked.slice(0, 100).map((p, i) => ({ + rank: i + 1, + name: authors[p.a], + aliases: ident.aliases?.[p.a] || [], + commits: p.commits, + share: nonMerge ? p.commits / nonMerge : 0, + first: p.first, + last: p.last, + active: p.active, + window: p.window, + fixes: p.fixes, + dirs: [...p.dirs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5), + monthly: p.monthly, + })); + const totalContributors = per.size; + const active3m = ranked.filter((p) => p.active).length; + let acc = 0; + let half = 0; + for (const p of ranked) { + acc += p.commits; + half++; + if (acc >= nonMerge / 2) break; + } + const busFactor = { + total: totalContributors, + active3m, + ratio: totalContributors ? active3m / totalContributors : 0, + warn: totalContributors > 0 && active3m / totalContributors < 0.3, + halfCount: half, + topShare: ranked[0] ? ranked[0].commits / nonMerge : 0, + }; + + // 2d momentum (all commits, author month) + cross-ref 4 + const monthCounts = new Map(); + for (const c of commits) monthCounts.set(monthOf(c.ad), (monthCounts.get(monthOf(c.ad)) || 0) + 1); + const allMonths = []; + { + const [fy, fm] = first.slice(0, 7).split('-').map(Number); + const [ly, lm] = latest.slice(0, 7).split('-').map(Number); + for (let y = fy, m = fm; y < ly || (y === ly && m <= lm); m === 12 ? ((m = 1), y++) : m++) { + const key = `${y}-${String(m).padStart(2, '0')}`; + allMonths.push({ m: key, n: monthCounts.get(key) || 0 }); + } + } + const nowMonth = (() => { + const d = new Date(anchor * 1000); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + })(); + const complete = allMonths.filter((x) => !(x.m === nowMonth && !anchorFallback)); + const partialMonth = complete.length !== allMonths.length ? nowMonth : null; + const last3 = complete.slice(-3); + const prev3 = complete.slice(-6, -3); + const avg = (a) => (a.length ? a.reduce((s, x) => s + x.n, 0) / a.length : 0); + const lastAvg = avg(last3); + const prevAvg = avg(prev3); + let trend = 'stable'; + if (prev3.length === 3 && prevAvg > 0 && lastAvg > prevAvg * 1.2) trend = 'rising'; + else if (prev3.length === 3 && lastAvg < prevAvg * 0.8) trend = 'declining'; + else { + const six = complete.slice(-6); + const changes = []; + for (let i = 1; i < six.length; i++) if (six[i - 1].n > 0) changes.push(Math.abs(six[i].n - six[i - 1].n) / six[i - 1].n); + if (changes.length >= 3 && changes.reduce((s, x) => s + x, 0) / changes.length > 0.5) trend = 'erratic'; + } + if (prev3.length < 3) trend = 'insufficient'; + + // 2e firefighting + const fires = commits.filter((c) => inWin(c) && c.fire); + const firefighting = { + count: fires.length, + total: windowCommits, + rate: windowCommits ? fires.length / windowCommits : 0, + list: fires.slice(0, 60).map((c) => ({ sha: c.sha, subj: c.subj, date: c.ad, author: authors[who(c)], kind: (c.subj.match(FIRE_RE)?.[0] || '').toLowerCase() })), + kinds: Object.entries(fires.reduce((m, c) => ((m[(c.subj.match(FIRE_RE)?.[0] || '').toLowerCase()] = (m[(c.subj.match(FIRE_RE)?.[0] || '').toLowerCase()] || 0) + 1), m), {})).sort((a, b) => b[1] - a[1]), + }; + + // Problems (deterministic signals) + const problems = []; + if (highRisk.length) { + problems.push({ level: 'warn', anchor: 'risk', title: `${highRisk.length} 个高风险文件:既是热点,又频繁被修 bug`, detail: highRisk.slice(0, 3).map((h) => h.path.split('/').pop()).join('、') }); + const solo = highRisk.filter((h) => h.exists && h.owner && h.owner.share >= 0.8); + if (solo.length) problems.push({ level: 'warn', anchor: 'risk', filter: 'solo', title: `${solo.length} 个高风险文件主要由一个人维护(≥80% 提交)`, detail: solo.slice(0, 3).map((h) => `${h.path.split('/').pop()} → ${h.owner.name}`).join('、') }); + } else { + problems.push({ level: 'good', anchor: 'risk', title: '热点文件和 bug 磁铁没有重叠', detail: 'skill 原话:No files appear in both lists — good sign.' }); + } + if (busFactor.warn) problems.push({ level: 'warn', anchor: 'people', title: `活跃贡献者比例偏低:${active3m} / ${totalContributors}(${Math.round(busFactor.ratio * 100)}%)`, detail: '近 3 个月活跃的人不足历史总人数的 30%,知识可能集中在少数人身上' }); + if (busFactor.halfCount <= 2 && totalContributors >= 4) problems.push({ level: 'warn', anchor: 'people', title: `${busFactor.halfCount} 个人贡献了一半以上的提交`, detail: `${contributors.slice(0, busFactor.halfCount).map((c) => c.name).join('、')}` }); + if (trend === 'declining') problems.push({ level: 'warn', anchor: 'momentum', title: `提交节奏在下降:近 3 个月月均 ${Math.round(lastAvg)},之前 3 个月 ${Math.round(prevAvg)}`, detail: '降幅超过 20%' }); + if (trend === 'erratic') problems.push({ level: 'info', anchor: 'momentum', title: '提交节奏波动大', detail: '近几个月环比变化平均超过 50%' }); + if (trend === 'rising') problems.push({ level: 'good', anchor: 'momentum', title: `提交节奏在上升:近 3 个月月均 ${Math.round(lastAvg)},之前 3 个月 ${Math.round(prevAvg)}`, detail: '增幅超过 20%' }); + if (firefighting.rate >= 0.05) problems.push({ level: 'warn', anchor: 'fire', title: `救火提交占 ${(firefighting.rate * 100).toFixed(1)}%(${firefighting.count} / ${windowCommits})`, detail: firefighting.kinds.map(([k, n]) => `${k} ${n}`).join('、') }); + else if (firefighting.count) problems.push({ level: 'info', anchor: 'fire', title: `窗口内有 ${firefighting.count} 次 revert / hotfix 类提交(${(firefighting.rate * 100).toFixed(1)}%)`, detail: firefighting.kinds.map(([k, n]) => `${k} ${n}`).join('、') }); + if (anchorFallback) problems.push({ level: 'info', anchor: 'vitals', title: `仓库最近 ${windowMonths === 12 ? '一年' : '半年'}没有提交`, detail: `最后一次提交在 ${latest},分析窗口改为以它为终点` }); + const deletedHot = hotspots.filter((h) => !h.exists).length; + if (deletedHot >= Math.max(3, N / 4)) problems.push({ level: 'info', anchor: 'risk', filter: 'deleted', title: `${deletedHot} 个热点文件已在 HEAD 中删除或改名`, detail: '它们的历史仍算在热点里,阅读时以现存文件为准' }); + + // Recommendations (skill template) + const readList = (highRisk.length ? highRisk : hotspots).filter((x) => x.exists !== false).slice(0, 3).map((x) => x.path); + const talkFile = (highRisk[0] || hotspots.find((h) => h.exists) || hotspots[0])?.path; + const talkOwner = talkFile ? (highRisk[0]?.owner || ownersOf(talkFile)[0]) : null; + const watchOut = []; + if (trend === 'declining') watchOut.push('提交节奏在下降'); + if (trend === 'erratic') watchOut.push('提交节奏不稳定'); + if (busFactor.warn) watchOut.push('活跃贡献者比例低,存在知识集中风险'); + if (firefighting.rate >= 0.05) watchOut.push('救火提交比例高'); + const recommendations = { startReading: readList, talkTo: talkOwner ? { name: talkOwner.name, file: talkFile, share: talkOwner.share } : null, watchOut }; + + return { + algo: ALGO_VERSION, + identity: ident.mode, + bots: [...botCommits.entries()].sort((a, b) => b[1] - a[1]).map(([id, n]) => ({ name: authors[id], commits: n })), + head: model.head, + branch: model.branch, + vitals: { commits: total, first, latest, branches: model.branches, tier, N, window: windowLabel, since: Number.isFinite(since) ? since : null, anchorFallback, windowCommits, files: model.tree.length }, + hotspots, + bugMagnets, + highRisk, + recentlyAdded: { files: recentFiles, dirs: [...addedDirs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10), total: [...added.values()].reduce((s, n) => s + n, 0) }, + contributors, + busFactor, + momentum: { months: allMonths, trend, lastAvg, prevAvg, partialMonth }, + firefighting, + problems, + recommendations, + activity: buildActivity(commits, anchor), + // Overview thumbnail of the code map: HEAD structure (files), window activity, small budget. + codemap: mapView(model, { since: Number.isFinite(since) ? since : null, lite: true, maxBlocks: 20, risk: new Set(highRisk.map((h) => h.path)), now }), + generatedAt: Date.now(), + }; +} + +function identOf(model, identity) { + return model.ids[identity] || model.ids.merged; +} + +/** Drill-down for one file. */ +export function fileDetail(model, path, { identity = 'merged' } = {}) { + const pi = model.pathIdx.get(path); + if (pi === undefined) return null; + const ident = identOf(model, identity); + const owners = new Map(); + const months = new Map(); + const list = []; + const co = new Map(); + let coRevs = 0; + let firstAdd = null; + let fixes = 0; + let total = 0; + for (const c of model.commits) { + const f = c.files; + let st = 0; + for (let i = 0; i < f.length; i += 2) if (f[i] === pi) { + st = f[i + 1]; + break; + } + if (!st) continue; + total++; + // Co-change with code-maat's thresholds: non-merge commits of at most 30 files. + if (!c.merge && f.length / 2 <= COUPLE.maxChangeset) { + coRevs++; + for (let i = 0; i < f.length; i += 2) if (f[i] !== pi) co.set(f[i], (co.get(f[i]) || 0) + 1); + } + if (c.fix) fixes++; + const id = ident.idOf[c.a]; + if (!c.merge && !ident.bot[id]) owners.set(id, (owners.get(id) || 0) + 1); + months.set(monthOf(c.ad), (months.get(monthOf(c.ad)) || 0) + 1); + if (st === 65) firstAdd = c.ad; + if (list.length < 40) list.push({ sha: c.sha, subj: c.subj, date: c.ad, author: ident.names[id], bot: ident.bot[id], fix: c.fix, fire: c.fire, status: String.fromCharCode(st) }); + } + const sum = [...owners.values()].reduce((s, n) => s + n, 0); + const ranked = [...co] + .map(([q, n]) => ({ path: model.paths[q], shared: n, degree: coRevs ? n / coRevs : 0, exists: model.treeSet.has(model.paths[q]) })) + .sort((a, b) => b.degree - a.degree || (a.path < b.path ? -1 : 1)); + const tag = (x) => ({ ...x, degree: Math.round(x.degree * 1000) / 1000, kind: coKind(model, path, x.path) }); + const strong = ranked.filter((x) => x.shared >= COUPLE.minShared && x.degree >= COUPLE.minDegree).slice(0, 8).map(tag); + return { + path, + exists: model.treeSet.has(path), + coupling: { revs: coRevs, strong, weak: strong.length ? [] : ranked.filter((x) => x.shared >= 3).slice(0, 3).map(tag), ...COUPLE }, + total, + fixes, + firstAdd, + owners: [...owners.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([a, n]) => ({ name: ident.names[a], commits: n, share: sum ? n / sum : 0 })), + ownerCount: owners.size, + months: [...months.entries()].sort(), + commits: list, + }; +} + +/** Drill-down for one contributor (by display name in the chosen identity mode). */ +export function personDetail(model, name, { identity = 'merged' } = {}) { + const ident = identOf(model, identity); + const id = ident.names.indexOf(name); + if (id < 0) return null; + const files = new Map(); + const dirs = new Map(); + const months = new Map(); + const list = []; + let commits = 0; + let merges = 0; + let fixes = 0; + let first = null; + let last = null; + for (const c of model.commits) { + if (ident.idOf[c.a] !== id) continue; + if (c.merge) { + merges++; + continue; + } + commits++; + if (c.fix) fixes++; + if (!last) last = c.ad; + first = c.ad; + months.set(monthOf(c.ad), (months.get(monthOf(c.ad)) || 0) + 1); + const seen = new Set(); + for (let i = 0; i < c.files.length; i += 2) { + const p = c.files[i]; + files.set(p, (files.get(p) || 0) + 1); + const d = topDir(model.paths[p]); + if (!seen.has(d)) { + seen.add(d); + dirs.set(d, (dirs.get(d) || 0) + 1); + } + } + if (list.length < 30) list.push({ sha: c.sha, subj: c.subj, date: c.ad, fix: c.fix, fire: c.fire }); + } + const emails = [...new Set(model.authors.filter((_, i) => ident.idOf[i] === id).map((a) => a.email))].slice(0, 5); + return { + name, + bot: ident.bot[id], + aliases: ident.aliases?.[id] || [], + emails, + commits, + merges, + fixes, + first, + last, + months: [...months.entries()].sort(), + files: [...files.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15).map(([p, n]) => ({ path: model.paths[p], count: n, exists: model.treeSet.has(model.paths[p]) })), + dirs: [...dirs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8), + recent: list, + }; +} + +/** ⌘K search over all historical paths and contributors. */ +export function search(model, q, { identity = 'merged', limit = 12 } = {}) { + const needle = String(q || '').trim().toLowerCase(); + if (!needle) return { files: [], people: [] }; + const ident = identOf(model, identity); + const files = []; + for (let i = 0; i < model.paths.length; i++) { + const p = model.paths[i]; + const lower = p.toLowerCase(); + const at = lower.indexOf(needle); + if (at < 0) continue; + const base = lower.slice(lower.lastIndexOf('/') + 1); + const exists = model.treeSet.has(p); + const score = (base === needle ? 4000 : base.startsWith(needle) ? 3000 : base.includes(needle) ? 2000 : 0) + (exists ? 1000 : 0) + Math.min(999, model.pathCount[i]); + files.push({ path: p, count: model.pathCount[i], exists, score }); + } + files.sort((a, b) => b.score - a.score); + const counts = new Map(); + for (const c of model.commits) if (!c.merge) counts.set(ident.idOf[c.a], (counts.get(ident.idOf[c.a]) || 0) + 1); + const people = []; + ident.names.forEach((n, id) => { + const hay = [n, ...(ident.aliases?.[id] || [])].join(' ').toLowerCase(); + if (hay.includes(needle)) people.push({ name: n, commits: counts.get(id) || 0, bot: ident.bot[id] }); + }); + people.sort((a, b) => b.commits - a.commits); + return { files: files.slice(0, limit).map(({ score, ...f }) => f), people: people.slice(0, 6) }; +} + +/** Compare two reports of the same repo (same identity mode). */ +export function diffReports(prev, next) { + if (!prev || prev.head === next.head) return null; + const a = new Set(prev.highRisk.map((h) => h.path)); + const b = new Set(next.highRisk.map((h) => h.path)); + return { + fromHead: prev.head, + fromAt: prev.generatedAt, + commits: next.vitals.commits - prev.vitals.commits, + riskAdded: [...b].filter((p) => !a.has(p)), + riskRemoved: [...a].filter((p) => !b.has(p)), + trendFrom: prev.momentum.trend, + trendTo: next.momentum.trend, + }; +} diff --git a/plugins/yujiachen-y/repo-xray/miniapp/node/codemap.mjs b/plugins/yujiachen-y/repo-xray/miniapp/node/codemap.mjs new file mode 100644 index 0000000..0e83c63 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/codemap.mjs @@ -0,0 +1,807 @@ +// @ts-check +/** + * Code map: the HEAD tree laid out as nested regions, coloured by what history says about them. + * + * - size: files at HEAD ("what is the project made of") or modifications in the chosen range + * - lenses: heat, bug share, owner, last change, knowledge loss, file type (all from one pass) + * - co-change (logical coupling) between regions, using code-maat's default thresholds + * - per-directory descriptions taken from the repository's own manifests and READMEs + */ + +/** code-maat defaults: --max-changeset-size 30, --min-revs 5, --min-shared-revs 5, --min-coupling 30 */ +export const COUPLE = Object.freeze({ maxChangeset: 30, minRevs: 5, minShared: 5, minDegree: 0.3 }); +const RANGE_MONTHS = { 3: 3, 6: 6, 12: 12 }; + +export function minusMonths(sec, months) { + const d = new Date(sec * 1000); + d.setMonth(d.getMonth() - months); + return Math.floor(d.getTime() / 1000); +} +const ym = (sec) => { + const d = new Date(sec * 1000); + return d.getFullYear() * 12 + d.getMonth(); +}; +const ymLabel = (n) => `${Math.floor(n / 12)}-${String((n % 12) + 1).padStart(2, '0')}`; + +// --------------------------------------------------------------------------- +// Path classification +// --------------------------------------------------------------------------- +const TYPE_RULES = [ + ['config', /(^|\/)(cmakelists\.txt|requirements[^/]*\.txt|constraints\.txt|go\.(mod|sum|work)|codeowners)$/i], + ['doc', /(^|\/)(readme|license|licence|changelog|changes|history|authors|contributing|notice|copying|code_of_conduct)([._-][\w.-]+)?$|\.(md|mdx|markdown|rst|txt|adoc|org|tex)$/i], + ['ts', /\.(ts|tsx|mts|cts)$/i], + ['js', /\.(js|jsx|mjs|cjs)$/i], + ['py', /\.(py|pyi|pyx|ipynb)$/i], + ['go', /\.go$/i], + ['rs', /\.rs$/i], + ['jvm', /\.(java|kt|kts|scala|groovy|clj|cljs|cljc)$/i], + ['c', /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|m|mm|cu|metal)$/i], + ['swift', /\.swift$/i], + ['rb', /\.(rb|rake|erb)$/i], + ['php', /\.php$/i], + ['dotnet', /\.(cs|fs|vb|razor|cshtml)$/i], + ['style', /\.(css|scss|sass|less|styl|pcss)$/i], + ['markup', /\.(html?|vue|svelte|astro|hbs|ejs|pug|jinja2?|j2|njk|liquid|twig|ftl|mustache|tmpl)$/i], + ['shell', /\.(sh|bash|zsh|fish|ps1|psm1|bat|cmd|nsh|nsi)$/i], + ['asset', /\.(png|jpe?g|gif|svg|webp|avif|heic|ico|icns|bmp|tiff?|woff2?|ttf|otf|eot|pfb|pfm|afm|bcmap|mp3|mp4|m4a|aac|flac|wav|ogg|webm|mov|pdf|docx?|xlsx?|pptx?|key|numbers|pages|zip|gz|tgz|bz2|xz|7z|rar|tar|dmg|pkg|deb|rpm|apk|ipa|jar|war|wasm|bin|exe|dll|so|dylib|node|pyc|class|psd|sketch|fig|ai|lottie)$/i], + ['config', /\.(json|jsonc|json5|jsonl|ndjson|ya?ml|toml|ini|cfg|conf|properties|env|lock|xml|xsd|xsl|xslt|dtd|plist|csv|tsv|sql|graphql|gql|proto|thrift|tf|hcl|nix|gradle|cmake|mk|csproj|props|targets|sln|slnx)$|(^|\/)(dockerfile|makefile|procfile|gemfile|rakefile)$|(^|\/)\.[^/]+$/i], +]; +export const CODE_TYPES = new Set(['ts', 'js', 'py', 'go', 'rs', 'jvm', 'c', 'swift', 'rb', 'php', 'dotnet', 'style', 'markup', 'shell']); +export function typeOf(path) { + for (const [k, re] of TYPE_RULES) if (re.test(path)) return k; + return 'other'; +} +const TEST_SEG = /(^|\/)(test|tests|__tests__|spec|specs|e2e|testdata|__mocks__|fixtures?)(\/|$)/i; +const TEST_FILE = /[._-](test|spec)s?\.[a-z0-9]+$|^test_[^/]*\.py$/i; +export const isTestPath = (p) => TEST_SEG.test(p) || TEST_FILE.test(p.slice(p.lastIndexOf('/') + 1)); +/** Bookkeeping that rides along with most changes: changelogs, release notes, lock files. */ +const PROCESS = /(^|\/)(changelogs?|\.changeset|release-notes?)(\/|$)|(^|\/)(changelog|history|changes|news|releases?)(\.[a-z]+)?$|(^|\/)(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|cargo\.lock|go\.sum|poetry\.lock|gemfile\.lock|composer\.lock|uv\.lock|pdm\.lock)$/i; +export const isProcessPath = (p) => PROCESS.test(p); +const MANIFEST = /^(package\.json|cargo\.toml|go\.mod|pyproject\.toml|setup\.py|pom\.xml|build\.gradle(\.kts)?|composer\.json|gemfile|mix\.exs|pubspec\.yaml|deno\.jsonc?|.+\.csproj)$/i; + +// --------------------------------------------------------------------------- +// HEAD index (cached per model) +// --------------------------------------------------------------------------- +function headIndex(model) { + if (model._head) return model._head; + const mk = (name, path, parent, file) => ({ name, path, parent, file, kids: new Map(), files: 0, code: 0, type: '', touches: 0 }); + const root = mk('', '', null, false); + /** @type {Map} */ + const dirs = new Map([['', root]]); + /** @type {Map} */ + const fileNodes = new Map(); + const pkgRoots = []; + for (const t of model.tree) { + const segs = t.path.split('/'); + let cur = root; + for (let i = 0; i < segs.length; i++) { + const file = i === segs.length - 1; + const key = file ? segs[i] : `${segs[i]}/`; + let k = cur.kids.get(key); + if (!k) { + k = mk(segs[i], file ? t.path : segs.slice(0, i + 1).join('/'), cur, file); + cur.kids.set(key, k); + if (!file) dirs.set(k.path, k); + } + cur = k; + } + cur.type = typeOf(t.path); + cur.touches = model.pathCount[model.pathIdx.get(t.path) ?? -1] || 0; + fileNodes.set(t.path, cur); + const j = t.path.lastIndexOf('/'); + if (MANIFEST.test(t.path.slice(j + 1))) pkgRoots.push(j < 0 ? '' : t.path.slice(0, j)); + } + const post = (n) => { + if (n.file) { + n.files = 1; + n.code = CODE_TYPES.has(n.type) ? 1 : 0; + return; + } + for (const k of n.kids.values()) { + post(k); + n.files += k.files; + n.code += k.code; + } + }; + post(root); + // Every historical path gets a "home": its HEAD file node, or the deepest directory that still exists. + const P = model.paths.length; + const home = new Array(P); + const gone = new Uint8Array(P); + for (let i = 0; i < P; i++) { + const p = model.paths[i]; + const f = fileNodes.get(p); + if (f) { + home[i] = f; + continue; + } + gone[i] = 1; + let d = p; + let n = null; + while (!n) { + const j = d.lastIndexOf('/'); + if (j < 0) { + n = root; + break; + } + d = d.slice(0, j); + n = dirs.get(d) || null; + } + home[i] = n; + } + pkgRoots.sort((a, b) => b.length - a.length); + const pkgOf = (p) => pkgRoots.find((r) => r === '' || p === r || p.startsWith(`${r}/`)) ?? ''; + model._head = { root, dirs, fileNodes, home, gone, pkgOf }; + return model._head; +} + +function lastActivity(model, identity) { + model._lastAct ??= {}; + if (model._lastAct[identity]) return model._lastAct[identity]; + const ident = model.ids[identity] || model.ids.merged; + const arr = new Float64Array(ident.names.length); + for (const c of model.commits) { + if (c.merge) continue; + const id = ident.idOf[c.a]; + if (c.ct > arr[id]) arr[id] = c.ct; + } + model._lastAct[identity] = arr; + return arr; +} + +/** "process" / "test" / "normal" for a co-change partner b of a. */ +export function coKind(model, a, b) { + if (isProcessPath(b)) return 'process'; + const H = headIndex(model); + const ta = isTestPath(a); + if (ta === isTestPath(b) || H.pkgOf(a) !== H.pkgOf(b)) return 'normal'; + return CODE_TYPES.has(typeOf(ta ? b : a)) ? 'test' : 'normal'; +} + +// --------------------------------------------------------------------------- +// Layout: adaptive expansion of the tree under the focus +// --------------------------------------------------------------------------- +/** + * Children worth their own block are those ≥ 0.5% of the view; smaller ones fold into one "其他 N 项" + * block. The focus opens first and shows all of its sizeable children (breadth first); then folders + * bigger than an "average" block (total / 24) open largest-first while the block budget allows. A + * split that does not fit is skipped, not the whole loop, so smaller folders can still open. A + * folder that is a heap of tiny files stays whole. + */ +function layoutBlocks(fnode, weightOf, maxBlocks) { + const total = weightOf(fnode); + if (!(total > 0)) return []; + const minKid = total * 0.005; + const big = total / 24; + const kidsOf = (n) => [...n.kids.values()].filter((k) => weightOf(k) > 0).sort((a, b) => weightOf(b) - weightOf(a) || b.touches - a.touches || (a.path < b.path ? -1 : 1)); + /** At most `cap` own blocks, plus one fold; `force` keeps `cap` children whatever their size. */ + const split = (n, cap, { force = false, focus = false } = {}) => { + const kids = kidsOf(n); + if (!kids.length) return null; + const keep = []; + let capped = false; + for (const k of kids) { + if (!force && keep.length && weightOf(k) < minKid) break; + if (keep.length >= cap) { + capped = true; + break; + } + keep.push(k); + } + const rest = kids.slice(keep.length); + if (rest.length === 1) keep.push(rest[0]); + else if (rest.length) { + const w = rest.reduce((s, k) => s + weightOf(k), 0); + // A heap of tiny files stays whole: for the focus when nothing sizeable is in it (it then opens + // forced), for any other folder when most of it would land in the fold anyway. + if (!force && (focus ? w >= weightOf(n) * 0.85 && keep.length <= 1 : w >= weightOf(n) * 0.6)) return null; + // A folder opens only when all of its sizeable children fit: a corner of it next to a big fold + // says less than the folder's own name (and the user can always step into it). + if (capped && !force && !focus) return null; + keep.push({ other: true, parent: n, rest, w, path: n.path, kids: new Map(), file: false }); + } + return keep; + }; + // The focus itself always opens up, even when it is a heap of small files. + let leaves = split(fnode, maxBlocks - 1, { focus: true }) || split(fnode, Math.max(4, Math.min(maxBlocks - 1, 24)), { force: true }) || []; + const done = new Set(); + for (;;) { + let best = null; + for (const l of leaves) if (!l.other && !l.file && !done.has(l) && weightOf(l) > big && (!best || weightOf(l) > weightOf(best))) best = l; + if (!best) break; + done.add(best); + const room = maxBlocks - leaves.length + 1; // blocks this split may use in place of `best` + const kids = room >= 2 ? split(best, room - 1) : null; + if (!kids || kids.length > room) continue; + leaves = leaves.flatMap((l) => (l === best ? kids : [l])); + } + return leaves; +} + +const walkFiles = (n, fn) => { + if (n.file) return fn(n); + for (const k of n.kids.values()) walkFiles(k, fn); +}; +const topN = (map, n) => [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, n); +const sumOf = (map) => { + let s = 0; + for (const v of map.values()) s += v; + return s; +}; + +// --------------------------------------------------------------------------- +// The view +// --------------------------------------------------------------------------- +/** + * @param {any} model + * @param {{ focus?: string, size?: string, range?: string, since?: number|null, identity?: string, + * maxBlocks?: number, lite?: boolean, risk?: Set, descs?: Record, now?: number }} o + */ +export function mapView(model, o = {}) { + const H = headIndex(model); + const focus = String(o.focus || '').replace(/^\/+|\/+$/g, ''); + const fnode = H.dirs.get(focus); + if (!fnode) return null; + const lite = Boolean(o.lite); + const size = o.size === 'churn' ? 'churn' : 'files'; + const now = o.now ?? Math.floor(Date.now() / 1000); + const commits = model.commits; + let minCt = Infinity; + let maxCt = -Infinity; + for (const c of commits) { + if (c.ct < minCt) minCt = c.ct; + if (c.ct > maxCt) maxCt = c.ct; + } + + // Range: same anchoring as the report window (now, or the last commit for dormant repositories). + let since = -Infinity; + let range = 'all'; + if (o.since !== undefined) { + since = o.since ?? -Infinity; + range = 'custom'; + } else if (RANGE_MONTHS[o.range]) { + range = String(o.range); + since = minusMonths(now, RANGE_MONTHS[o.range]); + if (maxCt < since) since = minusMonths(maxCt, RANGE_MONTHS[o.range]); + } + const m0 = ym(Number.isFinite(since) ? Math.max(since, minCt) : minCt); + const M = Math.max(1, ym(maxCt) - m0 + 1); + + // Modifications per historical path inside the range. + const P = model.paths.length; + const pc = new Int32Array(P); + const pb = new Int32Array(P); + for (const c of commits) { + if (c.ct < since) continue; + const f = c.files; + for (let i = 0; i < f.length; i += 2) { + pc[f[i]]++; + if (c.fix) pb[f[i]]++; + } + } + + // Layout weights. + let weightOf; + if (size === 'churn') { + const w = new Map(); + for (let i = 0; i < P; i++) { + if (!pc[i]) continue; + const chain = []; + let x = H.home[i]; + while (x && x !== fnode) { + chain.push(x); + x = x.parent; + } + if (!x) continue; + chain.push(fnode); + for (const y of chain) w.set(y, (w.get(y) || 0) + pc[i]); + } + weightOf = (n) => (n.other ? n.w : w.get(n) || 0); + } else weightOf = (n) => (n.other ? n.w : n.files); + + const raw = layoutBlocks(fnode, weightOf, Math.max(4, Math.min(80, o.maxBlocks || 44))); + const B = raw.length; + const F = B; // the focus as a whole + const DEL = -2; + const NONE = -1; + const mark = new Map(); + raw.forEach((l, i) => { + if (l.other) for (const r of l.rest) mark.set(r, i); + else mark.set(l, i); + }); + + // Region of every historical path: a block, the deleted-in-focus bucket, or an outside region + // (the focus' ancestors' siblings, e.g. focus packages/ui -> packages/daemon, changelogs). + const fSegs = focus ? focus.split('/') : []; + /** @type {{ path: string, node: any }[]} */ + const outs = []; + const outIdx = new Map(); + const reg = new Int32Array(P).fill(-9); + const regionOf = (i) => { + let r = reg[i]; + if (r !== -9) return r; + r = NONE; + let inside = false; + for (let x = H.home[i]; x; x = x.parent) { + const b = mark.get(x); + if (b !== undefined) { + r = b; + inside = true; + break; + } + if (x === fnode) { + r = H.gone[i] ? DEL : NONE; + inside = true; + break; + } + } + if (!inside && !lite) { + const s = model.paths[i].split('/'); + let k = 0; + while (k < fSegs.length && k < s.length - 1 && s[k] === fSegs[k]) k++; + const key = s.slice(0, k + 1).join('/'); + let id = outIdx.get(key); + if (id === undefined) { + const node = H.dirs.get(key) || H.fileNodes.get(key); + id = node ? B + 1 + outs.length : NONE; + if (node) outs.push({ path: key, node }); + outIdx.set(key, id); + } + r = id; + } + reg[i] = r; + return r; + }; + + // One pass over the whole history. + const R = B + 1; + const ident = model.ids[o.identity] || model.ids.merged; + const churn = new Int32Array(R); + const bug = new Int32Array(R); + const last = new Float64Array(R); + const mC = lite ? [] : Array.from({ length: R }, () => new Int32Array(M)); + const mB = lite ? [] : Array.from({ length: R }, () => new Int32Array(M)); + const mO = lite ? [] : Array.from({ length: R }, () => new Map()); + const own = lite ? [] : Array.from({ length: R }, () => new Map()); + const all = lite ? [] : Array.from({ length: R }, () => new Map()); + const revs = new Map(); + const pairMap = new Map(); + const BIG = 2 ** 20; + let delChurn = 0; + let delBug = 0; + let focusCommits = 0; + for (const c of commits) { + const f = c.files; + if (!f.length) continue; + const inRange = c.ct >= since; + const mi = inRange && !lite ? ym(c.ct) - m0 : -1; + const who = ident.idOf[c.a]; + const human = !c.merge && !ident.bot[who]; + const touched = new Set(); + let inFocus = false; + for (let i = 0; i < f.length; i += 2) { + const r = regionOf(f[i]); + if (r === NONE) continue; + const ins = r >= 0 && r < B; + if (ins || r === DEL) inFocus = true; + if (inRange && (ins || r === DEL)) { + churn[F]++; + if (c.fix) bug[F]++; + if (ins) { + churn[r]++; + if (c.fix) bug[r]++; + } else { + delChurn++; + if (c.fix) delBug++; + } + if (mi >= 0) { + mC[F][mi]++; + if (c.fix) mB[F][mi]++; + if (ins) { + mC[r][mi]++; + if (c.fix) mB[r][mi]++; + } + } + } + if (r !== DEL) touched.add(r); + } + if (inFocus) { + touched.add(F); + if (inRange) focusCommits++; + } + for (const r of touched) { + if (r > F) continue; + if (c.ct > last[r]) last[r] = c.ct; + if (lite || !human) continue; + all[r].set(who, (all[r].get(who) || 0) + 1); + if (!inRange) continue; + own[r].set(who, (own[r].get(who) || 0) + 1); + let mm = mO[r].get(mi); + if (!mm) mO[r].set(mi, (mm = new Map())); + mm.set(who, (mm.get(who) || 0) + 1); + } + if (lite || !inRange || c.merge || f.length / 2 > COUPLE.maxChangeset || !touched.size) continue; + const arr = [...touched]; + for (const r of arr) revs.set(r, (revs.get(r) || 0) + 1); + for (let x = 0; x < arr.length; x++) { + for (let y = x + 1; y < arr.length; y++) { + const a = Math.min(arr[x], arr[y]); + const b = Math.max(arr[x], arr[y]); + if (a > F || b === F) continue; // outside-outside, and block-inside-focus, carry no signal + const k = a * BIG + b; + pairMap.set(k, (pairMap.get(k) || 0) + 1); + } + } + } + + // Per-block static facts from the HEAD tree. + const risk = o.risk || new Set(); + const descs = o.descs || {}; + const facts = raw.map((l) => { + const roots = l.other ? l.rest : [l]; + const types = new Map(); + let files = 0; + let code = 0; + let riskN = 0; + for (const n of roots) { + files += n.files; + code += n.code; + if (lite && !risk.size) continue; + walkFiles(n, (fnn) => { + if (!lite) types.set(fnn.type, (types.get(fnn.type) || 0) + 1); + if (risk.has(fnn.path)) riskN++; + }); + } + return { files, code, types, risk: riskN }; + }); + const groupOf = (n) => { + if (n === fnode) return ''; + let x = n; + while (x.parent && x.parent !== fnode) x = x.parent; + return x.file ? '' : x.path; + }; + const firstFile = (n) => { + let f = null; + walkFiles(n, (x) => { + f ??= x; + }); + return f; + }; + + const blocks = raw.map((l, i) => { + const single = !l.other && !l.file && l.files === 1 ? firstFile(l) : null; + const node = l.other ? l.parent : single || l; + const kind = l.other ? 'other' : node.file ? 'file' : 'dir'; + const b = { + path: node.path, + name: l.other ? `其他 ${l.rest.length} 项` : node.name, + kind, + group: groupOf(l.other ? l.parent : l), + zoom: kind === 'dir' ? node.path : kind === 'other' && l.parent !== fnode ? l.parent.path : null, + files: facts[i].files, + churn: churn[i], + bug: bug[i], + risk: facts[i].risk, + }; + if (l.other) b.items = l.rest.slice(0, 12).map((n) => [n.path, n.file ? 'file' : 'dir', n.files]); + return b; + }); + + if (lite) { + return { focus, size, since: Number.isFinite(since) ? since : null, totals: { files: fnode.files, churn: churn[F], bug: bug[F], delChurn }, blocks }; + } + + // ---- full view: owners, loss, monthly series, co-change ----------------- + const lastAct = lastActivity(model, o.identity || 'merged'); + const cutoff = minusMonths(now, 3); + const aIdx = new Map(); + const authors = []; + const A = (id) => { + let k = aIdx.get(id); + if (k === undefined) { + k = authors.length; + authors.push(ident.names[id]); + aIdx.set(id, k); + } + return k; + }; + const regPath = (r) => (r < B ? blocks[r].path : r === F ? focus : outs[r - B - 1].path); + const regCode = (r) => { + if (r < B) return facts[r].files ? facts[r].code / facts[r].files : 0; + const n = r === F ? fnode : outs[r - B - 1].node; + return n.files ? n.code / n.files : 0; + }; + + // Process regions: name-based (changelogs, lock files) or statistically "rides along with everything". + // Outside regions only pair with in-view regions, so their bar is measured against in-view regions. + let eligibleIn = 0; + let eligibleAll = 0; + for (const [r, n] of revs) { + if (r === F || n < COUPLE.minRevs) continue; + eligibleAll++; + if (r < B) eligibleIn++; + } + const fans = new Map(); + for (const [k, n] of pairMap) { + const a = Math.floor(k / BIG); + const b = k % BIG; + for (const [x, y] of [[a, b], [b, a]]) { + if (x === F) continue; + const rx = revs.get(x) || 0; + if (rx >= COUPLE.minRevs && n / rx >= 0.5) fans.set(y, (fans.get(y) || 0) + 1); + } + } + const process = new Set(); + for (const r of revs.keys()) { + if (r === F) continue; + const bar = Math.max(3, Math.ceil((r < B ? eligibleAll : eligibleIn) * 0.4)); + const statistical = (fans.get(r) || 0) >= bar && regCode(r) < 0.5; + if (statistical || isProcessPath(regPath(r))) process.add(r); + } + const testOf = (r) => isTestPath(regPath(r)); + // "测试同步": a test region and a *code* region of the same package (docs next to tests are a real relation). + const kindOf = (x, y) => { + if (process.has(y)) return 'process'; + const tx = testOf(x); + if (tx === testOf(y) || H.pkgOf(regPath(x)) !== H.pkgOf(regPath(y))) return 'normal'; + return regCode(tx ? y : x) >= 0.5 ? 'test' : 'normal'; + }; + + const coupled = new Map(); + for (const [k, n] of pairMap) { + if (n < COUPLE.minShared) continue; + const a = Math.floor(k / BIG); + const b = k % BIG; + for (const [x, y] of [[a, b], [b, a]]) { + if (x > F || (x === F && y < F)) continue; + const rx = revs.get(x) || 0; + if (rx < COUPLE.minRevs || n / rx < COUPLE.minDegree) continue; + if (!coupled.has(x)) coupled.set(x, []); + coupled.get(x).push({ y, n, d: n / rx }); + } + } + const coupleOut = (x) => { + const list = (coupled.get(x) || []).sort((p, q) => q.d - p.d || q.n - p.n); + const normal = list.filter((e) => !process.has(e.y)).slice(0, 8); + return { + couple: normal.map((e) => ({ + path: regPath(e.y), + block: e.y < B ? e.y : -1, + target: e.y < B ? blocks[e.y].kind : outs[e.y - B - 1]?.node.file ? 'file' : 'dir', + degree: Math.round(e.d * 1000) / 1000, + shared: e.n, + kind: kindOf(x, e.y), + })), + processWith: list.filter((e) => process.has(e.y)).slice(0, 4).map((e) => ({ path: regPath(e.y), degree: Math.round(e.d * 100) / 100 })), + revs: revs.get(x) || 0, + }; + }; + + // Top files inside the range per region. + const tops = Array.from({ length: R }, () => []); + for (let i = 0; i < P; i++) { + if (!pc[i]) continue; + const r = regionOf(i); + if (r === NONE || r > F) continue; + const row = [model.paths[i], pc[i], pb[i], H.gone[i] ? 0 : 1]; + if (r !== DEL) tops[r].push(row); + tops[F].push(row); + } + + const regionOut = (r, typesMap) => { + const ownSum = sumOf(own[r]); + const allSum = sumOf(all[r]); + let lost = 0; + const lostBy = new Map(); + for (const [id, n] of all[r]) if (lastAct[id] < cutoff) { + lost += n; + lostBy.set(id, n); + } + const mo = new Array(M).fill(-1); + for (const [mi, mm] of mO[r]) { + let best = -1; + let bn = 0; + for (const [id, n] of mm) if (n > bn) { + bn = n; + best = id; + } + if (best >= 0) mo[mi] = A(best); + } + const types = [...typesMap.entries()].sort((a, b) => b[1] - a[1]); + return { + last: last[r] || null, + owners: topN(own[r], 5).map(([id, n]) => [A(id), n]), + ownerSum: ownSum, + loss: allSum ? Math.round((lost / allSum) * 1000) / 1000 : null, + lossTop: topN(lostBy, 2).map(([id]) => A(id)), + types, + top: tops[r].sort((a, b) => b[1] - a[1] || b[2] - a[2]).slice(0, 5), + m: Array.from(mC[r]), + mb: Array.from(mB[r]), + mo, + ...coupleOut(r), + test: testOf(r), + process: process.has(r), + }; + }; + + blocks.forEach((b, i) => { + Object.assign(b, regionOut(i, facts[i].types)); + b.code = facts[i].files ? Math.round((facts[i].code / facts[i].files) * 100) / 100 : 0; + b.desc = b.kind === 'dir' ? descs[b.path] || null : null; + }); + const focusTypes = new Map(); + for (const f of facts) for (const [k, n] of f.types) focusTypes.set(k, (focusTypes.get(k) || 0) + n); + const focusInfo = { + path: focus, + name: focus ? fnode.name : '', + kind: 'dir', + files: fnode.files, + churn: churn[F], + bug: bug[F], + risk: facts.reduce((s, f) => s + f.risk, 0), + code: fnode.files ? Math.round((fnode.code / fnode.files) * 100) / 100 : 0, + desc: descs[focus] || null, + ...regionOut(F, focusTypes), + }; + + // Strongest relations between blocks of this view. + const pairs = []; + for (const [k, n] of pairMap) { + const a = Math.floor(k / BIG); + const b = k % BIG; + if (a >= B || b >= B || n < COUPLE.minShared || process.has(a) || process.has(b)) continue; + const da = n / (revs.get(a) || Infinity); + const db = n / (revs.get(b) || Infinity); + const [from, to, d] = da >= db ? [a, b, da] : [b, a, db]; + if ((revs.get(from) || 0) < COUPLE.minRevs || d < COUPLE.minDegree) continue; + pairs.push({ from, to, degree: Math.round(d * 1000) / 1000, shared: n, kind: kindOf(from, to) }); + } + pairs.sort((p, q) => q.degree - p.degree || q.shared - p.shared); + + return { + focus, + size, + range, + since: Number.isFinite(since) ? since : null, + // Where the first frame really starts: the range start, or the first commit when that is later. + start: Number.isFinite(minCt) ? (Number.isFinite(since) ? Math.max(since, minCt) : minCt) : null, + latest: maxCt, + cutoff, + months: Array.from({ length: M }, (_, i) => ymLabel(m0 + i)), + totals: { files: fnode.files, churn: churn[F], bug: bug[F], delChurn, delBug, commits: focusCommits }, + couple: COUPLE, + process: [...process].map(regPath).slice(0, 6), + authors, + focusInfo, + blocks, + pairs: pairs.slice(0, 8), + }; +} + +// --------------------------------------------------------------------------- +// Directory descriptions from the repository's own files +// --------------------------------------------------------------------------- +const DESC_NAME = /^(package\.json|plugin\.json|cargo\.toml|pyproject\.toml|skill\.md|readme(\.(md|markdown|rst|txt))?)$/i; +const DESC_SKIP = /(^|\/)(node_modules|vendor|dist|build|out|target|coverage|__pycache__|\.venv|venv|fixtures?|testdata|__tests__)\//; + +/** README / manifest files that may describe a directory (depth ≤ 4), shallow first, so the cap drops the deepest. */ +export function pickDescFiles(tree, { maxDepth = 4, limit = 600 } = {}) { + const out = []; + for (const t of tree) { + if (DESC_SKIP.test(t.path)) continue; + const j = t.path.lastIndexOf('/'); + const base = t.path.slice(j + 1); + if (!DESC_NAME.test(base)) continue; + let dir = j < 0 ? '' : t.path.slice(0, j); + if (dir === '.claude-plugin') dir = ''; + else dir = dir.replace(/\/\.claude-plugin$/, ''); + const depth = dir ? dir.split('/').length : 0; + if (depth > maxDepth) continue; + const lower = base.toLowerCase(); + const rank = /^(package\.json|plugin\.json|cargo\.toml|pyproject\.toml)$/.test(lower) ? 0 : lower === 'skill.md' ? 1 : 2; + out.push({ path: t.path, oid: t.oid, dir, rank, depth }); + } + out.sort((a, b) => a.depth - b.depth || a.rank - b.rank); + return out.slice(0, limit); +} + +const JUNK = /^(todo|tbd|wip|n\/?a|none|description|test|\.\.\.|-)\.?$/i; +function cleanText(s) { + return s + .replace(/<[^>]+>/g, '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/(\*\*|__)(.+?)\1/g, '$2') + .replace(/(^|\s)[*_]([^*_]+)[*_](?=\s|$|[,。,.!?])/g, '$1$2') + .replace(/`([^`]+)`/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} +function clipText(s, n = 160) { + if (s.length <= n) return s; + const cut = s.slice(0, n); + // Prefer a full sentence; fall back to a clause, then to an ellipsis. + for (const marks of [['。', '. ', '!', '?'], [';', '; ']]) { + const p = Math.max(...marks.map((m) => cut.lastIndexOf(m))); + if (p > n * 0.25) return cut.slice(0, p + 1).trim(); + } + return `${cut.trimEnd()}…`; +} +function skillDesc(text) { + const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!m) return null; + const lines = m[1].split(/\r?\n/); + const i = lines.findIndex((l) => /^description\s*:/.test(l)); + if (i < 0) return null; + let v = lines[i].replace(/^description\s*:\s*/, '').trim(); + if (!v || /^[|>][-+]?$/.test(v)) { + const body = []; + for (let j = i + 1; j < lines.length && (/^\s+\S/.test(lines[j]) || !lines[j].trim()); j++) if (lines[j].trim()) body.push(lines[j].trim()); + v = body.join(' '); + } + return v.replace(/^["']|["']$/g, ''); +} +function readmeParagraph(text) { + const lines = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').split(/\r?\n/); + const para = []; + let fence = false; + for (let i = 0; i < lines.length; i++) { + const l = lines[i].trim(); + if (/^(```|~~~)/.test(l)) { + fence = !fence; + if (para.length) break; + continue; + } + if (fence) continue; + if (!l) { + if (para.length) break; + continue; + } + const next = (lines[i + 1] || '').trim(); + const underline = /^(=+|-+|~+)$/.test(next) && next.length >= 3; + const skip = underline || /^(#|!\[|<|\||>|[-*+]\s|\d+[.)]\s|={3,}|-{3,}|\[!\[|\[[^\]]*\]:)/.test(l) || /^\[[^\]]+\]\([^)]*\)$/.test(l); + if (skip) { + if (para.length) break; + if (underline) i++; + continue; + } + para.push(l); + if (para.join(' ').length > 400) break; + } + return para.join(' '); +} +function extractDesc(path, text) { + const base = path.slice(path.lastIndexOf('/') + 1).toLowerCase(); + let d = null; + try { + if (base === 'package.json' || base === 'plugin.json') d = JSON.parse(text)?.description; + else if (base === 'cargo.toml' || base === 'pyproject.toml') d = text.match(/^\s*description\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')/m)?.slice(1).find(Boolean); + else if (base === 'skill.md') d = skillDesc(text); + else d = readmeParagraph(text); + } catch { + return null; + } + if (typeof d !== 'string') return null; + d = cleanText(d); + if (d.length < 4 || JUNK.test(d)) return null; + return clipText(d); +} + +/** @returns {Record} dir -> description */ +export function buildDescs(files, blobs) { + const best = new Map(); + for (const f of files) { + const prev = best.get(f.dir); + if (prev && prev.rank <= f.rank) continue; + const text = blobs.get(f.oid); + if (!text) continue; + const d = extractDesc(f.path, text); + if (!d) continue; + // A one-word manifest description ("Workspace") loses to any README / SKILL.md sentence. + const rank = !/\s/.test(d) && d.length <= 16 ? 3 : f.rank; + if (prev && prev.rank <= rank) continue; + best.set(f.dir, { text: d, source: f.path.slice(f.path.lastIndexOf('/') + 1), rank }); + } + return Object.fromEntries([...best].map(([k, v]) => [k, { text: v.text, source: v.source }])); +} diff --git a/plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs b/plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs new file mode 100644 index 0000000..74641b0 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs @@ -0,0 +1,303 @@ +// @ts-check +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; + +export class GitError extends Error { + /** @param {string} code @param {string} message */ + constructor(code, message) { + super(message); + this.code = code; + } +} + +/** Child processes owned by this runtime (killed on dispose). */ +export const children = new Set(); + +/** + * Run git with an argument array (never a shell). + * @param {string[]} args + * @param {{ cwd?: string, env?: Record, timeout?: number, signal?: AbortSignal, input?: string, onStderr?: (chunk: string) => void, onStdout?: (chunk: Buffer) => void, maxBuffer?: number }} [opts] + * @returns {Promise} stdout (empty when onStdout streams it) + */ +export function git(args, opts = {}) { + return new Promise((resolvePromise, reject) => { + const env = { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + SSH_ASKPASS: '', + GCM_INTERACTIVE: 'never', + LC_ALL: 'C', + ...opts.env, + }; + const child = spawn('git', args, { cwd: opts.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + children.add(child); + const out = []; + let outLen = 0; + let err = ''; + let killedFor = ''; + const max = opts.maxBuffer ?? 256 * 1024 * 1024; + const timer = setTimeout(() => { + killedFor = 'timeout'; + child.kill('SIGKILL'); + }, opts.timeout ?? 120_000); + const onAbort = () => { + killedFor = 'aborted'; + child.kill('SIGKILL'); + }; + opts.signal?.addEventListener('abort', onAbort, { once: true }); + child.stdout.on('data', (c) => { + if (opts.onStdout) return opts.onStdout(c); + outLen += c.length; + if (outLen > max) { + killedFor = 'overflow'; + child.kill('SIGKILL'); + return; + } + out.push(c); + }); + child.stderr.on('data', (c) => { + const s = c.toString('utf8'); + if (err.length < 64 * 1024) err += s; + opts.onStderr?.(s); + }); + child.on('error', (e) => { + clearTimeout(timer); + children.delete(child); + reject(new GitError(e.code === 'ENOENT' ? 'git_missing' : 'git', e.code === 'ENOENT' ? '本机没有找到 git,请先安装 git' : 'git 启动失败')); + }); + child.on('close', (code) => { + clearTimeout(timer); + children.delete(child); + opts.signal?.removeEventListener('abort', onAbort); + if (killedFor === 'aborted') return reject(new GitError('aborted', '已取消')); + if (killedFor === 'timeout') return reject(new GitError('timeout', 'git 执行超时')); + if (killedFor === 'overflow') return reject(new GitError('overflow', 'git 输出过大')); + if (code !== 0) return reject(new GitError(classify(err), friendly(err))); + resolvePromise(Buffer.concat(out).toString('utf8')); + }); + if (opts.input != null) child.stdin.end(opts.input); + else child.stdin.end(); + }); +} + +function classify(stderr) { + if (/Authentication failed|could not read Username|403|401|HTTP Basic: Access denied|terminal prompts disabled/i.test(stderr)) return 'auth'; + if (/not found|does not exist|Repository not found|404/i.test(stderr)) return 'not_found'; + if (/Could not resolve host|Failed to connect|Connection (reset|refused|timed out)|SSL|TLS|gnutls|unable to access/i.test(stderr)) return 'network'; + if (/not a git repository/i.test(stderr)) return 'not_repo'; + if (/does not have any commits|bad default revision|unknown revision/i.test(stderr)) return 'empty'; + return 'git'; +} + +function friendly(stderr) { + const code = classify(stderr); + if (code === 'auth') return '没有权限访问这个仓库:请确认已用 gh / glab 登录,或在设置里为这个主机填写访问令牌'; + if (code === 'not_found') return '仓库不存在,或当前账号看不到它'; + if (code === 'network') return '网络连接失败:内网地址可能需要在设置里配置代理'; + if (code === 'not_repo') return '这个目录不是 git 仓库'; + if (code === 'empty') return '仓库还没有任何提交'; + const line = stderr.split('\n').map((s) => s.trim()).filter((s) => /^(fatal|error):/.test(s))[0]; + return line ? line.replace(/https?:\/\/[^\s@]*@/g, 'https://').slice(0, 200) : 'git 执行失败'; +} + +// --------------------------------------------------------------------------- +// Target parsing +// --------------------------------------------------------------------------- + +/** + * Parse a user-supplied repo reference. + * @returns {Promise<{ kind: 'remote', host: string, path: string, url: string, webUrl: string, provider: 'github'|'gitlab'|'other', id: string, name: string } | { kind: 'local', dir: string, id: string, name: string }>} + */ +export async function parseTarget(raw) { + const input = String(raw || '').trim(); + if (!input) throw new GitError('invalid', '请输入仓库地址或本地路径'); + if (input.length > 500) throw new GitError('invalid', '输入过长'); + + // Local path + if (input.startsWith('/') || input.startsWith('~') || /^[A-Za-z]:[\\/]/.test(input)) { + let dir = input.startsWith('~') ? join(homedir(), input.slice(1)) : input; + if (!isAbsolute(dir)) dir = resolve(dir); + try { + dir = await realpath(dir); + if (!(await stat(dir)).isDirectory()) throw new Error(); + } catch { + throw new GitError('invalid', '找不到这个目录'); + } + let top; + try { + top = (await git(['-C', dir, 'rev-parse', '--show-toplevel'], { timeout: 10_000 })).trim(); + } catch { + try { + // bare repository + const isBare = (await git(['-C', dir, 'rev-parse', '--is-bare-repository'], { timeout: 10_000 })).trim(); + if (isBare !== 'true') throw new Error(); + top = dir; + } catch { + throw new GitError('not_repo', '这个目录不是 git 仓库'); + } + } + const name = top.split(/[\\/]/).filter(Boolean).pop() || top; + return { kind: 'local', dir: top, id: 'l-' + hash(`local:${top}`), name }; + } + + let host; + let path; + let m; + if ((m = input.match(/^git@([^:/\s]+):(.+)$/))) { + host = m[1]; + path = m[2]; + } else if ((m = input.match(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/))) { + host = m[1]; + path = m[2]; + } else if (/^https?:\/\//i.test(input)) { + let u; + try { + u = new URL(input); + } catch { + throw new GitError('invalid', '地址格式不对'); + } + host = u.host; + path = decodeURIComponent(u.pathname); + } else if ((m = input.match(/^([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9._-]+)$/))) { + host = 'github.com'; + path = `${m[1]}/${m[2]}`; + } else if ((m = input.match(/^([a-z0-9-]+(?:\.[a-z0-9-]+)+)\/(.+)$/i))) { + host = m[1]; + path = m[2]; + } else { + throw new GitError('invalid', '无法识别:请输入 https 地址、owner/repo,或本地仓库的绝对路径'); + } + host = host.toLowerCase(); + path = path.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, ''); + // Strip web UI suffixes. + path = path.split('/-/')[0]; + const provider = host === 'github.com' || host.startsWith('github.') ? 'github' : host.includes('gitlab') ? 'gitlab' : 'other'; + if (provider === 'github') path = path.split('/').slice(0, 2).join('/'); + else path = path.replace(/\/(tree|blob|commits?|merge_requests|issues)(\/.*)?$/, ''); + if (!/^[A-Za-z0-9._~-]+(\/[A-Za-z0-9._~-]+)+$/.test(path)) throw new GitError('invalid', '仓库路径不合法'); + const key = `${host}/${path}`.toLowerCase(); + return { + kind: 'remote', + host, + path, + provider, + url: `https://${host}/${path}.git`, + webUrl: `https://${host}/${path}`, + id: 'r-' + hash(key), + name: path.split('/').pop() || path, + }; +} + +function hash(s) { + return createHash('sha1').update(s).digest('hex').slice(0, 12); +} + +// --------------------------------------------------------------------------- +// Network env (credentials + proxy) — injected through GIT_CONFIG_* env so +// nothing is written to repo config or visible in argv. +// --------------------------------------------------------------------------- + +/** @param {{ provider: string }} target @param {string} token @param {string} proxy */ +export function netEnv(target, token, proxy) { + const pairs = []; + if (token) { + const user = target.provider === 'github' ? 'x-access-token' : 'oauth2'; + pairs.push(['http.extraHeader', `Authorization: Basic ${Buffer.from(`${user}:${token}`).toString('base64')}`]); + } + if (proxy) pairs.push(['http.proxy', proxy]); + pairs.push(['credential.helper', '']); + const env = { GIT_CONFIG_COUNT: String(pairs.length) }; + pairs.forEach(([k, v], i) => { + env[`GIT_CONFIG_KEY_${i}`] = k; + env[`GIT_CONFIG_VALUE_${i}`] = v; + }); + return env; +} + +/** Parse "Receiving objects: 45% (x/y)" style progress. */ +export function progressParser(onProgress) { + let buf = ''; + return (chunk) => { + buf += chunk; + const parts = buf.split(/[\r\n]/); + buf = parts.pop() || ''; + for (const p of parts) { + const m = p.match(/(Counting objects|Compressing objects|Receiving objects|Resolving deltas):\s+(\d+)%/); + if (m) onProgress({ phase: m[1], pct: Number(m[2]) }); + } + }; +} + +/** Clone (bare, blob-less) or fetch an existing clone. */ +export async function syncRemote(target, dir, { env, signal, onProgress }) { + const exists = await stat(join(dir, 'HEAD')).then( + () => true, + () => false, + ); + const onStderr = progressParser(onProgress); + if (!exists) { + await git(['clone', '--bare', '--filter=blob:none', '--no-tags', '--progress', target.url, dir], { env, signal, timeout: 30 * 60_000, onStderr }); + await git(['-C', dir, 'config', 'remote.origin.fetch', '+refs/heads/*:refs/heads/*']); + return { cloned: true }; + } + await git(['-C', dir, 'fetch', '--prune', '--no-tags', '--progress', 'origin'], { env, signal, timeout: 15 * 60_000, onStderr }); + // Follow a default-branch rename. + try { + const out = await git(['-C', dir, 'ls-remote', '--symref', 'origin', 'HEAD'], { env, signal, timeout: 60_000 }); + const m = out.match(/^ref:\s+(refs\/heads\/\S+)\s+HEAD/m); + if (m) await git(['-C', dir, 'symbolic-ref', 'HEAD', m[1]]); + } catch {} + return { cloned: false }; +} + +/** Fetch specific blobs into a partial clone in one round trip. */ +export async function prefetchBlobs(dir, oids, { env, signal }) { + if (!oids.length) return; + const check = await git(['-C', dir, 'cat-file', '--batch-check=%(objectname) %(objecttype)'], { input: oids.join('\n') + '\n', env: { GIT_NO_LAZY_FETCH: '1' }, signal, timeout: 60_000 }); + const missing = check + .split('\n') + .filter((l) => / missing$/.test(l)) + .map((l) => l.split(' ')[0]); + for (let i = 0; i < missing.length; i += 200) { + await git(['-C', dir, '-c', 'fetch.negotiationAlgorithm=noop', 'fetch', '-q', '--no-tags', '--no-write-fetch-head', '--filter=blob:none', 'origin', ...missing.slice(i, i + 200)], { + env, + signal, + timeout: 5 * 60_000, + }); + } +} + +/** + * Read blobs with `cat-file --batch`, never lazily fetching. + * @returns {Promise>} oid -> text (only blobs < 512KB) + */ +export async function readBlobs(dir, oids, { signal }) { + const result = new Map(); + if (!oids.length) return result; + const chunks = []; + await git(['-C', dir, 'cat-file', '--batch'], { + input: oids.join('\n') + '\n', + env: { GIT_NO_LAZY_FETCH: '1' }, + signal, + timeout: 120_000, + onStdout: (c) => chunks.push(c), + }); + const buf = Buffer.concat(chunks); + let pos = 0; + while (pos < buf.length) { + const nl = buf.indexOf(10, pos); + if (nl < 0) break; + const header = buf.subarray(pos, nl).toString('utf8'); + pos = nl + 1; + const [oid, type, sizeStr] = header.split(' '); + if (type === 'missing' || !sizeStr) continue; + const size = Number(sizeStr); + if (type === 'blob' && size < 512 * 1024) result.set(oid, buf.subarray(pos, pos + size).toString('utf8')); + pos += size + 1; + } + return result; +} diff --git a/plugins/yujiachen-y/repo-xray/miniapp/node/miniapp-api.ts b/plugins/yujiachen-y/repo-xray/miniapp/node/miniapp-api.ts new file mode 100644 index 0000000..f5d8b7a --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/miniapp-api.ts @@ -0,0 +1,397 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export type MiniAppAgentRunStatus = 'finished' | 'error' | 'aborted'; + +export type MiniAppAgentQuestionMode = 'questionnaire' | 'feature-enable' | 'plan'; + +export interface MiniAppAgentQuestionOption { + readonly id: string; + readonly label: string; + readonly description?: string; +} + +export interface MiniAppAgentQuestionStep { + readonly id: string; + readonly header?: string; + readonly question: string; + readonly description?: string; + readonly selectionMode: 'single' | 'multiple'; + readonly options: readonly MiniAppAgentQuestionOption[]; + /** Free-text "other" is always accepted; see `MiniAppAgentQuestionAnswer.otherText`. */ + readonly allowOther: true; +} + +/** Same questionnaire the Host chat renders; render every step and answer each one. */ +export interface MiniAppAgentQuestionnaire { + readonly schemaVersion: 2; + readonly id: string; + readonly title?: string; + readonly steps: readonly MiniAppAgentQuestionStep[]; + readonly mode?: MiniAppAgentQuestionMode; + /** Plan review Markdown and other mode-specific data. */ + readonly modePayload?: JsonObject; + /** Unix ms; the request is withdrawn after this. */ + readonly expiresAt?: number; +} + +export interface MiniAppAgentQuestionAnswer { + readonly stepId: string; + readonly selectedOptionIds: readonly string[]; + readonly selectedOther: boolean; + readonly otherText?: string; + readonly skipped?: boolean; +} + +/** A model as the Host's composer selects it; take the values from `models()`. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + /** `''` means thinking is off; it is preserved, distinct from an absent `variant`. */ + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One model this user can use. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a Node-created session starts with. */ + readonly default: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** Canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +export interface MiniAppAgentRunResult { + /** Pass back in `run({ sessionId })` to continue the same conversation. */ + readonly sessionId: string; + /** Absent when no turn ran: an `answer` accepted without starting a continuation. */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + /** `aborted` only when the Host confirmed the abort; an unconfirmed one rejects with `AGENT_OUTCOME_UNKNOWN`. */ + readonly status: MiniAppAgentRunStatus; + /** Final assistant reply; absent when the turn produced none or did not finish. */ + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; render `request` and continue with `agent.answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + }; +} + +/** + * Progress of one run. `turnId` matches the result's, except that a `permission.requested` raised + * by a delegated child session carries that child's own `sessionId` and `turnId`; `reply` with + * those, and do not filter events by the root turn. Ignore unknown `type` values: new event + * types may be added without notice. + * - `run.queued`: all run slots are taken and the call waits at 1-based `position`; sent once, + * before the turn starts, so it has no `turnId` and names a session only when continuing one. + * - `turn.started`: the Agent began the turn; `model` is the model it was dispatched with. + * - `text.delta`: a piece of the reply as it streams. + * - `message.committed`: one complete assistant message (a turn can have several). + * - `tool.started` / `tool.finished`: a tool call by name; `ok` is false when it failed. + * - `turn.finished`: always last; same `status`, `text`, and `error` as the run result. + */ +/** + * Every event except `run.queued` names its session, so `reply` can be called from `onEvent` even + * before the `run` that created the session has returned. `answer` cannot: the paused `run` still holds the + * session's single-run slot while `question.requested` is delivered, so call `answer` only after + * that `run` resolves (an earlier call rejects with `BUSY`). Ignore event types you do not know. + */ +export type MiniAppAgentRunEvent = + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + /** A tool call is waiting for approval; decide it with `agent.reply`. */ + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + /** The turn paused on a questionnaire; continue with `agent.answer`. */ + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: MiniAppAgentRunStatus; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export interface MiniAppAgentRunOptions { + readonly signal?: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +export type MiniAppAgentErrorCode = + | 'NOT_PERMITTED' + | 'BUSY' + | 'SESSION_NOT_FOUND' + | 'INVALID_ARGUMENTS' + | 'AGENT_UNAVAILABLE' + | 'REQUEST_CANCELLED' + | 'AGENT_OUTCOME_UNKNOWN' + | 'REQUEST_NOT_FOUND'; + +export interface MiniAppAgentError extends Error { + readonly code: MiniAppAgentErrorCode; + /** + * True when nothing was started and the same call may be retried later. + * `AGENT_OUTCOME_UNKNOWN` is never retryable: the turn may have run. + */ + readonly retryable: boolean; + /** Set on every `BUSY`: the session already has a run in flight, or the wait queue is full. */ + readonly reason?: 'session_busy' | 'queue_full'; +} + +/** + * Present only when the manifest declares `agentAccess: ["session.run"]`. + * A new Node session uses the primary Agent unless `agent` names one from `agents()`, and works in + * this Mini App's own Agent directory and on the runtime default model; + * continuing a session keeps its Agent, directory and model (unless `model` switches it), including + * one a page created using its host chat's Agent, project directory and model; a session never + * changes its Agent. Runs one turn with that Agent's full capabilities + * (files, shell, Skills, MCP including this Mini App's own), and resolves when the turn ends. + * + * Interaction is yours to render: pass `onEvent` and handle `permission.requested` with + * `reply` and `question.requested` with `answer`. A permission request waits only while + * someone can answer it: this run passed `onEvent`, or a page of this Mini App currently has + * the session open in a chat side panel via `open`. Otherwise it is denied (the model is told + * why), and a questionnaire ends the turn with `result.pendingQuestion`. A run waiting on a + * permission is released by `cancel`/abort, process exit or republish. Up to `agentConcurrency` + * runs (default 4) per Mini App run at once; further `run` / `answer` calls wait in order (up to + * four times that many, see `run.queued`), and aborting a waiting call rejects it with + * `REQUEST_CANCELLED`. + */ +export interface MiniAppAgentClient { + run( + input: { + readonly text: string; + readonly sessionId?: string; + /** + * Switch the session to this model before the turn (validated like the composer validates a + * new session's model: it must be one `models()` lists; unusable is `INVALID_ARGUMENTS`, + * missing BYOK credentials end the turn with `status: 'error'`); the session keeps it. Omit to use the session's current model. + */ + readonly model?: MiniAppAgentModelSelection; + /** + * Only when starting a session (no `sessionId`): an agent `name` from `agents()`. Passing it + * with `sessionId` is `INVALID_ARGUMENTS`. + */ + readonly agent?: string; + }, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Decide a pending permission request. Never writes the user's global rules. Node replies + * have 16 separate pending slots; `run` / `answer` / `models` / `agents` / `create` and Connector + * requests share another 16. Filling that ordinary budget still permits replies. A full reply + * budget rejects with retryable `AGENT_UNAVAILABLE`. + */ + reply(input: { + readonly sessionId: string; + readonly requestId: string; + readonly decision: 'allowOnce' | 'deny'; + }): Promise; + /** + * Answer a questionnaire; the continuation turn is observed like `run`. Rejects with + * `AGENT_OUTCOME_UNKNOWN` when the answer was recorded but the continuation turn was not observed. + */ + answer( + input: { + readonly sessionId: string; + readonly requestId: string; + readonly answers: readonly MiniAppAgentQuestionAnswer[]; + /** Same as `run`'s `model`, applied before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; + }, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Returns the model catalog and runtime `default`. Pass `default` directly as `model` to + * `run` / `answer`. For a catalog entry, extract `providerId` / `modelId`, add a selected `variant` + * if needed and preserve `variant: ''`. If thinking is supported, select an effort from + * `thinking.efforts` and pass `thinking: { effort }`. + */ + models(): Promise; + /** The agents the user has configured; `agent` of `create` / a first `run` must be one of them. */ + agents(): Promise; + /** + * Create a session (agent, this Mini App's Agent directory, the runtime default model) without + * running a turn, and return its `sessionId`; continue it with `run({ sessionId })`. A page's + * `create` binds the session to its chat instead, and its `sessionId` can be continued here. + * Takes no Host run slot but uses the ordinary Node request budget. `agent` picks the session's + * agent from `agents()`; a session never changes it. + */ + create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; + readonly agent?: MiniAppAgentClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs b/plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs new file mode 100644 index 0000000..32e1696 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs @@ -0,0 +1,597 @@ +// @ts-check +import { execFile } from 'node:child_process'; +import { constants as fsc } from 'node:fs'; +import { access, readFile, readdir, rm, stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { homedir } from 'node:os'; +import { delimiter, join } from 'node:path'; + +import { ALGO_VERSION, buildModel, buildReport, diffReports, fileDetail, personDetail, search } from './analyze.mjs'; +import { buildDescs, mapView, pickDescFiles } from './codemap.mjs'; +import { GitError, children, git, netEnv, parseTarget, prefetchBlobs, readBlobs, syncRemote } from './git.mjs'; +import { buildTechStack, pickManifests } from './techstack.mjs'; +import { readJson, writeJsonAtomic } from './util.mjs'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + +const CLIENT_FILES = { + '/assets/app.js': ['app.js', 'text/javascript; charset=utf-8'], + '/assets/charts.js': ['charts.js', 'text/javascript; charset=utf-8'], + '/assets/styles.css': ['styles.css', 'text/css; charset=utf-8'], + '/assets/icon.svg': ['icon.svg', 'image/svg+xml'], +}; +const ID_RE = /^[rl]-[0-9a-f]{12}$/; + +/** + * @param {MiniAppContext} context + * @returns {Promise} + */ +export async function start(context) { + const { dataDir, logger: log } = context; + const clientDir = join(context.pluginRoot, 'miniapp/client'); + const configFile = join(dataDir, 'config.json'); + /** @type {{ repos: any[], settings: { proxy: string, tokens: Record } }} */ + const config = { repos: [], ...(await readJson(configFile, {})) }; + config.repos ??= []; + config.settings = { proxy: '', tokens: {}, scanRoots: null, ...(config.settings || {}) }; + const saveConfig = () => writeJsonAtomic(configFile, config); + const repoDir = (id) => join(dataDir, 'repos', `${id}.git`); + const reportFile = (id) => join(dataDir, 'reports', `${id}.json`); + + // --------------------------------------------------------------------------- + // Proxy + credentials (tokens never leave this process) + // --------------------------------------------------------------------------- + let proxy = { url: '', origin: 'none' }; + async function resolveProxy() { + const env = process.env; + let url = config.settings.proxy || ''; + let origin = url ? 'settings' : 'none'; + if (!url && (url = env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy || '')) origin = 'env'; + if (!url && process.platform === 'darwin') { + try { + const out = await runOut('/usr/sbin/scutil', ['--proxy']); + const get = (k) => out.match(new RegExp(`\\b${k}\\s*:\\s*(\\S+)`))?.[1]; + if (get('HTTPSEnable') === '1' && get('HTTPSProxy')) { + url = `http://${get('HTTPSProxy')}:${get('HTTPSPort')}`; + origin = 'system'; + } + } catch {} + } + proxy = { url, origin }; + } + + const binCache = new Map(); + async function findBin(name) { + if (binCache.has(name)) return binCache.get(name); + const dirs = [...(process.env.PATH || '').split(delimiter), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', join(homedir(), '.local/bin'), join(homedir(), '.local/share/mise/shims')].filter(Boolean); + for (const d of dirs) { + for (const ext of process.platform === 'win32' ? ['.exe', '.cmd', ''] : ['']) { + try { + await access(join(d, name + ext), fsc.X_OK); + binCache.set(name, join(d, name + ext)); + return join(d, name + ext); + } catch {} + } + } + binCache.set(name, null); + return null; + } + function runOut(cmd, args) { + return new Promise((resolve, reject) => { + const child = execFile(cmd, args, { timeout: 10_000, maxBuffer: 1 << 20, windowsHide: true }, (err, stdout) => { + children.delete(child); + err ? reject(err) : resolve(String(stdout).trim()); + }); + children.add(child); + }); + } + async function glabHosts() { + const files = [process.env.GLAB_CONFIG_DIR && join(process.env.GLAB_CONFIG_DIR, 'config.yml'), join(homedir(), '.config', 'glab-cli', 'config.yml'), join(homedir(), 'Library', 'Application Support', 'glab-cli', 'config.yml')].filter(Boolean); + for (const f of files) { + let text = ''; + try { + text = await readFile(/** @type {string} */ (f), 'utf8'); + } catch { + continue; + } + const hosts = new Set(); + let inHosts = false; + for (const line of text.split('\n')) { + if (/^hosts:\s*$/.test(line)) { + inHosts = true; + continue; + } + if (inHosts && /^\S/.test(line)) inHosts = false; + const m = inHosts && line.match(/^ {2,4}([a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+):\s*$/i); + if (m) hosts.add(m[1].toLowerCase()); + } + if (hosts.size) return hosts; + } + return new Set(); + } + const tokenCache = new Map(); + /** @returns {Promise<{ token: string, source: string }>} */ + async function credentialFor(host) { + if (config.settings.tokens[host]) return { token: config.settings.tokens[host], source: 'token' }; + const hit = tokenCache.get(host); + if (hit && Date.now() - hit.at < 10 * 60_000) return hit.value; + let value = { token: '', source: 'none' }; + const gh = await findBin('gh'); + if (gh && (host === 'github.com' || host.startsWith('github.'))) { + const t = await runOut(gh, ['auth', 'token', '--hostname', host]).catch(() => ''); + if (t) value = { token: t, source: 'gh' }; + } + if (!value.token) { + const glab = await findBin('glab'); + if (glab && (await glabHosts()).has(host)) { + const t = await runOut(glab, ['config', 'get', 'token', '--host', host]).catch(() => ''); + if (t) value = { token: t, source: 'glab' }; + } + } + tokenCache.set(host, { value, at: Date.now() }); + return value; + } + + // --------------------------------------------------------------------------- + // SSE + // --------------------------------------------------------------------------- + const streams = new Set(); + const broadcast = (event, data) => { + const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const res of streams) { + try { + res.write(payload); + } catch {} + } + }; + const heartbeat = setInterval(() => broadcast('ping', {}), 25_000); + + // --------------------------------------------------------------------------- + // Models (LRU) and jobs + // --------------------------------------------------------------------------- + const models = new Map(); + const remember = (id, m) => { + models.delete(id); + models.set(id, m); + while (models.size > 3) models.delete(models.keys().next().value); + }; + const workDirOf = (repo) => (repo.target.kind === 'local' ? repo.target.dir : repoDir(repo.id)); + // One build per repo at a time: the map tab, ⌘K and the file drawer may all ask right after a restart. + const building = new Map(); + function modelFor(repo, signal) { + const m = models.get(repo.id); + if (m) return Promise.resolve(m); + let p = building.get(repo.id); + if (!p) { + p = buildModel(workDirOf(repo), { signal }) + .then((built) => { + // A sync that finished meanwhile already stored a newer model; keep that one. A repo + // removed while this was building is not cached again. + if (!models.has(repo.id) && config.repos.some((r) => r.id === repo.id)) remember(repo.id, built); + return models.get(repo.id) || built; + }) + .finally(() => building.delete(repo.id)); + building.set(repo.id, p); + } + return p; + } + + const jobs = new Map(); + const publicJob = (j) => j && { repoId: j.repoId, running: j.running, stage: j.stage, pct: j.pct, detail: j.detail, error: j.error, startedAt: j.startedAt, finishedAt: j.finishedAt || null, steps: j.steps, stageStartedAt: j.stageStartedAt, reportHead: j.reportHead || null }; + let lastTick = 0; + const tick = (j, force) => { + if (!force && Date.now() - lastTick < 200) return; + lastTick = Date.now(); + broadcast('job', publicJob(j)); + }; + let activeNet = 0; + const netQueue = []; + const netSlot = async () => { + if (activeNet >= 2) await new Promise((r) => netQueue.push(r)); + activeNet++; + return () => { + activeNet--; + netQueue.shift()?.(); + }; + }; + + async function webBaseFor(repo) { + if (repo.target.kind === 'remote') return { url: repo.target.webUrl, provider: repo.target.provider }; + try { + const origin = (await git(['-C', repo.target.dir, 'config', '--get', 'remote.origin.url'], { timeout: 5000 })).trim(); + const t = await parseTarget(origin); + if (t.kind === 'remote') return { url: t.webUrl, provider: t.provider }; + } catch {} + return null; + } + + function startJob(repo) { + const existing = jobs.get(repo.id); + if (existing?.running) return existing; + const ac = new AbortController(); + const onAbort = () => ac.abort(); + context.signal.addEventListener('abort', onAbort, { once: true }); + const job = { repoId: repo.id, running: true, stage: 'queued', pct: 0, detail: '', error: null, startedAt: Date.now(), stageStartedAt: Date.now(), steps: [], reportHead: null, ac }; + const enter = (stage) => { + if (job.stage !== 'queued') job.steps.push({ stage: job.stage, ms: Date.now() - job.stageStartedAt }); + job.stage = stage; + job.stageStartedAt = Date.now(); + job.pct = 0; + job.detail = ''; + tick(job, true); + }; + jobs.set(repo.id, job); + tick(job, true); + (async () => { + const dir = workDirOf(repo); + let env = {}; + if (repo.target.kind === 'remote') { + await resolveProxy(); + const cred = await credentialFor(repo.target.host); + env = netEnv(repo.target, cred.token, proxy.url); + const release = await netSlot(); + try { + enter('sync'); + const r = await syncRemote(repo.target, dir, { + env, + signal: ac.signal, + onProgress: ({ phase, pct }) => { + job.detail = { 'Counting objects': '统计对象', 'Compressing objects': '压缩对象', 'Receiving objects': '下载对象', 'Resolving deltas': '解析增量' }[phase] || phase; + job.pct = pct; + tick(job); + }, + }); + repo.cloned = true; + if (r.cloned) log.info('repo.cloned', {}); + } finally { + release(); + } + } + enter('analyze'); + const model = await buildModel(dir, { + signal: ac.signal, + onProgress: (n) => { + job.detail = `${n.toLocaleString()} commits`; + tick(job); + }, + }); + remember(repo.id, model); + const prev = await readJson(reportFile(repo.id), null); + const web = await webBaseFor(repo); + const out = { + algo: ALGO_VERSION, + head: model.head, + merged: buildReport(model, { identity: 'merged' }), + raw: buildReport(model, { identity: 'raw' }), + tech: prev && prev.algo === ALGO_VERSION && prev.head === model.head ? prev.tech : null, + descs: prev && prev.algo === ALGO_VERSION && prev.head === model.head ? prev.descs || null : null, + web, + delta: null, + }; + if (prev?.merged && prev.algo === ALGO_VERSION) out.delta = diffReports(prev.merged, out.merged); + else if (prev?.merged) out.delta = prev.head !== model.head ? { fromHead: prev.head, fromAt: prev.merged.generatedAt, commits: out.merged.vitals.commits - (prev.merged.vitals?.commits || 0), riskAdded: [], riskRemoved: [] } : null; + // Publish the history report first; tech stack fills in afterwards. + await writeJsonAtomic(reportFile(repo.id), out); + job.reportHead = model.head; + repo.lastSync = { at: Date.now(), head: model.head, error: null }; + repo.summary = { commits: out.merged.vitals.commits, latest: out.merged.vitals.latest, risk: out.merged.highRisk.length, trend: out.merged.momentum.trend, spark: out.merged.momentum.months.slice(-12).map((x) => x.n) }; + await saveConfig(); + if (!out.tech || !out.descs) { + enter('tech'); + // Manifests for the tech stack plus README / manifest files that describe directories. + const manifests = pickManifests(model.tree); + const descFiles = pickDescFiles(model.tree); + const oids = [...new Set([...manifests.map((m) => m.oid), ...descFiles.map((f) => f.oid)])]; + if (repo.target.kind === 'remote') { + try { + await prefetchBlobs(dir, oids, { env, signal: ac.signal }); + } catch (e) { + if (e?.code === 'aborted') throw e; + log.warn('tech.prefetch_failed', { code: e?.code || 'error' }); + } + } + const blobs = await readBlobs(dir, oids, { signal: ac.signal }); + out.tech ??= buildTechStack(model.tree, blobs, manifests); + out.descs = buildDescs(descFiles, blobs); + await writeJsonAtomic(reportFile(repo.id), out); + } + job.steps.push({ stage: job.stage, ms: Date.now() - job.stageStartedAt }); + job.stage = 'done'; + repo.lastSync = { ...repo.lastSync, ms: Date.now() - job.startedAt, steps: job.steps }; + await saveConfig(); + })() + .catch(async (e) => { + job.error = e?.code === 'aborted' ? null : { code: e?.code || 'error', message: e instanceof GitError || e?.code === 'empty' ? e.message : '分析失败' }; + job.stage = e?.code === 'aborted' ? 'cancelled' : 'error'; + if (!(e instanceof GitError)) log.error('job.failed', { reason: String(e?.message || e).slice(0, 160) }); + if (job.error) { + repo.lastSync = { ...(repo.lastSync || {}), errorAt: Date.now(), error: job.error }; + await saveConfig().catch(() => {}); + } + if (repo.target.kind === 'remote' && e?.code === 'auth') tokenCache.delete(repo.target.host); + }) + .finally(() => { + job.running = false; + job.finishedAt = Date.now(); + context.signal.removeEventListener('abort', onAbort); + tick(job, true); + }); + return job; + } + + const identityOf = (url) => (url.searchParams.get('identity') === 'raw' ? 'raw' : 'merged'); + + // --------------------------------------------------------------------------- + // Local repository suggestions (read-only scan for .git directories) + // --------------------------------------------------------------------------- + const DEFAULT_ROOTS = ['~/Workspace', '~/workspace', '~/code', '~/Code', '~/src', '~/Projects', '~/projects', '~/Developer', '~/dev', '~/repos', '~/GitHub', '~/git']; + const expand = (p) => (p.startsWith('~') ? join(homedir(), p.slice(1)) : p); + const scanRoots = () => (config.settings.scanRoots?.length ? config.settings.scanRoots : DEFAULT_ROOTS).map(expand); + let suggestCache = null; + async function suggestLocal(refresh) { + if (!refresh && suggestCache && Date.now() - suggestCache.at < 5 * 60_000) return suggestCache.list; + const found = new Map(); + const SKIP = new Set(['node_modules', 'vendor', 'dist', 'build', 'target', 'Library', 'Pods']); + let budget = 4000; + async function walk(dir, depth) { + if (budget-- <= 0 || found.size >= 300) return; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + if (entries.some((e) => e.name === '.git')) { + let mtime = 0; + for (const f of ['.git/FETCH_HEAD', '.git/index', '.git/HEAD']) { + try { + mtime = Math.max(mtime, (await stat(join(dir, f))).mtimeMs); + } catch {} + } + if (!found.has(dir)) found.set(dir, mtime); + return; + } + if (depth >= 4) return; + for (const e of entries) { + if (!e.isDirectory() || e.name.startsWith('.') || SKIP.has(e.name)) continue; + await walk(join(dir, e.name), depth + 1); + } + } + const seen = new Set(); + for (const root of scanRoots()) { + try { + const real = await stat(root).then(() => root); + if (seen.has(real.toLowerCase())) continue; + seen.add(real.toLowerCase()); + await walk(real, 0); + } catch {} + } + const analyzed = new Set(config.repos.filter((r) => r.target.kind === 'local').map((r) => r.target.dir)); + const list = [...found.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 40) + .map(([dir, mtime]) => ({ path: dir.replace(homedir(), '~'), name: dir.split('/').pop(), mtime, analyzed: analyzed.has(dir) })); + suggestCache = { at: Date.now(), list }; + return list; + } + + // --------------------------------------------------------------------------- + // HTTP + // --------------------------------------------------------------------------- + const send = (res, status, body) => { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + res.end(JSON.stringify(body)); + }; + const fail = (res, e) => { + const code = e?.code || 'error'; + const status = { invalid: 400, not_repo: 400, not_found: 404, conflict: 409 }[code] || (e instanceof GitError ? 502 : 500); + if (!(e instanceof GitError)) log.error('request.failed', { reason: String(e?.message || e).slice(0, 160) }); + send(res, status, { error: { code, message: e instanceof GitError ? e.message : '内部错误' } }); + }; + async function body(req) { + const chunks = []; + let size = 0; + for await (const c of req) { + size += c.length; + if (size > 64 * 1024) throw new GitError('invalid', '请求体过大'); + chunks.push(c); + } + if (!chunks.length) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw new GitError('invalid', '请求体不是 JSON'); + } + } + const findRepo = (id) => { + if (!ID_RE.test(id)) throw new GitError('invalid', '仓库 ID 不合法'); + const r = config.repos.find((x) => x.id === id); + if (!r) throw new GitError('not_found', '仓库不在列表里'); + return r; + }; + const publicRepo = (r) => ({ + id: r.id, + name: r.name, + kind: r.target.kind, + provider: r.target.kind === 'remote' ? r.target.provider : 'local', + display: r.target.kind === 'remote' ? `${r.target.host}/${r.target.path}` : r.target.dir.replace(homedir(), '~'), + webUrl: r.target.kind === 'remote' ? r.target.webUrl : null, + localDir: r.target.kind === 'local' ? r.target.dir : null, + addedAt: r.addedAt, + lastSync: r.lastSync || null, + summary: r.summary || null, + job: publicJob(jobs.get(r.id)) || null, + }); + async function stateBody() { + await resolveProxy(); + const hosts = [...new Set(config.repos.filter((r) => r.target.kind === 'remote').map((r) => r.target.host))]; + const cred = await Promise.all(hosts.map(async (h) => ({ host: h, source: (await credentialFor(h)).source }))); + return { repos: config.repos.map(publicRepo), settings: { proxy: config.settings.proxy, proxyActive: proxy.origin, hosts: cred, tokenHosts: Object.keys(config.settings.tokens), scanRoots: scanRoots().map((r) => r.replace(homedir(), '~')) } }; + } + + async function handle(req, res) { + const url = new URL(req.url ?? '/', 'http://miniapp.local'); + const p = url.pathname; + const method = req.method || 'GET'; + let m; + if (method === 'GET' && (p === '/dashboard' || p === '/')) { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); + return res.end(await readFile(join(clientDir, 'index.html'))); + } + if (method === 'GET' && CLIENT_FILES[p]) { + const [f, type] = CLIENT_FILES[p]; + res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' }); + return res.end(await readFile(join(clientDir, f))); + } + if (method === 'GET' && p === '/api/stream') { + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' }); + res.write(': connected\n\n'); + streams.add(res); + req.on('close', () => streams.delete(res)); + return; + } + if (method === 'GET' && p === '/api/state') return send(res, 200, await stateBody()); + if (method === 'POST' && p === '/api/repos') { + const b = await body(req); + const target = await parseTarget(b.input); + let repo = config.repos.find((r) => r.id === target.id); + if (!repo) { + repo = { id: target.id, name: target.kind === 'remote' ? target.path : target.name, target, addedAt: Date.now(), lastSync: null }; + config.repos.unshift(repo); + await saveConfig(); + } + startJob(repo); + return send(res, 200, { repo: publicRepo(repo) }); + } + if ((m = p.match(/^\/api\/repos\/([^/]+)\/sync$/))) { + const repo = findRepo(m[1]); + if (method === 'POST') return send(res, 202, { job: publicJob(startJob(repo)) }); + if (method === 'DELETE') { + jobs.get(repo.id)?.ac.abort(); + return send(res, 200, { ok: true }); + } + } + if ((m = p.match(/^\/api\/repos\/([^/]+)$/)) && method === 'DELETE') { + const repo = findRepo(m[1]); + jobs.get(repo.id)?.ac.abort(); + config.repos = config.repos.filter((r) => r.id !== repo.id); + models.delete(repo.id); + await saveConfig(); + // Only Mini App-owned caches are removed; local repositories are never touched. + await rm(reportFile(repo.id), { force: true }); + if (repo.target.kind === 'remote') await rm(repoDir(repo.id), { recursive: true, force: true }); + return send(res, 200, { ok: true }); + } + if ((m = p.match(/^\/api\/repos\/([^/]+)\/report$/)) && method === 'GET') { + const repo = findRepo(m[1]); + const file = await readJson(reportFile(repo.id), null); + if (!file?.merged) return send(res, 404, { error: { code: 'no_report', message: '还没有分析结果' } }); + const identity = url.searchParams.get('identity') === 'raw' ? 'raw' : 'merged'; + // A report from an older algorithm is still served (no blank page); the client re-analyzes in the background. + return send(res, 200, { ...file[identity], tech: file.tech, web: file.web, delta: file.delta, about: file.descs?.[''] || null, stale: file.algo !== ALGO_VERSION }); + } + if ((m = p.match(/^\/api\/repos\/([^/]+)\/file$/)) && method === 'GET') { + const repo = findRepo(m[1]); + const path = url.searchParams.get('path') || ''; + if (!path || path.length > 1000) throw new GitError('invalid', '文件路径不合法'); + const model = await modelFor(repo, AbortSignal.timeout(120_000)); + const d = fileDetail(model, path, { identity: identityOf(url) }); + if (!d) throw new GitError('not_found', '历史里没有这个文件'); + return send(res, 200, d); + } + if ((m = p.match(/^\/api\/repos\/([^/]+)\/person$/)) && method === 'GET') { + const repo = findRepo(m[1]); + const name = (url.searchParams.get('name') || '').slice(0, 200); + const model = await modelFor(repo, AbortSignal.timeout(120_000)); + const d = personDetail(model, name, { identity: identityOf(url) }); + if (!d) throw new GitError('not_found', '历史里没有这个贡献者'); + return send(res, 200, d); + } + if ((m = p.match(/^\/api\/repos\/([^/]+)\/map$/)) && method === 'GET') { + const repo = findRepo(m[1]); + if (!repo.lastSync?.at) throw new GitError('not_found', '还没有分析结果'); + const q = url.searchParams; + const focus = (q.get('focus') || '').replace(/^\/+|\/+$/g, ''); + if (focus.length > 1000) throw new GitError('invalid', '目录路径不合法'); + const range = ['3', '6', '12', 'all'].includes(q.get('range') || '') ? q.get('range') : 'all'; + const model = await modelFor(repo, AbortSignal.timeout(120_000)); + const file = await readJson(reportFile(repo.id), null); + const view = mapView(model, { + focus, + size: q.get('size') === 'churn' ? 'churn' : 'files', + range, + identity: identityOf(url), + maxBlocks: Math.max(8, Math.min(60, Number(q.get('max')) || 44)), + risk: new Set((file?.merged?.highRisk || []).map((h) => h.path)), + descs: file?.descs || {}, + }); + if (!view) throw new GitError('not_found', 'HEAD 中没有这个目录(可能已删除或改名)'); + return send(res, 200, view); + } + if ((m = p.match(/^\/api\/repos\/([^/]+)\/search$/)) && method === 'GET') { + const repo = findRepo(m[1]); + if (!repo.lastSync?.at) return send(res, 200, { files: [], people: [] }); + const model = await modelFor(repo, AbortSignal.timeout(120_000)); + return send(res, 200, search(model, (url.searchParams.get('q') || '').slice(0, 200), { identity: identityOf(url) })); + } + if (method === 'GET' && p === '/api/suggest') return send(res, 200, { repos: await suggestLocal(url.searchParams.get('refresh') === '1') }); + if (method === 'POST' && p === '/api/settings') { + const b = await body(req); + if (typeof b.proxy === 'string') { + const px = b.proxy.trim(); + if (px && !/^(https?|socks5h?):\/\/[^\s]+$/i.test(px)) throw new GitError('invalid', '代理地址应类似 http://127.0.0.1:7890'); + config.settings.proxy = px; + } + if (Array.isArray(b.scanRoots)) { + config.settings.scanRoots = b.scanRoots.map((x) => String(x).trim()).filter(Boolean).slice(0, 10); + suggestCache = null; + } + if (b.token && typeof b.token.host === 'string') { + const host = b.token.host.trim().toLowerCase(); + if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?$/.test(host)) throw new GitError('invalid', '主机名不合法'); + const t = String(b.token.value || '').trim(); + if (t) config.settings.tokens[host] = t; + else delete config.settings.tokens[host]; + tokenCache.delete(host); + } + await saveConfig(); + return send(res, 200, await stateBody()); + } + send(res, 404, { error: { code: 'not_found', message: '接口不存在' } }); + } + + await resolveProxy(); + const server = createServer((req, res) => { + handle(req, res).catch((e) => (res.headersSent ? res.end() : fail(res, e))); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(context.listen.port, context.listen.host, () => { + server.off('error', reject); + resolve(undefined); + }); + }); + log.info('miniapp.runtime.listening', { proxy: proxy.origin }); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener('abort', onAbort); + clearInterval(heartbeat); + for (const j of jobs.values()) j.ac?.abort(); + for (const c of children) c.kill('SIGKILL'); + for (const s of streams) { + try { + s.end(); + } catch {} + } + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(() => resolve(undefined))); + }; + const onAbort = () => void dispose(); + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + return { dispose }; +} diff --git a/plugins/yujiachen-y/repo-xray/miniapp/node/techstack.mjs b/plugins/yujiachen-y/repo-xray/miniapp/node/techstack.mjs new file mode 100644 index 0000000..d5dc712 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/techstack.mjs @@ -0,0 +1,362 @@ +// @ts-check + +const IGNORE_DIR = /(^|\/)(node_modules|vendor|third_party|3rdparty|dist|build|out|target|\.next|\.nuxt|coverage|__pycache__|Pods|\.yarn|bower_components|\.venv|venv)\//; + +const LANG = { + ts: 'TypeScript', tsx: 'TypeScript', mts: 'TypeScript', cts: 'TypeScript', + js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', + py: 'Python', pyi: 'Python', ipynb: 'Jupyter Notebook', + go: 'Go', rs: 'Rust', java: 'Java', kt: 'Kotlin', kts: 'Kotlin', scala: 'Scala', groovy: 'Groovy', + swift: 'Swift', m: 'Objective-C', mm: 'Objective-C', + c: 'C', h: 'C/C++ Header', cc: 'C++', cpp: 'C++', cxx: 'C++', hpp: 'C++', hh: 'C++', + cs: 'C#', fs: 'F#', vb: 'Visual Basic', + rb: 'Ruby', php: 'PHP', pl: 'Perl', lua: 'Lua', r: 'R', jl: 'Julia', dart: 'Dart', ex: 'Elixir', exs: 'Elixir', erl: 'Erlang', clj: 'Clojure', hs: 'Haskell', ml: 'OCaml', zig: 'Zig', nim: 'Nim', sol: 'Solidity', + vue: 'Vue', svelte: 'Svelte', astro: 'Astro', + html: 'HTML', htm: 'HTML', css: 'CSS', scss: 'SCSS', sass: 'SCSS', less: 'Less', styl: 'Stylus', + sh: 'Shell', bash: 'Shell', zsh: 'Shell', fish: 'Shell', ps1: 'PowerShell', bat: 'Batch', + sql: 'SQL', proto: 'Protocol Buffers', thrift: 'Thrift', graphql: 'GraphQL', gql: 'GraphQL', + tf: 'HCL', hcl: 'HCL', nix: 'Nix', dockerfile: 'Dockerfile', cmake: 'CMake', gradle: 'Gradle', + md: 'Markdown', mdx: 'MDX', rst: 'reStructuredText', + json: 'JSON', yaml: 'YAML', yml: 'YAML', toml: 'TOML', xml: 'XML', +}; +const DATA_LANGS = new Set(['Markdown', 'MDX', 'reStructuredText', 'JSON', 'YAML', 'TOML', 'XML']); + +/** [regex on path, name, category] */ +const MARKERS = [ + [/(^|\/)Dockerfile[^/]*$|\.dockerfile$/i, 'Docker', '构建与部署'], + [/(^|\/)(docker-)?compose[^/]*\.ya?ml$/i, 'Docker Compose', '构建与部署'], + [/(^|\/)\.gitlab-ci\.ya?ml$/, 'GitLab CI', 'CI/CD'], + [/^\.github\/workflows\/[^/]+\.ya?ml$/, 'GitHub Actions', 'CI/CD'], + [/(^|\/)Jenkinsfile$/, 'Jenkins', 'CI/CD'], + [/(^|\/)\.circleci\/config\.yml$/, 'CircleCI', 'CI/CD'], + [/(^|\/)azure-pipelines\.ya?ml$/, 'Azure Pipelines', 'CI/CD'], + [/(^|\/)Chart\.yaml$/, 'Helm', '构建与部署'], + [/(^|\/)(kustomization\.ya?ml)$/, 'Kustomize', '构建与部署'], + [/\.tf$/, 'Terraform', '构建与部署'], + [/(^|\/)(Makefile|GNUmakefile)$/, 'Make', '构建与部署'], + [/(^|\/)CMakeLists\.txt$/, 'CMake', '构建与部署'], + [/(^|\/)BUILD(\.bazel)?$|(^|\/)WORKSPACE(\.bazel)?$|(^|\/)MODULE\.bazel$/, 'Bazel', '构建与部署'], + [/(^|\/)pnpm-workspace\.yaml$/, 'pnpm workspace', 'Monorepo'], + [/(^|\/)turbo\.json$/, 'Turborepo', 'Monorepo'], + [/(^|\/)nx\.json$/, 'Nx', 'Monorepo'], + [/(^|\/)lerna\.json$/, 'Lerna', 'Monorepo'], + [/(^|\/)(\.eslintrc[^/]*|eslint\.config\.[cm]?[jt]s)$/, 'ESLint', '代码质量'], + [/(^|\/)biome\.jsonc?$/, 'Biome', '代码质量'], + [/(^|\/)\.prettierrc[^/]*$|(^|\/)prettier\.config\.[cm]?js$/, 'Prettier', '代码质量'], + [/(^|\/)\.golangci\.ya?ml$/, 'golangci-lint', '代码质量'], + [/(^|\/)(ruff\.toml|\.ruff\.toml)$/, 'Ruff', '代码质量'], + [/(^|\/)\.pre-commit-config\.yaml$/, 'pre-commit', '代码质量'], + [/(^|\/)\.husky\//, 'Husky', '代码质量'], + [/(^|\/)sonar-project\.properties$/, 'SonarQube', '代码质量'], + [/(^|\/)tsconfig[^/]*\.json$/, 'TypeScript', '语言与运行时'], + [/(^|\/)\.nvmrc$|(^|\/)\.node-version$/, 'Node.js', '语言与运行时'], + [/(^|\/)\.python-version$/, 'Python', '语言与运行时'], + [/(^|\/)\.tool-versions$|(^|\/)mise\.toml$|(^|\/)\.mise\.toml$/, 'mise / asdf', '语言与运行时'], + [/(^|\/)vite\.config\.[cm]?[jt]s$/, 'Vite', '构建工具'], + [/(^|\/)webpack\.config\.[cm]?[jt]s$/, 'webpack', '构建工具'], + [/(^|\/)rollup\.config\.[cm]?[jt]s$/, 'Rollup', '构建工具'], + [/(^|\/)electron-builder\.(ya?ml|json|[cm]?js)$|(^|\/)forge\.config\.[cm]?[jt]s$/, 'Electron 打包', '构建工具'], + [/(^|\/)vitest\.(config|workspace)\.[cm]?[jt]s$/, 'Vitest', '测试'], + [/(^|\/)jest\.config\.[cm]?[jt]s$/, 'Jest', '测试'], + [/(^|\/)playwright\.config\.[cm]?[jt]s$/, 'Playwright', '测试'], + [/(^|\/)cypress\.config\.[cm]?[jt]s$/, 'Cypress', '测试'], + [/(^|\/)(pytest\.ini|conftest\.py)$/, 'pytest', '测试'], + [/(^|\/)tailwind\.config\.[cm]?[jt]s$/, 'Tailwind CSS', '前端'], + [/(^|\/)next\.config\.[cm]?[jt]s$/, 'Next.js', '前端'], + [/(^|\/)nuxt\.config\.[cm]?[jt]s$/, 'Nuxt', '前端'], + [/(^|\/)svelte\.config\.[cm]?js$/, 'SvelteKit', '前端'], + [/(^|\/)angular\.json$/, 'Angular', '前端'], + [/(^|\/)schema\.prisma$/, 'Prisma', '数据'], + [/(^|\/)alembic\.ini$/, 'Alembic', '数据'], + [/(^|\/)openapi[^/]*\.(ya?ml|json)$|(^|\/)swagger[^/]*\.(ya?ml|json)$/i, 'OpenAPI', '接口定义'], + [/\.proto$/, 'Protocol Buffers', '接口定义'], + [/\.thrift$/, 'Thrift', '接口定义'], + [/(^|\/)buf\.(gen\.)?yaml$/, 'Buf', '接口定义'], + [/\.graphqls?$/, 'GraphQL', '接口定义'], + [/(^|\/)Podfile$/, 'CocoaPods', '移动端'], + [/(^|\/)Package\.swift$/, 'Swift Package Manager', '移动端'], + [/\.xcodeproj\//, 'Xcode', '移动端'], + [/(^|\/)AndroidManifest\.xml$/, 'Android', '移动端'], + [/(^|\/)pubspec\.yaml$/, 'Flutter / Dart', '移动端'], + [/(^|\/)(AGENTS|CLAUDE)\.md$/, 'AI Agent 指令(AGENTS.md / CLAUDE.md)', '工程实践'], + [/(^|\/)\.cursor(rules|\/rules\/)/, 'Cursor rules', '工程实践'], + [/(^|\/)CODEOWNERS$/, 'CODEOWNERS', '工程实践'], + [/(^|\/)\.changeset\//, 'Changesets', '工程实践'], +]; + +const LOCKFILES = [ + [/(^|\/)pnpm-lock\.yaml$/, 'pnpm'], + [/(^|\/)yarn\.lock$/, 'Yarn'], + [/(^|\/)package-lock\.json$/, 'npm'], + [/(^|\/)bun\.lockb?$/, 'Bun'], + [/(^|\/)uv\.lock$/, 'uv'], + [/(^|\/)poetry\.lock$/, 'Poetry'], + [/(^|\/)Pipfile\.lock$/, 'Pipenv'], + [/(^|\/)Cargo\.lock$/, 'Cargo'], + [/(^|\/)go\.sum$/, 'Go modules'], + [/(^|\/)Gemfile\.lock$/, 'Bundler'], + [/(^|\/)composer\.lock$/, 'Composer'], + [/(^|\/)gradle\.lockfile$|(^|\/)gradlew$/, 'Gradle'], + [/(^|\/)mvnw$/, 'Maven'], +]; + +/** Known dependency → [display name, category]. Keys are exact names or prefixes ending with '*'. */ +const DEPS = { + react: ['React', '前端'], 'react-dom': ['React', '前端'], vue: ['Vue', '前端'], svelte: ['Svelte', '前端'], 'solid-js': ['Solid', '前端'], preact: ['Preact', '前端'], '@angular/core': ['Angular', '前端'], + next: ['Next.js', '前端'], nuxt: ['Nuxt', '前端'], '@remix-run/react': ['Remix', '前端'], astro: ['Astro', '前端'], gatsby: ['Gatsby', '前端'], + 'react-native': ['React Native', '移动端'], expo: ['Expo', '移动端'], + electron: ['Electron', '桌面端'], '@tauri-apps/api': ['Tauri', '桌面端'], + tailwindcss: ['Tailwind CSS', '前端'], 'styled-components': ['styled-components', '前端'], '@emotion/react': ['Emotion', '前端'], sass: ['Sass', '前端'], 'antd': ['Ant Design', '前端'], '@mui/material': ['MUI', '前端'], '@radix-ui/*': ['Radix UI', '前端'], 'lucide-react': ['Lucide', '前端'], 'framer-motion': ['Framer Motion', '前端'], + redux: ['Redux', '前端'], '@reduxjs/toolkit': ['Redux Toolkit', '前端'], zustand: ['Zustand', '前端'], jotai: ['Jotai', '前端'], mobx: ['MobX', '前端'], pinia: ['Pinia', '前端'], '@tanstack/react-query': ['TanStack Query', '前端'], swr: ['SWR', '前端'], 'react-router': ['React Router', '前端'], 'react-router-dom': ['React Router', '前端'], '@tanstack/react-router': ['TanStack Router', '前端'], + echarts: ['ECharts', '前端'], 'chart.js': ['Chart.js', '前端'], d3: ['D3', '前端'], three: ['three.js', '前端'], '@xyflow/react': ['React Flow', '前端'], 'monaco-editor': ['Monaco Editor', '前端'], '@codemirror/*': ['CodeMirror', '前端'], 'xterm': ['xterm.js', '前端'], '@xterm/xterm': ['xterm.js', '前端'], + express: ['Express', '后端'], fastify: ['Fastify', '后端'], koa: ['Koa', '后端'], hono: ['Hono', '后端'], '@nestjs/core': ['NestJS', '后端'], 'socket.io': ['Socket.IO', '后端'], ws: ['ws (WebSocket)', '后端'], '@trpc/server': ['tRPC', '后端'], 'graphql': ['GraphQL', '后端'], '@grpc/grpc-js': ['gRPC', '后端'], 'undici': ['undici', '后端'], + prisma: ['Prisma', '数据'], '@prisma/client': ['Prisma', '数据'], 'drizzle-orm': ['Drizzle', '数据'], typeorm: ['TypeORM', '数据'], sequelize: ['Sequelize', '数据'], mongoose: ['Mongoose', '数据'], 'better-sqlite3': ['SQLite', '数据'], sqlite3: ['SQLite', '数据'], ioredis: ['Redis', '数据'], redis: ['Redis', '数据'], pg: ['PostgreSQL', '数据'], mysql2: ['MySQL', '数据'], kysely: ['Kysely', '数据'], dexie: ['IndexedDB (Dexie)', '数据'], + zod: ['Zod', '工具库'], 'class-validator': ['class-validator', '工具库'], lodash: ['Lodash', '工具库'], 'date-fns': ['date-fns', '工具库'], dayjs: ['Day.js', '工具库'], immer: ['Immer', '工具库'], rxjs: ['RxJS', '工具库'], effect: ['Effect', '工具库'], 'yaml': ['YAML', '工具库'], 'commander': ['Commander', '工具库'], 'yargs': ['yargs', '工具库'], 'ink': ['Ink (CLI UI)', '工具库'], + openai: ['OpenAI SDK', 'AI'], '@anthropic-ai/sdk': ['Anthropic SDK', 'AI'], '@anthropic-ai/claude-agent-sdk': ['Claude Agent SDK', 'AI'], langchain: ['LangChain', 'AI'], '@langchain/*': ['LangChain', 'AI'], ai: ['Vercel AI SDK', 'AI'], '@ai-sdk/*': ['Vercel AI SDK', 'AI'], '@modelcontextprotocol/sdk': ['MCP SDK', 'AI'], '@google/genai': ['Google GenAI SDK', 'AI'], 'tiktoken': ['tiktoken', 'AI'], '@mariozechner/pi-ai': ['pi-ai', 'AI'], + typescript: ['TypeScript', '语言与运行时'], vite: ['Vite', '构建工具'], webpack: ['webpack', '构建工具'], esbuild: ['esbuild', '构建工具'], rollup: ['Rollup', '构建工具'], tsup: ['tsup', '构建工具'], turbo: ['Turborepo', 'Monorepo'], '@swc/core': ['SWC', '构建工具'], 'electron-builder': ['electron-builder', '构建工具'], '@electron-forge/cli': ['Electron Forge', '构建工具'], 'electron-vite': ['electron-vite', '构建工具'], rspack: ['Rspack', '构建工具'], '@rspack/core': ['Rspack', '构建工具'], tsx: ['tsx', '构建工具'], + vitest: ['Vitest', '测试'], jest: ['Jest', '测试'], mocha: ['Mocha', '测试'], '@playwright/test': ['Playwright', '测试'], playwright: ['Playwright', '测试'], cypress: ['Cypress', '测试'], '@testing-library/react': ['Testing Library', '测试'], msw: ['MSW', '测试'], storybook: ['Storybook', '测试'], + eslint: ['ESLint', '代码质量'], prettier: ['Prettier', '代码质量'], '@biomejs/biome': ['Biome', '代码质量'], husky: ['Husky', '代码质量'], 'lint-staged': ['lint-staged', '代码质量'], knip: ['Knip', '代码质量'], + '@sentry/*': ['Sentry', '可观测性'], '@opentelemetry/*': ['OpenTelemetry', '可观测性'], pino: ['pino', '可观测性'], winston: ['winston', '可观测性'], 'electron-log': ['electron-log', '可观测性'], + // Python + django: ['Django', '后端'], flask: ['Flask', '后端'], fastapi: ['FastAPI', '后端'], starlette: ['Starlette', '后端'], tornado: ['Tornado', '后端'], uvicorn: ['Uvicorn', '后端'], gunicorn: ['Gunicorn', '后端'], celery: ['Celery', '后端'], aiohttp: ['aiohttp', '后端'], grpcio: ['gRPC', '后端'], pydantic: ['Pydantic', '工具库'], + sqlalchemy: ['SQLAlchemy', '数据'], alembic: ['Alembic', '数据'], 'psycopg2': ['PostgreSQL', '数据'], 'psycopg2-binary': ['PostgreSQL', '数据'], pymongo: ['MongoDB', '数据'], pandas: ['pandas', '数据'], numpy: ['NumPy', '数据'], polars: ['Polars', '数据'], + torch: ['PyTorch', 'AI'], tensorflow: ['TensorFlow', 'AI'], jax: ['JAX', 'AI'], transformers: ['Transformers', 'AI'], vllm: ['vLLM', 'AI'], 'scikit-learn': ['scikit-learn', 'AI'], 'llama-index': ['LlamaIndex', 'AI'], anthropic: ['Anthropic SDK', 'AI'], 'langgraph': ['LangGraph', 'AI'], + pytest: ['pytest', '测试'], ruff: ['Ruff', '代码质量'], mypy: ['mypy', '代码质量'], black: ['Black', '代码质量'], + // Go + 'github.com/gin-gonic/gin': ['Gin', '后端'], 'github.com/labstack/echo/v4': ['Echo', '后端'], 'github.com/gofiber/fiber/v2': ['Fiber', '后端'], 'github.com/cloudwego/hertz': ['Hertz', '后端'], 'github.com/cloudwego/kitex': ['Kitex', '后端'], 'google.golang.org/grpc': ['gRPC', '后端'], 'github.com/go-kratos/kratos/v2': ['Kratos', '后端'], 'github.com/zeromicro/go-zero': ['go-zero', '后端'], + 'gorm.io/gorm': ['GORM', '数据'], 'github.com/redis/go-redis/v9': ['Redis', '数据'], 'github.com/go-redis/redis/v8': ['Redis', '数据'], 'go.mongodb.org/mongo-driver': ['MongoDB', '数据'], 'github.com/jmoiron/sqlx': ['sqlx', '数据'], 'entgo.io/ent': ['ent', '数据'], + 'github.com/spf13/cobra': ['Cobra', '工具库'], 'github.com/spf13/viper': ['Viper', '工具库'], 'go.uber.org/zap': ['zap', '可观测性'], 'github.com/sirupsen/logrus': ['logrus', '可观测性'], 'go.opentelemetry.io/otel': ['OpenTelemetry', '可观测性'], 'github.com/prometheus/client_golang': ['Prometheus', '可观测性'], 'github.com/stretchr/testify': ['testify', '测试'], + // Rust + tokio: ['Tokio', '后端'], axum: ['Axum', '后端'], 'actix-web': ['Actix Web', '后端'], serde: ['Serde', '工具库'], clap: ['clap', '工具库'], tauri: ['Tauri', '桌面端'], sqlx: ['SQLx', '数据'], diesel: ['Diesel', '数据'], tracing: ['tracing', '可观测性'], reqwest: ['reqwest', '后端'], + // JVM + 'spring-boot-starter*': ['Spring Boot', '后端'], 'spring-boot-starter-web': ['Spring Boot', '后端'], 'mybatis*': ['MyBatis', '数据'], 'junit*': ['JUnit', '测试'], 'lombok': ['Lombok', '工具库'], 'ktor*': ['Ktor', '后端'], + // Ruby / PHP + rails: ['Ruby on Rails', '后端'], sinatra: ['Sinatra', '后端'], rspec: ['RSpec', '测试'], 'laravel/framework': ['Laravel', '后端'], 'symfony/*': ['Symfony', '后端'], + // Flutter + flutter: ['Flutter', '移动端'], provider: ['provider', '前端'], riverpod: ['Riverpod', '前端'], +}; +const DEP_PREFIX = Object.entries(DEPS) + .filter(([k]) => k.endsWith('*')) + .map(([k, v]) => [k.slice(0, -1), v]); +function knownDep(name) { + if (DEPS[name] && !name.endsWith('*')) return DEPS[name]; + for (const [p, v] of DEP_PREFIX) if (name.startsWith(p)) return v; + return null; +} + +const MANIFEST_RE = /(^|\/)(package\.json|go\.mod|Cargo\.toml|pyproject\.toml|requirements[^/]*\.txt|Pipfile|setup\.cfg|pom\.xml|build\.gradle(\.kts)?|Gemfile|composer\.json|pubspec\.yaml)$/; +const MANIFEST_CAP = { 'package.json': 60, default: 15 }; + +/** Pick manifest blobs to read (shallow first, bounded). */ +export function pickManifests(tree) { + const byName = new Map(); + for (const t of tree) { + if (IGNORE_DIR.test(t.path) || !MANIFEST_RE.test(t.path) || /(^|\/)(test|tests|fixtures?|examples?|__tests__|testdata|e2e)\//.test(t.path)) continue; + const name = t.path.split('/').pop(); + const key = /^requirements/.test(name) ? 'requirements' : name; + if (!byName.has(key)) byName.set(key, []); + byName.get(key).push(t); + } + const picked = []; + for (const [key, list] of byName) { + list.sort((a, b) => a.path.split('/').length - b.path.split('/').length || a.path.localeCompare(b.path)); + picked.push(...list.slice(0, MANIFEST_CAP[key] ?? MANIFEST_CAP.default)); + } + return picked; +} + +function depsFrom(path, text) { + const name = path.split('/').pop(); + const out = []; + try { + if (name === 'package.json') { + const j = JSON.parse(text); + for (const k of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) if (j[k] && typeof j[k] === 'object') out.push(...Object.keys(j[k])); + return { deps: out, meta: { name: j.name, workspaces: Boolean(j.workspaces), engines: j.engines?.node || null, pm: typeof j.packageManager === 'string' ? j.packageManager.split('@')[0] : null } }; + } + if (name === 'go.mod') { + let inReq = false; + let goVer = null; + for (const raw of text.split('\n')) { + const l = raw.trim(); + if (l.startsWith('go ')) goVer = l.slice(3).trim(); + if (l.startsWith('require (')) { + inReq = true; + continue; + } + if (inReq && l === ')') inReq = false; + const m = inReq ? l.match(/^(\S+)\s+v/) : l.match(/^require\s+(\S+)\s+v/); + if (m && !l.includes('// indirect')) out.push(m[1]); + } + return { deps: out, meta: { go: goVer } }; + } + if (name === 'Cargo.toml') { + let sec = ''; + for (const raw of text.split('\n')) { + const l = raw.trim(); + const s = l.match(/^\[([^\]]+)\]/); + if (s) { + sec = s[1]; + continue; + } + if (/dependencies$/.test(sec)) { + const m = l.match(/^([A-Za-z0-9_-]+)\s*=/); + if (m) out.push(m[1]); + } + } + return { deps: out, meta: {} }; + } + if (name === 'pyproject.toml' || name === 'Pipfile' || name === 'setup.cfg') { + const lines = text.split('\n'); + let sec = ''; + let inArr = false; + for (const raw of lines) { + const l = raw.trim(); + const s = l.match(/^\[([^\]]+)\]/); + if (s) { + sec = s[1]; + inArr = false; + continue; + } + if (/^(dependencies|requires|install_requires)\s*=\s*\[/.test(l) || /^[a-z-]+\s*=\s*\[/.test(l) && /optional-dependencies|dependency-groups/.test(sec)) inArr = true; + if (inArr) { + for (const m of l.matchAll(/["']([A-Za-z0-9_.\-[\]]+)/g)) out.push(m[1].replace(/\[.*$/, '').toLowerCase()); + if (l.includes(']') && !/^\[/.test(l)) inArr = false; + continue; + } + if (/(poetry\.(dev-)?dependencies|^packages$|^dev-packages$|poetry\.group\.[^.]+\.dependencies)/.test(sec)) { + const m = l.match(/^([A-Za-z0-9_.-]+)\s*=/); + if (m && m[1] !== 'python') out.push(m[1].toLowerCase()); + } + } + return { deps: out, meta: {} }; + } + if (/^requirements/.test(name)) { + for (const raw of text.split('\n')) { + const m = raw.trim().match(/^([A-Za-z0-9_.-]+)/); + if (m && !raw.trim().startsWith('-')) out.push(m[1].toLowerCase()); + } + return { deps: out, meta: {} }; + } + if (name === 'pom.xml') { + for (const m of text.matchAll(/([^<]+)<\/artifactId>/g)) out.push(m[1]); + return { deps: out, meta: {} }; + } + if (/^build\.gradle/.test(name)) { + for (const m of text.matchAll(/(?:implementation|api|compileOnly|testImplementation|kapt|ksp)\s*\(?\s*["']([^:"']+):([^:"']+)/g)) out.push(m[2]); + return { deps: out, meta: {} }; + } + if (name === 'Gemfile') { + for (const m of text.matchAll(/^\s*gem\s+["']([^"']+)/gm)) out.push(m[1]); + return { deps: out, meta: {} }; + } + if (name === 'composer.json') { + const j = JSON.parse(text); + for (const k of ['require', 'require-dev']) if (j[k]) out.push(...Object.keys(j[k])); + return { deps: out, meta: {} }; + } + if (name === 'pubspec.yaml') { + let inDeps = false; + for (const raw of text.split('\n')) { + if (/^(dev_)?dependencies:/.test(raw)) { + inDeps = true; + continue; + } + if (/^\S/.test(raw)) inDeps = false; + const m = inDeps && raw.match(/^ {2}([a-z0-9_]+):/); + if (m) out.push(m[1]); + } + return { deps: out, meta: {} }; + } + } catch { + return { deps: [], meta: { parseError: true } }; + } + return { deps: out, meta: {} }; +} + +/** + * @param {{ path: string, oid: string }[]} tree + * @param {Map} blobs oid -> text + * @param {{ path: string, oid: string }[]} manifests + */ +export function buildTechStack(tree, blobs, manifests) { + const lang = new Map(); + let codeFiles = 0; + for (const t of tree) { + if (IGNORE_DIR.test(t.path)) continue; + const base = t.path.split('/').pop() || ''; + const ext = /^Dockerfile/i.test(base) ? 'dockerfile' : base.includes('.') ? base.split('.').pop().toLowerCase() : ''; + const l = LANG[ext]; + if (!l) continue; + if (/\.min\.(js|css)$/.test(base) || /(^|\/)(package-lock|pnpm-lock)\./.test(t.path)) continue; + lang.set(l, (lang.get(l) || 0) + 1); + if (!DATA_LANGS.has(l)) codeFiles++; + } + const languages = [...lang.entries()].filter(([l]) => !DATA_LANGS.has(l)).sort((a, b) => b[1] - a[1]).map(([name, files]) => ({ name, files, share: codeFiles ? files / codeFiles : 0 })); + const docs = [...lang.entries()].filter(([l]) => DATA_LANGS.has(l)).sort((a, b) => b[1] - a[1]).map(([name, files]) => ({ name, files })); + + /** name -> { category, evidence:Set, count } */ + const items = new Map(); + const add = (name, category, evidence, weight = 1) => { + let it = items.get(name); + if (!it) items.set(name, (it = { name, category, evidence: [], count: 0 })); + it.count += weight; + if (it.evidence.length < 3 && !it.evidence.includes(evidence)) it.evidence.push(evidence); + }; + for (const t of tree) { + if (IGNORE_DIR.test(t.path)) continue; + for (const [re, name, cat] of MARKERS) if (re.test(t.path)) add(name, cat, t.path); + } + const pms = new Map(); + for (const t of tree) { + if (IGNORE_DIR.test(t.path)) continue; + for (const [re, name] of LOCKFILES) if (re.test(t.path)) pms.set(name, (pms.get(name) || 0) + 1); + } + + const depCount = new Map(); + const unknown = new Map(); + let pkgCount = 0; + let workspaces = false; + const engines = new Set(); + let goVersion = null; + const manifestStats = new Map(); + for (const m of manifests) { + const text = blobs.get(m.oid); + const file = m.path.split('/').pop(); + manifestStats.set(file, (manifestStats.get(file) || 0) + 1); + if (text == null) continue; + const { deps, meta } = depsFrom(m.path, text); + if (file === 'package.json') { + pkgCount++; + if (meta.workspaces) workspaces = true; + if (meta.engines) engines.add(`Node ${meta.engines}`); + if (meta.pm) pms.set(meta.pm === 'pnpm' ? 'pnpm' : meta.pm === 'yarn' ? 'Yarn' : meta.pm, (pms.get(meta.pm) || 0) + 1); + } + if (meta.go) goVersion = meta.go; + for (const d of new Set(deps)) { + const k = knownDep(d); + if (k) add(k[0], k[1], `${m.path} → ${d}`); + else if (!d.startsWith('@types/')) unknown.set(d, (unknown.get(d) || 0) + 1); + depCount.set(d, (depCount.get(d) || 0) + 1); + } + } + if (workspaces || pkgCount > 3) add('npm workspaces / monorepo', 'Monorepo', `${pkgCount} 个 package.json`); + if (goVersion) add(`Go ${goVersion}`, '语言与运行时', 'go.mod'); + for (const e of engines) add(e, '语言与运行时', 'package.json engines'); + + const ORDER = ['语言与运行时', '前端', '桌面端', '移动端', '后端', 'AI', '数据', '接口定义', '工具库', '构建工具', 'Monorepo', '测试', '代码质量', '可观测性', 'CI/CD', '构建与部署', '工程实践']; + const groups = ORDER.map((cat) => ({ + category: cat, + items: [...items.values()].filter((i) => i.category === cat).sort((a, b) => b.count - a.count), + })).filter((g) => g.items.length); + return { + languages, + docs, + groups, + packageManagers: [...pms.entries()].sort((a, b) => b[1] - a[1]).map(([name, n]) => ({ name, n })), + manifests: [...manifestStats.entries()].map(([name, n]) => ({ name, n })), + packages: pkgCount, + otherDeps: [...unknown.entries()].sort((a, b) => b[1] - a[1]).slice(0, 16).map(([name, n]) => ({ name, n })), + files: tree.length, + }; +} diff --git a/plugins/yujiachen-y/repo-xray/miniapp/node/util.mjs b/plugins/yujiachen-y/repo-xray/miniapp/node/util.mjs new file mode 100644 index 0000000..feb2672 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/util.mjs @@ -0,0 +1,86 @@ +// @ts-check +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +export const DAY = 86_400_000; + +const pad = (n) => String(n).padStart(2, '0'); + +/** Local calendar date (YYYY-MM-DD) for a timestamp or Date. */ +export function ymd(value) { + const d = new Date(value); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +export function parseYmd(s) { + const [y, m, d] = String(s).split('-').map(Number); + return new Date(y, (m || 1) - 1, d || 1); +} + +export function addDays(s, n) { + const d = parseYmd(s); + return ymd(new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)); +} + +/** Whole days from a to b (b - a). */ +export function dayDiff(a, b) { + return Math.round((parseYmd(b).getTime() - parseYmd(a).getTime()) / DAY); +} + +export function mondayOf(s) { + const d = parseYmd(s); + return addDays(s, -((d.getDay() + 6) % 7)); +} + +export function weekday(s) { + return parseYmd(s).getDay(); // 0 = Sunday +} + +export async function readJson(file, fallback) { + try { + return JSON.parse(await readFile(file, 'utf8')); + } catch { + return fallback; + } +} + +export async function writeJsonAtomic(file, value) { + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; + await writeFile(tmp, JSON.stringify(value)); + await rename(tmp, file); +} + +/** File-system safe segment for ids and usernames. */ +export function safeSegment(s) { + return String(s).replace(/[^A-Za-z0-9._-]/g, (c) => `~${c.charCodeAt(0).toString(16)}`).slice(0, 120); +} + +export const sleep = (ms, signal) => + new Promise((resolve) => { + const t = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(t); + resolve(undefined); + }, { once: true }); + }); + +/** Run fn over items with bounded concurrency. Never rejects; returns settled results. */ +export async function pool(items, limit, fn) { + const results = new Array(items.length); + let i = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (i < items.length) { + const idx = i++; + try { + results[idx] = { ok: true, value: await fn(items[idx], idx) }; + } catch (error) { + results[idx] = { ok: false, error }; + } + } + }); + await Promise.all(workers); + return results; +} + +export const USERNAME_RE = /^[A-Za-z0-9_.][A-Za-z0-9_.-]{0,99}$/; diff --git a/plugins/yujiachen-y/repo-xray/package.json b/plugins/yujiachen-y/repo-xray/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} From 6dbb1cb33a97ba2a7a1d361100f9d91d3f9481f5 Mon Sep 17 00:00:00 2001 From: jiachen Date: Mon, 19 Oct 2026 10:39:27 +0800 Subject: [PATCH 2/4] repo-xray: keep the welcome icon at a fixed size The welcome page put the icon in a .brand-mark with no display value. Inside the sidebar's flex row that span is blockified, so its size applies; on the welcome page it stayed inline, its width was ignored, and the image's width: 100% filled the whole column (up to 880px). .brand-mark is now inline-block, and the welcome icon has a fixed 56px size in CSS instead of an inline style. Also shorten the input placeholder so it fits the right panel next to the sidebar (the focused input clipped the last characters), and add text-overflow: ellipsis for when the input is not focused. --- .../yujiachen-y/repo-xray/miniapp/client/app.js | 4 ++-- .../repo-xray/miniapp/client/styles.css | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/plugins/yujiachen-y/repo-xray/miniapp/client/app.js b/plugins/yujiachen-y/repo-xray/miniapp/client/app.js index e8ef81a..d85ddc4 100644 --- a/plugins/yujiachen-y/repo-xray/miniapp/client/app.js +++ b/plugins/yujiachen-y/repo-xray/miniapp/client/app.js @@ -345,7 +345,7 @@ function analyzeBar(value = '') { return `
      `; @@ -361,7 +361,7 @@ function renderWelcome() { const s = state.suggest; main.innerHTML = `
      - +

      读代码之前,先看一遍它的 git 历史

      找出最常被改、最常出 bug 的文件,谁在维护它们,项目节奏是升是降,以及它用了哪些技术。指标定义和 codebase-recon skill 保持一致,全部在本机计算。

      diff --git a/plugins/yujiachen-y/repo-xray/miniapp/client/styles.css b/plugins/yujiachen-y/repo-xray/miniapp/client/styles.css index 95c6107..7f1df5e 100644 --- a/plugins/yujiachen-y/repo-xray/miniapp/client/styles.css +++ b/plugins/yujiachen-y/repo-xray/miniapp/client/styles.css @@ -178,7 +178,10 @@ textarea { gap: 10px; padding: 4px 8px; } +/* A : inline-block so its size holds outside a flex parent too (the welcome page). */ .brand-mark { + display: inline-block; + vertical-align: middle; width: 30px; height: 30px; flex: none; @@ -1516,6 +1519,7 @@ dialog.drawer[open] { .analyze-bar .input { height: 40px; font-size: 14px; + text-overflow: ellipsis; } .analyze-bar .btn { height: 40px; @@ -3670,6 +3674,16 @@ table.map-list tr.is-sel > th { text-align: left; margin-bottom: 18px; } +/* Fixed size: the welcome icon never scales with the window. */ +.welcome-hero .hero-mark { + width: 56px; + height: 56px; + border-radius: 24%; + box-shadow: 0 10px 26px -10px rgb(6 182 212 / 60%); +} +.welcome-hero .hero-mark .scan { + opacity: 1; +} .welcome-hero h1 { font-size: 30px; line-height: 1.2; From 731c2e14f15878e906bd1bf603faf73ca369b277 Mon Sep 17 00:00:00 2001 From: jiachen Date: Mon, 19 Oct 2026 11:23:34 +0800 Subject: [PATCH 3/4] repo-xray: address review: no redirects with a token, 0600 JSON, Host/Origin check - netEnv(): with a token attached, set http.followRedirects=false. The unscoped http.extraHeader went with every request, and after a redirect git kept talking to the new host, so a 302 to another host received the token on POST git-upload-pack. A redirect now fails with a message asking for the repository's new address. - writeJsonAtomic() creates files with mode 0600 (config.json holds pasted tokens); start() also tightens an existing config.json. - Every route answers 403 unless Host is a loopback name and Origin, when sent, is this listener (same check as cq-viewer). - git() drops inherited GIT_DIR and the rest of `git rev-parse --local-env-vars`, GIT_CONFIG_KEY_n/VALUE_n, and GIT_TRACE*. - parseTarget() validates the host with the Settings host rule for every form, so "git@github.com@evil.example:a/b" is rejected; gh tokens are only used for github.com and hosts in gh's hosts.yml. - readBlobs() checks sizes first and reads only blobs under 512KB. - Removing a repository waits for its running job to finish before deleting the clone and report. - README (en, zh-CN): Data & access updated to match. --- plugins/yujiachen-y/repo-xray/README.md | 34 +++++---- plugins/yujiachen-y/repo-xray/README.zh-CN.md | 13 ++-- .../repo-xray/miniapp/node/git.mjs | 42 ++++++++++- .../repo-xray/miniapp/node/server.mjs | 72 +++++++++++++++++-- .../repo-xray/miniapp/node/util.mjs | 3 +- 5 files changed, 134 insertions(+), 30 deletions(-) diff --git a/plugins/yujiachen-y/repo-xray/README.md b/plugins/yujiachen-y/repo-xray/README.md index 0717f8a..f27f701 100644 --- a/plugins/yujiachen-y/repo-xray/README.md +++ b/plugins/yujiachen-y/repo-xray/README.md @@ -77,12 +77,13 @@ the Agent to "打开仓库透视". `node_modules`. For each directory it only checks for a `.git` entry and the modification times of `.git/FETCH_HEAD`, `.git/index`, and `.git/HEAD`. - The host names in `glab`'s config file (`$GLAB_CONFIG_DIR/config.yml`, - `~/.config/glab-cli/config.yml`, or `~/Library/Application Support/glab-cli/config.yml`), to know - which hosts `glab` is logged in to. Tokens are not read from this file. + `~/.config/glab-cli/config.yml`, or `~/Library/Application Support/glab-cli/config.yml`) and in + `gh`'s `hosts.yml` (`$GH_CONFIG_DIR`, `$XDG_CONFIG_HOME/gh`, or `~/.config/gh`), to know which + hosts these CLIs are logged in to. Tokens are not read from these files. - Whether `gh` and `glab` exist on `PATH`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `~/.local/bin`, or `~/.local/share/mise/shims`. - Files written, all inside the Host-provided `context.dataDir` (atomic write through a temporary - file): + file; JSON files are owner-only, mode `0600`): - `config.json`: the repository list and settings (scan roots, proxy, and any access tokens you paste in Settings, stored in plain text in this file). - `reports/.json`: the analysis result for each repository. @@ -92,24 +93,31 @@ the Agent to "打开仓库透视". repositories are never modified. - Network: only `git` goes online, over HTTPS, to the host of each remote repository you add: clone and fetch, `ls-remote` to follow a renamed default branch, and fetching the few manifest and README - files above. `git@host:path` and `ssh://` addresses are converted to HTTPS URLs. If a proxy is set, - git connects through it; the proxy comes from Settings, then `HTTPS_PROXY` / `https_proxy` / - `HTTP_PROXY` / `http_proxy`, then the macOS system HTTPS proxy. The page loads only the app's own - same-origin files and `/api/` routes. Links to repository pages on GitHub or GitLab open in your - browser only when you click them. No telemetry. + files above. `git@host:path` and `ssh://` addresses are converted to HTTPS URLs; host names are + validated first. If a proxy is set, git connects through it; the proxy comes from Settings, then + `HTTPS_PROXY` / `https_proxy` / `HTTP_PROXY` / `http_proxy`, then the macOS system HTTPS proxy. + The page loads only the app's own same-origin files and `/api/` routes. Links to repository pages + on GitHub or GitLab open in your browser only when you click them. No telemetry. +- Local server: it answers only requests whose `Host` is a loopback name and whose `Origin`, when + sent, is the page itself; anything else gets `403`. - Processes, all started with an argument array and never through a shell. Addresses and paths you enter are validated and passed as separate arguments, never in an option position: - `git`: `clone --bare --filter=blob:none`, `fetch`, `ls-remote`, `log`, `ls-tree`, `cat-file`, `rev-parse`, `for-each-ref`, `symbolic-ref`, and `config` (reads `remote.origin.url` of a local repository to build web links; sets `remote.origin.fetch` and `HEAD` only inside the app's own - clones). - - `gh auth token --hostname ` for GitHub hosts and `glab config get token --host ` for - hosts in `glab`'s config, only when those CLIs are installed, to reuse your existing login. + clones). Inherited variables that would point git at another repository (`GIT_DIR` and the rest + of `git rev-parse --local-env-vars`) or trace requests (`GIT_TRACE*`) are removed. + - `gh auth token --hostname ` for `github.com` and hosts in `gh`'s `hosts.yml`, and + `glab config get token --host ` for hosts in `glab`'s config, only when those CLIs are + installed, to reuse your existing login. - `/usr/sbin/scutil --proxy` on macOS, only when no proxy is set in Settings or the environment. - Credentials: tokens from `gh`, `glab`, or Settings stay in the Node process. They reach git as an `http.extraHeader` through `GIT_CONFIG_*` environment variables, never in command-line arguments, - repository config, the page, logs, or the event stream. Tokens from `gh` and `glab` are cached in - memory only. Git credential helpers and interactive prompts are turned off for these commands. + repository config, the page, logs, or the event stream. While a token is attached, git does not + follow HTTP redirects (`http.followRedirects=false`), so the token is only ever sent to the host + you added; a repository that redirects (renamed or moved) has to be added again with its new + address. Tokens from `gh` and `glab` are cached in memory only. Git credential helpers and + interactive prompts are turned off for these commands. - Agent turns: none. No `agentAccess` and no MCP endpoints. "发到对话…", "让 Agent 讲解…", and "让 Agent 读高风险文件…" call `window.mcode.miniapp.message.append` to place a prepared prompt (a summary of the report and some file paths) in the conversation input after the Host asks you to diff --git a/plugins/yujiachen-y/repo-xray/README.zh-CN.md b/plugins/yujiachen-y/repo-xray/README.zh-CN.md index acd9173..8573ca6 100644 --- a/plugins/yujiachen-y/repo-xray/README.zh-CN.md +++ b/plugins/yujiachen-y/repo-xray/README.zh-CN.md @@ -41,19 +41,20 @@ - 读取的文件: - 你添加的仓库。本机仓库只用下文列出的只读 git 命令原地读取:只看 `HEAD` 的历史,不 fetch,也不写入任何内容。 - 本机仓库推荐:在扫描目录下列出子目录(默认 `~/Workspace`、`~/workspace`、`~/code`、`~/Code`、`~/src`、`~/Projects`、`~/projects`、`~/Developer`、`~/dev`、`~/repos`、`~/GitHub`、`~/git`;可在设置里改成最多 10 个自定义目录),最深 4 层、最多 4000 个目录和 300 个仓库,跳过隐藏目录和 `node_modules` 等目录。每个目录只检查有没有 `.git`,以及 `.git/FETCH_HEAD`、`.git/index`、`.git/HEAD` 的修改时间。 - - `glab` 配置文件(`$GLAB_CONFIG_DIR/config.yml`、`~/.config/glab-cli/config.yml` 或 `~/Library/Application Support/glab-cli/config.yml`)里的主机名,用来判断 `glab` 登录了哪些主机;不从这个文件读取令牌。 + - `glab` 配置文件(`$GLAB_CONFIG_DIR/config.yml`、`~/.config/glab-cli/config.yml` 或 `~/Library/Application Support/glab-cli/config.yml`)和 `gh` 的 `hosts.yml`(`$GH_CONFIG_DIR`、`$XDG_CONFIG_HOME/gh` 或 `~/.config/gh`)里的主机名,用来判断这两个 CLI 登录了哪些主机;不从这些文件读取令牌。 - 在 `PATH`、`/opt/homebrew/bin`、`/usr/local/bin`、`/usr/bin`、`~/.local/bin`、`~/.local/share/mise/shims` 里查找 `gh` 和 `glab` 是否存在。 -- 写入的文件,全部位于宿主提供的 `context.dataDir` 内(先写临时文件再原子替换): +- 写入的文件,全部位于宿主提供的 `context.dataDir` 内(先写临时文件再原子替换;JSON 文件的权限是仅本人可读写的 `0600`): - `config.json`:仓库列表和设置(扫描目录、代理,以及你在设置里填写的访问令牌;令牌以明文保存在这个文件里)。 - `reports/.json`:每个仓库的分析结果。 - `repos/.git`:远程仓库的 bare、blob-less 克隆,只包含提交和目录树,外加技术栈识别和目录说明用到的少量清单文件与 README 类文件。 - 移除仓库时删除它的报告;如果是远程仓库,同时删除它的克隆。本机仓库从不改动。 -- 网络:只有 `git` 会联网,通过 HTTPS 访问你添加的远程仓库所在的主机:clone 和 fetch、用 `ls-remote` 跟进默认分支改名、拉取上面提到的少量清单与 README 文件。`git@host:path` 和 `ssh://` 形式的地址会转换成 HTTPS 地址。设置了代理时 git 经代理连接,代理依次取自设置、`HTTPS_PROXY` / `https_proxy` / `HTTP_PROXY` / `http_proxy` 环境变量、macOS 系统 HTTPS 代理。页面只加载本应用同源的文件和 `/api/` 路由。指向 GitHub / GitLab 页面的链接只有在你点击时才会在浏览器里打开。没有任何统计上报。 +- 网络:只有 `git` 会联网,通过 HTTPS 访问你添加的远程仓库所在的主机:clone 和 fetch、用 `ls-remote` 跟进默认分支改名、拉取上面提到的少量清单与 README 文件。`git@host:path` 和 `ssh://` 形式的地址会先校验主机名,再转换成 HTTPS 地址。设置了代理时 git 经代理连接,代理依次取自设置、`HTTPS_PROXY` / `https_proxy` / `HTTP_PROXY` / `http_proxy` 环境变量、macOS 系统 HTTPS 代理。页面只加载本应用同源的文件和 `/api/` 路由。指向 GitHub / GitLab 页面的链接只有在你点击时才会在浏览器里打开。没有任何统计上报。 +- 本地服务:只响应 `Host` 是回环地址、并且 `Origin`(如果带了)就是本页面的请求,其余一律返回 `403`。 - 进程:全部用参数数组启动,从不经过 shell。你输入的地址和路径都会先校验,并作为独立参数传入,不会出现在选项位置: - - `git`:`clone --bare --filter=blob:none`、`fetch`、`ls-remote`、`log`、`ls-tree`、`cat-file`、`rev-parse`、`for-each-ref`、`symbolic-ref`,以及 `config`(读取本机仓库的 `remote.origin.url` 来生成网页链接;只在应用自己的克隆里设置 `remote.origin.fetch` 和 `HEAD`)。 - - `gh auth token --hostname `(GitHub 主机)和 `glab config get token --host `(`glab` 配置里的主机),仅在装了这两个 CLI 时调用,用来复用已有登录。 + - `git`:`clone --bare --filter=blob:none`、`fetch`、`ls-remote`、`log`、`ls-tree`、`cat-file`、`rev-parse`、`for-each-ref`、`symbolic-ref`,以及 `config`(读取本机仓库的 `remote.origin.url` 来生成网页链接;只在应用自己的克隆里设置 `remote.origin.fetch` 和 `HEAD`)。继承来的、会把 git 指向别的仓库的环境变量(`GIT_DIR` 以及 `git rev-parse --local-env-vars` 列出的其余变量)和会打印请求的 `GIT_TRACE*` 都会被去掉。 + - `gh auth token --hostname `(`github.com` 和 `gh` 的 `hosts.yml` 里的主机)和 `glab config get token --host `(`glab` 配置里的主机),仅在装了这两个 CLI 时调用,用来复用已有登录。 - macOS 上的 `/usr/sbin/scutil --proxy`,仅在设置和环境变量里都没有代理时调用。 -- 凭据:来自 `gh`、`glab` 或设置的令牌只留在 Node 进程里,通过 `GIT_CONFIG_*` 环境变量以 `http.extraHeader` 的形式交给 git,不会出现在命令行参数、仓库配置、页面、日志或事件流里。来自 `gh` 和 `glab` 的令牌只缓存在内存中。这些 git 命令会关闭凭据助手和交互式提示。 +- 凭据:来自 `gh`、`glab` 或设置的令牌只留在 Node 进程里,通过 `GIT_CONFIG_*` 环境变量以 `http.extraHeader` 的形式交给 git,不会出现在命令行参数、仓库配置、页面、日志或事件流里。带着令牌时 git 不跟随 HTTP 跳转(`http.followRedirects=false`),令牌只会发给你添加的那台主机;会跳转的仓库(改名或迁移过)需要用新地址重新添加。来自 `gh` 和 `glab` 的令牌只缓存在内存中。这些 git 命令会关闭凭据助手和交互式提示。 - Agent:不运行 Agent,没有声明 `agentAccess`,也没有 MCP 端点。“发到对话…”“让 Agent 讲解…”“让 Agent 读高风险文件…”会调用 `window.mcode.miniapp.message.append`,在宿主弹窗确认后把一段准备好的提示词(报告摘要和部分文件路径)放进对话输入框,由你决定是否发送。环境不支持这个接口时,改为复制到剪贴板。 - 剪贴板:只在你点击复制类操作(SHA、路径、Markdown 报告)时写入。 - 浏览器存储:`localStorage` 只保存主题、上次打开的仓库和身份合并偏好。 diff --git a/plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs b/plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs index 74641b0..7165253 100644 --- a/plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs @@ -16,6 +16,24 @@ export class GitError extends Error { /** Child processes owned by this runtime (killed on dispose). */ export const children = new Set(); +/** Host names the app accepts, for repository addresses and Settings alike. */ +export const HOST_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?$/; + +// Inherited variables that would point git at another repository or inject config (git's own +// list, `git rev-parse --local-env-vars`, plus the numbered GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n +// pairs), and GIT_TRACE*, which can print requests. Everything else (PATH, HOME, proxies, CA +// settings) is passed through. +const LOCAL_ENV = new Set(['GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CONFIG', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT', 'GIT_OBJECT_DIRECTORY', 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_IMPLICIT_WORK_TREE', 'GIT_GRAFT_FILE', 'GIT_INDEX_FILE', 'GIT_NO_REPLACE_OBJECTS', 'GIT_REPLACE_REF_BASE', 'GIT_PREFIX', 'GIT_SHALLOW_FILE', 'GIT_COMMON_DIR']); +function inheritedEnv() { + /** @type {Record} */ + const env = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v === undefined || LOCAL_ENV.has(k) || /^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(k) || k.startsWith('GIT_TRACE')) continue; + env[k] = v; + } + return env; +} + /** * Run git with an argument array (never a shell). * @param {string[]} args @@ -25,7 +43,7 @@ export const children = new Set(); export function git(args, opts = {}) { return new Promise((resolvePromise, reject) => { const env = { - ...process.env, + ...inheritedEnv(), GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', @@ -85,6 +103,8 @@ export function git(args, opts = {}) { } function classify(stderr) { + // Before 'network': the message also says "unable to access". + if (/returned error: 30[1-8]\b/.test(stderr)) return 'redirect'; if (/Authentication failed|could not read Username|403|401|HTTP Basic: Access denied|terminal prompts disabled/i.test(stderr)) return 'auth'; if (/not found|does not exist|Repository not found|404/i.test(stderr)) return 'not_found'; if (/Could not resolve host|Failed to connect|Connection (reset|refused|timed out)|SSL|TLS|gnutls|unable to access/i.test(stderr)) return 'network'; @@ -95,6 +115,7 @@ function classify(stderr) { function friendly(stderr) { const code = classify(stderr); + if (code === 'redirect') return '服务器要求跳转到另一个地址(仓库可能改名或迁移了)。带着访问令牌时不会跟随跳转,以免令牌发到别处:请在网页上确认仓库的新地址,用新地址重新添加'; if (code === 'auth') return '没有权限访问这个仓库:请确认已用 gh / glab 登录,或在设置里为这个主机填写访问令牌'; if (code === 'not_found') return '仓库不存在,或当前账号看不到它'; if (code === 'network') return '网络连接失败:内网地址可能需要在设置里配置代理'; @@ -172,6 +193,8 @@ export async function parseTarget(raw) { throw new GitError('invalid', '无法识别:请输入 https 地址、owner/repo,或本地仓库的绝对路径'); } host = host.toLowerCase(); + // One rule for every form: rejects "git@github.com@evil.example:…" and similar. + if (!HOST_RE.test(host)) throw new GitError('invalid', '主机名不合法'); path = path.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, ''); // Strip web UI suffixes. path = path.split('/-/')[0]; @@ -207,6 +230,10 @@ export function netEnv(target, token, proxy) { if (token) { const user = target.provider === 'github' ? 'x-access-token' : 'oauth2'; pairs.push(['http.extraHeader', `Authorization: Basic ${Buffer.from(`${user}:${token}`).toString('base64')}`]); + // The header goes with every request this git process makes, and after following a redirect + // git keeps talking to the new address (a URL-scoped http..extraHeader is matched against + // the original URL, so it does not help). With a token attached, never follow redirects. + pairs.push(['http.followRedirects', 'false']); } if (proxy) pairs.push(['http.proxy', proxy]); pairs.push(['credential.helper', '']); @@ -278,9 +305,18 @@ export async function prefetchBlobs(dir, oids, { env, signal }) { export async function readBlobs(dir, oids, { signal }) { const result = new Map(); if (!oids.length) return result; + const MAX = 512 * 1024; + // Sizes first, so a huge blob is never read into memory. + const sizes = await git(['-C', dir, 'cat-file', '--batch-check=%(objectname) %(objecttype) %(objectsize)'], { input: oids.join('\n') + '\n', env: { GIT_NO_LAZY_FETCH: '1' }, signal, timeout: 60_000 }); + const small = sizes + .split('\n') + .map((l) => l.split(' ')) + .filter(([, type, size]) => type === 'blob' && Number(size) < MAX) + .map(([oid]) => oid); + if (!small.length) return result; const chunks = []; await git(['-C', dir, 'cat-file', '--batch'], { - input: oids.join('\n') + '\n', + input: small.join('\n') + '\n', env: { GIT_NO_LAZY_FETCH: '1' }, signal, timeout: 120_000, @@ -296,7 +332,7 @@ export async function readBlobs(dir, oids, { signal }) { const [oid, type, sizeStr] = header.split(' '); if (type === 'missing' || !sizeStr) continue; const size = Number(sizeStr); - if (type === 'blob' && size < 512 * 1024) result.set(oid, buf.subarray(pos, pos + size).toString('utf8')); + if (type === 'blob' && size < MAX) result.set(oid, buf.subarray(pos, pos + size).toString('utf8')); pos += size + 1; } return result; diff --git a/plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs b/plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs index 32e1696..41bb204 100644 --- a/plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs @@ -1,14 +1,14 @@ // @ts-check import { execFile } from 'node:child_process'; import { constants as fsc } from 'node:fs'; -import { access, readFile, readdir, rm, stat } from 'node:fs/promises'; +import { access, chmod, readFile, readdir, rm, stat } from 'node:fs/promises'; import { createServer } from 'node:http'; import { homedir } from 'node:os'; import { delimiter, join } from 'node:path'; import { ALGO_VERSION, buildModel, buildReport, diffReports, fileDetail, personDetail, search } from './analyze.mjs'; import { buildDescs, mapView, pickDescFiles } from './codemap.mjs'; -import { GitError, children, git, netEnv, parseTarget, prefetchBlobs, readBlobs, syncRemote } from './git.mjs'; +import { GitError, HOST_RE, children, git, netEnv, parseTarget, prefetchBlobs, readBlobs, syncRemote } from './git.mjs'; import { buildTechStack, pickManifests } from './techstack.mjs'; import { readJson, writeJsonAtomic } from './util.mjs'; @@ -22,6 +22,37 @@ const CLIENT_FILES = { '/assets/icon.svg': ['icon.svg', 'image/svg+xml'], }; const ID_RE = /^[rl]-[0-9a-f]{12}$/; +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']); + +/** + * The Host header must name this machine (a DNS-rebound name is refused), and a browser Origin, + * when sent, must be this listener: http, a loopback host, and our port. The page is same-origin. + * @param {import('node:http').IncomingMessage} req + * @param {number} port + */ +function isLoopbackRequest(req, port) { + const host = req.headers.host; + if (host !== undefined && !LOOPBACK_HOSTS.has(hostnameOf(host))) return false; + const origin = req.headers.origin; + if (origin === undefined) return true; + let url; + try { + url = new URL(origin); + } catch { + return false; // includes "null" (sandboxed iframes, file://) + } + const originPort = Number(url.port || (url.protocol === 'https:' ? 443 : 80)); + return url.protocol === 'http:' && LOOPBACK_HOSTS.has(hostnameOf(url.host)) && originPort === port; +} + +/** @param {string} hostHeader */ +function hostnameOf(hostHeader) { + const h = hostHeader.trim().toLowerCase(); + const bracketed = /^\[([^\]]*)\]/.exec(h); + if (bracketed) return bracketed[1]; + const colon = h.lastIndexOf(':'); + return colon === -1 ? h : h.slice(0, colon); +} /** * @param {MiniAppContext} context @@ -35,6 +66,8 @@ export async function start(context) { const config = { repos: [], ...(await readJson(configFile, {})) }; config.repos ??= []; config.settings = { proxy: '', tokens: {}, scanRoots: null, ...(config.settings || {}) }; + // Files written by earlier versions used the default mode; tighten an existing config now. + await chmod(configFile, 0o600).catch(() => {}); const saveConfig = () => writeJsonAtomic(configFile, config); const repoDir = (id) => join(dataDir, 'repos', `${id}.git`); const reportFile = (id) => join(dataDir, 'reports', `${id}.json`); @@ -110,6 +143,23 @@ export async function start(context) { } return new Set(); } + // Hosts gh is logged in to: the top-level keys of its hosts.yml (tokens are not read here). + async function ghHosts() { + const env = process.env; + const dir = env.GH_CONFIG_DIR || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, 'gh') : join(homedir(), '.config', 'gh')); + let text = ''; + try { + text = await readFile(join(dir, 'hosts.yml'), 'utf8'); + } catch { + return new Set(); + } + const hosts = new Set(); + for (const line of text.split('\n')) { + const m = line.match(/^([a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+):\s*$/i); + if (m) hosts.add(m[1].toLowerCase()); + } + return hosts; + } const tokenCache = new Map(); /** @returns {Promise<{ token: string, source: string }>} */ async function credentialFor(host) { @@ -118,7 +168,9 @@ export async function start(context) { if (hit && Date.now() - hit.at < 10 * 60_000) return hit.value; let value = { token: '', source: 'none' }; const gh = await findBin('gh'); - if (gh && (host === 'github.com' || host.startsWith('github.'))) { + // Only github.com and hosts gh is logged in to: for any other host gh would hand out + // GH_ENTERPRISE_TOKEN if that is set. + if (gh && (host === 'github.com' || (await ghHosts()).has(host))) { const t = await runOut(gh, ['auth', 'token', '--hostname', host]).catch(() => ''); if (t) value = { token: t, source: 'gh' }; } @@ -212,7 +264,7 @@ export async function start(context) { const ac = new AbortController(); const onAbort = () => ac.abort(); context.signal.addEventListener('abort', onAbort, { once: true }); - const job = { repoId: repo.id, running: true, stage: 'queued', pct: 0, detail: '', error: null, startedAt: Date.now(), stageStartedAt: Date.now(), steps: [], reportHead: null, ac }; + const job = { repoId: repo.id, running: true, stage: 'queued', pct: 0, detail: '', error: null, startedAt: Date.now(), stageStartedAt: Date.now(), steps: [], reportHead: null, ac, done: /** @type {Promise | null} */ (null) }; const enter = (stage) => { if (job.stage !== 'queued') job.steps.push({ stage: job.stage, ms: Date.now() - job.stageStartedAt }); job.stage = stage; @@ -223,7 +275,7 @@ export async function start(context) { }; jobs.set(repo.id, job); tick(job, true); - (async () => { + job.done = (async () => { const dir = workDirOf(repo); let env = {}; if (repo.target.kind === 'remote') { @@ -431,6 +483,8 @@ export async function start(context) { } async function handle(req, res) { + // Every route: the Host names this machine and a browser Origin, if any, is this page. + if (!isLoopbackRequest(req, context.listen.port)) return send(res, 403, { error: { code: 'forbidden', message: '只接受本机页面的请求' } }); const url = new URL(req.url ?? '/', 'http://miniapp.local'); const p = url.pathname; const method = req.method || 'GET'; @@ -474,9 +528,13 @@ export async function start(context) { } if ((m = p.match(/^\/api\/repos\/([^/]+)$/)) && method === 'DELETE') { const repo = findRepo(m[1]); - jobs.get(repo.id)?.ac.abort(); config.repos = config.repos.filter((r) => r.id !== repo.id); models.delete(repo.id); + // Let a running job's git exit and the job write its last file before deleting them. + const job = jobs.get(repo.id); + job?.ac.abort(); + await job?.done; + jobs.delete(repo.id); await saveConfig(); // Only Mini App-owned caches are removed; local repositories are never touched. await rm(reportFile(repo.id), { force: true }); @@ -549,7 +607,7 @@ export async function start(context) { } if (b.token && typeof b.token.host === 'string') { const host = b.token.host.trim().toLowerCase(); - if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?$/.test(host)) throw new GitError('invalid', '主机名不合法'); + if (!HOST_RE.test(host)) throw new GitError('invalid', '主机名不合法'); const t = String(b.token.value || '').trim(); if (t) config.settings.tokens[host] = t; else delete config.settings.tokens[host]; diff --git a/plugins/yujiachen-y/repo-xray/miniapp/node/util.mjs b/plugins/yujiachen-y/repo-xray/miniapp/node/util.mjs index feb2672..0d7cc8f 100644 --- a/plugins/yujiachen-y/repo-xray/miniapp/node/util.mjs +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/util.mjs @@ -44,10 +44,11 @@ export async function readJson(file, fallback) { } } +/** Owner-only (0600): config.json can hold access tokens. rename() keeps the temp file's mode. */ export async function writeJsonAtomic(file, value) { await mkdir(dirname(file), { recursive: true }); const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; - await writeFile(tmp, JSON.stringify(value)); + await writeFile(tmp, JSON.stringify(value), { mode: 0o600 }); await rename(tmp, file); } From 2396f5594cfb7b1b8d7fe8d76f0e6dce1025bcac Mon Sep 17 00:00:00 2001 From: jiachen Date: Tue, 20 Oct 2026 02:53:22 +0800 Subject: [PATCH 4/4] repo-xray: kill git's whole process group on abort; bound the DELETE wait Aborting killed only the direct git child. git-remote-https shares its stdout/stderr pipes, so 'close' waited until the remote answered: with a remote that accepts the connection and never responds, DELETE (which now awaits the job) hung and the helper was left running, also after dispose(). - git() spawns git in its own process group on POSIX (detached) and the abort, timeout, and overflow paths kill the group (killTree); Windows keeps the plain kill. dispose() uses the same kill. - git() rejects at once when its signal is already aborted, so a job that resumes after an abort never starts another git command. - DELETE saves the config before waiting, and waits at most 5 s: the cap bounds the gh / glab / scutil lookups, which do not take the signal. --- .../repo-xray/miniapp/node/git.mjs | 25 ++++++++++++++++--- .../repo-xray/miniapp/node/server.mjs | 13 +++++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs b/plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs index 7165253..bf48017 100644 --- a/plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs @@ -34,6 +34,22 @@ function inheritedEnv() { return env; } +/** + * SIGKILL a child together with the helpers it started (git-remote-https, ssh). They share its + * pipes, so killing git alone leaves them running and 'close' waits on the network. On POSIX a + * child from git() leads its own process group; anything else (or Windows) gets a plain kill. + * @param {import('node:child_process').ChildProcess} child + */ +export function killTree(child) { + if (process.platform !== 'win32' && child.pid) { + try { + process.kill(-child.pid, 'SIGKILL'); + return; + } catch {} + } + child.kill('SIGKILL'); +} + /** * Run git with an argument array (never a shell). * @param {string[]} args @@ -42,6 +58,7 @@ function inheritedEnv() { */ export function git(args, opts = {}) { return new Promise((resolvePromise, reject) => { + if (opts.signal?.aborted) return reject(new GitError('aborted', '已取消')); const env = { ...inheritedEnv(), GIT_TERMINAL_PROMPT: '0', @@ -51,7 +68,7 @@ export function git(args, opts = {}) { LC_ALL: 'C', ...opts.env, }; - const child = spawn('git', args, { cwd: opts.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + const child = spawn('git', args, { cwd: opts.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, detached: process.platform !== 'win32' }); children.add(child); const out = []; let outLen = 0; @@ -60,11 +77,11 @@ export function git(args, opts = {}) { const max = opts.maxBuffer ?? 256 * 1024 * 1024; const timer = setTimeout(() => { killedFor = 'timeout'; - child.kill('SIGKILL'); + killTree(child); }, opts.timeout ?? 120_000); const onAbort = () => { killedFor = 'aborted'; - child.kill('SIGKILL'); + killTree(child); }; opts.signal?.addEventListener('abort', onAbort, { once: true }); child.stdout.on('data', (c) => { @@ -72,7 +89,7 @@ export function git(args, opts = {}) { outLen += c.length; if (outLen > max) { killedFor = 'overflow'; - child.kill('SIGKILL'); + killTree(child); return; } out.push(c); diff --git a/plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs b/plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs index 41bb204..3058643 100644 --- a/plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs @@ -8,9 +8,9 @@ import { delimiter, join } from 'node:path'; import { ALGO_VERSION, buildModel, buildReport, diffReports, fileDetail, personDetail, search } from './analyze.mjs'; import { buildDescs, mapView, pickDescFiles } from './codemap.mjs'; -import { GitError, HOST_RE, children, git, netEnv, parseTarget, prefetchBlobs, readBlobs, syncRemote } from './git.mjs'; +import { GitError, HOST_RE, children, git, killTree, netEnv, parseTarget, prefetchBlobs, readBlobs, syncRemote } from './git.mjs'; import { buildTechStack, pickManifests } from './techstack.mjs'; -import { readJson, writeJsonAtomic } from './util.mjs'; +import { readJson, sleep, writeJsonAtomic } from './util.mjs'; /** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ /** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ @@ -530,12 +530,13 @@ export async function start(context) { const repo = findRepo(m[1]); config.repos = config.repos.filter((r) => r.id !== repo.id); models.delete(repo.id); - // Let a running job's git exit and the job write its last file before deleting them. + await saveConfig(); + // Stop a running job before deleting its files. The abort kills git and its helpers at once; + // the cap only bounds steps that do not take the signal (the gh / glab / scutil lookups). const job = jobs.get(repo.id); job?.ac.abort(); - await job?.done; + if (job?.done) await Promise.race([job.done, sleep(5000)]); jobs.delete(repo.id); - await saveConfig(); // Only Mini App-owned caches are removed; local repositories are never touched. await rm(reportFile(repo.id), { force: true }); if (repo.target.kind === 'remote') await rm(repoDir(repo.id), { recursive: true, force: true }); @@ -639,7 +640,7 @@ export async function start(context) { context.signal.removeEventListener('abort', onAbort); clearInterval(heartbeat); for (const j of jobs.values()) j.ac?.abort(); - for (const c of children) c.kill('SIGKILL'); + for (const c of children) killTree(c); for (const s of streams) { try { s.end();