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..f27f701 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/README.md @@ -0,0 +1,156 @@ +# 仓库透视 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`) 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; 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. + - `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; 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). 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. 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 + 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..8573ca6 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/README.zh-CN.md @@ -0,0 +1,65 @@ +# 仓库透视 (`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`)和 `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` 内(先写临时文件再原子替换;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 页面的链接只有在你点击时才会在浏览器里打开。没有任何统计上报。 +- 本地服务:只响应 `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`)。继承来的、会把 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,不会出现在命令行参数、仓库配置、页面、日志或事件流里。带着令牌时 git 不跟随 HTTP 跳转(`http.followRedirects=false`),令牌只会发给你添加的那台主机;会跳转的仓库(改名或迁移过)需要用新地址重新添加。来自 `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 0000000..5308a97 Binary files /dev/null and b/plugins/yujiachen-y/repo-xray/docs/preview.jpg differ diff --git a/plugins/yujiachen-y/repo-xray/icon.png b/plugins/yujiachen-y/repo-xray/icon.png new file mode 100644 index 0000000..0fcaffa Binary files /dev/null and b/plugins/yujiachen-y/repo-xray/icon.png differ 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..d85ddc4 --- /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 `
  • + ${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 = ` +
    +
    +
    + + ${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..7f1df5e --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/client/styles.css @@ -0,0 +1,4164 @@ +: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; +} +/* 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; + 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; + text-overflow: ellipsis; +} +.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; +} +/* 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; + 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..bf48017 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/git.mjs @@ -0,0 +1,356 @@ +// @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(); + +/** 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; +} + +/** + * 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 + * @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) => { + if (opts.signal?.aborted) return reject(new GitError('aborted', '已取消')); + const env = { + ...inheritedEnv(), + 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, detached: process.platform !== 'win32' }); + children.add(child); + const out = []; + let outLen = 0; + let err = ''; + let killedFor = ''; + const max = opts.maxBuffer ?? 256 * 1024 * 1024; + const timer = setTimeout(() => { + killedFor = 'timeout'; + killTree(child); + }, opts.timeout ?? 120_000); + const onAbort = () => { + killedFor = 'aborted'; + killTree(child); + }; + 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'; + killTree(child); + 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) { + // 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'; + 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 === 'redirect') return '服务器要求跳转到另一个地址(仓库可能改名或迁移了)。带着访问令牌时不会跟随跳转,以免令牌发到别处:请在网页上确认仓库的新地址,用新地址重新添加'; + 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(); + // 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]; + 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')}`]); + // 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', '']); + 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 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: small.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 < 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/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..3058643 --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/server.mjs @@ -0,0 +1,656 @@ +// @ts-check +import { execFile } from 'node:child_process'; +import { constants as fsc } from 'node:fs'; +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, HOST_RE, children, git, killTree, netEnv, parseTarget, prefetchBlobs, readBlobs, syncRemote } from './git.mjs'; +import { buildTechStack, pickManifests } from './techstack.mjs'; +import { readJson, sleep, 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}$/; +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 + * @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 || {}) }; + // 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`); + + // --------------------------------------------------------------------------- + // 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(); + } + // 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) { + 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'); + // 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' }; + } + 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, 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; + job.stageStartedAt = Date.now(); + job.pct = 0; + job.detail = ''; + tick(job, true); + }; + jobs.set(repo.id, job); + tick(job, true); + job.done = (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) { + // 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'; + 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]); + config.repos = config.repos.filter((r) => r.id !== repo.id); + models.delete(repo.id); + 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(); + if (job?.done) await Promise.race([job.done, sleep(5000)]); + jobs.delete(repo.id); + // 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 (!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]; + 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) killTree(c); + 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..0d7cc8f --- /dev/null +++ b/plugins/yujiachen-y/repo-xray/miniapp/node/util.mjs @@ -0,0 +1,87 @@ +// @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; + } +} + +/** 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), { mode: 0o600 }); + 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" + } +}