diff --git a/.gitignore b/.gitignore index db38f15..8a5b6a4 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ __pycache__/ .claude/ .gstack/ .worktrees/ +.codebuddy/ # Local-only drafts and scratch notes (kept out of the open-source repo) eval/ @@ -32,3 +33,5 @@ docs/superpowers/ docs/designs/auto-update.md docs/designs/ci-pipeline.md docs/designs/knowledge-feed.md +roadmap_jael.md +validation/ diff --git a/README.md b/README.md index 2c29215..231b2cf 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,14 @@ The CLI picks a provider automatically from the repo URL: | `teamai source` | Manage cross-team skill subscription sources (`add`/`remove`/`list`/`browse`) | | `teamai contribute --file [--scope ]` | Push an AI-generated experience document to the team repo | | `teamai recall ` | Search the team knowledge base, automatically merging user + project scope results | +| `teamai import --from-repo ` | Clone a remote repo and generate a per-repo summary under `docs/team-codebase/repos/.md`; AI recommends a business domain and persists the assignment to `.teamai/domains.yaml` | +| `teamai import --from-repo-list ` | Batch import a whitelist of repos with concurrency control, then aggregate the results into per-domain views | +| `teamai import --from-org --bootstrap` | List every repo under an organization (GitHub or TGit), AI-cluster them into business domains, and run an interactive review before the first full sync | +| `teamai import --from-iwiki [--iwiki-dual]` | Import iWiki documents as learnings; in dual mode also extract business-API / external-knowledge / glossary sections into `docs/team-codebase/external-knowledge.md` | +| `teamai cache --status \| --gc` | Inspect or garbage-collect the shallow-clone cache at `~/.teamai/cache/repos/` (LRU + size cap, default 5GB) | +| `teamai codebase --lint [--fix]` | Cross-file consistency lint over `docs/team-codebase` and `.teamai/`; reports anchor / orphan / source-invalid / sync-stale issues; `--fix` applies low-risk mechanical fixes | +| `teamai review [id] [--apply \| --reject \| --all-apply]` | Inspect and process pending codebase changes from `.teamai/pending-review.jsonl`; `--apply` patches in place via section anchors | +| `teamai domains drift [url] [--apply \| --lock \| --apply-all]` | Inspect and resolve domain-drift signals; `--apply` reassigns the repo to the recommended domain and refreshes the aggregate views | | `teamai digest` | Generate a team AI usage weekly digest (skill leaderboard, new/updated skills, session summaries) | | `teamai hooks` | Manage AI-tool hooks (list / inject / remove) | | `teamai uninstall [--force]` | Uninstall teamai: remove hooks, rules, skills, env, docs, and `~/.teamai/` | @@ -110,9 +118,9 @@ Member A Member B - `teamai push` creates a dedicated branch (`teamai/push//`), pushes it, then opens a Merge Request and assigns reviewers automatically. - `teamai init` lets you configure default reviewers (stored in the `reviewers` field of `teamai.yaml`). -- `teamai init` injects hooks tailored to each tool's format (`SessionStart`, `Stop`, `PostToolUse`, `UserPromptSubmit`, etc.). During sessions the hooks run `teamai pull`, `teamai update`, tracking, dashboard updates, and so on (supports Claude Code, Codex, Claude Code Internal, Codex Internal, Cursor, CodeBuddy IDE, OpenClaw, WorkBuddy). -- Skills sync to `~/.claude/skills/`, `~/.codex/skills/`, `~/.codex-internal/skills/`, `~/.claude-internal/skills/`, `~/.cursor/skills/`, `~/.codebuddy/skills/`. -- Rules sync to each tool's rules directory and are merged into `CLAUDE.md` via marker comments (supported for claude, claude-internal, codebuddy). +- `teamai init` injects hooks tailored to each tool's format (`SessionStart`, `Stop`, `PostToolUse`, `UserPromptSubmit`, etc.). During sessions the hooks run `teamai pull`, `teamai update`, tracking, dashboard updates, and so on (supports Claude Code, Codex, Cursor, CodeBuddy IDE, OpenClaw, WorkBuddy). +- Skills sync to `~/.claude/skills/`, `~/.codex/skills/`, `~/.cursor/skills/`, `~/.codebuddy/skills/`. +- Rules sync to each tool's rules directory and are merged into `CLAUDE.md` via marker comments (supported for claude, codebuddy). - Knowledge syncs to `~/.teamai/docs/`. - Learnings sync to `~/.teamai/learnings/` and back the recall index (shared team-wide, not partitioned by role). - Culture syncs the team culture file (`culture.md`): its frontmatter and body are compiled and injected into every AI tool's `CLAUDE.md`. @@ -292,6 +300,42 @@ Author: alice | Score: 12.0 | Tags: fuse, deploy - Searches implicitly upvote matched docs; good docs naturally float up over time. - Votes are written to each scope's own repo, so attribution stays correct. +`teamai recall` results carry a `[]` tag so callers can quickly tell which knowledge bucket a hit came from. The shared search index covers four categories: + +| Type | Source | Notes | +|------|--------|-------| +| `[learnings]` | `~/.teamai/learnings/*.md` | session experience documents | +| `[docs]` | team repo `docs/**/*.md` | shared project knowledge | +| `[rules]` | team repo `rules/**/*.md` | coding rules and conventions | +| `[skills]` | team repo `skills//SKILL.md` | reusable AI skills | + +The index is rebuilt automatically on every `teamai pull`. Indexes built by older versions (no `version` field or missing `type`) are detected and rebuilt transparently on first use. + +### TodoWrite reminder hook + +`teamai pull` registers a PostToolUse hook on the `TodoWrite` tool. The first time a session writes a TODO list, the hook injects a one-time reminder asking the agent to invoke `teamai-recall` if it has not already done so. Per-session deduplication uses `~/.teamai/sessions/-todowrite-hint.json` (24 h TTL). + +To disable the reminder globally, set: + +```bash +export TEAMAI_RECALL_DISABLED=1 +``` + +The same env var also disables the auto-recall hook. + +### `agents` resource type + +The team repo can ship custom subagent definitions under a flat `agents/` directory (one `*.md` file per agent). They follow the same push / pull / remove semantics as `rules`: + +```text +team-repo/ + agents/ + code-reviewer.md # team-authored subagent + .removed # tombstone (auto-managed by `teamai remove agents `) +``` + +`teamai pull` copies them into every Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`). The CLI built-in `teamai-recall.md` is deployed alongside team agents and is **excluded** from `teamai push` (it is CLI-managed, not team-managed). + ## Update ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 536f933..475e225 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -79,6 +79,14 @@ CLI 会根据用户传入的 repo URL 自动选择 provider: | `teamai source` | 管理跨团队 skill 订阅源(`add`/`remove`/`list`/`browse`) | | `teamai contribute --file [--scope ]` | 将 AI 生成的经验文档推送到团队仓库 | | `teamai recall ` | 搜索团队知识库,自动合并 user + project 双 scope 结果 | +| `teamai import --from-repo ` | 拉取远端仓库并生成单仓视图 `docs/team-codebase/repos/.md`;AI 推荐业务域并写入 `.teamai/domains.yaml` | +| `teamai import --from-repo-list ` | 按白名单批量导入多个仓库(支持并发),并按业务域聚合产出 | +| `teamai import --from-org --bootstrap` | 列出组织/group 下所有仓库(GitHub / TGit),AI 聚类为业务域,交互式 review 后完成首次全量同步 | +| `teamai import --from-iwiki [--iwiki-dual]` | 把 iWiki 文档导入为 learnings;dual 模式同时把业务接口 / 外部知识源 / 术语表抽取到 `docs/team-codebase/external-knowledge.md` | +| `teamai cache --status \| --gc` | 查看或回收 shallow-clone 缓存目录 `~/.teamai/cache/repos/`(LRU + 容量上限,默认 5GB) | +| `teamai codebase --lint [--fix]` | 对 `docs/team-codebase` 与 `.teamai/` 做跨文件一致性 lint;报告锚点 / 孤儿 / 源失效 / 同步陈旧等问题;`--fix` 应用低风险机械修复 | +| `teamai review [id] [--apply \| --reject \| --all-apply]` | 浏览并处理 `.teamai/pending-review.jsonl` 中的待审 codebase 变更;`--apply` 通过章节锚点原地写入 | +| `teamai domains drift [url] [--apply \| --lock \| --apply-all]` | 浏览并处理域漂移信号;`--apply` 把仓库重新归类到推荐域并刷新聚合视图 | | `teamai digest` | 生成团队 AI 使用周报(skill 排行、新增/更新 skill、session 摘要) | | `teamai hooks` | 管理 AI 工具 hooks(list / inject / remove) | | `teamai uninstall [--force]` | 卸载 teamai:移除 hooks、rules、skills、env、docs、~/.teamai/ | @@ -110,9 +118,9 @@ CLI 会根据用户传入的 repo URL 自动选择 provider: - `teamai push` 会创建独立分支(`teamai/push//`),推送后自动创建 Merge Request 并指派 reviewers - `teamai init` 初始化时可配置默认 reviewers(记录在 `teamai.yaml` 的 `reviewers` 字段) -- `teamai init` 会自动注入与各工具格式对齐的 hooks(含 `SessionStart`、`Stop`、`PostToolUse`、`UserPromptSubmit` 等),会话中会执行 `teamai pull`、`teamai update`、追踪与仪表盘等(支持 Claude Code、Codex、Claude Code Internal、Codex Internal、Cursor、CodeBuddy IDE、OpenClaw、WorkBuddy) -- Skills 同步到 `~/.claude/skills/`、`~/.codex/skills/`、`~/.codex-internal/skills/`、`~/.claude-internal/skills/`、`~/.cursor/skills/`、`~/.codebuddy/skills/` -- Rules 同步到各工具的 rules 目录,并通过标记注释合并到 `CLAUDE.md`(支持 claude、claude-internal、codebuddy) +- `teamai init` 会自动注入与各工具格式对齐的 hooks(含 `SessionStart`、`Stop`、`PostToolUse`、`UserPromptSubmit` 等),会话中会执行 `teamai pull`、`teamai update`、追踪与仪表盘等(支持 Claude Code、Codex、Cursor、CodeBuddy IDE、OpenClaw、WorkBuddy) +- Skills 同步到 `~/.claude/skills/`、`~/.codex/skills/`、`~/.cursor/skills/`、`~/.codebuddy/skills/` +- Rules 同步到各工具的 rules 目录,并通过标记注释合并到 `CLAUDE.md`(支持 claude、codebuddy) - Knowledge 同步到 `~/.teamai/docs/` - Learnings 同步到 `~/.teamai/learnings/`,并基于该目录构建 recall 索引(全团队共享,不按角色拆分) - Culture 同步团队文化文件(`culture.md`),编译 frontmatter 和 body 后注入到各 AI 工具的 `CLAUDE.md` @@ -292,6 +300,42 @@ Author: alice | Score: 12.0 | Tags: fuse, deploy - 搜索自动投票,好文档自然浮到顶部 - 投票按 scope 分别写入各自的 repo,归属正确 +`teamai recall` 的输出会给每条命中前置 `[]` 标签,方便调用方快速判断知识来源。共享检索索引覆盖四类内容: + +| 类型 | 源路径 | 说明 | +|------|--------|------| +| `[learnings]` | `~/.teamai/learnings/*.md` | session 经验文档 | +| `[docs]` | 团队仓库 `docs/**/*.md` | 共享项目知识 | +| `[rules]` | 团队仓库 `rules/**/*.md` | 编码规则和约定 | +| `[skills]` | 团队仓库 `skills//SKILL.md` | 可复用 AI skill | + +索引在每次 `teamai pull` 时自动重建。旧版索引(无 `version` 字段或缺少 `type`)会在首次使用时被自动检测并重建,对调用方透明 + +### TodoWrite 提醒 hook + +`teamai pull` 会在 `TodoWrite` 工具上注册一个 PostToolUse hook。当 session 第一次写 TODO 列表时,hook 会注入一次性提醒,要求 agent 在尚未调用 `teamai-recall` 时先调用一次。session 级去重通过 `~/.teamai/sessions/-todowrite-hint.json` 实现(TTL 24 小时) + +如果要全局关闭该提醒,请设置: + +```bash +export TEAMAI_RECALL_DISABLED=1 +``` + +该环境变量同时也会关闭 auto-recall hook + +### `agents` 资源类型 + +团队仓库可以在扁平的 `agents/` 目录下放置自定义 subagent 定义(每个 agent 一个 `*.md`),push / pull / remove 语义与 `rules` 保持一致: + +```text +team-repo/ + agents/ + code-reviewer.md # 团队作者编写的 subagent + .removed # tombstone(由 `teamai remove agents ` 自动管理) +``` + +`teamai pull` 会把它们复制到每个 Tier-1 工具的 `agents/` 目录(例如 `~/.claude/agents/`)。CLI 内置的 `teamai-recall.md` 会与团队 agents 一起部署,并在 `teamai push` 时被自动排除(由 CLI 管理,不归团队仓库) + ## 更新 ```bash diff --git a/agents/teamai-recall.md b/agents/teamai-recall.md new file mode 100644 index 0000000..4a4bac2 --- /dev/null +++ b/agents/teamai-recall.md @@ -0,0 +1,93 @@ +--- +name: teamai-recall +description: Search the team knowledge base (skills + learnings + docs + rules) and return a compact, structured summary with doc_ids — instead of dumping full knowledge content into the main conversation. Invoke this BEFORE any task involving code changes, troubleshooting, or design. +tools: Bash, Read, Grep, Glob +--- + +# teamai-recall + +You are a knowledge retrieval agent for the **teamai** ecosystem. Your sole +job is to search the local team knowledge base and return a **compact** +structured summary to the main conversation. The main conversation will +delegate tasks to you so its own context window is not polluted by raw +knowledge content. + +## When you are invoked + +The main conversation invokes you with a **natural language task description** +as input (e.g. "fix flaky integration tests", "design retry policy for +upstream API"). Treat this as your query. + +## What you must do — step by step + +### Step 1 — Read the codebase manifest (optional but preferred) + +If `~/.teamai/docs/codebase.md` exists, read it first. It lists the team's +repositories and their purposes. Extract a one-sentence repo-list summary +to prepend to your final output. If the file does not exist, **silently +skip** this step — never error out. + +### Step 2 — Extract keywords from the task description + +Pick 3–6 high-signal keywords from the user query. Strip filler words +("the", "how", "please"). Mix English and Chinese terms when both appear. + +### Step 3 — Run the teamai recall command + +Execute: + +```bash +teamai recall " ..." +``` + +This searches all four knowledge categories (`skills`, `learnings`, +`docs`, `rules`) via the local search index. Capture the full output. + +If the command fails, knowledge base is empty, or returns zero hits, +emit a single line `No relevant team knowledge found for: ` and +stop. + +### Step 4 — Read the top hits + +For each hit returned by `teamai recall`, read the source file directly +(use `Read`) and condense each into **one or two sentences**. Cap your +total summary at ~1500 characters. Drop hits that on closer inspection +are clearly off-topic. + +### Step 5 — Emit a structured response + +Return your output in **this exact format** to the main conversation: + +``` +## Team Knowledge Recall + +> Repos: + +1. **[] ** — + + Confidence: + +2. **[] ** — + + Confidence: + +... + + +``` + +Where: +- `` is one of `skills` / `learnings` / `docs` / `rules` +- `` is the filename without extension (e.g. `api-timeout-fix`) +- The trailing HTML comment **must** list every doc_id you returned — + later phases (Phase 3 Stop hook) will parse this from the conversation + transcript. + +## Hard rules + +- **Do not** copy entire file contents into your response. Summarize. +- **Do not** call `teamai recall` more than 3 times in one invocation. +- **Do not** invoke other subagents. +- If `teamai` CLI is not on PATH, return `teamai CLI not available` and stop. +- Output total ≤ ~2000 characters. The whole point of using a subagent is + to keep the main conversation's context lean. diff --git a/examples/ci/README.md b/examples/ci/README.md new file mode 100644 index 0000000..536960a --- /dev/null +++ b/examples/ci/README.md @@ -0,0 +1,43 @@ +# CI 调度示例说明 + +本目录提供两个 CI 调度示例,用于定期自动同步团队 codebase 摘要。 + +## 文件说明 + +| 文件 | 用途 | +|------|------| +| `github-actions-teamai-sync.yml` | GitHub Actions 示例,复制到 `.github/workflows/teamai-sync.yml` 启用 | +| `coding-ci-teamai-sync.yaml` | Coding CI 示例,复制到 `.coding-ci.yaml` 或合并到现有配置 | + +## 使用前提 + +1. **这两个文件不会自动启用**,需要团队手动复制到对应位置。 +2. 触发频率建议每日一次(示例中为 UTC 02:17)。 +3. 必须配置好对应 secret: + - GitHub Actions:`TEAMAI_SYNC_TOKEN`(需要 repo 读写权限) + - Coding CI:`TAI_PAT_TOKEN`(同上) +4. `.teamai/repo-whitelist.yaml` 必须存在且至少包含一个 repo entry。 + +## 增量模式说明 + +示例中使用了 `--incremental` 标志: + +- **首次运行**:缓存目录不存在,自动降级为全量 `shallow clone`,速度同初次导入。 +- **后续运行**:检测到缓存 + `LAST_SYNC` 存在,执行 `fetch + reset`,仅拉取增量,速度显著提升。 +- **fetch 失败**:自动 fallback 到全量 clone,不阻塞流程。 + +## 产物提交 + +同步完成后,CI 示例会自动将以下文件 commit & push 回主仓库: + +- `docs/team-codebase/` — 各仓库 codebase 摘要及聚合索引 +- `.teamai/domains.yaml` — 域归属记录 +- `.teamai/domains.history.jsonl` — 域操作历史(含漂移检测记录) + +## codebase lint 示例(`codebase-lint.yml`) + +`codebase-lint.yml` 对 `docs/team-codebase/` 与 `.teamai/` 产物做全局一致性检查: + +- **触发条件**:PR 修改 codebase 相关文件时、每日 04:37 UTC 定时、手动触发 +- **检查内容**:锚点未闭合、孤儿 md、source 失效、计数不一致、stale 等 12 类问题 +- **退出码**:有 `high` 级问题时非零退出,可直接拦截 PR 合入;报告以 artifact 形式上传 diff --git a/examples/ci/codebase-lint.yml b/examples/ci/codebase-lint.yml new file mode 100644 index 0000000..2c65ed4 --- /dev/null +++ b/examples/ci/codebase-lint.yml @@ -0,0 +1,27 @@ +name: Team Codebase Lint +on: + pull_request: + paths: + - 'docs/team-codebase/**' + - '.teamai/**' + schedule: + - cron: '37 4 * * *' + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: npm install -g teamai-cli + - run: teamai codebase --lint --severity high --json > lint-report.json + continue-on-error: false + - name: Upload report + if: always() + uses: actions/upload-artifact@v4 + with: + name: codebase-lint-report + path: lint-report.json diff --git a/examples/ci/coding-ci-teamai-sync.yaml b/examples/ci/coding-ci-teamai-sync.yaml new file mode 100644 index 0000000..d411547 --- /dev/null +++ b/examples/ci/coding-ci-teamai-sync.yaml @@ -0,0 +1,18 @@ +# 复制到 .coding-ci.yaml 或合并到现有配置 +schedule: + - cron: '17 2 * * *' + job: teamai-sync + +jobs: + teamai-sync: + runs-on: docker + image: node:20 + steps: + - checkout + - run: npm install -g teamai-cli + - run: teamai import --from-repo-list .teamai/repo-whitelist.yaml --incremental + env: + TAI_PAT_TOKEN: ${{ secrets.TAI_PAT_TOKEN }} + - run: | + git add docs/team-codebase .teamai/domains.yaml .teamai/domains.history.jsonl + git diff --cached --quiet || (git commit -m "chore(team-codebase): scheduled sync" && git push) diff --git a/examples/ci/github-actions-teamai-sync.yml b/examples/ci/github-actions-teamai-sync.yml new file mode 100644 index 0000000..1518d49 --- /dev/null +++ b/examples/ci/github-actions-teamai-sync.yml @@ -0,0 +1,26 @@ +# 复制到 .github/workflows/teamai-sync.yml 启用 +name: Team Codebase Sync +on: + schedule: + - cron: '17 2 * * *' # 每日 02:17 UTC + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: npm install -g teamai-cli + - run: teamai import --from-repo-list .teamai/repo-whitelist.yaml --incremental + env: + GITHUB_TOKEN: ${{ secrets.TEAMAI_SYNC_TOKEN }} + - name: Commit & push if changed + run: | + git config user.name "teamai-sync" + git config user.email "teamai-sync@users.noreply.github.com" + git add docs/team-codebase .teamai/domains.yaml .teamai/domains.history.jsonl + git diff --cached --quiet || git commit -m "chore(team-codebase): scheduled sync" + git push diff --git a/package-lock.json b/package-lock.json index 0244452..ab4461f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,261 +1,547 @@ { "name": "teamai-cli", "version": "0.16.6", - "lockfileVersion": 1, + "lockfileVersion": 3, "requires": true, - "dependencies": { - "@ampproject/remapping": { + "packages": { + "": { + "name": "teamai-cli", + "version": "0.16.6", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^12.1.0", + "fs-extra": "^11.2.0", + "gray-matter": "^4.0.3", + "ora": "^8.1.0", + "simple-git": "^3.27.0", + "smol-toml": "^1.3.1", + "yaml": "^2.6.0", + "zod": "^3.24.0" + }, + "bin": { + "teamai": "dist/index.js" + }, + "devDependencies": { + "@types/fs-extra": "^11.0.4", + "@types/node": "^20.17.0", + "@vitest/coverage-v8": "^2.1.9", + "standard-version": "^9.5.0", + "tsup": "^8.3.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://mirrors.tencent.com/npm/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, - "requires": { + "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" } }, - "@babel/code-frame": { + "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://mirrors.tencent.com/npm/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, - "requires": { + "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" } }, - "@babel/helper-string-parser": { + "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://mirrors.tencent.com/npm/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true + "dev": true, + "engines": { + "node": ">=6.9.0" + } }, - "@babel/helper-validator-identifier": { + "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", "resolved": "https://mirrors.tencent.com/npm/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true + "dev": true, + "engines": { + "node": ">=6.9.0" + } }, - "@babel/parser": { + "node_modules/@babel/parser": { "version": "7.29.2", "resolved": "https://mirrors.tencent.com/npm/@babel/parser/-/parser-7.29.2.tgz", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, - "requires": { + "dependencies": { "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" } }, - "@babel/types": { + "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://mirrors.tencent.com/npm/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, - "requires": { + "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" } }, - "@bcoe/v8-coverage": { + "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://mirrors.tencent.com/npm/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "@esbuild/aix-ppc64": { + "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/android-arm": { + "node_modules/@esbuild/android-arm": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/android-arm/-/android-arm-0.27.3.tgz", "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/android-arm64": { + "node_modules/@esbuild/android-arm64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/android-x64": { + "node_modules/@esbuild/android-x64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/android-x64/-/android-x64-0.27.3.tgz", "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/darwin-arm64": { + "node_modules/@esbuild/darwin-arm64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/darwin-x64": { + "node_modules/@esbuild/darwin-x64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/freebsd-arm64": { + "node_modules/@esbuild/freebsd-arm64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/freebsd-x64": { + "node_modules/@esbuild/freebsd-x64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/linux-arm": { + "node_modules/@esbuild/linux-arm": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/linux-arm64": { + "node_modules/@esbuild/linux-arm64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/linux-ia32": { + "node_modules/@esbuild/linux-ia32": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/linux-loong64": { + "node_modules/@esbuild/linux-loong64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/linux-mips64el": { + "node_modules/@esbuild/linux-mips64el": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/linux-ppc64": { + "node_modules/@esbuild/linux-ppc64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/linux-riscv64": { + "node_modules/@esbuild/linux-riscv64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/linux-s390x": { + "node_modules/@esbuild/linux-s390x": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/linux-x64": { + "node_modules/@esbuild/linux-x64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/netbsd-arm64": { + "node_modules/@esbuild/netbsd-arm64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/netbsd-x64": { + "node_modules/@esbuild/netbsd-x64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/openbsd-arm64": { + "node_modules/@esbuild/openbsd-arm64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/openbsd-x64": { + "node_modules/@esbuild/openbsd-x64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/openharmony-arm64": { + "node_modules/@esbuild/openharmony-arm64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/sunos-x64": { + "node_modules/@esbuild/sunos-x64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/win32-arm64": { + "node_modules/@esbuild/win32-arm64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/win32-ia32": { + "node_modules/@esbuild/win32-ia32": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "@esbuild/win32-x64": { + "node_modules/@esbuild/win32-x64": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "@hutson/parse-repository-url": { + "node_modules/@hutson/parse-repository-url": { "version": "3.0.2", "resolved": "https://mirrors.tencent.com/npm/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz", "integrity": "sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==", - "dev": true + "dev": true, + "engines": { + "node": ">=6.9.0" + } }, - "@isaacs/cliui": { + "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://mirrors.tencent.com/npm/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, - "requires": { + "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", @@ -263,339 +549,509 @@ "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "emoji-regex": { - "version": "9.2.2", - "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "string-width": { - "version": "5.1.2", - "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "requires": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - } - }, - "strip-ansi-cjs": { - "version": "npm:strip-ansi@6.0.1", - "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" } }, - "@istanbuljs/schema": { + "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://mirrors.tencent.com/npm/@istanbuljs/schema/-/schema-0.1.3.tgz", "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "@jridgewell/gen-mapping": { + "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://mirrors.tencent.com/npm/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, - "requires": { + "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, - "@jridgewell/resolve-uri": { + "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://mirrors.tencent.com/npm/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true + "dev": true, + "engines": { + "node": ">=6.0.0" + } }, - "@jridgewell/sourcemap-codec": { + "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://mirrors.tencent.com/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true }, - "@jridgewell/trace-mapping": { + "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://mirrors.tencent.com/npm/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, - "requires": { + "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "@kwsites/file-exists": { + "node_modules/@kwsites/file-exists": { "version": "1.1.1", "resolved": "https://mirrors.tencent.com/npm/@kwsites/file-exists/-/file-exists-1.1.1.tgz", "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", - "requires": { + "dependencies": { "debug": "^4.1.1" } }, - "@kwsites/promise-deferred": { + "node_modules/@kwsites/promise-deferred": { "version": "1.1.1", "resolved": "https://mirrors.tencent.com/npm/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==" }, - "@pkgjs/parseargs": { + "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://mirrors.tencent.com/npm/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, - "optional": true + "optional": true, + "engines": { + "node": ">=14" + } }, - "@rollup/rollup-android-arm-eabi": { + "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "android" + ] }, - "@rollup/rollup-android-arm64": { + "node_modules/@rollup/rollup-android-arm64": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "android" + ] }, - "@rollup/rollup-darwin-arm64": { + "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "darwin" + ] }, - "@rollup/rollup-darwin-x64": { + "node_modules/@rollup/rollup-darwin-x64": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "darwin" + ] }, - "@rollup/rollup-freebsd-arm64": { + "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "freebsd" + ] }, - "@rollup/rollup-freebsd-x64": { + "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "freebsd" + ] }, - "@rollup/rollup-linux-arm-gnueabihf": { + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-linux-arm-musleabihf": { + "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-linux-arm64-gnu": { + "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-linux-arm64-musl": { + "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-linux-loong64-gnu": { + "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-linux-loong64-musl": { + "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-linux-ppc64-gnu": { + "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-linux-ppc64-musl": { + "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-linux-riscv64-gnu": { + "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-linux-riscv64-musl": { + "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-linux-s390x-gnu": { + "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-linux-x64-gnu": { + "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-linux-x64-musl": { + "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "@rollup/rollup-openbsd-x64": { + "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "openbsd" + ] }, - "@rollup/rollup-openharmony-arm64": { + "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "openharmony" + ] }, - "@rollup/rollup-win32-arm64-msvc": { + "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "win32" + ] }, - "@rollup/rollup-win32-ia32-msvc": { + "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "win32" + ] }, - "@rollup/rollup-win32-x64-gnu": { + "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "win32" + ] }, - "@rollup/rollup-win32-x64-msvc": { + "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], "dev": true, - "optional": true + "optional": true, + "os": [ + "win32" + ] }, - "@simple-git/args-pathspec": { + "node_modules/@simple-git/args-pathspec": { "version": "1.0.2", "resolved": "https://mirrors.tencent.com/npm/@simple-git/args-pathspec/-/args-pathspec-1.0.2.tgz", "integrity": "sha512-nEFVejViHUoL8wU8GTcwqrvqfUG40S5ts6S4fr1u1Ki5CklXlRDYThPVA/qurTmCYFGnaX3XpVUmICLHdvhLaA==" }, - "@simple-git/argv-parser": { + "node_modules/@simple-git/argv-parser": { "version": "1.0.3", "resolved": "https://mirrors.tencent.com/npm/@simple-git/argv-parser/-/argv-parser-1.0.3.tgz", "integrity": "sha512-NMKv9sJcSN2VvnPT9Ja7eKfGy8Q8mMFLwPTCcuZMtv3+mYcLIZflg31S/tp2XCCyiY7YAx6cgBHQ0fwA2fWHpQ==", - "requires": { + "dependencies": { "@simple-git/args-pathspec": "^1.0.2" } }, - "@types/estree": { + "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://mirrors.tencent.com/npm/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, - "@types/fs-extra": { + "node_modules/@types/fs-extra": { "version": "11.0.4", "resolved": "https://mirrors.tencent.com/npm/@types/fs-extra/-/fs-extra-11.0.4.tgz", "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", "dev": true, - "requires": { + "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, - "@types/jsonfile": { + "node_modules/@types/jsonfile": { "version": "6.1.4", "resolved": "https://mirrors.tencent.com/npm/@types/jsonfile/-/jsonfile-6.1.4.tgz", "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", "dev": true, - "requires": { + "dependencies": { "@types/node": "*" } }, - "@types/minimist": { + "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://mirrors.tencent.com/npm/@types/minimist/-/minimist-1.2.5.tgz", "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true }, - "@types/node": { + "node_modules/@types/node": { "version": "20.19.37", "resolved": "https://mirrors.tencent.com/npm/@types/node/-/node-20.19.37.tgz", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, - "requires": { + "dependencies": { "undici-types": "~6.21.0" } }, - "@types/normalize-package-data": { + "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://mirrors.tencent.com/npm/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, - "@vitest/coverage-v8": { + "node_modules/@vitest/coverage-v8": { "version": "2.1.9", "resolved": "https://mirrors.tencent.com/npm/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", "dev": true, - "requires": { + "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", "debug": "^4.3.7", @@ -608,392 +1064,526 @@ "std-env": "^3.8.0", "test-exclude": "^7.0.1", "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "@vitest/expect": { + "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://mirrors.tencent.com/npm/@vitest/expect/-/expect-2.1.9.tgz", "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", "dev": true, - "requires": { + "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "@vitest/mocker": { + "node_modules/@vitest/mocker": { "version": "2.1.9", "resolved": "https://mirrors.tencent.com/npm/@vitest/mocker/-/mocker-2.1.9.tgz", "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", "dev": true, - "requires": { + "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "@vitest/pretty-format": { + "node_modules/@vitest/pretty-format": { "version": "2.1.9", "resolved": "https://mirrors.tencent.com/npm/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", "dev": true, - "requires": { + "dependencies": { "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "@vitest/runner": { + "node_modules/@vitest/runner": { "version": "2.1.9", "resolved": "https://mirrors.tencent.com/npm/@vitest/runner/-/runner-2.1.9.tgz", "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", "dev": true, - "requires": { + "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" }, - "dependencies": { - "pathe": { - "version": "1.1.2", - "resolved": "https://mirrors.tencent.com/npm/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true - } + "funding": { + "url": "https://opencollective.com/vitest" } }, - "@vitest/snapshot": { + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://mirrors.tencent.com/npm/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/@vitest/snapshot": { "version": "2.1.9", "resolved": "https://mirrors.tencent.com/npm/@vitest/snapshot/-/snapshot-2.1.9.tgz", "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", "dev": true, - "requires": { + "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" }, - "dependencies": { - "pathe": { - "version": "1.1.2", - "resolved": "https://mirrors.tencent.com/npm/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true - } + "funding": { + "url": "https://opencollective.com/vitest" } }, - "@vitest/spy": { + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://mirrors.tencent.com/npm/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/@vitest/spy": { "version": "2.1.9", "resolved": "https://mirrors.tencent.com/npm/@vitest/spy/-/spy-2.1.9.tgz", "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", "dev": true, - "requires": { + "dependencies": { "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "@vitest/utils": { + "node_modules/@vitest/utils": { "version": "2.1.9", "resolved": "https://mirrors.tencent.com/npm/@vitest/utils/-/utils-2.1.9.tgz", "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", "dev": true, - "requires": { + "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "JSONStream": { - "version": "1.3.5", - "resolved": "https://mirrors.tencent.com/npm/JSONStream/-/JSONStream-1.3.5.tgz", - "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", - "dev": true, - "requires": { - "jsonparse": "^1.2.0", - "through": ">=2.2.7 <3" - } - }, - "acorn": { + "node_modules/acorn": { "version": "8.16.0", "resolved": "https://mirrors.tencent.com/npm/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } }, - "add-stream": { + "node_modules/add-stream": { "version": "1.0.0", "resolved": "https://mirrors.tencent.com/npm/add-stream/-/add-stream-1.0.0.tgz", "integrity": "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==", "dev": true }, - "ansi-regex": { + "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } }, - "ansi-styles": { + "node_modules/ansi-styles": { "version": "6.2.3", "resolved": "https://mirrors.tencent.com/npm/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } }, - "any-promise": { + "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://mirrors.tencent.com/npm/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", "dev": true }, - "argparse": { + "node_modules/argparse": { "version": "1.0.10", "resolved": "https://mirrors.tencent.com/npm/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "requires": { + "dependencies": { "sprintf-js": "~1.0.2" } }, - "array-ify": { + "node_modules/array-ify": { "version": "1.0.0", "resolved": "https://mirrors.tencent.com/npm/array-ify/-/array-ify-1.0.0.tgz", "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", "dev": true }, - "arrify": { + "node_modules/arrify": { "version": "1.0.1", "resolved": "https://mirrors.tencent.com/npm/arrify/-/arrify-1.0.1.tgz", "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "assertion-error": { + "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://mirrors.tencent.com/npm/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true + "dev": true, + "engines": { + "node": ">=12" + } }, - "balanced-match": { + "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://mirrors.tencent.com/npm/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "brace-expansion": { + "node_modules/brace-expansion": { "version": "2.0.3", "resolved": "https://mirrors.tencent.com/npm/brace-expansion/-/brace-expansion-2.0.3.tgz", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, - "requires": { + "dependencies": { "balanced-match": "^1.0.0" } }, - "buffer-from": { + "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://mirrors.tencent.com/npm/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "bundle-require": { + "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://mirrors.tencent.com/npm/bundle-require/-/bundle-require-5.1.0.tgz", "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", "dev": true, - "requires": { + "dependencies": { "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" } }, - "cac": { + "node_modules/cac": { "version": "6.7.14", "resolved": "https://mirrors.tencent.com/npm/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "camelcase": { + "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://mirrors.tencent.com/npm/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true + "dev": true, + "engines": { + "node": ">=6" + } }, - "camelcase-keys": { + "node_modules/camelcase-keys": { "version": "6.2.2", "resolved": "https://mirrors.tencent.com/npm/camelcase-keys/-/camelcase-keys-6.2.2.tgz", "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", "dev": true, - "requires": { + "dependencies": { "camelcase": "^5.3.1", "map-obj": "^4.0.0", "quick-lru": "^4.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "chai": { + "node_modules/chai": { "version": "5.3.3", "resolved": "https://mirrors.tencent.com/npm/chai/-/chai-5.3.3.tgz", "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, - "requires": { + "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" } }, - "chalk": { + "node_modules/chalk": { "version": "5.6.2", "resolved": "https://mirrors.tencent.com/npm/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } }, - "check-error": { + "node_modules/check-error": { "version": "2.1.3", "resolved": "https://mirrors.tencent.com/npm/check-error/-/check-error-2.1.3.tgz", "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true + "dev": true, + "engines": { + "node": ">= 16" + } }, - "chokidar": { + "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://mirrors.tencent.com/npm/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, - "requires": { + "dependencies": { "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "cli-cursor": { + "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://mirrors.tencent.com/npm/cli-cursor/-/cli-cursor-5.0.0.tgz", "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "requires": { + "dependencies": { "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "cli-spinners": { + "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://mirrors.tencent.com/npm/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==" + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "cliui": { + "node_modules/cliui": { "version": "7.0.4", "resolved": "https://mirrors.tencent.com/npm/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, - "requires": { + "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://mirrors.tencent.com/npm/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://mirrors.tencent.com/npm/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://mirrors.tencent.com/npm/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - } + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, - "color-convert": { + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://mirrors.tencent.com/npm/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://mirrors.tencent.com/npm/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "requires": { + "dependencies": { "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, - "color-name": { + "node_modules/color-name": { "version": "1.1.4", "resolved": "https://mirrors.tencent.com/npm/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "commander": { + "node_modules/commander": { "version": "12.1.0", "resolved": "https://mirrors.tencent.com/npm/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==" + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "engines": { + "node": ">=18" + } }, - "compare-func": { + "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://mirrors.tencent.com/npm/compare-func/-/compare-func-2.0.0.tgz", "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", "dev": true, - "requires": { + "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, - "concat-map": { + "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://mirrors.tencent.com/npm/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, - "concat-stream": { + "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://mirrors.tencent.com/npm/concat-stream/-/concat-stream-2.0.0.tgz", "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", "dev": true, - "requires": { + "engines": [ + "node >= 6.0" + ], + "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, - "confbox": { + "node_modules/confbox": { "version": "0.1.8", "resolved": "https://mirrors.tencent.com/npm/confbox/-/confbox-0.1.8.tgz", "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "dev": true }, - "consola": { + "node_modules/consola": { "version": "3.4.2", "resolved": "https://mirrors.tencent.com/npm/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true + "dev": true, + "engines": { + "node": "^14.18.0 || >=16.10.0" + } }, - "conventional-changelog": { + "node_modules/conventional-changelog": { "version": "3.1.25", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog/-/conventional-changelog-3.1.25.tgz", "integrity": "sha512-ryhi3fd1mKf3fSjbLXOfK2D06YwKNic1nC9mWqybBHdObPd8KJ2vjaXZfYj1U23t+V8T8n0d7gwnc9XbIdFbyQ==", "dev": true, - "requires": { + "dependencies": { "conventional-changelog-angular": "^5.0.12", "conventional-changelog-atom": "^2.0.8", "conventional-changelog-codemirror": "^2.0.8", @@ -1005,59 +1595,74 @@ "conventional-changelog-jquery": "^3.0.11", "conventional-changelog-jshint": "^2.0.9", "conventional-changelog-preset-loader": "^2.3.4" + }, + "engines": { + "node": ">=10" } }, - "conventional-changelog-angular": { + "node_modules/conventional-changelog-angular": { "version": "5.0.13", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-angular/-/conventional-changelog-angular-5.0.13.tgz", "integrity": "sha512-i/gipMxs7s8L/QeuavPF2hLnJgH6pEZAttySB6aiQLWcX3puWDL3ACVmvBhJGxnAy52Qc15ua26BufY6KpmrVA==", "dev": true, - "requires": { + "dependencies": { "compare-func": "^2.0.0", "q": "^1.5.1" + }, + "engines": { + "node": ">=10" } }, - "conventional-changelog-atom": { + "node_modules/conventional-changelog-atom": { "version": "2.0.8", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-atom/-/conventional-changelog-atom-2.0.8.tgz", "integrity": "sha512-xo6v46icsFTK3bb7dY/8m2qvc8sZemRgdqLb/bjpBsH2UyOS8rKNTgcb5025Hri6IpANPApbXMg15QLb1LJpBw==", "dev": true, - "requires": { + "dependencies": { "q": "^1.5.1" + }, + "engines": { + "node": ">=10" } }, - "conventional-changelog-codemirror": { + "node_modules/conventional-changelog-codemirror": { "version": "2.0.8", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-codemirror/-/conventional-changelog-codemirror-2.0.8.tgz", "integrity": "sha512-z5DAsn3uj1Vfp7po3gpt2Boc+Bdwmw2++ZHa5Ak9k0UKsYAO5mH1UBTN0qSCuJZREIhX6WU4E1p3IW2oRCNzQw==", "dev": true, - "requires": { + "dependencies": { "q": "^1.5.1" + }, + "engines": { + "node": ">=10" } }, - "conventional-changelog-config-spec": { + "node_modules/conventional-changelog-config-spec": { "version": "2.1.0", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-config-spec/-/conventional-changelog-config-spec-2.1.0.tgz", "integrity": "sha512-IpVePh16EbbB02V+UA+HQnnPIohgXvJRxHcS5+Uwk4AT5LjzCZJm5sp/yqs5C6KZJ1jMsV4paEV13BN1pvDuxQ==", "dev": true }, - "conventional-changelog-conventionalcommits": { + "node_modules/conventional-changelog-conventionalcommits": { "version": "4.6.3", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-4.6.3.tgz", "integrity": "sha512-LTTQV4fwOM4oLPad317V/QNQ1FY4Hju5qeBIM1uTHbrnCE+Eg4CdRZ3gO2pUeR+tzWdp80M2j3qFFEDWVqOV4g==", "dev": true, - "requires": { + "dependencies": { "compare-func": "^2.0.0", "lodash": "^4.17.15", "q": "^1.5.1" + }, + "engines": { + "node": ">=10" } }, - "conventional-changelog-core": { + "node_modules/conventional-changelog-core": { "version": "4.2.4", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-core/-/conventional-changelog-core-4.2.4.tgz", "integrity": "sha512-gDVS+zVJHE2v4SLc6B0sLsPiloR0ygU7HaDW14aNJE1v4SlqJPILPl/aJC7YdtRE4CybBf8gDwObBvKha8Xlyg==", "dev": true, - "requires": { + "dependencies": { "add-stream": "^1.0.0", "conventional-changelog-writer": "^5.0.0", "conventional-commits-parser": "^3.2.0", @@ -1072,66 +1677,87 @@ "read-pkg": "^3.0.0", "read-pkg-up": "^3.0.0", "through2": "^4.0.0" + }, + "engines": { + "node": ">=10" } }, - "conventional-changelog-ember": { + "node_modules/conventional-changelog-ember": { "version": "2.0.9", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-ember/-/conventional-changelog-ember-2.0.9.tgz", "integrity": "sha512-ulzIReoZEvZCBDhcNYfDIsLTHzYHc7awh+eI44ZtV5cx6LVxLlVtEmcO+2/kGIHGtw+qVabJYjdI5cJOQgXh1A==", "dev": true, - "requires": { + "dependencies": { "q": "^1.5.1" + }, + "engines": { + "node": ">=10" } }, - "conventional-changelog-eslint": { + "node_modules/conventional-changelog-eslint": { "version": "3.0.9", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-eslint/-/conventional-changelog-eslint-3.0.9.tgz", "integrity": "sha512-6NpUCMgU8qmWmyAMSZO5NrRd7rTgErjrm4VASam2u5jrZS0n38V7Y9CzTtLT2qwz5xEChDR4BduoWIr8TfwvXA==", "dev": true, - "requires": { + "dependencies": { "q": "^1.5.1" + }, + "engines": { + "node": ">=10" } }, - "conventional-changelog-express": { + "node_modules/conventional-changelog-express": { "version": "2.0.6", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-express/-/conventional-changelog-express-2.0.6.tgz", "integrity": "sha512-SDez2f3iVJw6V563O3pRtNwXtQaSmEfTCaTBPCqn0oG0mfkq0rX4hHBq5P7De2MncoRixrALj3u3oQsNK+Q0pQ==", "dev": true, - "requires": { + "dependencies": { "q": "^1.5.1" + }, + "engines": { + "node": ">=10" } }, - "conventional-changelog-jquery": { + "node_modules/conventional-changelog-jquery": { "version": "3.0.11", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-jquery/-/conventional-changelog-jquery-3.0.11.tgz", "integrity": "sha512-x8AWz5/Td55F7+o/9LQ6cQIPwrCjfJQ5Zmfqi8thwUEKHstEn4kTIofXub7plf1xvFA2TqhZlq7fy5OmV6BOMw==", "dev": true, - "requires": { + "dependencies": { "q": "^1.5.1" + }, + "engines": { + "node": ">=10" } }, - "conventional-changelog-jshint": { + "node_modules/conventional-changelog-jshint": { "version": "2.0.9", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-jshint/-/conventional-changelog-jshint-2.0.9.tgz", "integrity": "sha512-wMLdaIzq6TNnMHMy31hql02OEQ8nCQfExw1SE0hYL5KvU+JCTuPaDO+7JiogGT2gJAxiUGATdtYYfh+nT+6riA==", "dev": true, - "requires": { + "dependencies": { "compare-func": "^2.0.0", "q": "^1.5.1" + }, + "engines": { + "node": ">=10" } }, - "conventional-changelog-preset-loader": { + "node_modules/conventional-changelog-preset-loader": { "version": "2.3.4", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-preset-loader/-/conventional-changelog-preset-loader-2.3.4.tgz", "integrity": "sha512-GEKRWkrSAZeTq5+YjUZOYxdHq+ci4dNwHvpaBC3+ENalzFWuCWa9EZXSuZBpkr72sMdKB+1fyDV4takK1Lf58g==", - "dev": true + "dev": true, + "engines": { + "node": ">=10" + } }, - "conventional-changelog-writer": { + "node_modules/conventional-changelog-writer": { "version": "5.0.1", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog-writer/-/conventional-changelog-writer-5.0.1.tgz", "integrity": "sha512-5WsuKUfxW7suLblAbFnxAcrvf6r+0b7GvNaWUwUIk0bXMnENP/PEieGKVUQrjPqwPT4o3EPAASBXiY6iHooLOQ==", "dev": true, - "requires": { + "dependencies": { "conventional-commits-filter": "^2.0.7", "dateformat": "^3.0.0", "handlebars": "^4.7.7", @@ -1142,45 +1768,61 @@ "split": "^1.0.0", "through2": "^4.0.0" }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://mirrors.tencent.com/npm/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } + "bin": { + "conventional-changelog-writer": "cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/conventional-changelog-writer/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://mirrors.tencent.com/npm/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" } }, - "conventional-commits-filter": { + "node_modules/conventional-commits-filter": { "version": "2.0.7", "resolved": "https://mirrors.tencent.com/npm/conventional-commits-filter/-/conventional-commits-filter-2.0.7.tgz", "integrity": "sha512-ASS9SamOP4TbCClsRHxIHXRfcGCnIoQqkvAzCSbZzTFLfcTqJVugB0agRgsEELsqaeWgsXv513eS116wnlSSPA==", "dev": true, - "requires": { + "dependencies": { "lodash.ismatch": "^4.4.0", "modify-values": "^1.0.0" + }, + "engines": { + "node": ">=10" } }, - "conventional-commits-parser": { + "node_modules/conventional-commits-parser": { "version": "3.2.4", "resolved": "https://mirrors.tencent.com/npm/conventional-commits-parser/-/conventional-commits-parser-3.2.4.tgz", "integrity": "sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==", "dev": true, - "requires": { - "JSONStream": "^1.0.4", + "dependencies": { "is-text-path": "^1.0.1", + "JSONStream": "^1.0.4", "lodash": "^4.17.15", "meow": "^8.0.0", "split2": "^3.0.0", "through2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.js" + }, + "engines": { + "node": ">=10" } }, - "conventional-recommended-bump": { + "node_modules/conventional-recommended-bump": { "version": "6.1.0", "resolved": "https://mirrors.tencent.com/npm/conventional-recommended-bump/-/conventional-recommended-bump-6.1.0.tgz", "integrity": "sha512-uiApbSiNGM/kkdL9GTOLAqC4hbptObFo4wW2QRyHsKciGAfQuLU1ShZ1BIVI/+K2BE/W1AWYQMCXAsv4dyKPaw==", "dev": true, - "requires": { + "dependencies": { "concat-stream": "^2.0.0", "conventional-changelog-preset-loader": "^2.3.4", "conventional-commits-filter": "^2.0.7", @@ -1189,193 +1831,261 @@ "git-semver-tags": "^4.1.1", "meow": "^8.0.0", "q": "^1.5.1" + }, + "bin": { + "conventional-recommended-bump": "cli.js" + }, + "engines": { + "node": ">=10" } }, - "core-util-is": { + "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://mirrors.tencent.com/npm/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, - "cross-spawn": { + "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://mirrors.tencent.com/npm/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, - "requires": { + "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" } }, - "dargs": { + "node_modules/dargs": { "version": "7.0.0", "resolved": "https://mirrors.tencent.com/npm/dargs/-/dargs-7.0.0.tgz", "integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "dateformat": { + "node_modules/dateformat": { "version": "3.0.3", "resolved": "https://mirrors.tencent.com/npm/dateformat/-/dateformat-3.0.3.tgz", "integrity": "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==", - "dev": true + "dev": true, + "engines": { + "node": "*" + } }, - "debug": { + "node_modules/debug": { "version": "4.4.3", "resolved": "https://mirrors.tencent.com/npm/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "requires": { + "dependencies": { "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "decamelize": { + "node_modules/decamelize": { "version": "1.2.0", "resolved": "https://mirrors.tencent.com/npm/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "decamelize-keys": { + "node_modules/decamelize-keys": { "version": "1.1.1", "resolved": "https://mirrors.tencent.com/npm/decamelize-keys/-/decamelize-keys-1.1.1.tgz", "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", "dev": true, - "requires": { + "dependencies": { "decamelize": "^1.1.0", "map-obj": "^1.0.0" }, - "dependencies": { - "map-obj": { - "version": "1.0.1", - "resolved": "https://mirrors.tencent.com/npm/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", - "dev": true - } + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "engines": { + "node": ">=0.10.0" } }, - "deep-eql": { + "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://mirrors.tencent.com/npm/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true + "dev": true, + "engines": { + "node": ">=6" + } }, - "detect-indent": { + "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://mirrors.tencent.com/npm/detect-indent/-/detect-indent-6.1.0.tgz", "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "detect-newline": { + "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://mirrors.tencent.com/npm/detect-newline/-/detect-newline-3.1.0.tgz", "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "dot-prop": { + "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://mirrors.tencent.com/npm/dot-prop/-/dot-prop-5.3.0.tgz", "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", "dev": true, - "requires": { + "dependencies": { "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" } }, - "dotgitignore": { + "node_modules/dotgitignore": { "version": "2.1.0", "resolved": "https://mirrors.tencent.com/npm/dotgitignore/-/dotgitignore-2.1.0.tgz", "integrity": "sha512-sCm11ak2oY6DglEPpCB8TixLjWAxd3kJTs6UIcSasNYxXdFPV+YKlye92c8H4kKFqV5qYMIh7d+cYecEg0dIkA==", "dev": true, - "requires": { + "dependencies": { "find-up": "^3.0.0", "minimatch": "^3.0.4" }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dotgitignore/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://mirrors.tencent.com/npm/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, "dependencies": { - "brace-expansion": { - "version": "1.1.13", - "resolved": "https://mirrors.tencent.com/npm/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "find-up": { - "version": "3.0.0", - "resolved": "https://mirrors.tencent.com/npm/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "requires": { - "locate-path": "^3.0.0" - } - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://mirrors.tencent.com/npm/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "minimatch": { - "version": "3.1.5", - "resolved": "https://mirrors.tencent.com/npm/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://mirrors.tencent.com/npm/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "requires": { - "p-limit": "^2.0.0" - } - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://mirrors.tencent.com/npm/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true - } + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dotgitignore/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dotgitignore/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dotgitignore/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://mirrors.tencent.com/npm/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dotgitignore/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dotgitignore/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "engines": { + "node": ">=4" } }, - "eastasianwidth": { + "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://mirrors.tencent.com/npm/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, - "emoji-regex": { + "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" }, - "error-ex": { + "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://mirrors.tencent.com/npm/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, - "requires": { + "dependencies": { "is-arrayish": "^0.2.1" } }, - "es-module-lexer": { + "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://mirrors.tencent.com/npm/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true }, - "esbuild": { + "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/esbuild/-/esbuild-0.27.3.tgz", "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, - "requires": { + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", @@ -1404,265 +2114,374 @@ "@esbuild/win32-x64": "0.27.3" } }, - "escalade": { + "node_modules/escalade": { "version": "3.2.0", "resolved": "https://mirrors.tencent.com/npm/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true + "dev": true, + "engines": { + "node": ">=6" + } }, - "escape-string-regexp": { + "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://mirrors.tencent.com/npm/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true + "dev": true, + "engines": { + "node": ">=0.8.0" + } }, - "esprima": { + "node_modules/esprima": { "version": "4.0.1", "resolved": "https://mirrors.tencent.com/npm/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } }, - "estree-walker": { + "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://mirrors.tencent.com/npm/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "requires": { + "dependencies": { "@types/estree": "^1.0.0" } }, - "expect-type": { + "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://mirrors.tencent.com/npm/expect-type/-/expect-type-1.3.0.tgz", "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true + "dev": true, + "engines": { + "node": ">=12.0.0" + } }, - "extend-shallow": { + "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://mirrors.tencent.com/npm/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "requires": { + "dependencies": { "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" } }, - "fdir": { + "node_modules/fdir": { "version": "6.5.0", "resolved": "https://mirrors.tencent.com/npm/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } }, - "figures": { + "node_modules/figures": { "version": "3.2.0", "resolved": "https://mirrors.tencent.com/npm/figures/-/figures-3.2.0.tgz", "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", "dev": true, - "requires": { + "dependencies": { "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "find-up": { + "node_modules/find-up": { "version": "5.0.0", "resolved": "https://mirrors.tencent.com/npm/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "requires": { + "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://mirrors.tencent.com/npm/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "dependencies": { - "locate-path": { - "version": "6.0.0", - "resolved": "https://mirrors.tencent.com/npm/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://mirrors.tencent.com/npm/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://mirrors.tencent.com/npm/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - } + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://mirrors.tencent.com/npm/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://mirrors.tencent.com/npm/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "fix-dts-default-cjs-exports": { + "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://mirrors.tencent.com/npm/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", "dev": true, - "requires": { + "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, - "foreground-child": { + "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://mirrors.tencent.com/npm/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, - "requires": { + "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "fs-extra": { + "node_modules/fs-extra": { "version": "11.3.4", "resolved": "https://mirrors.tencent.com/npm/fs-extra/-/fs-extra-11.3.4.tgz", "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", - "requires": { + "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" } }, - "fsevents": { + "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://mirrors.tencent.com/npm/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "optional": true + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } }, - "function-bind": { + "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://mirrors.tencent.com/npm/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "get-caller-file": { + "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://mirrors.tencent.com/npm/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } }, - "get-east-asian-width": { + "node_modules/get-east-asian-width": { "version": "1.5.0", "resolved": "https://mirrors.tencent.com/npm/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==" + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "get-pkg-repo": { + "node_modules/get-pkg-repo": { "version": "4.2.1", "resolved": "https://mirrors.tencent.com/npm/get-pkg-repo/-/get-pkg-repo-4.2.1.tgz", "integrity": "sha512-2+QbHjFRfGB74v/pYWjd5OhU3TDIC2Gv/YKUTk/tCvAz0pkn/Mz6P3uByuBimLOcPvN2jYdScl3xGFSrx0jEcA==", "dev": true, - "requires": { + "dependencies": { "@hutson/parse-repository-url": "^3.0.0", "hosted-git-info": "^4.0.0", "through2": "^2.0.0", "yargs": "^16.2.0" }, + "bin": { + "get-pkg-repo": "src/cli.js" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-pkg-repo/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, "dependencies": { - "readable-stream": { - "version": "2.3.8", - "resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://mirrors.tencent.com/npm/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } - }, - "through2": { - "version": "2.0.5", - "resolved": "https://mirrors.tencent.com/npm/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, - "requires": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - } - } + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/get-pkg-repo/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/get-pkg-repo/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://mirrors.tencent.com/npm/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/get-pkg-repo/node_modules/through2": { + "version": "2.0.5", + "resolved": "https://mirrors.tencent.com/npm/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" } }, - "git-raw-commits": { + "node_modules/git-raw-commits": { "version": "2.0.11", "resolved": "https://mirrors.tencent.com/npm/git-raw-commits/-/git-raw-commits-2.0.11.tgz", "integrity": "sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==", + "deprecated": "This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.", "dev": true, - "requires": { + "dependencies": { "dargs": "^7.0.0", "lodash": "^4.17.15", "meow": "^8.0.0", "split2": "^3.0.0", "through2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.js" + }, + "engines": { + "node": ">=10" } }, - "git-remote-origin-url": { + "node_modules/git-remote-origin-url": { "version": "2.0.0", "resolved": "https://mirrors.tencent.com/npm/git-remote-origin-url/-/git-remote-origin-url-2.0.0.tgz", "integrity": "sha512-eU+GGrZgccNJcsDH5LkXR3PB9M958hxc7sbA8DFJjrv9j4L2P/eZfKhM+QD6wyzpiv+b1BpK0XrYCxkovtjSLw==", "dev": true, - "requires": { + "dependencies": { "gitconfiglocal": "^1.0.0", "pify": "^2.3.0" + }, + "engines": { + "node": ">=4" } }, - "git-semver-tags": { + "node_modules/git-semver-tags": { "version": "4.1.1", "resolved": "https://mirrors.tencent.com/npm/git-semver-tags/-/git-semver-tags-4.1.1.tgz", "integrity": "sha512-OWyMt5zBe7xFs8vglMmhM9lRQzCWL3WjHtxNNfJTMngGym7pC1kh8sP6jevfydJ6LP3ZvGxfb6ABYgPUM0mtsA==", + "deprecated": "This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.", "dev": true, - "requires": { + "dependencies": { "meow": "^8.0.0", "semver": "^6.0.0" }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://mirrors.tencent.com/npm/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } + "bin": { + "git-semver-tags": "cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/git-semver-tags/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://mirrors.tencent.com/npm/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" } }, - "gitconfiglocal": { + "node_modules/gitconfiglocal": { "version": "1.0.0", "resolved": "https://mirrors.tencent.com/npm/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz", "integrity": "sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ==", "dev": true, - "requires": { + "dependencies": { "ini": "^1.3.2" } }, - "glob": { + "node_modules/glob": { "version": "10.5.0", "resolved": "https://mirrors.tencent.com/npm/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "requires": { + "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", @@ -1670,434 +2489,593 @@ "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://mirrors.tencent.com/npm/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, "dependencies": { - "minimatch": { - "version": "9.0.9", - "resolved": "https://mirrors.tencent.com/npm/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.2" - } - } + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "graceful-fs": { + "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://mirrors.tencent.com/npm/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, - "gray-matter": { + "node_modules/gray-matter": { "version": "4.0.3", "resolved": "https://mirrors.tencent.com/npm/gray-matter/-/gray-matter-4.0.3.tgz", "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "requires": { + "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" } }, - "handlebars": { + "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://mirrors.tencent.com/npm/handlebars/-/handlebars-4.7.9.tgz", "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, - "requires": { + "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", - "uglify-js": "^3.1.4", "wordwrap": "^1.0.0" }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://mirrors.tencent.com/npm/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" } }, - "hard-rejection": { + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://mirrors.tencent.com/npm/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/hard-rejection": { "version": "2.1.0", "resolved": "https://mirrors.tencent.com/npm/hard-rejection/-/hard-rejection-2.1.0.tgz", "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", - "dev": true + "dev": true, + "engines": { + "node": ">=6" + } }, - "has-flag": { + "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://mirrors.tencent.com/npm/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "hasown": { + "node_modules/hasown": { "version": "2.0.2", "resolved": "https://mirrors.tencent.com/npm/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, - "requires": { + "dependencies": { "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "hosted-git-info": { + "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://mirrors.tencent.com/npm/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", "dev": true, - "requires": { + "dependencies": { "lru-cache": "^6.0.0" }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://mirrors.tencent.com/npm/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "dependencies": { - "lru-cache": { - "version": "6.0.0", - "resolved": "https://mirrors.tencent.com/npm/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - } + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" } }, - "html-escaper": { + "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://mirrors.tencent.com/npm/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "indent-string": { + "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://mirrors.tencent.com/npm/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "inherits": { + "node_modules/inherits": { "version": "2.0.4", "resolved": "https://mirrors.tencent.com/npm/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, - "ini": { + "node_modules/ini": { "version": "1.3.8", "resolved": "https://mirrors.tencent.com/npm/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, - "is-arrayish": { + "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://mirrors.tencent.com/npm/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, - "is-core-module": { + "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://mirrors.tencent.com/npm/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, - "requires": { + "dependencies": { "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "is-extendable": { + "node_modules/is-extendable": { "version": "0.1.1", "resolved": "https://mirrors.tencent.com/npm/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==" + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "engines": { + "node": ">=0.10.0" + } }, - "is-fullwidth-code-point": { + "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://mirrors.tencent.com/npm/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "is-interactive": { + "node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://mirrors.tencent.com/npm/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==" + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "is-obj": { + "node_modules/is-obj": { "version": "2.0.0", "resolved": "https://mirrors.tencent.com/npm/is-obj/-/is-obj-2.0.0.tgz", "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "is-plain-obj": { + "node_modules/is-plain-obj": { "version": "1.1.0", "resolved": "https://mirrors.tencent.com/npm/is-plain-obj/-/is-plain-obj-1.1.0.tgz", "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "is-text-path": { + "node_modules/is-text-path": { "version": "1.0.1", "resolved": "https://mirrors.tencent.com/npm/is-text-path/-/is-text-path-1.0.1.tgz", "integrity": "sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==", "dev": true, - "requires": { + "dependencies": { "text-extensions": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" } }, - "is-unicode-supported": { + "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://mirrors.tencent.com/npm/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==" + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "isarray": { + "node_modules/isarray": { "version": "1.0.0", "resolved": "https://mirrors.tencent.com/npm/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true }, - "isexe": { + "node_modules/isexe": { "version": "2.0.0", "resolved": "https://mirrors.tencent.com/npm/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "istanbul-lib-coverage": { + "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://mirrors.tencent.com/npm/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "istanbul-lib-report": { + "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://mirrors.tencent.com/npm/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "requires": { + "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" } }, - "istanbul-lib-source-maps": { + "node_modules/istanbul-lib-source-maps": { "version": "5.0.6", "resolved": "https://mirrors.tencent.com/npm/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, - "requires": { + "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" } }, - "istanbul-reports": { + "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://mirrors.tencent.com/npm/istanbul-reports/-/istanbul-reports-3.2.0.tgz", "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, - "requires": { + "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "jackspeak": { + "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://mirrors.tencent.com/npm/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, - "requires": { - "@isaacs/cliui": "^8.0.2", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, - "joycon": { + "node_modules/joycon": { "version": "3.1.1", "resolved": "https://mirrors.tencent.com/npm/joycon/-/joycon-3.1.1.tgz", "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true + "dev": true, + "engines": { + "node": ">=10" + } }, - "js-tokens": { + "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://mirrors.tencent.com/npm/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true }, - "js-yaml": { + "node_modules/js-yaml": { "version": "3.14.2", "resolved": "https://mirrors.tencent.com/npm/js-yaml/-/js-yaml-3.14.2.tgz", "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "requires": { + "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "json-parse-better-errors": { + "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://mirrors.tencent.com/npm/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "dev": true }, - "json-parse-even-better-errors": { + "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://mirrors.tencent.com/npm/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, - "json-stringify-safe": { + "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://mirrors.tencent.com/npm/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "dev": true }, - "jsonfile": { + "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://mirrors.tencent.com/npm/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "requires": { - "graceful-fs": "^4.1.6", + "dependencies": { "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "jsonparse": { + "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://mirrors.tencent.com/npm/jsonparse/-/jsonparse-1.3.1.tgz", "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true + "dev": true, + "engines": [ + "node >= 0.2.0" + ] + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://mirrors.tencent.com/npm/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } }, - "kind-of": { + "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://mirrors.tencent.com/npm/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "engines": { + "node": ">=0.10.0" + } }, - "lilconfig": { + "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://mirrors.tencent.com/npm/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } }, - "lines-and-columns": { + "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://mirrors.tencent.com/npm/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, - "load-json-file": { + "node_modules/load-json-file": { "version": "4.0.0", "resolved": "https://mirrors.tencent.com/npm/load-json-file/-/load-json-file-4.0.0.tgz", "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", "dev": true, - "requires": { + "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://mirrors.tencent.com/npm/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, "dependencies": { - "parse-json": { - "version": "4.0.0", - "resolved": "https://mirrors.tencent.com/npm/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", - "dev": true, - "requires": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" - } - }, - "pify": { - "version": "3.0.0", - "resolved": "https://mirrors.tencent.com/npm/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true - } + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" } }, - "load-tsconfig": { + "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://mirrors.tencent.com/npm/load-tsconfig/-/load-tsconfig-0.2.5.tgz", "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", - "dev": true + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } }, - "locate-path": { + "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://mirrors.tencent.com/npm/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, - "requires": { + "dependencies": { "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" } }, - "lodash": { + "node_modules/lodash": { "version": "4.18.1", "resolved": "https://mirrors.tencent.com/npm/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true }, - "lodash.ismatch": { + "node_modules/lodash.ismatch": { "version": "4.4.0", "resolved": "https://mirrors.tencent.com/npm/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", "integrity": "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==", "dev": true }, - "log-symbols": { + "node_modules/log-symbols": { "version": "6.0.0", "resolved": "https://mirrors.tencent.com/npm/log-symbols/-/log-symbols-6.0.0.tgz", "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "requires": { + "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" }, - "dependencies": { - "is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://mirrors.tencent.com/npm/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==" - } + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://mirrors.tencent.com/npm/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "loupe": { + "node_modules/loupe": { "version": "3.2.1", "resolved": "https://mirrors.tencent.com/npm/loupe/-/loupe-3.2.1.tgz", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true }, - "lru-cache": { + "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://mirrors.tencent.com/npm/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, - "magic-string": { + "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://mirrors.tencent.com/npm/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, - "requires": { + "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "magicast": { + "node_modules/magicast": { "version": "0.3.5", "resolved": "https://mirrors.tencent.com/npm/magicast/-/magicast-0.3.5.tgz", "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "dev": true, - "requires": { + "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, - "make-dir": { + "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://mirrors.tencent.com/npm/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, - "requires": { + "dependencies": { "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "map-obj": { + "node_modules/map-obj": { "version": "4.3.0", "resolved": "https://mirrors.tencent.com/npm/map-obj/-/map-obj-4.3.0.tgz", "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "meow": { + "node_modules/meow": { "version": "8.1.2", "resolved": "https://mirrors.tencent.com/npm/meow/-/meow-8.1.2.tgz", "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==", "dev": true, - "requires": { + "dependencies": { "@types/minimist": "^1.2.0", "camelcase-keys": "^6.2.2", "decamelize-keys": "^1.1.0", @@ -2110,219 +3088,295 @@ "type-fest": "^0.18.0", "yargs-parser": "^20.2.3" }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://mirrors.tencent.com/npm/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://mirrors.tencent.com/npm/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/meow/node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://mirrors.tencent.com/npm/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, "dependencies": { - "find-up": { - "version": "4.1.0", - "resolved": "https://mirrors.tencent.com/npm/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "hosted-git-info": { - "version": "2.8.9", - "resolved": "https://mirrors.tencent.com/npm/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "dev": true - }, - "read-pkg": { - "version": "5.2.0", - "resolved": "https://mirrors.tencent.com/npm/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", - "dev": true, - "requires": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, - "dependencies": { - "normalize-package-data": { - "version": "2.5.0", - "resolved": "https://mirrors.tencent.com/npm/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, - "requires": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "type-fest": { - "version": "0.6.0", - "resolved": "https://mirrors.tencent.com/npm/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", - "dev": true - } - } - }, - "read-pkg-up": { - "version": "7.0.1", - "resolved": "https://mirrors.tencent.com/npm/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", - "dev": true, - "requires": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "dependencies": { - "type-fest": { - "version": "0.8.1", - "resolved": "https://mirrors.tencent.com/npm/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true - } - } - }, - "semver": { - "version": "5.7.2", - "resolved": "https://mirrors.tencent.com/npm/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true - } + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://mirrors.tencent.com/npm/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://mirrors.tencent.com/npm/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://mirrors.tencent.com/npm/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" } }, - "mimic-function": { + "node_modules/meow/node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://mirrors.tencent.com/npm/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://mirrors.tencent.com/npm/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://mirrors.tencent.com/npm/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==" + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "min-indent": { + "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://mirrors.tencent.com/npm/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true + "dev": true, + "engines": { + "node": ">=4" + } }, - "minimatch": { + "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://mirrors.tencent.com/npm/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "requires": { + "dependencies": { "brace-expansion": "^5.0.5" }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://mirrors.tencent.com/npm/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://mirrors.tencent.com/npm/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, "dependencies": { - "balanced-match": { - "version": "4.0.4", - "resolved": "https://mirrors.tencent.com/npm/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true - }, - "brace-expansion": { - "version": "5.0.5", - "resolved": "https://mirrors.tencent.com/npm/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "requires": { - "balanced-match": "^4.0.2" - } - } + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, - "minimist": { + "node_modules/minimist": { "version": "1.2.8", "resolved": "https://mirrors.tencent.com/npm/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "minimist-options": { + "node_modules/minimist-options": { "version": "4.1.0", "resolved": "https://mirrors.tencent.com/npm/minimist-options/-/minimist-options-4.1.0.tgz", "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", "dev": true, - "requires": { + "dependencies": { "arrify": "^1.0.1", "is-plain-obj": "^1.1.0", "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" } }, - "minipass": { + "node_modules/minipass": { "version": "7.1.3", "resolved": "https://mirrors.tencent.com/npm/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } }, - "mlly": { + "node_modules/mlly": { "version": "1.8.1", "resolved": "https://mirrors.tencent.com/npm/mlly/-/mlly-1.8.1.tgz", "integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==", "dev": true, - "requires": { + "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, - "modify-values": { + "node_modules/modify-values": { "version": "1.0.1", "resolved": "https://mirrors.tencent.com/npm/modify-values/-/modify-values-1.0.1.tgz", "integrity": "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "ms": { + "node_modules/ms": { "version": "2.1.3", "resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "mz": { + "node_modules/mz": { "version": "2.7.0", "resolved": "https://mirrors.tencent.com/npm/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", "dev": true, - "requires": { + "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, - "nanoid": { + "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://mirrors.tencent.com/npm/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } }, - "neo-async": { + "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://mirrors.tencent.com/npm/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, - "normalize-package-data": { + "node_modules/normalize-package-data": { "version": "3.0.3", "resolved": "https://mirrors.tencent.com/npm/normalize-package-data/-/normalize-package-data-3.0.3.tgz", "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", "dev": true, - "requires": { + "dependencies": { "hosted-git-info": "^4.0.1", "is-core-module": "^2.5.0", "semver": "^7.3.4", "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" } }, - "object-assign": { + "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://mirrors.tencent.com/npm/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "onetime": { + "node_modules/onetime": { "version": "7.0.0", "resolved": "https://mirrors.tencent.com/npm/onetime/-/onetime-7.0.0.tgz", "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "requires": { + "dependencies": { "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "ora": { + "node_modules/ora": { "version": "8.2.0", "resolved": "https://mirrors.tencent.com/npm/ora/-/ora-8.2.0.tgz", "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", - "requires": { + "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", @@ -2332,343 +3386,523 @@ "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "p-limit": { + "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://mirrors.tencent.com/npm/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, - "requires": { + "dependencies": { "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "p-locate": { + "node_modules/p-locate": { "version": "4.1.0", "resolved": "https://mirrors.tencent.com/npm/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, - "requires": { + "dependencies": { "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" } }, - "p-try": { + "node_modules/p-try": { "version": "2.2.0", "resolved": "https://mirrors.tencent.com/npm/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true + "dev": true, + "engines": { + "node": ">=6" + } }, - "package-json-from-dist": { + "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://mirrors.tencent.com/npm/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, - "parse-json": { + "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://mirrors.tencent.com/npm/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, - "requires": { + "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "path-exists": { + "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://mirrors.tencent.com/npm/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "path-key": { + "node_modules/path-key": { "version": "3.1.1", "resolved": "https://mirrors.tencent.com/npm/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "path-parse": { + "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://mirrors.tencent.com/npm/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "path-scurry": { + "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://mirrors.tencent.com/npm/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, - "requires": { + "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "path-type": { + "node_modules/path-type": { "version": "3.0.0", "resolved": "https://mirrors.tencent.com/npm/path-type/-/path-type-3.0.0.tgz", "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", "dev": true, - "requires": { + "dependencies": { "pify": "^3.0.0" }, - "dependencies": { - "pify": { - "version": "3.0.0", - "resolved": "https://mirrors.tencent.com/npm/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true - } + "engines": { + "node": ">=4" + } + }, + "node_modules/path-type/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" } }, - "pathe": { + "node_modules/pathe": { "version": "2.0.3", "resolved": "https://mirrors.tencent.com/npm/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, - "pathval": { + "node_modules/pathval": { "version": "2.0.1", "resolved": "https://mirrors.tencent.com/npm/pathval/-/pathval-2.0.1.tgz", "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true + "dev": true, + "engines": { + "node": ">= 14.16" + } }, - "picocolors": { + "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://mirrors.tencent.com/npm/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, - "picomatch": { + "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://mirrors.tencent.com/npm/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, - "pify": { + "node_modules/pify": { "version": "2.3.0", "resolved": "https://mirrors.tencent.com/npm/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "pirates": { + "node_modules/pirates": { "version": "4.0.7", "resolved": "https://mirrors.tencent.com/npm/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true + "dev": true, + "engines": { + "node": ">= 6" + } }, - "pkg-types": { + "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://mirrors.tencent.com/npm/pkg-types/-/pkg-types-1.3.1.tgz", "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", "dev": true, - "requires": { + "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, - "postcss": { + "node_modules/postcss": { "version": "8.5.8", "resolved": "https://mirrors.tencent.com/npm/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, - "requires": { + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" } }, - "postcss-load-config": { + "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://mirrors.tencent.com/npm/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "dev": true, - "requires": { + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "process-nextick-args": { + "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://mirrors.tencent.com/npm/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, - "q": { + "node_modules/q": { "version": "1.5.1", "resolved": "https://mirrors.tencent.com/npm/q/-/q-1.5.1.tgz", "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", - "dev": true + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "dev": true, + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } }, - "quick-lru": { + "node_modules/quick-lru": { "version": "4.0.1", "resolved": "https://mirrors.tencent.com/npm/quick-lru/-/quick-lru-4.0.1.tgz", "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "read-pkg": { + "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://mirrors.tencent.com/npm/read-pkg/-/read-pkg-3.0.0.tgz", "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", "dev": true, - "requires": { + "dependencies": { "load-json-file": "^4.0.0", "normalize-package-data": "^2.3.2", "path-type": "^3.0.0" }, - "dependencies": { - "hosted-git-info": { - "version": "2.8.9", - "resolved": "https://mirrors.tencent.com/npm/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "dev": true - }, - "normalize-package-data": { - "version": "2.5.0", - "resolved": "https://mirrors.tencent.com/npm/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, - "requires": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "semver": { - "version": "5.7.2", - "resolved": "https://mirrors.tencent.com/npm/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true - } + "engines": { + "node": ">=4" } }, - "read-pkg-up": { + "node_modules/read-pkg-up": { "version": "3.0.0", "resolved": "https://mirrors.tencent.com/npm/read-pkg-up/-/read-pkg-up-3.0.0.tgz", "integrity": "sha512-YFzFrVvpC6frF1sz8psoHDBGF7fLPc+llq/8NB43oagqWkx8ar5zYtsTORtOjw9W2RHLpWP+zTWwBvf1bCmcSw==", "dev": true, - "requires": { + "dependencies": { "find-up": "^2.0.0", "read-pkg": "^3.0.0" }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "2.1.0", + "resolved": "https://mirrors.tencent.com/npm/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", + "dev": true, "dependencies": { - "find-up": { - "version": "2.1.0", - "resolved": "https://mirrors.tencent.com/npm/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", - "dev": true, - "requires": { - "locate-path": "^2.0.0" - } - }, - "locate-path": { - "version": "2.0.0", - "resolved": "https://mirrors.tencent.com/npm/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", - "dev": true, - "requires": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - } - }, - "p-limit": { - "version": "1.3.0", - "resolved": "https://mirrors.tencent.com/npm/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "dev": true, - "requires": { - "p-try": "^1.0.0" - } - }, - "p-locate": { - "version": "2.0.0", - "resolved": "https://mirrors.tencent.com/npm/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", - "dev": true, - "requires": { - "p-limit": "^1.1.0" - } - }, - "p-try": { - "version": "1.0.0", - "resolved": "https://mirrors.tencent.com/npm/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", - "dev": true - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://mirrors.tencent.com/npm/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true - } + "locate-path": "^2.0.0" + }, + "engines": { + "node": ">=4" } }, - "readable-stream": { - "version": "3.6.2", - "resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "dependencies": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=4" } }, - "readdirp": { - "version": "4.1.2", - "resolved": "https://mirrors.tencent.com/npm/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "1.3.0", + "resolved": "https://mirrors.tencent.com/npm/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "dependencies": { + "p-try": "^1.0.0" + }, + "engines": { + "node": ">=4" + } }, - "redent": { - "version": "3.0.0", - "resolved": "https://mirrors.tencent.com/npm/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", "dev": true, - "requires": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" + "dependencies": { + "p-limit": "^1.1.0" + }, + "engines": { + "node": ">=4" } }, - "require-directory": { + "node_modules/read-pkg-up/node_modules/p-try": { + "version": "1.0.0", + "resolved": "https://mirrors.tencent.com/npm/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://mirrors.tencent.com/npm/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://mirrors.tencent.com/npm/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://mirrors.tencent.com/npm/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://mirrors.tencent.com/npm/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://mirrors.tencent.com/npm/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "resolve": { + "node_modules/resolve": { "version": "1.22.11", "resolved": "https://mirrors.tencent.com/npm/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, - "requires": { + "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "resolve-from": { + "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://mirrors.tencent.com/npm/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "restore-cursor": { + "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://mirrors.tencent.com/npm/restore-cursor/-/restore-cursor-5.1.0.tgz", "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "requires": { + "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "rollup": { + "node_modules/rollup": { "version": "4.59.0", "resolved": "https://mirrors.tencent.com/npm/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, - "requires": { + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", @@ -2694,148 +3928,206 @@ "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", - "@types/estree": "1.0.8", "fsevents": "~2.3.2" } }, - "safe-buffer": { + "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] }, - "section-matter": { + "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://mirrors.tencent.com/npm/section-matter/-/section-matter-1.0.0.tgz", "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "requires": { + "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" } }, - "semver": { + "node_modules/semver": { "version": "7.7.4", "resolved": "https://mirrors.tencent.com/npm/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } }, - "shebang-command": { + "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://mirrors.tencent.com/npm/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "requires": { + "dependencies": { "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "shebang-regex": { + "node_modules/shebang-regex": { "version": "3.0.0", "resolved": "https://mirrors.tencent.com/npm/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "siginfo": { + "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://mirrors.tencent.com/npm/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, - "signal-exit": { + "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://mirrors.tencent.com/npm/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, - "simple-git": { + "node_modules/simple-git": { "version": "3.35.2", "resolved": "https://mirrors.tencent.com/npm/simple-git/-/simple-git-3.35.2.tgz", "integrity": "sha512-ZMjl06lzTm1EScxEGuM6+mEX+NQd14h/B3x0vWU+YOXAMF8sicyi1K4cjTfj5is+35ChJEHDl1EjypzYFWH2FA==", - "requires": { + "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "@simple-git/args-pathspec": "^1.0.2", "@simple-git/argv-parser": "^1.0.3", "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://mirrors.tencent.com/npm/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" } }, - "source-map": { + "node_modules/source-map": { "version": "0.7.6", "resolved": "https://mirrors.tencent.com/npm/source-map/-/source-map-0.7.6.tgz", "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true + "dev": true, + "engines": { + "node": ">= 12" + } }, - "source-map-js": { + "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://mirrors.tencent.com/npm/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "spdx-correct": { + "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://mirrors.tencent.com/npm/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "dev": true, - "requires": { + "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, - "spdx-exceptions": { + "node_modules/spdx-exceptions": { "version": "2.5.0", "resolved": "https://mirrors.tencent.com/npm/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true }, - "spdx-expression-parse": { + "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://mirrors.tencent.com/npm/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, - "requires": { + "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, - "spdx-license-ids": { + "node_modules/spdx-license-ids": { "version": "3.0.23", "resolved": "https://mirrors.tencent.com/npm/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true }, - "split": { + "node_modules/split": { "version": "1.0.1", "resolved": "https://mirrors.tencent.com/npm/split/-/split-1.0.1.tgz", "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", "dev": true, - "requires": { + "dependencies": { "through": "2" + }, + "engines": { + "node": "*" } }, - "split2": { + "node_modules/split2": { "version": "3.2.2", "resolved": "https://mirrors.tencent.com/npm/split2/-/split2-3.2.2.tgz", "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", "dev": true, - "requires": { + "dependencies": { "readable-stream": "^3.0.0" } }, - "sprintf-js": { + "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://mirrors.tencent.com/npm/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, - "stackback": { + "node_modules/stackback": { "version": "0.0.2", "resolved": "https://mirrors.tencent.com/npm/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, - "standard-version": { + "node_modules/standard-version": { "version": "9.5.0", "resolved": "https://mirrors.tencent.com/npm/standard-version/-/standard-version-9.5.0.tgz", "integrity": "sha512-3zWJ/mmZQsOaO+fOlsa0+QK90pwhNd042qEcw6hKFNoLFs7peGyvPffpEBbK/DSGPbyOvli0mUIFv5A4qTjh2Q==", "dev": true, - "requires": { + "dependencies": { "chalk": "^2.4.2", "conventional-changelog": "3.1.25", "conventional-changelog-config-spec": "2.1.0", @@ -2851,163 +4143,212 @@ "stringify-package": "^1.0.1", "yargs": "^16.0.0" }, + "bin": { + "standard-version": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/standard-version/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://mirrors.tencent.com/npm/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://mirrors.tencent.com/npm/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://mirrors.tencent.com/npm/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://mirrors.tencent.com/npm/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://mirrors.tencent.com/npm/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://mirrors.tencent.com/npm/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://mirrors.tencent.com/npm/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/standard-version/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://mirrors.tencent.com/npm/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/standard-version/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://mirrors.tencent.com/npm/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/standard-version/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://mirrors.tencent.com/npm/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/standard-version/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" } }, - "std-env": { + "node_modules/standard-version/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://mirrors.tencent.com/npm/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/std-env": { "version": "3.10.0", "resolved": "https://mirrors.tencent.com/npm/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, - "stdin-discarder": { + "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://mirrors.tencent.com/npm/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==" + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "string-width": { + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://mirrors.tencent.com/npm/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { "version": "7.2.0", "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "requires": { + "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "string-width-cjs": { - "version": "npm:string-width@4.2.3", + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, - "requires": { + "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } } }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://mirrors.tencent.com/npm/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "requires": { - "safe-buffer": "~5.2.0" + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, - "stringify-package": { + "node_modules/stringify-package": { "version": "1.0.1", "resolved": "https://mirrors.tencent.com/npm/stringify-package/-/stringify-package-1.0.1.tgz", "integrity": "sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg==", + "deprecated": "This module is not used anymore, and has been replaced by @npmcli/package-json", "dev": true }, - "strip-ansi": { + "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "requires": { + "dependencies": { "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "strip-bom": { + "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://mirrors.tencent.com/npm/strip-bom/-/strip-bom-3.0.0.tgz", "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true + "dev": true, + "engines": { + "node": ">=4" + } }, - "strip-bom-string": { + "node_modules/strip-bom-string": { "version": "1.0.0", "resolved": "https://mirrors.tencent.com/npm/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==" + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "engines": { + "node": ">=0.10.0" + } }, - "strip-indent": { + "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://mirrors.tencent.com/npm/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, - "requires": { + "dependencies": { "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" } }, - "sucrase": { + "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://mirrors.tencent.com/npm/sucrase/-/sucrase-3.35.1.tgz", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "dev": true, - "requires": { + "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", @@ -3016,144 +4357,191 @@ "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, - "dependencies": { - "commander": { - "version": "4.1.1", - "resolved": "https://mirrors.tencent.com/npm/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true - } + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" } }, - "supports-color": { + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://mirrors.tencent.com/npm/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://mirrors.tencent.com/npm/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "requires": { + "dependencies": { "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, - "supports-preserve-symlinks-flag": { + "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://mirrors.tencent.com/npm/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "test-exclude": { + "node_modules/test-exclude": { "version": "7.0.2", "resolved": "https://mirrors.tencent.com/npm/test-exclude/-/test-exclude-7.0.2.tgz", "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", "dev": true, - "requires": { + "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" } }, - "text-extensions": { + "node_modules/text-extensions": { "version": "1.9.0", "resolved": "https://mirrors.tencent.com/npm/text-extensions/-/text-extensions-1.9.0.tgz", "integrity": "sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==", - "dev": true + "dev": true, + "engines": { + "node": ">=0.10" + } }, - "thenify": { + "node_modules/thenify": { "version": "3.3.1", "resolved": "https://mirrors.tencent.com/npm/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "dev": true, - "requires": { + "dependencies": { "any-promise": "^1.0.0" } }, - "thenify-all": { + "node_modules/thenify-all": { "version": "1.6.0", "resolved": "https://mirrors.tencent.com/npm/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "dev": true, - "requires": { + "dependencies": { "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" } }, - "through": { + "node_modules/through": { "version": "2.3.8", "resolved": "https://mirrors.tencent.com/npm/through/-/through-2.3.8.tgz", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, - "through2": { + "node_modules/through2": { "version": "4.0.2", "resolved": "https://mirrors.tencent.com/npm/through2/-/through2-4.0.2.tgz", "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", "dev": true, - "requires": { + "dependencies": { "readable-stream": "3" } }, - "tinybench": { + "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://mirrors.tencent.com/npm/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true }, - "tinyexec": { + "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://mirrors.tencent.com/npm/tinyexec/-/tinyexec-0.3.2.tgz", "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true }, - "tinyglobby": { + "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://mirrors.tencent.com/npm/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, - "requires": { + "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "tinypool": { + "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://mirrors.tencent.com/npm/tinypool/-/tinypool-1.1.1.tgz", "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } }, - "tinyrainbow": { + "node_modules/tinyrainbow": { "version": "1.2.0", "resolved": "https://mirrors.tencent.com/npm/tinyrainbow/-/tinyrainbow-1.2.0.tgz", "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true + "dev": true, + "engines": { + "node": ">=14.0.0" + } }, - "tinyspy": { + "node_modules/tinyspy": { "version": "3.0.2", "resolved": "https://mirrors.tencent.com/npm/tinyspy/-/tinyspy-3.0.2.tgz", "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true + "dev": true, + "engines": { + "node": ">=14.0.0" + } }, - "tree-kill": { + "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://mirrors.tencent.com/npm/tree-kill/-/tree-kill-1.2.2.tgz", "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true + "dev": true, + "bin": { + "tree-kill": "cli.js" + } }, - "trim-newlines": { + "node_modules/trim-newlines": { "version": "3.0.1", "resolved": "https://mirrors.tencent.com/npm/trim-newlines/-/trim-newlines-3.0.1.tgz", "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", - "dev": true + "dev": true, + "engines": { + "node": ">=8" + } }, - "ts-interface-checker": { + "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://mirrors.tencent.com/npm/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, - "tsup": { + "node_modules/tsup": { "version": "8.5.1", "resolved": "https://mirrors.tencent.com/npm/tsup/-/tsup-8.5.1.tgz", "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", "dev": true, - "requires": { + "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", @@ -3171,299 +4559,614 @@ "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } } }, - "type-fest": { + "node_modules/type-fest": { "version": "0.18.1", "resolved": "https://mirrors.tencent.com/npm/type-fest/-/type-fest-0.18.1.tgz", "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", - "dev": true + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "typedarray": { + "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://mirrors.tencent.com/npm/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "dev": true }, - "typescript": { + "node_modules/typescript": { "version": "5.9.3", "resolved": "https://mirrors.tencent.com/npm/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } }, - "ufo": { + "node_modules/ufo": { "version": "1.6.3", "resolved": "https://mirrors.tencent.com/npm/ufo/-/ufo-1.6.3.tgz", "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "dev": true }, - "uglify-js": { + "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://mirrors.tencent.com/npm/uglify-js/-/uglify-js-3.19.3.tgz", "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", "dev": true, - "optional": true + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } }, - "undici-types": { + "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://mirrors.tencent.com/npm/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true }, - "universalify": { + "node_modules/universalify": { "version": "2.0.1", "resolved": "https://mirrors.tencent.com/npm/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==" + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } }, - "util-deprecate": { + "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://mirrors.tencent.com/npm/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, - "validate-npm-package-license": { + "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://mirrors.tencent.com/npm/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, - "requires": { + "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, - "vite": { + "node_modules/vite": { "version": "5.4.21", "resolved": "https://mirrors.tencent.com/npm/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, - "requires": { + "dependencies": { "esbuild": "^0.21.3", - "fsevents": "~2.3.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, - "dependencies": { - "@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "dev": true, - "optional": true - }, - "@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "dev": true, - "optional": true - }, - "@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "dev": true, - "optional": true - }, - "@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "dev": true, - "optional": true - }, - "@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "dev": true, - "optional": true - }, - "@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "dev": true, - "optional": true - }, - "@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "dev": true, - "optional": true - }, - "@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "dev": true, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { "optional": true }, - "@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "dev": true, + "less": { "optional": true }, - "@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "dev": true, + "lightningcss": { "optional": true }, - "@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "dev": true, + "sass": { "optional": true }, - "@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "dev": true, + "sass-embedded": { "optional": true }, - "@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "dev": true, + "stylus": { "optional": true }, - "@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "dev": true, + "sugarss": { "optional": true }, - "@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "dev": true, + "terser": { "optional": true - }, - "esbuild": { - "version": "0.21.5", - "resolved": "https://mirrors.tencent.com/npm/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "requires": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } } } }, - "vite-node": { + "node_modules/vite-node": { "version": "2.1.9", "resolved": "https://mirrors.tencent.com/npm/vite-node/-/vite-node-2.1.9.tgz", "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", "dev": true, - "requires": { + "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, - "dependencies": { - "pathe": { - "version": "1.1.2", - "resolved": "https://mirrors.tencent.com/npm/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true - } + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "vitest": { + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://mirrors.tencent.com/npm/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://mirrors.tencent.com/npm/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { "version": "2.1.9", "resolved": "https://mirrors.tencent.com/npm/vitest/-/vitest-2.1.9.tgz", "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, - "requires": { + "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", @@ -3485,153 +5188,239 @@ "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, - "dependencies": { - "pathe": { - "version": "1.1.2", - "resolved": "https://mirrors.tencent.com/npm/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true } } }, - "which": { + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://mirrors.tencent.com/npm/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/which": { "version": "2.0.2", "resolved": "https://mirrors.tencent.com/npm/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "requires": { + "dependencies": { "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" } }, - "why-is-node-running": { + "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://mirrors.tencent.com/npm/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, - "requires": { + "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" } }, - "wordwrap": { + "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://mirrors.tencent.com/npm/wordwrap/-/wordwrap-1.0.0.tgz", "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", "dev": true }, - "wrap-ansi": { + "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://mirrors.tencent.com/npm/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, - "requires": { + "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" }, - "dependencies": { - "emoji-regex": { - "version": "9.2.2", - "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "string-width": { - "version": "5.1.2", - "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "requires": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - } - } + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "wrap-ansi-cjs": { - "version": "npm:wrap-ansi@7.0.0", + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", "resolved": "https://mirrors.tencent.com/npm/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, - "requires": { + "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://mirrors.tencent.com/npm/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://mirrors.tencent.com/npm/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "xtend": { + "node_modules/xtend": { "version": "4.0.2", "resolved": "https://mirrors.tencent.com/npm/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true + "dev": true, + "engines": { + "node": ">=0.4" + } }, - "y18n": { + "node_modules/y18n": { "version": "5.0.8", "resolved": "https://mirrors.tencent.com/npm/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true + "dev": true, + "engines": { + "node": ">=10" + } }, - "yallist": { + "node_modules/yallist": { "version": "4.0.0", "resolved": "https://mirrors.tencent.com/npm/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "yaml": { + "node_modules/yaml": { "version": "2.8.3", "resolved": "https://mirrors.tencent.com/npm/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==" + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } }, - "yargs": { + "node_modules/yargs": { "version": "16.2.0", "resolved": "https://mirrors.tencent.com/npm/yargs/-/yargs-16.2.0.tgz", "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, - "requires": { + "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", @@ -3640,57 +5429,79 @@ "y18n": "^5.0.5", "yargs-parser": "^20.2.2" }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } + "engines": { + "node": ">=10" } }, - "yargs-parser": { + "node_modules/yargs-parser": { "version": "20.2.9", "resolved": "https://mirrors.tencent.com/npm/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, - "yocto-queue": { + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://mirrors.tencent.com/npm/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "zod": { + "node_modules/zod": { "version": "3.25.76", "resolved": "https://mirrors.tencent.com/npm/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 415c0c8..dbc19fd 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "files": [ "dist/**/*.js", "skills", + "agents", "README.md", "CHANGELOG.md", "LICENSE" @@ -51,6 +52,7 @@ "gray-matter": "^4.0.3", "ora": "^8.1.0", "simple-git": "^3.27.0", + "smol-toml": "^1.3.1", "yaml": "^2.6.0", "zod": "^3.24.0" }, diff --git a/src/__tests__/agent-format.test.ts b/src/__tests__/agent-format.test.ts new file mode 100644 index 0000000..8616276 --- /dev/null +++ b/src/__tests__/agent-format.test.ts @@ -0,0 +1,588 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + })), +})); + +import { + parseAgentYaml, + serializeAgentYaml, + renderForClaude, + renderForClaudeInternal, + renderForCodebuddy, + renderForCodex, + renderForCodexInternal, + renderForCursor, + reverseFromClaude, + reverseFromCodebuddy, + reverseFromCodex, + reverseFromCursor, + mergeReverseResults, +} from '../resources/agent-format.js'; +import type { AgentSpec, ToolName, ParseResult } from '../resources/agent-format.js'; +import { AgentsHandler } from '../resources/agents.js'; +import type { AgentResourceItem } from '../resources/agents.js'; +import type { TeamaiConfig, LocalConfig } from '../types.js'; + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +/** Minimal AgentSpec for testing. */ +function makeSpec(overrides: Partial = {}): AgentSpec { + return { + name: 'test-agent', + description: 'A test agent for unit tests', + instructions: 'You are a helpful assistant.\nDo things well.', + ...overrides, + }; +} + +function buildTeamConfig(toolPaths: TeamaiConfig['toolPaths']): TeamaiConfig { + return { + team: 'test', + description: '', + repo: 'https://example.com/test/repo.git', + provider: 'tgit' as const, + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths, + } as TeamaiConfig; +} + +// ─── parseAgentYaml ─────────────────────────────────────────────────────────── + +describe('parseAgentYaml', () => { + it('parses a valid YAML spec', () => { + const yaml = `name: my-agent\ndescription: Does stuff\ninstructions: Be helpful\nmodel: claude-opus-4\n`; + const result: ParseResult = parseAgentYaml(yaml, 'my-agent.yaml'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.name).toBe('my-agent'); + expect(result.spec.description).toBe('Does stuff'); + expect(result.spec.instructions).toBe('Be helpful'); + expect(result.spec.model).toBe('claude-opus-4'); + }); + + it('parses optional fields: tools, targets, tool_extras', () => { + const yaml = `name: a\ndescription: b\ninstructions: c\ntools:\n - Bash\n - Read\ntargets:\n - claude\n - codex\n`; + const result = parseAgentYaml(yaml, 'a.yaml'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.tools).toEqual(['Bash', 'Read']); + expect(result.spec.targets).toEqual(['claude', 'codex']); + }); + + it('returns ok=false on missing required field: name', () => { + const yaml = `description: b\ninstructions: c\n`; + const result = parseAgentYaml(yaml, 'bad.yaml'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain('missing required field name'); + }); + + it('returns ok=false on missing required field: description', () => { + const yaml = `name: a\ninstructions: c\n`; + const result = parseAgentYaml(yaml, 'bad.yaml'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain('missing required field description'); + }); + + it('returns ok=false on missing required field: instructions', () => { + const yaml = `name: a\ndescription: b\n`; + const result = parseAgentYaml(yaml, 'bad.yaml'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain('missing required field instructions'); + }); + + it('returns ok=false on YAML syntax error', () => { + const yaml = `name: [unclosed`; + const result = parseAgentYaml(yaml, 'bad.yaml'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain('parse error'); + }); +}); + +// ─── renderForClaude / ClaudeInternal / Codebuddy ──────────────────────────── + +describe('renderForClaude', () => { + it('produces markdown with YAML frontmatter and body', () => { + const spec = makeSpec({ model: 'claude-sonnet', tools: ['Bash'] }); + const { ext, content } = renderForClaude(spec); + expect(ext).toBe('.md'); + expect(content).toContain('name: test-agent'); + expect(content).toContain('description: A test agent'); + expect(content).toContain('model: claude-sonnet'); + expect(content).toContain('- Bash'); + expect(content).toContain('You are a helpful assistant.'); + }); + + it('omits model and tools when not present', () => { + const { content } = renderForClaude(makeSpec()); + expect(content).not.toContain('model:'); + expect(content).not.toContain('tools:'); + }); + + it('flattens tool_extras.claude into frontmatter', () => { + const spec = makeSpec({ tool_extras: { claude: { allowedTools: ['Bash'], subagentModel: 'haiku' } } }); + const { content } = renderForClaude(spec); + expect(content).toContain('allowedTools:'); + expect(content).toContain('subagentModel: haiku'); + }); + + it('renderForClaudeInternal produces same format', () => { + const spec = makeSpec({ tool_extras: { 'claude-internal': { extra_field: 'val' } } }); + const { ext, content } = renderForClaudeInternal(spec); + expect(ext).toBe('.md'); + expect(content).toContain('extra_field: val'); + expect(content).toContain('name: test-agent'); + }); + + it('renderForCodebuddy flattens codebuddy extras', () => { + const spec = makeSpec({ tool_extras: { codebuddy: { permissionMode: 'strict' } } }); + const { ext, content } = renderForCodebuddy(spec); + expect(ext).toBe('.md'); + expect(content).toContain('permissionMode: strict'); + }); +}); + +// ─── renderForCodex / CodexInternal ───────────────────────────────────────── + +describe('renderForCodex', () => { + it('produces TOML with developer_instructions', () => { + const spec = makeSpec({ model: 'gpt-4o' }); + const { ext, content } = renderForCodex(spec); + expect(ext).toBe('.toml'); + expect(content).toContain('name = "test-agent"'); + expect(content).toContain('description = "A test agent'); + expect(content).toContain('developer_instructions'); + expect(content).toContain('You are a helpful assistant.'); + expect(content).toContain('model = "gpt-4o"'); + }); + + it('does NOT include tools field (codex uses mcp_servers)', () => { + const spec = makeSpec({ tools: ['Bash', 'Read'] }); + const { content } = renderForCodex(spec); + expect(content).not.toContain('"tools"'); + expect(content).not.toContain('tools ='); + }); + + it('flattens tool_extras.codex into top-level TOML fields', () => { + const spec = makeSpec({ + tool_extras: { codex: { sandbox_mode: 'network-disabled', model_reasoning_effort: 'high' } }, + }); + const { content } = renderForCodex(spec); + expect(content).toContain('sandbox_mode'); + expect(content).toContain('model_reasoning_effort'); + }); + + it('renderForCodexInternal produces same TOML format with codex-internal extras', () => { + const spec = makeSpec({ tool_extras: { 'codex-internal': { env_override: 'test' } } }); + const { ext, content } = renderForCodexInternal(spec); + expect(ext).toBe('.toml'); + expect(content).toContain('env_override'); + }); +}); + +// ─── renderForCursor ───────────────────────────────────────────────────────── + +describe('renderForCursor', () => { + it('uses agent_id instead of name in frontmatter', () => { + const spec = makeSpec({ tools: ['Bash'] }); + const { ext, content } = renderForCursor(spec); + expect(ext).toBe('.md'); + expect(content).toContain('agent_id: test-agent'); + expect(content).not.toContain('name: test-agent'); + expect(content).toContain('description:'); + expect(content).toContain('- Bash'); + expect(content).toContain('You are a helpful assistant.'); + }); + + it('flattens tool_extras.cursor into frontmatter', () => { + const spec = makeSpec({ tool_extras: { cursor: { composer_mode: true } } }); + const { content } = renderForCursor(spec); + expect(content).toContain('composer_mode: true'); + }); +}); + +// ─── reverseFromClaude ─────────────────────────────────────────────────────── + +describe('reverseFromClaude', () => { + it('reverses a valid claude .md file', () => { + const content = `---\nname: my-agent\ndescription: Helps with code\nmodel: claude-sonnet\n---\nDo the thing\n`; + const result = reverseFromClaude('/path/to/my-agent.md', content); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.name).toBe('my-agent'); + expect(result.spec.description).toBe('Helps with code'); + expect(result.spec.instructions).toBe('Do the thing'); + expect(result.spec.model).toBe('claude-sonnet'); + }); + + it('infers name from filename when frontmatter lacks name', () => { + const content = `---\ndescription: Helps\n---\nInstructions here\n`; + const result = reverseFromClaude('/agents/inferred-name.md', content); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.name).toBe('inferred-name'); + }); + + it('returns error when description is missing', () => { + const content = `---\nname: a\n---\nBody\n`; + const result = reverseFromClaude('/agents/a.md', content); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain('description'); + }); + + it('returns error when body is empty', () => { + const content = `---\nname: a\ndescription: b\n---\n\n`; + const result = reverseFromClaude('/agents/a.md', content); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain('instructions'); + }); + + it('collects non-common frontmatter fields as tool_extras.claude', () => { + const content = `---\nname: a\ndescription: b\ncustom_field: secret\n---\nBody\n`; + const result = reverseFromClaude('/agents/a.md', content); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.tool_extras?.['claude']).toEqual({ custom_field: 'secret' }); + }); +}); + +// ─── reverseFromCodebuddy ──────────────────────────────────────────────────── + +describe('reverseFromCodebuddy', () => { + it('reverses a codebuddy .md file and sets tool_extras.codebuddy', () => { + const content = `---\nname: cb-agent\ndescription: Codebuddy helper\npermissionMode: strict\n---\nInstructions\n`; + const result = reverseFromCodebuddy('/agents/cb-agent.md', content); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.tool_extras?.['codebuddy']).toEqual({ permissionMode: 'strict' }); + expect(result.spec.tool_extras?.['claude']).toBeUndefined(); + }); + + it('returns error on missing description', () => { + const content = `---\nname: a\n---\nBody\n`; + const result = reverseFromCodebuddy('/agents/a.md', content); + expect(result.ok).toBe(false); + }); +}); + +// ─── reverseFromCodex ──────────────────────────────────────────────────────── + +describe('reverseFromCodex', () => { + it('reverses a valid codex .toml file', () => { + const content = `name = "codex-agent"\ndescription = "Codex helper"\ndeveloper_instructions = "Do stuff"\n`; + const result = reverseFromCodex('/agents/codex-agent.toml', content); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.name).toBe('codex-agent'); + expect(result.spec.instructions).toBe('Do stuff'); + }); + + it('collects non-common TOML fields as tool_extras.codex', () => { + const content = `name = "a"\ndescription = "b"\ndeveloper_instructions = "c"\nsandbox_mode = "network-disabled"\n`; + const result = reverseFromCodex('/agents/a.toml', content); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.tool_extras?.['codex']).toEqual({ sandbox_mode: 'network-disabled' }); + }); + + it('returns error on missing developer_instructions', () => { + const content = `name = "a"\ndescription = "b"\n`; + const result = reverseFromCodex('/agents/a.toml', content); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain('developer_instructions'); + }); + + it('returns error on TOML parse failure', () => { + const content = `name = unclosed [`; + const result = reverseFromCodex('/agents/a.toml', content); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain('parse error'); + }); +}); + +// ─── reverseFromCursor ─────────────────────────────────────────────────────── + +describe('reverseFromCursor', () => { + it('reverses a valid cursor .md file using agent_id', () => { + const content = `---\nagent_id: cursor-agent\ndescription: Cursor helper\n---\nInstructions here\n`; + const result = reverseFromCursor('/agents/cursor-agent.md', content); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.name).toBe('cursor-agent'); + expect(result.spec.description).toBe('Cursor helper'); + }); + + it('collects non-common cursor fields as tool_extras.cursor', () => { + const content = `---\nagent_id: a\ndescription: b\ncomposer_mode: true\n---\nBody\n`; + const result = reverseFromCursor('/agents/a.md', content); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.tool_extras?.['cursor']).toEqual({ composer_mode: true }); + }); + + it('returns error on missing description', () => { + const content = `---\nagent_id: a\n---\nBody\n`; + const result = reverseFromCursor('/agents/a.md', content); + expect(result.ok).toBe(false); + }); + + it('returns error on empty body', () => { + const content = `---\nagent_id: a\ndescription: b\n---\n\n`; + const result = reverseFromCursor('/agents/a.md', content); + expect(result.ok).toBe(false); + }); +}); + +// ─── mergeReverseResults ───────────────────────────────────────────────────── + +describe('mergeReverseResults', () => { + it('merges specs from multiple tools when all common fields agree', () => { + const spec: AgentSpec = makeSpec({ model: 'gpt-4' }); + const claudeSpec: AgentSpec = { ...spec, tool_extras: { claude: { extra: 'c' } } }; + const codexSpec: AgentSpec = { ...spec, tool_extras: { codex: { sandbox_mode: 'off' } } }; + + const result = mergeReverseResults({ claude: claudeSpec, codex: codexSpec }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.name).toBe('test-agent'); + expect(result.spec.tool_extras?.['claude']).toEqual({ extra: 'c' }); + expect(result.spec.tool_extras?.['codex']).toEqual({ sandbox_mode: 'off' }); + }); + + it('returns conflicts when description differs across tools', () => { + const spec1 = makeSpec({ description: 'Version A' }); + const spec2 = makeSpec({ description: 'Version B' }); + + const result = mergeReverseResults({ claude: spec1, cursor: spec2 }); + expect(result.ok).toBe(false); + if (result.ok) return; + const conflict = result.conflicts.find((c) => c.field === 'description'); + expect(conflict).toBeDefined(); + expect(conflict?.values).toMatchObject({ claude: 'Version A', cursor: 'Version B' }); + }); + + it('returns conflicts when model differs across tools', () => { + const spec1 = makeSpec({ model: 'gpt-4' }); + const spec2 = makeSpec({ model: 'claude-opus' }); + + const result = mergeReverseResults({ claude: spec1, codex: spec2 }); + expect(result.ok).toBe(false); + if (result.ok) return; + const conflict = result.conflicts.find((c) => c.field === 'model'); + expect(conflict).toBeDefined(); + }); + + it('returns ok for a single tool input', () => { + const spec = makeSpec(); + const result = mergeReverseResults({ claude: spec }); + expect(result.ok).toBe(true); + }); + + it('treats tool_extras as independent and merges them without conflict', () => { + const spec = makeSpec(); + const result = mergeReverseResults({ + claude: { ...spec, tool_extras: { claude: { fieldA: 1 } } }, + codebuddy: { ...spec, tool_extras: { codebuddy: { fieldB: 2 } } }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.tool_extras?.['claude']).toEqual({ fieldA: 1 }); + expect(result.spec.tool_extras?.['codebuddy']).toEqual({ fieldB: 2 }); + }); +}); + +// ─── AgentsHandler.pushItem — skip path ────────────────────────────────────── + +describe('AgentsHandler.pushItem — skipReason path', () => { + let tmpDir: string; + let repoPath: string; + let handler: AgentsHandler; + let localConfig: LocalConfig; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-agents-push-test-')); + repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(path.join(repoPath, 'agents')); + + vi.stubEnv('HOME', tmpDir); + + handler = new AgentsHandler(); + localConfig = { + repo: { localPath: repoPath, remote: 'https://example.com' }, + username: 'testuser', + additionalRoles: [], + scope: 'user', + }; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('skips writing to team repo when skipReason is set', async () => { + const { log: mockLog } = await import('../utils/logger.js'); + + await handler.pushItem( + { + name: 'conflicted-agent', + type: 'agents', + sourcePath: path.join(tmpDir, 'conflicted-agent.md'), + relativePath: 'agents/conflicted-agent.md', + skipReason: 'conflicting description across tools', + } as AgentResourceItem, + buildTeamConfig({}), + localConfig, + ); + + const teamYaml = path.join(repoPath, 'agents', 'conflicted-agent.yaml'); + const teamMd = path.join(repoPath, 'agents', 'conflicted-agent.md'); + expect(await fse.pathExists(teamYaml)).toBe(false); + expect(await fse.pathExists(teamMd)).toBe(false); + expect(mockLog.warn).toHaveBeenCalled(); + }); + + it('writes YAML to team repo when mergedSpec is provided', async () => { + const spec = makeSpec(); + + await handler.pushItem( + { + name: 'test-agent', + type: 'agents', + sourcePath: path.join(tmpDir, 'test-agent.md'), + relativePath: 'agents/test-agent.yaml', + mergedSpec: spec, + } as AgentResourceItem, + buildTeamConfig({}), + localConfig, + ); + + const teamYaml = path.join(repoPath, 'agents', 'test-agent.yaml'); + expect(await fse.pathExists(teamYaml)).toBe(true); + const written = await fse.readFile(teamYaml, 'utf8'); + expect(written).toContain('name: test-agent'); + expect(written).toContain('description:'); + expect(written).toContain('instructions:'); + }); +}); + +// ─── AgentsHandler.pullItem — multi-target ─────────────────────────────────── + +describe('AgentsHandler.pullItem — multi-target', () => { + let tmpDir: string; + let homeDir: string; + let repoPath: string; + let handler: AgentsHandler; + let localConfig: LocalConfig; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-agents-pull-test-')); + homeDir = path.join(tmpDir, 'home'); + repoPath = path.join(tmpDir, 'team-repo'); + + await fse.ensureDir(path.join(repoPath, 'agents')); + await fse.ensureDir(path.join(homeDir, '.claude', 'agents')); + await fse.ensureDir(path.join(homeDir, '.codex')); + + vi.stubEnv('HOME', homeDir); + + handler = new AgentsHandler(); + localConfig = { + repo: { localPath: repoPath, remote: 'https://example.com' }, + username: 'testuser', + additionalRoles: [], + scope: 'user', + }; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('deploys only to spec.targets=[claude, codex] with correct extensions', async () => { + const spec: AgentSpec = makeSpec({ + targets: ['claude', 'codex'] as ToolName[], + model: 'claude-haiku', + }); + const yamlContent = serializeAgentYaml(spec); + const yamlPath = path.join(repoPath, 'agents', 'test-agent.yaml'); + await fse.writeFile(yamlPath, yamlContent); + + // Create .codex/agents directory (marks codex as installed) + await fse.ensureDir(path.join(homeDir, '.codex', 'agents')); + + const teamConfig = buildTeamConfig({ + claude: { skills: '.claude/skills', agents: '.claude/agents' }, + codex: { skills: '.codex/skills', agents: '.codex/agents' }, + cursor: { skills: '.cursor/skills', agents: '.cursor/agents' }, + }); + + await handler.pullItem( + { name: 'test-agent', type: 'agents', sourcePath: yamlPath, relativePath: 'agents/test-agent.yaml' }, + teamConfig, + localConfig, + ); + + // claude: .md + expect(await fse.pathExists(path.join(homeDir, '.claude', 'agents', 'test-agent.md'))).toBe(true); + // codex: .toml + expect(await fse.pathExists(path.join(homeDir, '.codex', 'agents', 'test-agent.toml'))).toBe(true); + // cursor: not in targets, should not be created + expect(await fse.pathExists(path.join(homeDir, '.cursor', 'agents', 'test-agent.md'))).toBe(false); + }); + + it('legacy .md items are copied only to claude/codebuddy/claude-internal', async () => { + const mdPath = path.join(repoPath, 'agents', 'legacy.md'); + await fse.writeFile(mdPath, '# legacy agent'); + + await fse.ensureDir(path.join(homeDir, '.codebuddy', 'agents')); + await fse.ensureDir(path.join(homeDir, '.codex', 'agents')); + + const teamConfig = buildTeamConfig({ + claude: { skills: '.claude/skills', agents: '.claude/agents' }, + codebuddy: { skills: '.codebuddy/skills', agents: '.codebuddy/agents' }, + codex: { skills: '.codex/skills', agents: '.codex/agents' }, + }); + + await handler.pullItem( + { name: 'legacy', type: 'agents', sourcePath: mdPath, relativePath: 'agents/legacy.md', legacy: true } as AgentResourceItem, + teamConfig, + localConfig, + ); + + expect(await fse.pathExists(path.join(homeDir, '.claude', 'agents', 'legacy.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.codebuddy', 'agents', 'legacy.md'))).toBe(true); + // codex is not a legacy target + expect(await fse.pathExists(path.join(homeDir, '.codex', 'agents', 'legacy.md'))).toBe(false); + }); +}); diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts new file mode 100644 index 0000000..7c59cee --- /dev/null +++ b/src/__tests__/agents.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + })), +})); + +import { AgentsHandler } from '../resources/agents.js'; +import type { TeamaiConfig, LocalConfig } from '../types.js'; + +/** + * Build a minimal TeamaiConfig with the given toolPaths. + * Returns a proxy object cast to TeamaiConfig — the handler only reads + * `toolPaths`, so other fields can stay shallow. + */ +function buildTeamConfig( + toolPaths: TeamaiConfig['toolPaths'], +): TeamaiConfig { + return { + team: 'test', + description: '', + repo: 'https://example.com/test/repo.git', + provider: 'tgit' as const, + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths, + } as TeamaiConfig; +} + +describe('AgentsHandler — Phase 1 push/pull/remove', () => { + let tmpDir: string; + let homeDir: string; + let repoPath: string; + let handler: AgentsHandler; + let teamConfig: TeamaiConfig; + let localConfig: LocalConfig; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-agents-test-')); + homeDir = path.join(tmpDir, 'home'); + repoPath = path.join(tmpDir, 'team-repo'); + + await fse.ensureDir(path.join(repoPath, 'agents')); + await fse.ensureDir(path.join(homeDir, '.claude', 'agents')); + await fse.ensureDir(path.join(homeDir, '.codebuddy', 'agents')); + // cursor intentionally has no agents directory — Tier-3 tool + + vi.stubEnv('HOME', homeDir); + + handler = new AgentsHandler(); + + teamConfig = buildTeamConfig({ + claude: { skills: '.claude/skills', rules: '.claude/rules', agents: '.claude/agents' }, + codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', agents: '.codebuddy/agents' }, + // No agents path: should be silently skipped + cursor: { skills: '.cursor/skills', rules: '.cursor/rules' }, + }); + + localConfig = { + repo: { localPath: repoPath, remote: 'https://example.com/test/repo.git' }, + username: 'testuser', + additionalRoles: [], + scope: 'user', + }; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + // ── scanTeamForPull ───────────────────────────────────── + + it('scanTeamForPull returns *.md files from team repo agents/', async () => { + await fse.writeFile(path.join(repoPath, 'agents', 'code-reviewer.md'), '# code reviewer'); + await fse.writeFile(path.join(repoPath, 'agents', 'doc-writer.md'), '# doc writer'); + // Non-md files must be ignored + await fse.writeFile(path.join(repoPath, 'agents', 'README.txt'), 'should be ignored'); + + const items = await handler.scanTeamForPull(teamConfig, localConfig); + const names = items.map((i) => i.name).sort(); + expect(names).toEqual(['code-reviewer', 'doc-writer']); + expect(items.every((i) => i.type === 'agents')).toBe(true); + }); + + it('scanTeamForPull returns empty when team repo has no agents directory', async () => { + await fse.remove(path.join(repoPath, 'agents')); + const items = await handler.scanTeamForPull(teamConfig, localConfig); + expect(items).toEqual([]); + }); + + // ── pullItem ──────────────────────────────────────────── + + it('pullItem deploys *.md to every tool whose toolPaths.agents is configured', async () => { + const srcPath = path.join(repoPath, 'agents', 'helper.md'); + await fse.writeFile(srcPath, '# helper agent'); + + await handler.pullItem( + { + name: 'helper', + type: 'agents', + sourcePath: srcPath, + relativePath: 'agents/helper.md', + }, + teamConfig, + localConfig, + ); + + expect(await fse.pathExists(path.join(homeDir, '.claude/agents/helper.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.codebuddy/agents/helper.md'))).toBe(true); + }); + + it('pullItem silently skips tools without agents path (cursor/codex/etc.)', async () => { + const srcPath = path.join(repoPath, 'agents', 'helper.md'); + await fse.writeFile(srcPath, '# helper agent'); + + // cursor only has skills/rules, no agents — must not blow up + await handler.pullItem( + { + name: 'helper', + type: 'agents', + sourcePath: srcPath, + relativePath: 'agents/helper.md', + }, + teamConfig, + localConfig, + ); + + expect(await fse.pathExists(path.join(homeDir, '.cursor/agents/helper.md'))).toBe(false); + }); + + it('pullItem skips tools that are not installed (no tool root dir)', async () => { + // Add another tool whose root does NOT exist on the user machine + const cfg = buildTeamConfig({ + claude: { skills: '.claude/skills', agents: '.claude/agents' }, + 'claude-internal': { skills: '.claude-internal/skills', agents: '.claude-internal/agents' }, + }); + const srcPath = path.join(repoPath, 'agents', 'helper.md'); + await fse.writeFile(srcPath, '# helper'); + + await handler.pullItem( + { name: 'helper', type: 'agents', sourcePath: srcPath, relativePath: 'agents/helper.md' }, + cfg, + localConfig, + ); + + expect(await fse.pathExists(path.join(homeDir, '.claude/agents/helper.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.claude-internal/agents/helper.md'))).toBe(false); + }); + + // ── scanLocalForPush ──────────────────────────────────── + + it('scanLocalForPush detects a modified agent across tool dirs as "modified"', async () => { + await fse.writeFile(path.join(repoPath, 'agents', 'shared.md'), 'team version'); + await fse.writeFile(path.join(homeDir, '.claude/agents', 'shared.md'), 'local edits'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'shared'); + expect(item).toBeDefined(); + expect(item!.status).toBe('modified'); + }); + + it('scanLocalForPush detects a brand-new local agent as "new"', async () => { + await fse.writeFile(path.join(homeDir, '.claude/agents', 'brand-new.md'), '# brand new'); + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'brand-new'); + expect(item).toBeDefined(); + expect(item!.status).toBe('new'); + }); + + it('scanLocalForPush ignores local copies identical to team repo', async () => { + await fse.writeFile(path.join(repoPath, 'agents', 'same.md'), 'identical'); + await fse.writeFile(path.join(homeDir, '.claude/agents', 'same.md'), 'identical'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items.find((i) => i.name === 'same')).toBeUndefined(); + }); + + it('scanLocalForPush excludes built-in CLI agents (e.g. teamai-recall)', async () => { + await fse.writeFile( + path.join(homeDir, '.claude/agents', 'teamai-recall.md'), + '# managed by CLI — must not be pushed', + ); + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items.find((i) => i.name === 'teamai-recall')).toBeUndefined(); + }); + + // ── pushItem ──────────────────────────────────────────── + + it('pushItem copies the local md file into team-repo/agents/', async () => { + const localFile = path.join(homeDir, '.claude/agents', 'pushed.md'); + await fse.writeFile(localFile, '# pushed agent'); + + await handler.pushItem( + { name: 'pushed', type: 'agents', sourcePath: localFile, relativePath: 'agents/pushed.md' }, + teamConfig, + localConfig, + ); + + const teamFile = path.join(repoPath, 'agents', 'pushed.md'); + expect(await fse.pathExists(teamFile)).toBe(true); + expect((await fse.readFile(teamFile, 'utf8'))).toBe('# pushed agent'); + }); + + // ── removeItem + tombstone ────────────────────────────── + + it('removeItem deletes from team repo and all tool agents/ dirs and writes a tombstone', async () => { + await fse.writeFile(path.join(repoPath, 'agents', 'old.md'), 'old'); + await fse.writeFile(path.join(homeDir, '.claude/agents', 'old.md'), 'old'); + await fse.writeFile(path.join(homeDir, '.codebuddy/agents', 'old.md'), 'old'); + + const removed = await handler.removeItem('old', teamConfig, localConfig); + + expect(await fse.pathExists(path.join(repoPath, 'agents', 'old.md'))).toBe(false); + expect(await fse.pathExists(path.join(homeDir, '.claude/agents', 'old.md'))).toBe(false); + expect(await fse.pathExists(path.join(homeDir, '.codebuddy/agents', 'old.md'))).toBe(false); + expect(removed.length).toBeGreaterThanOrEqual(3); + + // Tombstone must be present so the agent is not re-pushed if a stale local + // copy reappears. + const tombstone = await fse.readFile(path.join(repoPath, 'agents', '.removed'), 'utf8'); + expect(tombstone.split('\n').map((l) => l.trim())).toContain('old'); + }); + + it('scanLocalForPush respects tombstones (skips removed items)', async () => { + await fse.writeFile(path.join(repoPath, 'agents', '.removed'), 'ghost\n'); + await fse.writeFile(path.join(homeDir, '.claude/agents', 'ghost.md'), '# revived'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items.find((i) => i.name === 'ghost')).toBeUndefined(); + }); +}); diff --git a/src/__tests__/aggregate.test.ts b/src/__tests__/aggregate.test.ts new file mode 100644 index 0000000..9ff616b --- /dev/null +++ b/src/__tests__/aggregate.test.ts @@ -0,0 +1,155 @@ +// -*- coding: utf-8 -*- +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; +import { stringify as yamlStringify } from 'yaml'; + +import { regenerateAggregate } from '../aggregate.js'; +import { getTeamCodebasePaths } from '../utils/team-codebase-paths.js'; +import type { DomainsFile } from '../domains/index.js'; + +// ─── Helpers ──────────────────────────────────────────── + +function makeDomainsFile(overrides: Partial = {}): DomainsFile { + return { + version: 1, + confidence_threshold: 0.6, + domains: [], + ...overrides, + }; +} + +async function writeRepoMd(reposDir: string, slug: string, frontmatter: Record, body: string): Promise { + const fm = yamlStringify(frontmatter).trim(); + const content = `---\n${fm}\n---\n\n${body}`; + await fs.ensureDir(reposDir); + await fs.writeFile(path.join(reposDir, `${slug}.md`), content, 'utf8'); +} + +// ─── Tests ────────────────────────────────────────────── + +describe('regenerateAggregate', () => { + let tmpDir: string; + let paths: ReturnType; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-aggregate-test-')); + paths = getTeamCodebasePaths(tmpDir); + await fs.ensureDir(paths.reposDir); + await fs.ensureDir(paths.domainsDir); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + it('两个域各一仓 → 生成正确的 domain-*.md 与 index.md', async () => { + // 准备 3 个 fake slug.md + await writeRepoMd(paths.reposDir, 'github-org-repo-a', { + repo_url: 'https://github.com/org/repo-a', + repo_name: 'Repo A', + primary_language: 'TypeScript', + line_count: 5000, + last_synced: '2026-06-01T00:00:00Z', + }, '# Repo A\n\n这是 Repo A 的摘要,用于推理服务。\n'); + + await writeRepoMd(paths.reposDir, 'github-org-repo-b', { + repo_url: 'https://github.com/org/repo-b', + repo_name: 'Repo B', + primary_language: 'Python', + line_count: 3000, + last_synced: '2026-06-02T00:00:00Z', + }, '# Repo B\n\n这是 Repo B 的摘要,用于训练服务。\n'); + + await writeRepoMd(paths.reposDir, 'github-org-repo-c', { + repo_url: 'https://github.com/org/repo-c', + repo_name: 'Repo C', + primary_language: 'Go', + line_count: 2000, + last_synced: '2026-06-03T00:00:00Z', + }, '# Repo C\n\n这是 Repo C 的摘要,用于推理优化。\n'); + + const domains = makeDomainsFile({ + domains: [ + { + name: '推理', + description: '推理相关仓库', + repos: [ + { url: 'https://github.com/org/repo-a', confidence: 0.9, signal: 'README', locked: false }, + { url: 'https://github.com/org/repo-c', confidence: 0.85, signal: 'description', locked: false }, + ], + }, + { + name: '训练', + description: '训练相关仓库', + repos: [ + { url: 'https://github.com/org/repo-b', confidence: 0.8, signal: 'README', locked: false }, + ], + }, + ], + }); + + const result = await regenerateAggregate({ paths, domains }); + + // domain-*.md 生成 + expect(result.domainFiles).toHaveLength(2); + expect(result.indexFile).toBe(paths.index); + + // domain-推理.md 存在 + const domainInferPath = path.join(paths.domainsDir, 'domain-推理.md'); + expect(await fs.pathExists(domainInferPath)).toBe(true); + const domainInferContent = await fs.readFile(domainInferPath, 'utf8'); + expect(domainInferContent).toContain('# 业务域:推理'); + expect(domainInferContent).toContain('Repo A'); + expect(domainInferContent).toContain('Repo C'); + + // index.md 存在 + expect(await fs.pathExists(paths.index)).toBe(true); + const indexContent = await fs.readFile(paths.index, 'utf8'); + expect(indexContent).toContain('# 团队 Codebase 索引'); + expect(indexContent).toContain('推理'); + expect(indexContent).toContain('训练'); + }); + + it('不属于任何 domain 的 repo 进未分类', async () => { + await writeRepoMd(paths.reposDir, 'github-org-orphan', { + repo_url: 'https://github.com/org/orphan', + repo_name: 'Orphan Repo', + }, '# Orphan\n\n孤儿仓库。\n'); + + const domains = makeDomainsFile({ domains: [] }); + const result = await regenerateAggregate({ paths, domains }); + + // 未分类 domain 文件 + const unclassifiedPath = path.join(paths.domainsDir, 'domain-未分类.md'); + expect(await fs.pathExists(unclassifiedPath)).toBe(true); + expect(result.domainFiles).toHaveLength(1); + }); + + it('旧 domain 文件在本轮无仓时被清理', async () => { + // 写一个旧的 domain-old.md + const oldFile = path.join(paths.domainsDir, 'domain-old-domain.md'); + await fs.writeFile(oldFile, '# old', 'utf8'); + + await writeRepoMd(paths.reposDir, 'github-org-new', { + repo_url: 'https://github.com/org/new', + }, '# New\n\n新仓库。\n'); + + const domains = makeDomainsFile({ + domains: [{ + name: '新域', + description: '', + repos: [{ url: 'https://github.com/org/new', locked: false }], + }], + }); + + await regenerateAggregate({ paths, domains }); + + // 旧文件已删除 + expect(await fs.pathExists(oldFile)).toBe(false); + // 新文件存在 + expect(await fs.pathExists(path.join(paths.domainsDir, 'domain-新域.md'))).toBe(true); + }); +}); diff --git a/src/__tests__/ai-client.test.ts b/src/__tests__/ai-client.test.ts new file mode 100644 index 0000000..1107044 --- /dev/null +++ b/src/__tests__/ai-client.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { ChildProcess } from 'node:child_process'; +import type { EventEmitter } from 'node:events'; + +// ─── Mock child_process ──────────────────────────────────────────────────── +// vi.mock 会被 hoist 到文件顶部,factory 中不能引用外部 const/let 变量 +// 改用 vi.fn() 内联,通过 vi.mocked(spawn) 在测试中动态设置行为 + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), + // detectClaudeCli 通过 execFileSync('bash', ['-lc', 'command -v ']) 获取绝对路径, + // 测试环境中返回伪路径,配合 existsSync mock 让探测成功并选中第一个候选 'claude'。 + execFileSync: vi.fn(() => '/usr/local/bin/claude\n'), +})); + +// mock existsSync,使探测到的伪路径被视为存在 +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => true), +})); + +import { spawn } from 'node:child_process'; +import { callClaude, callClaudeParallel } from '../utils/ai-client.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +interface MockProcess { + stdout: EventEmitter & { on: ReturnType }; + stderr: EventEmitter & { on: ReturnType }; + on: ReturnType; + kill: ReturnType; +} + +function makeMockProcess(): MockProcess { + const stdoutListeners: Record void> = {}; + const stderrListeners: Record void> = {}; + const processListeners: Record void> = {}; + + const proc: MockProcess = { + stdout: { + on: vi.fn((event: string, cb: (chunk: Buffer) => void) => { + stdoutListeners[event] = cb; + }), + } as unknown as MockProcess['stdout'], + stderr: { + on: vi.fn((event: string, cb: (chunk: Buffer) => void) => { + stderrListeners[event] = cb; + }), + } as unknown as MockProcess['stderr'], + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + processListeners[event] = cb; + }), + kill: vi.fn(), + }; + + (proc as unknown as Record)._emit = { + stdout: (chunk: Buffer) => stdoutListeners['data']?.(chunk), + stderr: (chunk: Buffer) => stderrListeners['data']?.(chunk), + close: (code: number | null) => processListeners['close']?.(code), + error: (err: Error) => processListeners['error']?.(err), + }; + + return proc; +} + +// ─── callClaude ──────────────────────────────────────────────────────────── + +describe('callClaude', () => { + let proc: MockProcess; + let emitters: { + stdout: (chunk: Buffer) => void; + stderr: (chunk: Buffer) => void; + close: (code: number | null) => void; + error: (err: Error) => void; + }; + + beforeEach(() => { + proc = makeMockProcess(); + emitters = (proc as unknown as Record)._emit as typeof emitters; + vi.mocked(spawn).mockReturnValue(proc as unknown as ChildProcess); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('正常情况:stdout 输出 hello world,退出码 0,返回 trim 后字符串', async () => { + const promise = callClaude('test prompt'); + + emitters.stdout(Buffer.from('hello world')); + emitters.close(0); + + const result = await promise; + expect(result).toBe('hello world'); + }); + + it('退出码非 0:stderr 有内容,抛出包含 AI call failed 的 Error', async () => { + const promise = callClaude('test prompt'); + + emitters.stderr(Buffer.from('something went wrong')); + emitters.close(1); + + await expect(promise).rejects.toThrow('AI call failed'); + }); + + it('超时:进程永不退出,在超时后抛出包含 timed out 的 Error', async () => { + vi.useFakeTimers(); + + const promise = callClaude('test prompt', { timeout: 100 }); + + // 推进 100ms 触发超时 + vi.advanceTimersByTime(100); + + await expect(promise).rejects.toThrow('timed out'); + expect(proc.kill).toHaveBeenCalled(); + + vi.useRealTimers(); + }); +}); + +// ─── callClaudeParallel ──────────────────────────────────────────────────── + +describe('callClaudeParallel', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('正常情况:3 个 task,返回数组顺序与输入一致', async () => { + const responses = ['result-A', 'result-B', 'result-C']; + let callIndex = 0; + + // 直接 mock spawn,每次调用顺序返回对应响应 + vi.mocked(spawn).mockImplementation(() => { + const response = responses[callIndex++]; + const proc = makeMockProcess(); + const emitters = (proc as unknown as Record)._emit as { + stdout: (chunk: Buffer) => void; + close: (code: number | null) => void; + }; + // 在下一个微任务触发 + Promise.resolve().then(() => { + emitters.stdout(Buffer.from(response)); + emitters.close(0); + }); + return proc as unknown as ChildProcess; + }); + + const tasks = [ + { prompt: 'prompt-A', parse: (s: string) => s.toUpperCase() }, + { prompt: 'prompt-B', parse: (s: string) => s.toUpperCase() }, + { prompt: 'prompt-C', parse: (s: string) => s.toUpperCase() }, + ]; + + const results = await callClaudeParallel(tasks, 3); + + expect(results).toEqual(['RESULT-A', 'RESULT-B', 'RESULT-C']); + }); + + it('并发限制:5 个 task,concurrency=2,同一时刻最多 2 个并发', async () => { + let running = 0; + let maxRunning = 0; + + vi.mocked(spawn).mockImplementation(() => { + running++; + if (running > maxRunning) maxRunning = running; + + const proc = makeMockProcess(); + const emitters = (proc as unknown as Record)._emit as { + stdout: (chunk: Buffer) => void; + close: (code: number | null) => void; + }; + + // 立即完成,不阻塞 + Promise.resolve().then(() => { + running--; + emitters.stdout(Buffer.from('done')); + emitters.close(0); + }); + + return proc as unknown as ChildProcess; + }); + + const tasks = Array.from({ length: 5 }, (_, i) => ({ + prompt: `prompt-${i}`, + parse: (s: string) => s, + })); + + await callClaudeParallel(tasks, 2); + + // 最大并发不超过 2 + expect(maxRunning).toBeLessThanOrEqual(2); + }); +}); diff --git a/src/__tests__/builtin-agents.test.ts b/src/__tests__/builtin-agents.test.ts new file mode 100644 index 0000000..44dd7d6 --- /dev/null +++ b/src/__tests__/builtin-agents.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, +})); + +import { deployBuiltinAgents, BUILTIN_AGENT_NAMES } from '../builtin-agents.js'; +import type { TeamaiConfig, LocalConfig } from '../types.js'; + +function buildTeamConfig(toolPaths: TeamaiConfig['toolPaths']): TeamaiConfig { + return { + team: 'test', + description: '', + repo: 'https://example.com/test/repo.git', + provider: 'tgit' as const, + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths, + } as TeamaiConfig; +} + +describe('deployBuiltinAgents', () => { + let tmpDir: string; + let homeDir: string; + let builtinAgentsDir: string; + let localConfig: LocalConfig; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-builtin-agents-test-')); + homeDir = path.join(tmpDir, 'home'); + // Per import.meta.url resolution in builtin-agents.ts, the built-in dir is + // resolved as `/../agents`. The compiled module lives in dist/, but + // when running the source under vitest the URL points to src/, so we + // populate /agents/ alongside src/ to match the resolution. + // We use the actual repo path so both code paths succeed. + builtinAgentsDir = path.join(process.cwd(), 'agents'); + + await fse.ensureDir(path.join(homeDir, '.claude', 'agents')); + await fse.ensureDir(path.join(homeDir, '.codebuddy', 'agents')); + // Cursor has no agents dir — should be silently skipped + + vi.stubEnv('HOME', homeDir); + + localConfig = { + repo: { localPath: path.join(tmpDir, 'team-repo'), remote: 'https://example.com/test/repo.git' }, + username: 'testuser', + additionalRoles: [], + scope: 'user', + }; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('BUILTIN_AGENT_NAMES contains teamai-recall', () => { + expect(BUILTIN_AGENT_NAMES.has('teamai-recall')).toBe(true); + }); + + it('deploys built-in agent files to every installed tool with agents path', async () => { + // Sanity: built-in dir must contain teamai-recall.md (added in Task 3) + const recallSrc = path.join(builtinAgentsDir, 'teamai-recall.md'); + if (!fs.existsSync(recallSrc)) { + // Skip the test gracefully when the package has not been built / agents + // dir not present in the test workspace. + console.warn(`Skipping: built-in agents dir not found at ${builtinAgentsDir}`); + return; + } + + const teamConfig = buildTeamConfig({ + claude: { agents: '.claude/agents' }, + codebuddy: { agents: '.codebuddy/agents' }, + cursor: { skills: '.cursor/skills' }, // No agents — skipped + }); + + const deployed = await deployBuiltinAgents(teamConfig, localConfig); + + // Two installed tools × at least one built-in agent file + expect(deployed).toBeGreaterThanOrEqual(2); + expect(await fse.pathExists(path.join(homeDir, '.claude/agents/teamai-recall.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.codebuddy/agents/teamai-recall.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.cursor/agents/teamai-recall.md'))).toBe(false); + }); + + it('overwrites stale local copies with the CLI-built-in version', async () => { + const recallSrc = path.join(builtinAgentsDir, 'teamai-recall.md'); + if (!fs.existsSync(recallSrc)) return; // Same skip guard + + const localPath = path.join(homeDir, '.claude/agents/teamai-recall.md'); + await fse.writeFile(localPath, '# stale outdated copy'); + + const teamConfig = buildTeamConfig({ + claude: { agents: '.claude/agents' }, + }); + + await deployBuiltinAgents(teamConfig, localConfig); + + const written = await fse.readFile(localPath, 'utf8'); + expect(written).not.toBe('# stale outdated copy'); + expect(written).toContain('teamai-recall'); + }); + + it('returns 0 and does not throw when no tools are installed', async () => { + // Wipe all installed tool roots + await fse.remove(path.join(homeDir, '.claude')); + await fse.remove(path.join(homeDir, '.codebuddy')); + + const teamConfig = buildTeamConfig({ + claude: { agents: '.claude/agents' }, + codebuddy: { agents: '.codebuddy/agents' }, + }); + + const deployed = await deployBuiltinAgents(teamConfig, localConfig); + expect(deployed).toBe(0); + }); + + it('silently skips when the built-in agents directory does not exist', async () => { + // Point HOME at a fresh dir; even if the package agents/ dir exists in + // workspace, no tool roots are present, so deployment count is 0. + const teamConfig = buildTeamConfig({}); + const deployed = await deployBuiltinAgents(teamConfig, localConfig); + expect(deployed).toBe(0); + }); +}); diff --git a/src/__tests__/cache-cmd.test.ts b/src/__tests__/cache-cmd.test.ts new file mode 100644 index 0000000..ab9d08c --- /dev/null +++ b/src/__tests__/cache-cmd.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as cacheIndexModule from '../utils/cache-index.js'; +import type { CacheCmdOptions } from '../cache-cmd.js'; + +// ─── Tests ─────────────────────────────────────────────── + +describe('cache-cmd', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation((code?: number | string | null) => { + throw new Error(`process.exit called with code ${code}`); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ─── --status ──────────────────────────────────────── + + describe('--status', () => { + it('默认路径调用 getCacheStatus', async () => { + const mockStatus = { + root: '/mock/cache', + totalBytes: 1024, + entryCount: 1, + entries: [ + { + key: 'github/owner/repo', + size_bytes: 1024, + last_used: '2025-01-01T00:00:00.000Z', + last_synced_sha: 'abcdef12', + }, + ], + }; + vi.spyOn(cacheIndexModule, 'getCacheStatus').mockResolvedValue(mockStatus); + + const { cacheCmd } = await import('../cache-cmd.js'); + const opts: CacheCmdOptions = { dryRun: false, verbose: false }; + await cacheCmd(opts); + + expect(cacheIndexModule.getCacheStatus).toHaveBeenCalledOnce(); + }); + + it('--json 输出合法 JSON', async () => { + const mockStatus = { + root: '/mock/cache', + totalBytes: 0, + entryCount: 0, + entries: [], + }; + vi.spyOn(cacheIndexModule, 'getCacheStatus').mockResolvedValue(mockStatus); + + const outputs: string[] = []; + consoleSpy.mockImplementation((msg: unknown) => { + if (typeof msg === 'string') outputs.push(msg); + }); + + const { cacheCmd } = await import('../cache-cmd.js'); + const opts: CacheCmdOptions = { dryRun: false, verbose: false, json: true }; + await cacheCmd(opts); + + const allOutput = outputs.join(''); + expect(() => JSON.parse(allOutput)).not.toThrow(); + const parsed = JSON.parse(allOutput) as Record; + expect(parsed).toHaveProperty('root'); + expect(parsed).toHaveProperty('entries'); + }); + }); + + // ─── --gc ──────────────────────────────────────────── + + describe('--gc', () => { + it('--gc 路径调用 gcCache', async () => { + const mockResult: cacheIndexModule.GcResult = { + before: { totalBytes: 1000, entryCount: 2 }, + after: { totalBytes: 500, entryCount: 1 }, + removed: [{ key: 'github/owner/old', size_bytes: 500, reason: 'stale' }], + skipped: [], + }; + vi.spyOn(cacheIndexModule, 'gcCache').mockResolvedValue(mockResult); + + const { cacheCmd } = await import('../cache-cmd.js'); + const opts: CacheCmdOptions = { dryRun: false, verbose: false, gc: true }; + await cacheCmd(opts); + + expect(cacheIndexModule.gcCache).toHaveBeenCalledOnce(); + }); + + it('--gc --json 输出合法 JSON', async () => { + const mockResult: cacheIndexModule.GcResult = { + before: { totalBytes: 1000, entryCount: 1 }, + after: { totalBytes: 0, entryCount: 0 }, + removed: [{ key: 'github/owner/old', size_bytes: 1000, reason: 'stale' }], + skipped: [], + }; + vi.spyOn(cacheIndexModule, 'gcCache').mockResolvedValue(mockResult); + + const outputs: string[] = []; + consoleSpy.mockImplementation((msg: unknown) => { + if (typeof msg === 'string') outputs.push(msg); + }); + + const { cacheCmd } = await import('../cache-cmd.js'); + const opts: CacheCmdOptions = { dryRun: false, verbose: false, gc: true, json: true }; + await cacheCmd(opts); + + const allOutput = outputs.join(''); + expect(() => JSON.parse(allOutput)).not.toThrow(); + const parsed = JSON.parse(allOutput) as Record; + expect(parsed).toHaveProperty('before'); + expect(parsed).toHaveProperty('removed'); + }); + + it('skipped 非空时退出码为 1', async () => { + const mockResult: cacheIndexModule.GcResult = { + before: { totalBytes: 1000, entryCount: 1 }, + after: { totalBytes: 1000, entryCount: 1 }, + removed: [], + skipped: [{ key: 'github/owner/broken', reason: '删除失败: EPERM' }], + }; + vi.spyOn(cacheIndexModule, 'gcCache').mockResolvedValue(mockResult); + + const { cacheCmd } = await import('../cache-cmd.js'); + const opts: CacheCmdOptions = { dryRun: false, verbose: false, gc: true }; + await expect(cacheCmd(opts)).rejects.toThrow('process.exit called with code 1'); + }); + + it('skipped 非空且 --json 时退出码为 1', async () => { + const mockResult: cacheIndexModule.GcResult = { + before: { totalBytes: 1000, entryCount: 1 }, + after: { totalBytes: 1000, entryCount: 1 }, + removed: [], + skipped: [{ key: 'github/owner/broken', reason: '删除失败' }], + }; + vi.spyOn(cacheIndexModule, 'gcCache').mockResolvedValue(mockResult); + + const { cacheCmd } = await import('../cache-cmd.js'); + const opts: CacheCmdOptions = { dryRun: false, verbose: false, gc: true, json: true }; + await expect(cacheCmd(opts)).rejects.toThrow('process.exit called with code 1'); + }); + }); +}); diff --git a/src/__tests__/cache-gc.test.ts b/src/__tests__/cache-gc.test.ts new file mode 100644 index 0000000..1672ce8 --- /dev/null +++ b/src/__tests__/cache-gc.test.ts @@ -0,0 +1,167 @@ +import path from 'node:path'; +import os from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { promises as nodeFs } from 'node:fs'; + +import fs from 'fs-extra'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + gcCache, + getCacheStatus, + loadCacheIndex, + saveCacheIndex, + type CacheIndex, +} from '../utils/cache-index.js'; + +// ─── Helpers ──────────────────────────────────────────── + +function makeTmpDir(): string { + return path.join(os.tmpdir(), `cache-gc-test-${randomUUID()}`); +} + +function daysAgo(days: number): string { + const d = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + return d.toISOString(); +} + +/** + * 创建一个真实目录并写入指定字节数的文件(使用稀疏文件近似)。 + */ +async function makeRepoDir(root: string, key: string, approxBytes: number): Promise { + const absPath = path.join(root, key); + await fs.ensureDir(absPath); + const buf = Buffer.alloc(approxBytes); + await fs.writeFile(path.join(absPath, 'data'), buf); + return absPath; +} + +// ─── Tests ─────────────────────────────────────────────── + +describe('cache-gc', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = makeTmpDir(); + await fs.ensureDir(tmpDir); + process.env.TEAMAI_CACHE_DIR = tmpDir; + delete process.env.TEAMAI_CACHE_MAX_BYTES; + }); + + afterEach(async () => { + delete process.env.TEAMAI_CACHE_DIR; + delete process.env.TEAMAI_CACHE_MAX_BYTES; + await fs.remove(tmpDir); + }); + + /** + * 构造 4 个条目: + * a - 最近使用,100 bytes + * b - 31 天前 (stale),100 bytes + * c - 60 天前 (stale),100 bytes + * d - 最近 1 天,2 GB(稀疏文件) + */ + async function buildFixture(): Promise { + const TWO_GB = 2 * 1024 * 1024 * 1024; + + await makeRepoDir(tmpDir, 'github/owner/a', 100); + await makeRepoDir(tmpDir, 'github/owner/b', 100); + await makeRepoDir(tmpDir, 'github/owner/c', 100); + + // d:写一个 2GB 稀疏文件(使用 node:fs ftruncate) + const dDir = path.join(tmpDir, 'github/owner/d'); + await fs.ensureDir(dDir); + const fh = await nodeFs.open(path.join(dDir, 'big'), 'w'); + await fh.truncate(TWO_GB); + await fh.close(); + + const idx: CacheIndex = { + version: 1, + updated_at: new Date().toISOString(), + entries: [ + { key: 'github/owner/a', size_bytes: 100, last_used: daysAgo(0) }, + { key: 'github/owner/b', size_bytes: 100, last_used: daysAgo(31) }, + { key: 'github/owner/c', size_bytes: 100, last_used: daysAgo(60) }, + { key: 'github/owner/d', size_bytes: TWO_GB, last_used: daysAgo(1) }, + ], + }; + await saveCacheIndex(idx); + } + + it('默认 maxBytes=5GB 时只删 b/c(stale);a/d 保留', async () => { + await buildFixture(); + + const result = await gcCache({ maxBytes: 5 * 1024 * 1024 * 1024, staleDays: 30 }); + + const removedKeys = result.removed.map((r: { key: string }) => r.key).sort(); + expect(removedKeys).toEqual(['github/owner/b', 'github/owner/c']); + expect(result.removed.every((r: { reason: string }) => r.reason === 'stale')).toBe(true); + + const idx = await loadCacheIndex(); + const keys = idx.entries.map((e: { key: string }) => e.key).sort(); + expect(keys).toEqual(['github/owner/a', 'github/owner/d']); + }); + + it('maxBytes=1GB 时除 stale 外,d 因超容也被淘汰', async () => { + await buildFixture(); + + const ONE_GB = 1024 * 1024 * 1024; + const result = await gcCache({ maxBytes: ONE_GB, staleDays: 30 }); + + const removedKeys = result.removed.map((r: { key: string }) => r.key).sort(); + expect(removedKeys).toContain('github/owner/b'); + expect(removedKeys).toContain('github/owner/c'); + expect(removedKeys).toContain('github/owner/d'); + + const staleCount = result.removed.filter((r: { reason: string }) => r.reason === 'stale').length; + const overCapCount = result.removed.filter((r: { reason: string }) => r.reason === 'over-cap').length; + expect(staleCount).toBe(2); + expect(overCapCount).toBe(1); + + const idx = await loadCacheIndex(); + const keys = idx.entries.map((e: { key: string }) => e.key); + expect(keys).toEqual(['github/owner/a']); + }); + + it('dryRun=true 不动盘', async () => { + await buildFixture(); + + const result = await gcCache({ maxBytes: 5 * 1024 * 1024 * 1024, staleDays: 30, dryRun: true }); + + // b/c 应被报告为 removed,但不删盘 + expect(result.removed.length).toBeGreaterThanOrEqual(2); + + // 磁盘上 b/c 仍存在 + expect(await fs.pathExists(path.join(tmpDir, 'github/owner/b'))).toBe(true); + expect(await fs.pathExists(path.join(tmpDir, 'github/owner/c'))).toBe(true); + + // 索引不变(dryRun 下不写盘) + const idx = await loadCacheIndex(); + expect(idx.entries).toHaveLength(4); + }); + + it('getCacheStatus:索引中存在但磁盘已删的 entry 被自愈', async () => { + // 构造一个索引,指向不存在的目录 + const idx: CacheIndex = { + version: 1, + updated_at: new Date().toISOString(), + entries: [ + { key: 'github/owner/exists', size_bytes: 100, last_used: daysAgo(0) }, + { key: 'github/owner/gone', size_bytes: 200, last_used: daysAgo(0) }, + ], + }; + // 只创建 exists 目录 + await makeRepoDir(tmpDir, 'github/owner/exists', 100); + await saveCacheIndex(idx); + + const status = await getCacheStatus(); + + // gone 被自愈删除 + expect(status.entryCount).toBe(1); + expect(status.entries[0].key).toBe('github/owner/exists'); + + // 索引已持久化 + const saved = await loadCacheIndex(); + expect(saved.entries).toHaveLength(1); + }); +}); diff --git a/src/__tests__/cache-index.test.ts b/src/__tests__/cache-index.test.ts new file mode 100644 index 0000000..191712b --- /dev/null +++ b/src/__tests__/cache-index.test.ts @@ -0,0 +1,204 @@ +import path from 'node:path'; +import os from 'node:os'; +import { randomUUID } from 'node:crypto'; + +import fs from 'fs-extra'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + loadCacheIndex, + saveCacheIndex, + statDirSize, + touchCacheEntry, + type CacheIndex, +} from '../utils/cache-index.js'; + +// ─── Helpers ──────────────────────────────────────────── + +function makeTmpDir(): string { + const tmp = path.join(os.tmpdir(), `cache-index-test-${randomUUID()}`); + return tmp; +} + +// ─── Tests ─────────────────────────────────────────────── + +describe('cache-index', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = makeTmpDir(); + await fs.ensureDir(tmpDir); + process.env.TEAMAI_CACHE_DIR = tmpDir; + }); + + afterEach(async () => { + delete process.env.TEAMAI_CACHE_DIR; + await fs.remove(tmpDir); + vi.restoreAllMocks(); + }); + + // ─── loadCacheIndex ───────────────────────────────── + + describe('loadCacheIndex', () => { + it('文件不存在时返回空索引', async () => { + const idx = await loadCacheIndex(); + expect(idx.version).toBe(1); + expect(idx.entries).toEqual([]); + }); + + it('文件损坏(非 JSON)时返回空索引(不抛错)', async () => { + const indexPath = path.join(tmpDir, '.cache-index.json'); + await fs.writeFile(indexPath, 'NOT_VALID_JSON', 'utf8'); + + const idx = await loadCacheIndex(); + expect(idx.version).toBe(1); + expect(idx.entries).toEqual([]); + }); + + it('version 不符合时返回空索引', async () => { + const indexPath = path.join(tmpDir, '.cache-index.json'); + await fs.writeFile(indexPath, JSON.stringify({ version: 2, entries: [] }), 'utf8'); + + const idx = await loadCacheIndex(); + expect(idx.version).toBe(1); + expect(idx.entries).toEqual([]); + }); + }); + + // ─── saveCacheIndex ───────────────────────────────── + + describe('saveCacheIndex', () => { + it('往返一致', async () => { + const idx: CacheIndex = { + version: 1, + updated_at: new Date().toISOString(), + entries: [ + { + key: 'github/owner/repo', + size_bytes: 1234, + last_used: '2025-01-01T00:00:00.000Z', + last_synced_sha: 'abc12345', + }, + ], + }; + + await saveCacheIndex(idx); + const loaded = await loadCacheIndex(); + + expect(loaded.version).toBe(1); + expect(loaded.entries).toHaveLength(1); + expect(loaded.entries[0].key).toBe('github/owner/repo'); + expect(loaded.entries[0].size_bytes).toBe(1234); + expect(loaded.entries[0].last_synced_sha).toBe('abc12345'); + }); + }); + + // ─── touchCacheEntry ──────────────────────────────── + + describe('touchCacheEntry', () => { + it('新增条目 + 计算 size_bytes', async () => { + // 创建真实目录 + 文件 + const repoDir = path.join(tmpDir, 'github', 'myorg', 'myrepo'); + await fs.ensureDir(repoDir); + await fs.writeFile(path.join(repoDir, 'file.txt'), 'hello world', 'utf8'); + + await touchCacheEntry({ provider: 'github', owner: 'myorg', repo: 'myrepo' }); + + const idx = await loadCacheIndex(); + expect(idx.entries).toHaveLength(1); + expect(idx.entries[0].key).toBe('github/myorg/myrepo'); + expect(idx.entries[0].size_bytes).toBeGreaterThan(0); + expect(idx.entries[0].last_used).toBeTruthy(); + }); + + it('已存在条目 → 更新 last_used / size / sha 字段', async () => { + const repoDir = path.join(tmpDir, 'github', 'myorg', 'myrepo'); + await fs.ensureDir(repoDir); + await fs.writeFile(path.join(repoDir, 'file.txt'), 'content', 'utf8'); + + // 第一次 touch + await touchCacheEntry({ provider: 'github', owner: 'myorg', repo: 'myrepo', lastSyncedSha: 'sha1111' }); + const idx1 = await loadCacheIndex(); + const firstUsed = idx1.entries[0].last_used; + + // 等 1ms 后再 touch + await new Promise((r) => setTimeout(r, 5)); + // 写大一点的文件 + await fs.writeFile(path.join(repoDir, 'big.txt'), 'x'.repeat(1000), 'utf8'); + await touchCacheEntry({ provider: 'github', owner: 'myorg', repo: 'myrepo', lastSyncedSha: 'sha2222' }); + + const idx2 = await loadCacheIndex(); + expect(idx2.entries).toHaveLength(1); + expect(idx2.entries[0].last_synced_sha).toBe('sha2222'); + expect(new Date(idx2.entries[0].last_used).getTime()).toBeGreaterThanOrEqual( + new Date(firstUsed).getTime(), + ); + expect(idx2.entries[0].size_bytes).toBeGreaterThanOrEqual(1000); + }); + + it('未提供 lastSyncedSha 时保留已有 sha', async () => { + const repoDir = path.join(tmpDir, 'github', 'myorg', 'myrepo'); + await fs.ensureDir(repoDir); + + await touchCacheEntry({ provider: 'github', owner: 'myorg', repo: 'myrepo', lastSyncedSha: 'keepme' }); + await touchCacheEntry({ provider: 'github', owner: 'myorg', repo: 'myrepo' }); + + const idx = await loadCacheIndex(); + expect(idx.entries[0].last_synced_sha).toBe('keepme'); + }); + }); + + // ─── statDirSize ──────────────────────────────────── + + describe('statDirSize', () => { + it('目录不存在时返回 0', async () => { + const size = await statDirSize(path.join(tmpDir, 'nonexistent')); + expect(size).toBe(0); + }); + + it('真实递归累加文件大小', async () => { + const dir = path.join(tmpDir, 'dirtest'); + await fs.ensureDir(path.join(dir, 'sub')); + await fs.writeFile(path.join(dir, 'a.txt'), 'aa', 'utf8'); // 2 bytes + await fs.writeFile(path.join(dir, 'sub', 'b.txt'), 'bbb', 'utf8'); // 3 bytes + + const size = await statDirSize(dir); + expect(size).toBeGreaterThanOrEqual(5); + }); + + it('软链接跳过(不跟随)', async () => { + const dir = path.join(tmpDir, 'linktest'); + await fs.ensureDir(dir); + await fs.writeFile(path.join(dir, 'real.txt'), 'realcontent', 'utf8'); + + // 软链目标不存在也没关系 + try { + await fs.symlink(path.join(tmpDir, 'nonexist'), path.join(dir, 'link')); + } catch { + // 某些环境可能不支持软链接,直接跳过该断言 + return; + } + + // 不应因软链抛错 + const size = await statDirSize(dir); + expect(size).toBeGreaterThan(0); + }); + }); + + describe('loadCacheIndex — 文件大小限制', () => { + it('索引文件超过 10 MB 时返回空索引(size 超限走 catch → 返回 emptyIndex)', async () => { + const indexPath = path.join(tmpDir, '.cache-index.json'); + // 写入 11 MB 内容(真实文件,非 mock fs.stat) + const chunk = 'x'.repeat(1024 * 1024); + let content = ''; + for (let i = 0; i < 11; i++) content += chunk; + await fs.writeFile(indexPath, content, 'utf8'); + + // loadCacheIndex 内部 catch 会返回 emptyIndex 而非抛出 + // 但 size 超限路径会 throw,被 catch 捕获后返回空 + const idx = await loadCacheIndex(); + expect(idx.version).toBe(1); + expect(idx.entries).toHaveLength(0); + }); + }); +}); diff --git a/src/__tests__/clone-sanitize.test.ts b/src/__tests__/clone-sanitize.test.ts new file mode 100644 index 0000000..7200271 --- /dev/null +++ b/src/__tests__/clone-sanitize.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeGitUrl } from '../clone.js'; + +describe('sanitizeGitUrl', () => { + it('masks token in https URL', () => { + const url = 'https://x-access-token:ghp_secret123@github.com/org/repo.git'; + expect(sanitizeGitUrl(url)).toBe('https://***@github.com/org/repo.git'); + }); + + it('masks basic auth credentials in https URL', () => { + const url = 'https://user:password@example.com/repo.git'; + expect(sanitizeGitUrl(url)).toBe('https://***@example.com/repo.git'); + }); + + it('leaves clean https URL unchanged', () => { + const url = 'https://github.com/org/repo.git'; + expect(sanitizeGitUrl(url)).toBe(url); + }); + + it('leaves SSH URL unchanged', () => { + const url = 'git@github.com:org/repo.git'; + expect(sanitizeGitUrl(url)).toBe(url); + }); + + it('masks token embedded in an error message string', () => { + const msg = 'git clone failed: https://x-access-token:abc123@github.com/repo error'; + expect(sanitizeGitUrl(msg)).toBe('git clone failed: https://***@github.com/repo error'); + }); + + it('masks multiple occurrences', () => { + const msg = 'https://tok1@a.com and https://tok2@b.com'; + expect(sanitizeGitUrl(msg)).toBe('https://***@a.com and https://***@b.com'); + }); +}); diff --git a/src/__tests__/codebase-fix.test.ts b/src/__tests__/codebase-fix.test.ts new file mode 100644 index 0000000..012d13d --- /dev/null +++ b/src/__tests__/codebase-fix.test.ts @@ -0,0 +1,178 @@ +import path from 'node:path'; +import os from 'node:os'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; + +import { fixTeamCodebase } from '../codebase-lint.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function isoAgo(days: number): string { + const d = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + return d.toISOString(); +} + +async function setupBase(tmpdir: string): Promise { + const reposDir = path.join(tmpdir, 'docs', 'team-codebase', 'repos'); + const domainsDir = path.join(tmpdir, 'docs', 'team-codebase', 'domains'); + const teamaiDir = path.join(tmpdir, '.teamai'); + + await fs.ensureDir(reposDir); + await fs.ensureDir(domainsDir); + await fs.ensureDir(teamaiDir); + + // domains.yaml with one repo + await fs.writeFile( + path.join(teamaiDir, 'domains.yaml'), + 'version: 1\ndomains:\n - name: core\n description: core\n repos:\n - url: "https://github.com/org/repo-a"\n', + 'utf8' + ); + + // repo md with correct frontmatter + await fs.writeFile( + path.join(reposDir, 'github__org__repo-a.md'), + `---\ntitle: "Codebase 概览"\nlastUpdated: "${isoAgo(1)}"\nsource: "/tmp/placeholder"\ngenerator: "teamai-cli"\nschemaVersion: 1\n---\n\n# repo-a\n`, + 'utf8' + ); + + // domain md + await fs.writeFile( + path.join(domainsDir, 'domain-core.md'), + '---\ndomain: "core"\ngenerator: "teamai import (P5.2 aggregate)"\n---\n\n## 仓库列表\n\n| 仓库 | 描述 | 域 |\n|------|------|----|\n| repo-a | desc | core |\n', + 'utf8' + ); + + // index.md + await fs.writeFile( + path.join(tmpdir, 'docs', 'team-codebase', 'index.md'), + '---\ngenerator: "teamai import (P5.2 aggregate)"\nlast_generated: "2025-01-01T00:00:00.000Z"\ndomain_count: 1\nrepo_count: 1\nschemaVersion: 1\n---\n\n# Team Codebase\n', + 'utf8' + ); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('fixTeamCodebase', () => { + let tmpdir: string; + + beforeEach(async () => { + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), 'codebase-fix-')); + }); + + afterEach(async () => { + await fs.remove(tmpdir); + }); + + it('orphan-md fix 后文件被移到 .archived/', async () => { + await setupBase(tmpdir); + const reposDir = path.join(tmpdir, 'docs', 'team-codebase', 'repos'); + // Add orphan md + const orphanPath = path.join(reposDir, 'github__org__orphan.md'); + await fs.writeFile( + orphanPath, + '---\ntitle: "Orphan"\ngenerator: "teamai-cli"\nschemaVersion: 1\n---\n\n# orphan\n', + 'utf8' + ); + + const result = await fixTeamCodebase({ cwd: tmpdir }); + + // orphan should be in applied + const orphanFix = result.applied.find((a) => a.category === 'orphan-md'); + expect(orphanFix).toBeDefined(); + + // original file should no longer exist + expect(await fs.pathExists(orphanPath)).toBe(false); + + // archived file should exist + const archivedPath = path.join(reposDir, '.archived', 'github__org__orphan.md'); + expect(await fs.pathExists(archivedPath)).toBe(true); + }); + + it('frontmatter-missing fix 后 schemaVersion 被补齐', async () => { + await setupBase(tmpdir); + const reposDir = path.join(tmpdir, 'docs', 'team-codebase', 'repos'); + const mdPath = path.join(reposDir, 'github__org__repo-a.md'); + // Overwrite without schemaVersion + await fs.writeFile( + mdPath, + `---\ntitle: "Codebase 概览"\nlastUpdated: "${isoAgo(1)}"\nsource: "/tmp/placeholder"\ngenerator: "teamai-cli"\n---\n\n# repo-a\n`, + 'utf8' + ); + + const result = await fixTeamCodebase({ cwd: tmpdir }); + + const fmFix = result.applied.find((a) => a.category === 'frontmatter-missing'); + expect(fmFix).toBeDefined(); + + // Verify schemaVersion is in file + const content = await fs.readFile(mdPath, 'utf8'); + expect(content).toContain('schemaVersion'); + }); + + it('index-mismatch fix 后 frontmatter 数字被更新', async () => { + await setupBase(tmpdir); + // Add extra repo md to make count mismatch + const reposDir = path.join(tmpdir, 'docs', 'team-codebase', 'repos'); + await fs.writeFile( + path.join(reposDir, 'github__org__repo-b.md'), + `---\ntitle: "Codebase 概览"\nlastUpdated: "${isoAgo(1)}"\nsource: "/tmp/placeholder"\ngenerator: "teamai-cli"\nschemaVersion: 1\n---\n\n# repo-b\n`, + 'utf8' + ); + // Now index.md still says repo_count: 1, but there are 2 md files → mismatch + + const result = await fixTeamCodebase({ cwd: tmpdir }); + + // repo-b is orphan-md as well (not in domains.yaml) + // orphan-md fix moves repo-b to .archived, so after fix both counts may align + // The key check: fixResult has index-mismatch in applied OR the index is updated + const indexFix = result.applied.find((a) => a.category === 'index-mismatch'); + if (indexFix) { + const indexContent = await fs.readFile( + path.join(tmpdir, 'docs', 'team-codebase', 'index.md'), + 'utf8' + ); + // After orphan-md fix moved repo-b, repo count should be 1 again + expect(indexContent).toContain('repo_count'); + } + // At minimum, no errors thrown + expect(result).toBeDefined(); + }); + + it('dry-run 不动文件', async () => { + await setupBase(tmpdir); + const reposDir = path.join(tmpdir, 'docs', 'team-codebase', 'repos'); + const orphanPath = path.join(reposDir, 'github__org__orphan.md'); + await fs.writeFile( + orphanPath, + '---\ntitle: "Orphan"\ngenerator: "teamai-cli"\nschemaVersion: 1\n---\n\n# orphan\n', + 'utf8' + ); + + await fixTeamCodebase({ cwd: tmpdir, dryRun: true }); + + // File should still exist because dry-run + expect(await fs.pathExists(orphanPath)).toBe(true); + const archivedPath = path.join(reposDir, '.archived', 'github__org__orphan.md'); + expect(await fs.pathExists(archivedPath)).toBe(false); + }); + + it('high 类(anchor-unclosed)从不被 fix,出现在 skipped', async () => { + await setupBase(tmpdir); + // Create external-knowledge.md with unclosed anchor + await fs.writeFile( + path.join(tmpdir, 'docs', 'team-codebase', 'external-knowledge.md'), + '\nno close\n', + 'utf8' + ); + + const result = await fixTeamCodebase({ cwd: tmpdir }); + + const anchorSkipped = result.skipped.filter((s) => s.category === 'anchor-unclosed'); + expect(anchorSkipped.length).toBeGreaterThanOrEqual(1); + + // Should not appear in applied + const anchorApplied = result.applied.filter((a) => a.category === 'anchor-unclosed'); + expect(anchorApplied).toHaveLength(0); + }); +}); diff --git a/src/__tests__/codebase-lint.test.ts b/src/__tests__/codebase-lint.test.ts new file mode 100644 index 0000000..494f9b2 --- /dev/null +++ b/src/__tests__/codebase-lint.test.ts @@ -0,0 +1,299 @@ +import path from 'node:path'; +import os from 'node:os'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; + +import { lintTeamCodebase } from '../codebase-lint.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function isoAgo(days: number): string { + const d = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + return d.toISOString(); +} + +interface ScaffoldOptions { + cwd: string; + repoSlugs?: string[]; + domainNames?: string[]; + repoFrontmatter?: Record; + domainFrontmatter?: Record; + indexFrontmatter?: Record; + externalKnowledge?: string; + domainsYaml?: string; + repoWhitelist?: string; + sourceMarks?: string; + pendingReview?: string; + repoCount?: number; + domainCount?: number; +} + +async function scaffold(opts: ScaffoldOptions): Promise { + const reposDir = path.join(opts.cwd, 'docs', 'team-codebase', 'repos'); + const domainsDir = path.join(opts.cwd, 'docs', 'team-codebase', 'domains'); + const teamaiDir = path.join(opts.cwd, '.teamai'); + + await fs.ensureDir(reposDir); + await fs.ensureDir(domainsDir); + await fs.ensureDir(teamaiDir); + + // Default domains.yaml + const defaultUrl = 'https://github.com/org/repo-a'; + const defaultSlug = 'github__org__repo-a'; + const slugs = opts.repoSlugs ?? [defaultSlug]; + const urls: string[] = []; + for (const slug of slugs) { + const parts = slug.split('__'); + if (parts.length === 3) { + const [provider, owner, repo] = parts; + if (provider === 'github') { + urls.push(`https://github.com/${owner}/${repo}`); + } else { + urls.push(`https://git.woa.com/${owner}/${repo}`); + } + } else { + urls.push(defaultUrl); + } + } + + const domainsYaml = + opts.domainsYaml ?? + `version: 1\ndomains:\n - name: core\n description: core services\n repos:\n${urls.map((u) => ` - url: "${u}"`).join('\n')}\n`; + await fs.writeFile(path.join(teamaiDir, 'domains.yaml'), domainsYaml, 'utf8'); + + // Default repo-whitelist.yaml (match the urls in domains.yaml) + if (opts.repoWhitelist !== undefined) { + await fs.writeFile(path.join(teamaiDir, 'repo-whitelist.yaml'), opts.repoWhitelist, 'utf8'); + } else { + // Create a default whitelist matching the urls so whitelist cross-check passes + const defaultWhitelist = + `version: 1\nrepos:\n${urls.map((u) => ` - url: "${u}"`).join('\n')}\n`; + await fs.writeFile(path.join(teamaiDir, 'repo-whitelist.yaml'), defaultWhitelist, 'utf8'); + } + + // Write repo .md files + for (const slug of slugs) { + const fm = { + title: 'Codebase 概览', + lastUpdated: isoAgo(1), + source: path.join(os.homedir(), '.teamai', 'cache', 'repos', 'placeholder'), + generator: 'teamai-cli', + schemaVersion: 1, + ...(opts.repoFrontmatter ?? {}), + }; + const fmLines = Object.entries(fm).map(([k, v]) => `${k}: ${JSON.stringify(v)}`); + const content = `---\n${fmLines.join('\n')}\n---\n\n# ${slug}\n`; + await fs.writeFile(path.join(reposDir, `${slug}.md`), content, 'utf8'); + } + + // Write domain .md files + const domainNames = opts.domainNames ?? ['core']; + for (const name of domainNames) { + const fm = { + domain: name, + description: `${name} domain`, + repo_count: slugs.length, + last_synced: isoAgo(1), + generator: 'teamai import (P5.2 aggregate)', + ...(opts.domainFrontmatter ?? {}), + }; + const fmLines = Object.entries(fm).map(([k, v]) => `${k}: ${JSON.stringify(v)}`); + const tableRows = slugs + .map((s) => `| ${s} | desc | - |`) + .join('\n'); + const content = + `---\n${fmLines.join('\n')}\n---\n\n## 仓库列表\n\n| 仓库 | 描述 | 域 |\n|------|------|----|\n${tableRows}\n`; + await fs.writeFile(path.join(domainsDir, `domain-${name}.md`), content, 'utf8'); + } + + // Write index.md + const repoCnt = opts.repoCount ?? slugs.length; + const domainCnt = opts.domainCount ?? domainNames.length; + const indexFm = { + generator: 'teamai import (P5.2 aggregate)', + last_generated: isoAgo(1), + domain_count: domainCnt, + repo_count: repoCnt, + schemaVersion: 1, + ...(opts.indexFrontmatter ?? {}), + }; + const indexFmLines = Object.entries(indexFm).map(([k, v]) => `${k}: ${JSON.stringify(v)}`); + await fs.writeFile( + path.join(opts.cwd, 'docs', 'team-codebase', 'index.md'), + `---\n${indexFmLines.join('\n')}\n---\n\n# Team Codebase\n`, + 'utf8' + ); + + if (opts.externalKnowledge !== undefined) { + await fs.writeFile( + path.join(opts.cwd, 'docs', 'team-codebase', 'external-knowledge.md'), + opts.externalKnowledge, + 'utf8' + ); + } + + if (opts.sourceMarks !== undefined) { + await fs.writeFile(path.join(teamaiDir, 'source-marks.jsonl'), opts.sourceMarks, 'utf8'); + } + + if (opts.pendingReview !== undefined) { + await fs.writeFile(path.join(teamaiDir, 'pending-review.jsonl'), opts.pendingReview, 'utf8'); + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('lintTeamCodebase', () => { + let tmpdir: string; + + beforeEach(async () => { + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), 'codebase-lint-')); + }); + + afterEach(async () => { + await fs.remove(tmpdir); + }); + + it('全部正确时返回 0 issues', async () => { + await scaffold({ cwd: tmpdir }); + const report = await lintTeamCodebase({ cwd: tmpdir }); + // Only filter out source-invalid info (CI env has no cache) + const nonInfoIssues = report.issues.filter((i) => i.severity !== 'info'); + expect(nonInfoIssues).toHaveLength(0); + }); + + it('anchor-unclosed 报 high', async () => { + await scaffold({ + cwd: tmpdir, + externalKnowledge: [ + '', + 'no close tag here', + ].join('\n'), + }); + const report = await lintTeamCodebase({ cwd: tmpdir }); + const anchorIssues = report.issues.filter((i) => i.category === 'anchor-unclosed'); + expect(anchorIssues.length).toBeGreaterThanOrEqual(1); + expect(anchorIssues[0].severity).toBe('high'); + expect(anchorIssues[0].fixable).toBe(false); + }); + + it('orphan-md 报 high 且 fixable=true', async () => { + // Create a md that has no corresponding url in domains.yaml + await scaffold({ cwd: tmpdir, repoSlugs: ['github__org__repo-a'] }); + // Add an extra orphan md + await fs.writeFile( + path.join(tmpdir, 'docs', 'team-codebase', 'repos', 'github__org__orphan.md'), + '---\ntitle: Orphan\ngenerator: teamai-cli\nschemaVersion: 1\n---\n\n# orphan\n', + 'utf8' + ); + const report = await lintTeamCodebase({ cwd: tmpdir }); + const orphanIssues = report.issues.filter((i) => i.category === 'orphan-md'); + expect(orphanIssues.length).toBeGreaterThanOrEqual(1); + expect(orphanIssues[0].severity).toBe('high'); + expect(orphanIssues[0].fixable).toBe(true); + }); + + it('sync-stale 90天前 报 medium', async () => { + await scaffold({ + cwd: tmpdir, + repoFrontmatter: { lastUpdated: isoAgo(90) }, + }); + const report = await lintTeamCodebase({ cwd: tmpdir, staleDays: 60 }); + const staleIssues = report.issues.filter((i) => i.category === 'sync-stale'); + expect(staleIssues.length).toBeGreaterThanOrEqual(1); + expect(staleIssues[0].severity).toBe('medium'); + }); + + it('index-mismatch 报 medium 且 fixable=true', async () => { + await scaffold({ + cwd: tmpdir, + repoCount: 99, // wrong count + }); + const report = await lintTeamCodebase({ cwd: tmpdir }); + const mismatchIssues = report.issues.filter((i) => i.category === 'index-mismatch'); + expect(mismatchIssues.length).toBeGreaterThanOrEqual(1); + expect(mismatchIssues[0].severity).toBe('medium'); + expect(mismatchIssues[0].fixable).toBe(true); + }); + + it('frontmatter-missing 报 high 当缺失 title', async () => { + await scaffold({ + cwd: tmpdir, + repoFrontmatter: { title: '' }, + }); + const report = await lintTeamCodebase({ cwd: tmpdir }); + const fmIssues = report.issues.filter( + (i) => i.category === 'frontmatter-missing' && i.description.includes('title') + ); + expect(fmIssues.length).toBeGreaterThanOrEqual(1); + expect(fmIssues[0].severity).toBe('high'); + }); + + it('aggregate-row-mismatch 报 low', async () => { + // The domain md has only 1 row but domains.yaml has 2 repos + const url1 = 'https://github.com/org/repo-a'; + const url2 = 'https://github.com/org/repo-b'; + const domainsYaml = `version: 1\ndomains:\n - name: core\n description: core\n repos:\n - url: "${url1}"\n - url: "${url2}"\n`; + await scaffold({ + cwd: tmpdir, + repoSlugs: ['github__org__repo-a', 'github__org__repo-b'], + domainsYaml, + }); + // Rewrite domain md with only 1 row + const domainsDir = path.join(tmpdir, 'docs', 'team-codebase', 'domains'); + await fs.writeFile( + path.join(domainsDir, 'domain-core.md'), + '---\ndomain: "core"\ngenerator: "teamai import (P5.2 aggregate)"\n---\n\n## 仓库列表\n\n| 仓库 | 描述 | 域 |\n|------|------|----|\n| repo-a | desc | core |\n', + 'utf8' + ); + const report = await lintTeamCodebase({ cwd: tmpdir }); + const rowIssues = report.issues.filter((i) => i.category === 'aggregate-row-mismatch'); + expect(rowIssues.length).toBeGreaterThanOrEqual(1); + expect(rowIssues[0].severity).toBe('low'); + }); + + it('pending-review-backlog 超阈值报 info', async () => { + const lines = Array.from({ length: 12 }, (_, i) => + JSON.stringify({ id: i, file: 'test.md', section: 'sec' }) + ).join('\n'); + await scaffold({ + cwd: tmpdir, + pendingReview: lines, + }); + const report = await lintTeamCodebase({ cwd: tmpdir, pendingReviewThreshold: 10 }); + const backlogIssues = report.issues.filter( + (i) => i.category === 'pending-review-backlog' + ); + expect(backlogIssues.length).toBe(1); + expect(backlogIssues[0].severity).toBe('info'); + }); + + it('multi-source-conflict 24h 内不同 source 报 medium', async () => { + const now = new Date().toISOString(); + const lines = [ + JSON.stringify({ file: 'test.md', section: 'biz', source: 'iwiki://a', ts: now }), + JSON.stringify({ file: 'test.md', section: 'biz', source: 'iwiki://b', ts: now }), + ].join('\n'); + await scaffold({ cwd: tmpdir, sourceMarks: lines }); + const report = await lintTeamCodebase({ cwd: tmpdir }); + const conflictIssues = report.issues.filter( + (i) => i.category === 'multi-source-conflict' + ); + expect(conflictIssues.length).toBeGreaterThanOrEqual(1); + expect(conflictIssues[0].severity).toBe('medium'); + }); + + it('severity 过滤只返回 >= medium 的问题', async () => { + const lines = Array.from({ length: 12 }, (_, i) => + JSON.stringify({ id: i, file: 'test.md', section: 'sec' }) + ).join('\n'); + await scaffold({ + cwd: tmpdir, + pendingReview: lines, + }); + const report = await lintTeamCodebase({ cwd: tmpdir, severity: 'medium' }); + const infoIssues = report.issues.filter((i) => i.severity === 'info'); + expect(infoIssues).toHaveLength(0); + }); +}); diff --git a/src/__tests__/codebase.test.ts b/src/__tests__/codebase.test.ts new file mode 100644 index 0000000..fe63ede --- /dev/null +++ b/src/__tests__/codebase.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ─── mock gray-matter ────────────────────────────────────────────────────── +vi.mock('gray-matter', () => ({ + default: vi.fn((content: string) => { + // 简单模拟:识别 ---\ntags: [a, b]\n--- 格式 + const match = content.match(/^---\ntags:\s*\[([^\]]*)\]\n---/); + if (match) { + const tags = match[1]!.split(',').map((t) => t.trim()).filter(Boolean); + return { data: { tags }, content }; + } + return { data: {}, content }; + }), +})); + +// ─── mock node:fs ────────────────────────────────────────────────────────── +vi.mock('node:fs', () => ({ + default: { + existsSync: vi.fn(() => false), + readdirSync: vi.fn(() => []), + readFileSync: vi.fn(() => ''), + }, + existsSync: vi.fn(() => false), + readdirSync: vi.fn(() => []), + readFileSync: vi.fn(() => ''), +})); + +// ─── mock node:child_process ─────────────────────────────────────────────── +vi.mock('node:child_process', () => ({ + execSync: vi.fn(() => ''), +})); + +// ─── mock utils/git ──────────────────────────────────────────────────────── +vi.mock('../utils/git.js', () => ({ + createGit: vi.fn(() => ({ + log: vi.fn(async () => ({ all: [] })), + })), +})); + +// ─── mock utils/ai-client ───────────────────────────────────────────────── +vi.mock('../utils/ai-client.js', () => ({ + callClaude: vi.fn(), +})); + +import fs from 'node:fs'; +import { callClaude } from '../utils/ai-client.js'; +import { + generateCodebaseMd, + generateCodebaseIndex, + lintCodebaseMd, +} from '../codebase.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +const mockCallClaude = vi.mocked(callClaude); +const mockFsExistsSync = vi.mocked(fs.existsSync); +const mockFsReaddirSync = vi.mocked(fs.readdirSync); +const mockFsReadFileSync = vi.mocked(fs.readFileSync); + +beforeEach(() => { + vi.clearAllMocks(); + // 默认:fs 调用都返回"不存在" + mockFsExistsSync.mockReturnValue(false); + mockFsReaddirSync.mockReturnValue([]); + mockFsReadFileSync.mockReturnValue(''); +}); + +// ─── generateCodebaseMd ──────────────────────────────────────────────────── + +describe('generateCodebaseMd', () => { + it('输出顶部应包含标准 frontmatter(lastUpdated / source / generator)', async () => { + mockCallClaude.mockResolvedValue('# Codebase 概览\n\n## 项目概述\n内容'); + + const result = await generateCodebaseMd({ repoPath: '/repo/test' }); + + expect(result).toMatch(/^---\n/); + expect(result).toContain('lastUpdated:'); + expect(result).toContain('source: /repo/test'); + expect(result).toContain('generator: teamai-cli'); + }); + + it('AI 输出已含 frontmatter 时应去重,最终只有一份 frontmatter', async () => { + const aiOutputWithFrontmatter = + '---\ntitle: 旧标题\n---\n\n# Codebase 概览\n\n## 项目概述\n内容'; + mockCallClaude.mockResolvedValue(aiOutputWithFrontmatter); + + const result = await generateCodebaseMd({ repoPath: '/repo/test' }); + + // 只应出现一次 `---\n`(即新 frontmatter 的开头) + const frontmatterCount = (result.match(/^---$/gm) ?? []).length; + expect(frontmatterCount).toBe(2); // 开头 --- 和结束 --- + expect(result).toContain('generator: teamai-cli'); + // 旧 frontmatter 内容不应保留 + expect(result).not.toContain('旧标题'); + }); + + it('有 learningsSuggestions 时,callClaude 的 prompt 应包含建议内容', async () => { + mockCallClaude.mockResolvedValue('# Codebase 概览'); + + await generateCodebaseMd({ + repoPath: '/repo/test', + learningsSuggestions: [ + { section: '技术栈', action: 'update', content: '新增 vitest 依赖' }, + ], + }); + + const prompt = mockCallClaude.mock.calls[0]![0] as string; + expect(prompt).toContain('最近 MR 提炼建议'); + expect(prompt).toContain('技术栈'); + expect(prompt).toContain('新增 vitest 依赖'); + }); + + it('有 learningsDir 且目录存在时,prompt 应包含高频标签', async () => { + // 模拟 learningsDir 存在并有两个 .md 文件 + mockFsExistsSync.mockImplementation((p: fs.PathLike) => { + return String(p) === '/repo/test/learnings'; + }); + mockFsReaddirSync.mockImplementation((p: fs.PathLike | fs.PathOrFileDescriptor) => { + if (String(p) === '/repo/test/learnings') { + return ['a.md', 'b.md'] as unknown as ReturnType; + } + return [] as unknown as ReturnType; + }); + mockFsReadFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => { + if (String(p).endsWith('.md')) { + return '---\ntags: [typescript, testing]\n---\n内容'; + } + return ''; + }); + + mockCallClaude.mockResolvedValue('# Codebase 概览'); + + await generateCodebaseMd({ + repoPath: '/repo/test', + learningsDir: '/repo/test/learnings', + }); + + const prompt = mockCallClaude.mock.calls[0]![0] as string; + expect(prompt).toContain('高频标签'); + }); +}); + +// ─── generateCodebaseIndex ───────────────────────────────────────────────── + +describe('generateCodebaseIndex', () => { + it('happy path:AI 返回合法 JSON,输出应含表格行', async () => { + const validJson = JSON.stringify([ + { section: '项目概述', summary: '描述项目背景', keywords: ['CLI', 'TypeScript'] }, + { section: '技术栈', summary: '列出使用的技术', keywords: ['Node.js', 'vitest', 'tsup'] }, + ]); + mockCallClaude.mockResolvedValue(validJson); + + const result = await generateCodebaseIndex('# Codebase\n\n## 项目概述\n内容'); + + expect(result).toContain('| 章节 | 摘要 | 关键词 |'); + expect(result).toContain('项目概述'); + expect(result).toContain('技术栈'); + expect(result).toContain('lastUpdated:'); + }); + + it('AI 返回非 JSON 时,应不抛异常并返回兜底 markdown', async () => { + mockCallClaude.mockResolvedValue('抱歉,我无法生成索引。'); + + const result = await generateCodebaseIndex('# Codebase'); + + expect(result).not.toThrow; + expect(result).toContain('title: Codebase 索引'); + expect(result).toContain('⚠️'); + }); + + it('AI 返回包裹在代码块中的 JSON 时,应能正确解析', async () => { + const validJson = JSON.stringify([ + { section: '测试覆盖', summary: '测试策略与覆盖率', keywords: ['unit', 'e2e'] }, + ]); + mockCallClaude.mockResolvedValue(`\`\`\`json\n${validJson}\n\`\`\``); + + const result = await generateCodebaseIndex('# Codebase\n\n## 测试覆盖\n内容'); + + expect(result).toContain('测试覆盖'); + }); +}); + +// ─── lintCodebaseMd ──────────────────────────────────────────────────────── + +describe('lintCodebaseMd', () => { + it('happy path:AI 返回合法 JSON,应正确解析 issues 列表', async () => { + const validJson = JSON.stringify({ + summary: '发现 2 个问题', + issues: [ + { + severity: 'high', + category: 'outdated', + location: '技术栈', + description: 'Node 版本已过时', + suggestion: '更新至 Node 20', + }, + { + severity: 'medium', + category: 'missing', + location: '测试覆盖', + description: '缺少 E2E 测试说明', + suggestion: '补充 E2E 章节', + }, + ], + }); + mockCallClaude.mockResolvedValue(validJson); + + const report = await lintCodebaseMd('# Codebase'); + + expect(report.summary).toBe('发现 2 个问题'); + expect(report.issues).toHaveLength(2); + expect(report.issues[0]!.severity).toBe('high'); + expect(report.issues[0]!.category).toBe('outdated'); + }); + + it('AI 返回非 JSON 时,应不抛异常并返回兜底 report', async () => { + mockCallClaude.mockResolvedValue('文档看起来不错!'); + + const report = await lintCodebaseMd('# Codebase'); + + expect(report.issues).toEqual([]); + expect(report.summary).toBe('解析失败,无法 lint'); + }); + + it('callClaude 抛出异常时,应不向上传播并返回兜底 report', async () => { + mockCallClaude.mockRejectedValue(new Error('AI 服务不可用')); + + const report = await lintCodebaseMd('# Codebase'); + + expect(report.issues).toEqual([]); + expect(report.summary).toBe('解析失败,无法 lint'); + }); + + it('AI 返回包含 JSON 的混合文本时,应能提取 JSON', async () => { + const validJson = JSON.stringify({ + summary: '无问题', + issues: [], + }); + mockCallClaude.mockResolvedValue(`以下是检查结果:\n${validJson}\n感谢使用。`); + + const report = await lintCodebaseMd('# Codebase'); + + expect(report.summary).toBe('无问题'); + expect(report.issues).toHaveLength(0); + }); +}); diff --git a/src/__tests__/dedup.test.ts b/src/__tests__/dedup.test.ts new file mode 100644 index 0000000..7edcfea --- /dev/null +++ b/src/__tests__/dedup.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { extractKeywords, overlapRatio, findSupersededLearnings } from '../utils/dedup.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-dedup-test-')); +} + +function formatDatePrefix(daysAgo: number): string { + const date = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); + return date.toISOString().slice(0, 10); +} + +// ─── extractKeywords ─────────────────────────────────────────────────────── + +describe('extractKeywords', () => { + it('提取英文关键词,过滤停用词', () => { + const keywords = extractKeywords('The quick brown fox'); + expect(keywords.has('quick')).toBe(true); + expect(keywords.has('brown')).toBe(true); + expect(keywords.has('fox')).toBe(true); + expect(keywords.has('the')).toBe(false); + }); + + it('提取 CJK 关键词,过滤 CJK 停用词', () => { + const keywords = extractKeywords('优化性能问题的解决方案'); + // '的' 是 CJK 停用词,不应出现 + expect(keywords.has('的')).toBe(false); + // 其余非停用词单字应出现 + expect(keywords.has('优')).toBe(true); + expect(keywords.has('化')).toBe(true); + expect(keywords.has('性')).toBe(true); + expect(keywords.has('能')).toBe(true); + }); + + it('过滤长度 < 2 的英文词(单字母)', () => { + const keywords = extractKeywords('a b c do run'); + expect(keywords.has('a')).toBe(false); + expect(keywords.has('b')).toBe(false); + expect(keywords.has('c')).toBe(false); + // 'do' 是停用词,'run' 应出现 + expect(keywords.has('run')).toBe(true); + }); +}); + +// ─── overlapRatio ────────────────────────────────────────────────────────── + +describe('overlapRatio', () => { + it('完全相同集合返回 1.0', () => { + const setA = new Set(['a', 'b', 'c']); + const setB = new Set(['a', 'b', 'c']); + expect(overlapRatio(setA, setB)).toBe(1.0); + }); + + it('完全不同集合返回 0.0', () => { + const setA = new Set(['a', 'b']); + const setB = new Set(['c', 'd']); + expect(overlapRatio(setA, setB)).toBe(0.0); + }); + + it('部分重叠:{a,b,c} 和 {b,c,d} 返回 0.5', () => { + const setA = new Set(['a', 'b', 'c']); + const setB = new Set(['b', 'c', 'd']); + // 交集 {b,c}=2,并集 {a,b,c,d}=4,Jaccard=0.5 + expect(overlapRatio(setA, setB)).toBe(0.5); + }); + + it('空集合返回 0', () => { + const empty = new Set(); + const nonEmpty = new Set(['a', 'b']); + expect(overlapRatio(empty, nonEmpty)).toBe(0); + expect(overlapRatio(nonEmpty, empty)).toBe(0); + expect(overlapRatio(empty, empty)).toBe(0); + }); +}); + +// ─── findSupersededLearnings ─────────────────────────────────────────────── + +describe('findSupersededLearnings', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('正常:14 天内文件关键词高度重叠,应返回该文件且 overlap ≥ 0.6', async () => { + const prefix = formatDatePrefix(3); // 3 天前 + const filename = `${prefix}-optimize-performance.md`; + const content = `--- +title: "optimize performance solution" +--- +optimize performance solution issue resolve method +`; + fs.writeFileSync(path.join(tmpDir, filename), content, 'utf-8'); + + const draftKeywords = new Set(['optimize', 'performance', 'solution', 'issue', 'resolve']); + const results = await findSupersededLearnings(draftKeywords, tmpDir, 14); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].filename).toBe(filename); + expect(results[0].overlap).toBeGreaterThanOrEqual(0.6); + }); + + it('超出 14 天的文件不应返回', async () => { + const prefix = formatDatePrefix(20); // 20 天前 + const filename = `${prefix}-old-learning.md`; + const content = `--- +title: "old learning" +--- +optimize performance solution issue resolve method +`; + fs.writeFileSync(path.join(tmpDir, filename), content, 'utf-8'); + + const draftKeywords = new Set(['optimize', 'performance', 'solution', 'issue', 'resolve']); + const results = await findSupersededLearnings(draftKeywords, tmpDir, 14); + + expect(results).toHaveLength(0); + }); + + it('目录不存在时返回空数组', async () => { + const nonExistentDir = path.join(tmpDir, 'not-exist'); + const draftKeywords = new Set(['optimize', 'performance']); + const results = await findSupersededLearnings(draftKeywords, nonExistentDir, 14); + + expect(results).toEqual([]); + }); + + it('低重叠(< 0.6)的文件不应返回', async () => { + const prefix = formatDatePrefix(1); // 1 天前 + const filename = `${prefix}-unrelated.md`; + const content = `--- +title: "unrelated topic" +--- +kubernetes docker container deployment cluster +`; + fs.writeFileSync(path.join(tmpDir, filename), content, 'utf-8'); + + const draftKeywords = new Set(['python', 'pandas', 'dataframe', 'numpy', 'csv']); + const results = await findSupersededLearnings(draftKeywords, tmpDir, 14); + + expect(results).toHaveLength(0); + }); +}); diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index f0aeb34..f65417c 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -144,6 +144,8 @@ describe('doctor — hook checks', () => { expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('dashboard-report'); expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('contribute-check'); expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('auto-recall'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toHaveLength(7); + expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('todowrite-hint'); + expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('mr-hint'); + expect(TEAMAI_HOOK_SUBCOMMANDS).toHaveLength(9); }); }); diff --git a/src/__tests__/domain-drift.test.ts b/src/__tests__/domain-drift.test.ts new file mode 100644 index 0000000..c2d52b0 --- /dev/null +++ b/src/__tests__/domain-drift.test.ts @@ -0,0 +1,184 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs-extra'; + +// ─── Mocks ────────────────────────────────────────────── + +vi.mock('../domains/recommend.js', () => ({ + recommendDomain: vi.fn(), +})); + +vi.mock('../domains/store.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + appendHistory: vi.fn().mockResolvedValue(undefined), + }; +}); + +// ─── Imports (after mocks) ─────────────────────────────── + +import { detectDomainDrift } from '../import-repo.js'; +import { recommendDomain } from '../domains/recommend.js'; +import { appendHistory } from '../domains/store.js'; +import type { DomainsFile } from '../domains/index.js'; + +// ─── Helpers ──────────────────────────────────────────── + +function buildDomains( + repoUrl: string, + domainName: string, + repoConfidence: number, +): DomainsFile { + return { + version: 1, + confidence_threshold: 0.6, + domains: [ + { + name: domainName, + description: '', + repos: [ + { url: repoUrl, confidence: repoConfidence, signal: 'test', locked: false }, + ], + }, + ], + }; +} + +async function makeWorkdir(): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-drift-test-')); + await fs.ensureDir(path.join(tmpDir, '.teamai')); + return tmpDir; +} + +// ─── Tests ────────────────────────────────────────────── + +describe('detectDomainDrift', () => { + let workdir: string; + const TEST_URL = 'https://github.com/owner/testrepo'; + const OLD_SHA = 'oldsha0001234567890abcdef1234567890abcdef'; + const NEW_SHA = 'newsha0001234567890abcdef1234567890abcdef'; + const newMeta = { url: TEST_URL, name: 'testrepo' }; + + beforeEach(async () => { + workdir = await makeWorkdir(); + vi.mocked(appendHistory).mockClear(); + vi.mocked(recommendDomain).mockClear(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.remove(workdir); + }); + + it('oldSha 为 null 时不报告(非增量场景)', async () => { + const domains = buildDomains(TEST_URL, '推理', 0.84); + vi.mocked(recommendDomain).mockResolvedValue({ + domain: '平台', confidence: 0.9, signal: 'test', alternatives: [], + }); + + await detectDomainDrift({ + cwd: workdir, url: TEST_URL, newMeta, domains, + oldSha: null, newSha: NEW_SHA, + }); + + expect(appendHistory).not.toHaveBeenCalled(); + }); + + it('推荐域与当前域相同 → 不报告', async () => { + const domains = buildDomains(TEST_URL, '推理', 0.84); + vi.mocked(recommendDomain).mockResolvedValue({ + domain: '推理', confidence: 0.9, signal: 'test', alternatives: [], + }); + + await detectDomainDrift({ + cwd: workdir, url: TEST_URL, newMeta, domains, + oldSha: OLD_SHA, newSha: NEW_SHA, + }); + + expect(appendHistory).not.toHaveBeenCalled(); + }); + + it('推荐不同域 + confidence > 0.5 + 偏差 > 0.4 → appendHistory 被调', async () => { + const domains = buildDomains(TEST_URL, '推理', 0.5); + vi.mocked(recommendDomain).mockResolvedValue({ + domain: '平台', confidence: 0.95, signal: 'README changed', alternatives: [], + }); + + await detectDomainDrift({ + cwd: workdir, url: TEST_URL, newMeta, domains, + oldSha: OLD_SHA, newSha: NEW_SHA, + }); + + expect(appendHistory).toHaveBeenCalledTimes(1); + const callArg = vi.mocked(appendHistory).mock.calls[0]![1]; + expect(callArg.action).toBe('recommend'); + expect(callArg.details.kind).toBe('drift'); + expect(callArg.details.oldDomain).toBe('推理'); + expect(callArg.details.newRecommendedDomain).toBe('平台'); + }); + + it('推荐不同域但 confidence <= 0.5 → 不报告', async () => { + const domains = buildDomains(TEST_URL, '推理', 0.84); + vi.mocked(recommendDomain).mockResolvedValue({ + domain: '平台', confidence: 0.45, signal: 'low confidence', alternatives: [], + }); + + await detectDomainDrift({ + cwd: workdir, url: TEST_URL, newMeta, domains, + oldSha: OLD_SHA, newSha: NEW_SHA, + }); + + expect(appendHistory).not.toHaveBeenCalled(); + }); + + it('推荐不同域但偏差 <= 0.4 → 不报告', async () => { + const domains = buildDomains(TEST_URL, '推理', 0.75); + // confidence 差值 = |0.9 - 0.75| = 0.15 <= 0.4 + vi.mocked(recommendDomain).mockResolvedValue({ + domain: '平台', confidence: 0.9, signal: 'small diff', alternatives: [], + }); + + await detectDomainDrift({ + cwd: workdir, url: TEST_URL, newMeta, domains, + threshold: 0.4, + oldSha: OLD_SHA, newSha: NEW_SHA, + }); + + expect(appendHistory).not.toHaveBeenCalled(); + }); + + it('url 不在任何域中 → 跳过(不调 recommendDomain)', async () => { + const domains: DomainsFile = { + version: 1, + confidence_threshold: 0.6, + domains: [ + { name: '推理', description: '', repos: [] }, + ], + }; + + await detectDomainDrift({ + cwd: workdir, url: TEST_URL, newMeta, domains, + oldSha: OLD_SHA, newSha: NEW_SHA, + }); + + expect(recommendDomain).not.toHaveBeenCalled(); + expect(appendHistory).not.toHaveBeenCalled(); + }); + + it('recommendDomain 抛错 → 不阻塞主流程(不抛错)', async () => { + const domains = buildDomains(TEST_URL, '推理', 0.5); + vi.mocked(recommendDomain).mockRejectedValue(new Error('AI timeout')); + + await expect( + detectDomainDrift({ + cwd: workdir, url: TEST_URL, newMeta, domains, + oldSha: OLD_SHA, newSha: NEW_SHA, + }), + ).resolves.toBeUndefined(); + + expect(appendHistory).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/domain-inference.test.ts b/src/__tests__/domain-inference.test.ts new file mode 100644 index 0000000..ad28bce --- /dev/null +++ b/src/__tests__/domain-inference.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { inferDomain } from '../utils/search-index.js'; + +// --------------------------------------------------------------------------- +// P1.4: inferDomain() unit tests +// +// Verifies the four-layer priority: +// 1. frontmatter > 2. tags > 3. path > 4. type fallback +// --------------------------------------------------------------------------- + +describe('inferDomain()', () => { + // ── 1. Frontmatter override ───────────────────────────────────────────── + + it('frontmatter "technical" overrides ops tags', () => { + expect( + inferDomain('technical', ['k8s', 'deploy'], '/path/learnings/foo.md', 'learnings'), + ).toBe('technical'); + }); + + it('frontmatter "ops" overrides technical tags', () => { + expect( + inferDomain('ops', ['api', 'debug'], '/path/docs/bar.md', 'docs'), + ).toBe('ops'); + }); + + it('frontmatter "support" overrides type fallback', () => { + expect( + inferDomain('support', [], '/path/skills/helper.md', 'skills'), + ).toBe('support'); + }); + + it('frontmatter "neutral" is respected', () => { + expect( + inferDomain('neutral', ['api', 'debug'], '/path/learnings/foo.md', 'learnings'), + ).toBe('neutral'); + }); + + it('unknown frontmatter value falls through to tag inference', () => { + // 'infra' is not a valid KnowledgeDomain — should fall through to tags + expect( + inferDomain('infra', ['k8s', 'deploy'], '/path/learnings/foo.md', 'learnings'), + ).toBe('ops'); + }); + + // ── 2. Tag-based inference ─────────────────────────────────────────────── + + it('detects technical from "api" and "debug" tags', () => { + expect( + inferDomain(undefined, ['api', 'debug'], '/path/learnings/foo.md', 'learnings'), + ).toBe('technical'); + }); + + it('detects ops from "k8s" and "deploy" tags', () => { + expect( + inferDomain(undefined, ['k8s', 'deploy'], '/path/learnings/bar.md', 'learnings'), + ).toBe('ops'); + }); + + it('detects support from "faq" and "user" tags', () => { + expect( + inferDomain(undefined, ['faq', 'user'], '/path/docs/guide.md', 'docs'), + ).toBe('support'); + }); + + it('tie-break: technical beats ops when both score equally', () => { + // One tag from each domain → technical wins + expect( + inferDomain(undefined, ['api', 'k8s'], '/path/learnings/mixed.md', 'learnings'), + ).toBe('technical'); + }); + + it('tie-break: ops beats support when both score equally', () => { + expect( + inferDomain(undefined, ['deploy', 'user'], '/path/learnings/mixed.md', 'learnings'), + ).toBe('ops'); + }); + + // ── 3. Path-based inference ────────────────────────────────────────────── + + it('infers technical from docs/architecture/ path', () => { + expect( + inferDomain(undefined, [], '/home/user/.teamai/docs/architecture/design.md', 'docs'), + ).toBe('technical'); + }); + + it('infers ops from learnings/ops/ path', () => { + expect( + inferDomain(undefined, [], '/home/user/.teamai/learnings/ops/k8s-upgrade.md', 'learnings'), + ).toBe('ops'); + }); + + it('infers support from docs/support/ path', () => { + expect( + inferDomain(undefined, [], '/home/user/.teamai/docs/support/onboarding.md', 'docs'), + ).toBe('support'); + }); + + // ── 4. Type fallback ───────────────────────────────────────────────────── + + it('skills with no tags/path → technical', () => { + expect( + inferDomain(undefined, [], '/home/user/.claude/agents/skill.md', 'skills'), + ).toBe('technical'); + }); + + it('rules with no tags/path → technical', () => { + expect( + inferDomain(undefined, [], '/home/user/.claude/rules/coding-style.md', 'rules'), + ).toBe('technical'); + }); + + it('learnings with no tags/path → neutral', () => { + expect( + inferDomain(undefined, [], '/home/user/.teamai/learnings/misc.md', 'learnings'), + ).toBe('neutral'); + }); + + it('docs with no tags/path → neutral', () => { + expect( + inferDomain(undefined, [], '/home/user/.teamai/docs/misc.md', 'docs'), + ).toBe('neutral'); + }); +}); diff --git a/src/__tests__/domains-review-ops.test.ts b/src/__tests__/domains-review-ops.test.ts new file mode 100644 index 0000000..9156777 --- /dev/null +++ b/src/__tests__/domains-review-ops.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { DomainsFile, HistoryEvent } from '../domains/schema.js'; + +// mock prompt 工具(在 import reviewDomains 之前) +vi.mock('../utils/prompt.js', () => ({ + askQuestion: vi.fn(), + askConfirmation: vi.fn(), +})); + +import { reviewDomains } from '../domains/review.js'; +import { askQuestion } from '../utils/prompt.js'; + +/** 构建一个简单的 DomainsFile 用于测试。 */ +function makeDraft(): DomainsFile { + return { + version: 1, + confidence_threshold: 0.6, + domains: [ + { + name: '基础设施', + description: '底层基础设施', + confidence: 0.9, + repos: [ + { + url: 'https://github.com/org/infra', + confidence: 0.9, + signal: '包含 k8s 配置', + locked: false, + }, + ], + }, + { + name: '前端应用', + description: '前端相关仓库', + confidence: 0.8, + repos: [ + { + url: 'https://github.com/org/webapp', + confidence: 0.3, // 低置信度 + signal: '包含 React 代码', + locked: false, + }, + ], + }, + ], + }; +} + +describe('reviewDomains — 操作事件', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('TTY 环境下输入 m 应触发 merge 事件', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true }); + + try { + // m 0 1 → 合并,然后 a → 接受 + vi.mocked(askQuestion) + .mockResolvedValueOnce('m 0 1') + .mockResolvedValueOnce('a'); + + const events: HistoryEvent[] = []; + const result = await reviewDomains(makeDraft(), { + onEvent: (e) => { events.push(e); }, + }); + + expect(result.finalize).toBe('save'); + const mergeEvent = events.find((e) => e.action === 'merge'); + expect(mergeEvent).toBeDefined(); + expect(mergeEvent?.details).toMatchObject({ into: '基础设施', merged: '前端应用' }); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, writable: true }); + } + }); + + it('TTY 环境下输入 e 应触发 rename 事件', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true }); + + try { + // e 0 → 重命名,然后 a → 接受 + vi.mocked(askQuestion) + .mockResolvedValueOnce('e 0') + .mockResolvedValueOnce('新域名') + .mockResolvedValueOnce('a'); + + const events: HistoryEvent[] = []; + const result = await reviewDomains(makeDraft(), { + onEvent: (e) => { events.push(e); }, + }); + + expect(result.finalize).toBe('save'); + const renameEvent = events.find((e) => e.action === 'rename'); + expect(renameEvent).toBeDefined(); + expect(renameEvent?.details).toMatchObject({ from: '基础设施', to: '新域名' }); + // 验证结果中域名已更新 + const updatedDomain = result.result.domains.find((d) => d.name === '新域名'); + expect(updatedDomain).toBeDefined(); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, writable: true }); + } + }); + + it('TTY 环境下输入 x 应触发 reassign 事件', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true }); + + try { + // x 1 0 基础设施 → 将前端应用[0]移到基础设施,然后 a + vi.mocked(askQuestion) + .mockResolvedValueOnce('x 1 0 基础设施') + .mockResolvedValueOnce('a'); + + const events: HistoryEvent[] = []; + const result = await reviewDomains(makeDraft(), { + onEvent: (e) => { events.push(e); }, + }); + + expect(result.finalize).toBe('save'); + const reassignEvent = events.find((e) => e.action === 'reassign'); + expect(reassignEvent).toBeDefined(); + expect(reassignEvent?.details).toMatchObject({ + url: 'https://github.com/org/webapp', + from: '前端应用', + to: '基础设施', + }); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, writable: true }); + } + }); + + it('TTY 环境下输入 l 应触发 lock 事件', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true }); + + try { + vi.mocked(askQuestion) + .mockResolvedValueOnce('l 0 0') + .mockResolvedValueOnce('a'); + + const events: HistoryEvent[] = []; + const result = await reviewDomains(makeDraft(), { + onEvent: (e) => { events.push(e); }, + }); + + expect(result.finalize).toBe('save'); + const lockEvent = events.find((e) => e.action === 'lock'); + expect(lockEvent).toBeDefined(); + // 验证仓库已被锁定 + const lockedRepo = result.result.domains[0]?.repos[0]; + expect(lockedRepo?.locked).toBe(true); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, writable: true }); + } + }); +}); diff --git a/src/__tests__/domains-review.test.ts b/src/__tests__/domains-review.test.ts new file mode 100644 index 0000000..651c0fe --- /dev/null +++ b/src/__tests__/domains-review.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { DomainsFile, HistoryEvent } from '../domains/schema.js'; + +// mock prompt 工具(在 import reviewDomains 之前) +vi.mock('../utils/prompt.js', () => ({ + askQuestion: vi.fn(), + askConfirmation: vi.fn(), +})); + +import { reviewDomains } from '../domains/review.js'; +import { askQuestion } from '../utils/prompt.js'; + +/** 构建一个简单的 DomainsFile 用于测试。 */ +function makeDraft(): DomainsFile { + return { + version: 1, + confidence_threshold: 0.6, + domains: [ + { + name: '基础设施', + description: '底层基础设施', + confidence: 0.9, + repos: [ + { + url: 'https://github.com/org/infra', + confidence: 0.9, + signal: '包含 k8s 配置', + locked: false, + }, + ], + }, + { + name: '前端应用', + description: '前端相关仓库', + confidence: 0.8, + repos: [ + { + url: 'https://github.com/org/webapp', + confidence: 0.3, // 低置信度 + signal: '包含 React 代码', + locked: false, + }, + ], + }, + ], + }; +} + +describe('reviewDomains', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('非 TTY 环境下应直接返回 draft + finalize="draft"', async () => { + // 确保非 TTY 环境(CI 环境下 isTTY 通常为 undefined/false) + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true }); + + try { + const draft = makeDraft(); + const result = await reviewDomains(draft); + expect(result.finalize).toBe('draft'); + expect(result.result).toEqual(draft); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, writable: true }); + } + }); + + it('非 TTY 下不应调用 askQuestion', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true }); + + try { + await reviewDomains(makeDraft()); + expect(askQuestion).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, writable: true }); + } + }); + + it('TTY 环境下输入 a 应返回 finalize="save"', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true }); + + try { + vi.mocked(askQuestion).mockResolvedValueOnce('a'); + + const events: HistoryEvent[] = []; + const result = await reviewDomains(makeDraft(), { + onEvent: (e) => { events.push(e); }, + }); + + expect(result.finalize).toBe('save'); + expect(events).toHaveLength(1); + expect(events[0]?.action).toBe('accept'); + expect(events[0]?.actor).toBe('user'); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, writable: true }); + } + }); + + it('TTY 环境下输入 q 选 3 应返回 finalize="abort"', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true }); + + try { + vi.mocked(askQuestion) + .mockResolvedValueOnce('q') + .mockResolvedValueOnce('3'); + + const draft = makeDraft(); + const result = await reviewDomains(draft); + + expect(result.finalize).toBe('abort'); + // abort 时返回原始 draft + expect(result.result).toEqual(draft); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, writable: true }); + } + }); +}); diff --git a/src/__tests__/domains-schema.test.ts b/src/__tests__/domains-schema.test.ts new file mode 100644 index 0000000..d5b606b --- /dev/null +++ b/src/__tests__/domains-schema.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from 'vitest'; +import { + DomainsFileSchema, + DomainEntrySchema, + RepoEntrySchema, + HistoryEventSchema, +} from '../domains/schema.js'; + +describe('RepoEntrySchema', () => { + it('应正确解析合法仓库条目', () => { + const result = RepoEntrySchema.parse({ url: 'https://github.com/org/repo' }); + expect(result.url).toBe('https://github.com/org/repo'); + expect(result.locked).toBe(false); + }); + + it('应拒绝非法 URL', () => { + expect(() => RepoEntrySchema.parse({ url: 'not-a-url' })).toThrow(); + }); + + it('应拒绝 confidence > 1', () => { + expect(() => + RepoEntrySchema.parse({ url: 'https://github.com/org/repo', confidence: 1.5 }) + ).toThrow(); + }); + + it('应拒绝 confidence < 0', () => { + expect(() => + RepoEntrySchema.parse({ url: 'https://github.com/org/repo', confidence: -0.1 }) + ).toThrow(); + }); + + it('signal 字段可选', () => { + const result = RepoEntrySchema.parse({ url: 'https://github.com/org/repo' }); + expect(result.signal).toBeUndefined(); + }); +}); + +describe('DomainEntrySchema', () => { + it('应拒绝缺少 name 的条目', () => { + expect(() => DomainEntrySchema.parse({ repos: [] })).toThrow(); + }); + + it('应拒绝空 name', () => { + expect(() => DomainEntrySchema.parse({ name: '' })).toThrow(); + }); + + it('description 默认为空字符串', () => { + const result = DomainEntrySchema.parse({ name: '基础设施' }); + expect(result.description).toBe(''); + }); + + it('repos 默认为空数组', () => { + const result = DomainEntrySchema.parse({ name: '基础设施' }); + expect(result.repos).toEqual([]); + }); +}); + +describe('DomainsFileSchema', () => { + it('version 默认为 1', () => { + const result = DomainsFileSchema.parse({}); + expect(result.version).toBe(1); + }); + + it('confidence_threshold 默认为 0.6', () => { + const result = DomainsFileSchema.parse({}); + expect(result.confidence_threshold).toBe(0.6); + }); + + it('domains 默认为空数组', () => { + const result = DomainsFileSchema.parse({}); + expect(result.domains).toEqual([]); + }); + + it('应拒绝 version 不为 1', () => { + expect(() => DomainsFileSchema.parse({ version: 2 })).toThrow(); + }); + + it('应拒绝 confidence_threshold > 1', () => { + expect(() => DomainsFileSchema.parse({ confidence_threshold: 1.5 })).toThrow(); + }); + + it('应正确解析完整的 domains 文件', () => { + const input = { + version: 1 as const, + confidence_threshold: 0.7, + domains: [ + { + name: '基础设施', + description: '底层基础设施仓库', + repos: [ + { + url: 'https://github.com/org/infra', + confidence: 0.9, + signal: '包含 k8s 配置', + }, + ], + }, + ], + }; + const result = DomainsFileSchema.parse(input); + expect(result.domains[0]?.name).toBe('基础设施'); + expect(result.domains[0]?.repos[0]?.locked).toBe(false); + }); + + it('generated_at 和 generator 字段可选', () => { + const result = DomainsFileSchema.parse({}); + expect(result.generated_at).toBeUndefined(); + expect(result.generator).toBeUndefined(); + }); +}); + +describe('HistoryEventSchema', () => { + it('应正确解析合法事件', () => { + const event = { + ts: '2024-01-01T00:00:00.000Z', + actor: 'ai' as const, + action: 'recommend' as const, + details: { domain: '基础设施' }, + }; + const result = HistoryEventSchema.parse(event); + expect(result.actor).toBe('ai'); + expect(result.action).toBe('recommend'); + }); + + it('应拒绝非法 actor', () => { + expect(() => + HistoryEventSchema.parse({ + ts: '2024-01-01T00:00:00.000Z', + actor: 'system', + action: 'accept', + details: {}, + }) + ).toThrow(); + }); + + it('应拒绝非法 action', () => { + expect(() => + HistoryEventSchema.parse({ + ts: '2024-01-01T00:00:00.000Z', + actor: 'user', + action: 'delete', + details: {}, + }) + ).toThrow(); + }); + + it('所有合法 action 枚举应通过', () => { + const validActions = ['recommend', 'accept', 'reject', 'merge', 'split', 'rename', 'lock', 'reassign']; + for (const action of validActions) { + expect(() => + HistoryEventSchema.parse({ + ts: '2024-01-01T00:00:00.000Z', + actor: 'user', + action, + details: {}, + }) + ).not.toThrow(); + } + }); +}); diff --git a/src/__tests__/domains-store.test.ts b/src/__tests__/domains-store.test.ts new file mode 100644 index 0000000..05c9705 --- /dev/null +++ b/src/__tests__/domains-store.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { + loadDomains, + loadDomainsDraft, + saveDomains, + saveDomainsDraft, + clearDomainsDraft, + appendHistory, +} from '../domains/store.js'; +import type { DomainsFile, HistoryEvent } from '../domains/schema.js'; + +/** 创建一个合法的 DomainsFile 用于测试。 */ +function makeDomainsFile(overrides: Partial = {}): DomainsFile { + return { + version: 1, + confidence_threshold: 0.6, + domains: [ + { + name: '基础设施', + description: '底层基础设施', + repos: [ + { + url: 'https://github.com/org/infra', + confidence: 0.9, + signal: '包含 k8s 配置', + locked: false, + }, + ], + }, + ], + ...overrides, + }; +} + +describe('domains store', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-domains-test-')); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + describe('loadDomains', () => { + it('文件不存在时返回带空 domains 的默认值', async () => { + const result = await loadDomains(tmpDir); + expect(result.version).toBe(1); + expect(result.domains).toEqual([]); + expect(result.confidence_threshold).toBe(0.6); + }); + + it('加载写入的数据后应往返一致', async () => { + const data = makeDomainsFile(); + await saveDomains(tmpDir, data); + const loaded = await loadDomains(tmpDir); + expect(loaded.version).toBe(1); + expect(loaded.domains[0]?.name).toBe('基础设施'); + expect(loaded.domains[0]?.repos[0]?.url).toBe('https://github.com/org/infra'); + }); + + it('加载非法 YAML 时应抛出含字段名的错误', async () => { + const filePath = path.join(tmpDir, '.teamai', 'domains.yaml'); + await fs.ensureDir(path.dirname(filePath)); + // url 故意写成非法值 + await fs.writeFile( + filePath, + 'version: 1\nconfidence_threshold: 0.6\n' + + 'domains:\n - name: test\n repos:\n' + + ' - url: not-valid-url\n', + 'utf8' + ); + await expect(loadDomains(tmpDir)).rejects.toThrow(/Invalid domains file/); + }); + }); + + describe('loadDomainsDraft', () => { + it('草稿不存在时返回 null', async () => { + const result = await loadDomainsDraft(tmpDir); + expect(result).toBeNull(); + }); + + it('加载草稿后应往返一致', async () => { + const data = makeDomainsFile({ generator: 'test-generator' }); + await saveDomainsDraft(tmpDir, data); + const loaded = await loadDomainsDraft(tmpDir); + expect(loaded).not.toBeNull(); + expect(loaded?.generator).toBe('test-generator'); + }); + }); + + describe('saveDomains 与 saveDomainsDraft', () => { + it('应写到不同路径', async () => { + const data = makeDomainsFile(); + await saveDomains(tmpDir, data); + await saveDomainsDraft(tmpDir, data); + + const domainsPath = path.join(tmpDir, '.teamai', 'domains.yaml'); + const draftPath = path.join(tmpDir, '.teamai', 'domains.draft.yaml'); + expect(await fs.pathExists(domainsPath)).toBe(true); + expect(await fs.pathExists(draftPath)).toBe(true); + }); + + it('应自动创建父目录', async () => { + const data = makeDomainsFile(); + await saveDomains(tmpDir, data); + const domainsPath = path.join(tmpDir, '.teamai', 'domains.yaml'); + expect(await fs.pathExists(domainsPath)).toBe(true); + }); + }); + + describe('clearDomainsDraft', () => { + it('草稿存在时应删除', async () => { + const data = makeDomainsFile(); + await saveDomainsDraft(tmpDir, data); + await clearDomainsDraft(tmpDir); + const draftPath = path.join(tmpDir, '.teamai', 'domains.draft.yaml'); + expect(await fs.pathExists(draftPath)).toBe(false); + }); + + it('草稿不存在时不报错', async () => { + await expect(clearDomainsDraft(tmpDir)).resolves.not.toThrow(); + }); + }); + + describe('appendHistory', () => { + it('多次调用应产生多行 jsonl', async () => { + const event1: HistoryEvent = { + ts: '2024-01-01T00:00:00.000Z', + actor: 'user', + action: 'accept', + details: { count: 3 }, + }; + const event2: HistoryEvent = { + ts: '2024-01-01T01:00:00.000Z', + actor: 'ai', + action: 'recommend', + details: { domain: '基础设施' }, + }; + + await appendHistory(tmpDir, event1); + await appendHistory(tmpDir, event2); + + const historyPath = path.join(tmpDir, '.teamai', 'domains.history.jsonl'); + const content = await fs.readFile(historyPath, 'utf8'); + const lines = content.trim().split('\n'); + expect(lines).toHaveLength(2); + + const parsed1 = JSON.parse(lines[0]!) as HistoryEvent; + const parsed2 = JSON.parse(lines[1]!) as HistoryEvent; + expect(parsed1.action).toBe('accept'); + expect(parsed2.action).toBe('recommend'); + }); + + it('history 文件父目录不存在时应自动创建', async () => { + const event: HistoryEvent = { + ts: '2024-01-01T00:00:00.000Z', + actor: 'user', + action: 'lock', + details: { url: 'https://github.com/org/repo' }, + }; + await expect(appendHistory(tmpDir, event)).resolves.not.toThrow(); + const historyPath = path.join(tmpDir, '.teamai', 'domains.history.jsonl'); + expect(await fs.pathExists(historyPath)).toBe(true); + }); + }); + + describe('loadDomains — 文件大小限制', () => { + it('文件超过 10 MB 时抛出 size 超限错误', async () => { + const domainsPath = path.join(tmpDir, '.teamai', 'domains.yaml'); + await fs.ensureDir(path.join(tmpDir, '.teamai')); + // 写入 11 MB 内容(真实文件,非 mock fs.stat) + const chunk = 'a'.repeat(1024 * 1024); // 1 MB + let content = ''; + for (let i = 0; i < 11; i++) content += chunk; + await fs.writeFile(domainsPath, content, 'utf8'); + + await expect(loadDomains(tmpDir)).rejects.toThrow('exceeds max allowed size 10MB'); + }); + }); +}); diff --git a/src/__tests__/drift-cmd.test.ts b/src/__tests__/drift-cmd.test.ts new file mode 100644 index 0000000..8cb37e9 --- /dev/null +++ b/src/__tests__/drift-cmd.test.ts @@ -0,0 +1,319 @@ +// -*- coding: utf-8 -*- +/** + * drift-cmd.test.ts — driftCmd 单元测试。 + */ + +import { describe, it, expect, beforeEach, vi, type MockInstance } from 'vitest'; + +// ─── Mocks ────────────────────────────────────────────── + +vi.mock('../review-store.js', () => ({ + loadPendingReview: vi.fn(), + removePendingReview: vi.fn(), + appendPendingReview: vi.fn(), +})); + +vi.mock('../domains/index.js', () => ({ + loadDomains: vi.fn(), + saveDomains: vi.fn(), + appendHistory: vi.fn(), +})); + +vi.mock('../aggregate.js', () => ({ + regenerateAggregate: vi.fn(), +})); + +vi.mock('../utils/team-codebase-paths.js', () => ({ + getTeamCodebasePaths: vi.fn().mockReturnValue({ reposDir: '/fake/repos', aggregateFile: '/fake/agg.md' }), +})); + +vi.mock('../utils/prompt.js', () => ({ + askConfirmation: vi.fn(), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// ─── Imports (after mocks) ─────────────────────────────── + +import { driftCmd } from '../drift-cmd.js'; +import { + loadPendingReview, + removePendingReview, +} from '../review-store.js'; +import { + loadDomains, + saveDomains, + appendHistory, +} from '../domains/index.js'; +import { regenerateAggregate } from '../aggregate.js'; +import { askConfirmation } from '../utils/prompt.js'; +import type { PendingReviewItem } from '../review-store.js'; +import type { DomainsFile } from '../domains/index.js'; + +// ─── Helpers ──────────────────────────────────────────── + +function makeDriftItem(overrides: Partial = {}): PendingReviewItem { + return { + id: 'test-id-001', + ts: new Date().toISOString(), + kind: 'domain-drift', + target: { file: '.teamai/domains.yaml' }, + payload: { + url: 'https://github.com/team/myrepo', + oldDomain: '推理', + newRecommendedDomain: '平台', + oldConfidence: 0.5, + newConfidence: 0.9, + signal: 'README changed', + oldSha: 'abc', + newSha: 'def', + }, + source: 'drift-detector', + risk: 'medium', + ...overrides, + }; +} + +function makeDomains(domainName: string, repoUrl: string): DomainsFile { + return { + version: 1, + confidence_threshold: 0.6, + domains: [ + { + name: domainName, + description: '', + confidence: 1.0, + repos: [ + { url: repoUrl, confidence: 0.5, signal: 'test', locked: false }, + ], + }, + { + name: '平台', + description: '', + confidence: 1.0, + repos: [], + }, + ], + }; +} + +// ─── Tests ────────────────────────────────────────────── + +describe('driftCmd', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(loadPendingReview).mockResolvedValue([]); + vi.mocked(removePendingReview).mockResolvedValue(true); + vi.mocked(loadDomains).mockResolvedValue(makeDomains('推理', 'https://github.com/team/myrepo')); + vi.mocked(saveDomains).mockResolvedValue(undefined); + vi.mocked(appendHistory).mockResolvedValue(undefined); + vi.mocked(regenerateAggregate).mockResolvedValue({ filesWritten: [], errors: [] } as never); + vi.mocked(askConfirmation).mockResolvedValue(true); + }); + + it('list 模式:无 repoUrlArg + 无 applyAll → 渲染漂移项(不调 applyOne)', async () => { + const item = makeDriftItem(); + vi.mocked(loadPendingReview).mockResolvedValue([item]); + + await driftCmd({ skipAggregate: true }); + + expect(loadPendingReview).toHaveBeenCalledTimes(1); + expect(saveDomains).not.toHaveBeenCalled(); + }); + + it('apply 单条 + 新域已存在 → 移动 repo / appendHistory / removePendingReview / regenerateAggregate', async () => { + const item = makeDriftItem(); + vi.mocked(loadPendingReview).mockResolvedValue([item]); + + await driftCmd({ + repoUrlArg: 'https://github.com/team/myrepo', + apply: true, + }); + + expect(saveDomains).toHaveBeenCalledTimes(1); + const savedDomains = vi.mocked(saveDomains).mock.calls[0]![1] as DomainsFile; + // 旧域 repos 应为空 + const oldEntry = savedDomains.domains.find((d) => d.name === '推理'); + expect(oldEntry?.repos).toHaveLength(0); + // 新域应有 entry + const newEntry = savedDomains.domains.find((d) => d.name === '平台'); + expect(newEntry?.repos).toHaveLength(1); + expect(newEntry?.repos[0]?.url).toBe('https://github.com/team/myrepo'); + + expect(appendHistory).toHaveBeenCalledTimes(1); + expect(vi.mocked(appendHistory).mock.calls[0]![1].action).toBe('reassign'); + + expect(removePendingReview).toHaveBeenCalledWith(expect.anything(), 'test-id-001'); + expect(regenerateAggregate).toHaveBeenCalledTimes(1); + }); + + it('apply 单条 + 新域不存在 + askConfirmation true → 自动新建域', async () => { + const domains = makeDomains('推理', 'https://github.com/team/myrepo'); + // 移除平台域 + domains.domains = domains.domains.filter((d) => d.name !== '平台'); + vi.mocked(loadDomains).mockResolvedValue(domains); + + const item = makeDriftItem(); + vi.mocked(loadPendingReview).mockResolvedValue([item]); + vi.mocked(askConfirmation).mockResolvedValue(true); + + // 模拟 TTY 环境 + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + await driftCmd({ + repoUrlArg: 'https://github.com/team/myrepo', + apply: true, + skipAggregate: true, + }); + + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + + expect(saveDomains).toHaveBeenCalledTimes(1); + const savedDomains = vi.mocked(saveDomains).mock.calls[0]![1] as DomainsFile; + const newEntry = savedDomains.domains.find((d) => d.name === '平台'); + expect(newEntry).toBeDefined(); + expect(newEntry?.repos).toHaveLength(1); + }); + + it('apply 单条 + 新域不存在 + 非 TTY → 报错跳过', async () => { + const domains = makeDomains('推理', 'https://github.com/team/myrepo'); + domains.domains = domains.domains.filter((d) => d.name !== '平台'); + vi.mocked(loadDomains).mockResolvedValue(domains); + + const item = makeDriftItem(); + vi.mocked(loadPendingReview).mockResolvedValue([item]); + + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + const originalExitCode = process.exitCode; + await driftCmd({ + repoUrlArg: 'https://github.com/team/myrepo', + apply: true, + skipAggregate: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + process.exitCode = originalExitCode; + + // 非 TTY 下不应写入 + expect(saveDomains).not.toHaveBeenCalled(); + }); + + it('lock:repos[i].locked = true,相关 drift 被移除', async () => { + const item = makeDriftItem(); + vi.mocked(loadPendingReview).mockResolvedValue([item]); + + await driftCmd({ + repoUrlArg: 'https://github.com/team/myrepo', + lock: true, + skipAggregate: true, + }); + + expect(saveDomains).toHaveBeenCalledTimes(1); + const savedDomains = vi.mocked(saveDomains).mock.calls[0]![1] as DomainsFile; + const domainEntry = savedDomains.domains.find((d) => d.name === '推理'); + expect(domainEntry?.repos[0]?.locked).toBe(true); + + expect(appendHistory).toHaveBeenCalledTimes(1); + expect(vi.mocked(appendHistory).mock.calls[0]![1].action).toBe('lock'); + + expect(removePendingReview).toHaveBeenCalledWith(expect.anything(), 'test-id-001'); + }); + + it('apply-all + threshold=0.7 → 应用 confidence > 0.7 的项;低于阈值的跳过', async () => { + const highConf = makeDriftItem({ + id: 'id-high', + payload: { + url: 'https://github.com/team/myrepo', + oldDomain: '推理', + newRecommendedDomain: '平台', + oldConfidence: 0.5, + newConfidence: 0.85, + signal: 'high', + oldSha: 'abc', + newSha: 'def', + }, + }); + const lowConf = makeDriftItem({ + id: 'id-low', + payload: { + url: 'https://github.com/team/other', + oldDomain: '推理', + newRecommendedDomain: '平台', + oldConfidence: 0.5, + newConfidence: 0.6, + signal: 'low', + oldSha: 'abc', + newSha: 'def', + }, + }); + vi.mocked(loadPendingReview).mockResolvedValue([highConf, lowConf]); + + await driftCmd({ + applyAll: true, + threshold: '0.7', + skipAggregate: true, + json: true, + }); + + // 仅 highConf 被 apply,lowConf 被跳过 + expect(saveDomains).toHaveBeenCalledTimes(1); + expect(removePendingReview).toHaveBeenCalledWith(expect.anything(), 'id-high'); + expect(removePendingReview).not.toHaveBeenCalledWith(expect.anything(), 'id-low'); + }); + + it('apply-all:单条失败不阻塞批量', async () => { + const item1 = makeDriftItem({ + id: 'id-1', + payload: { + url: 'https://github.com/team/myrepo', + oldDomain: '不存在的域', + newRecommendedDomain: '平台', + oldConfidence: 0.5, + newConfidence: 0.9, + signal: 'test', + oldSha: 'abc', + newSha: 'def', + }, + }); + const item2 = makeDriftItem({ + id: 'id-2', + payload: { + url: 'https://github.com/team/myrepo', + oldDomain: '推理', + newRecommendedDomain: '平台', + oldConfidence: 0.5, + newConfidence: 0.85, + signal: 'test', + oldSha: 'abc', + newSha: 'def', + }, + }); + vi.mocked(loadPendingReview).mockResolvedValue([item1, item2]); + + // 第一次 loadDomains 给失败域,第二次正常 + vi.mocked(loadDomains) + .mockResolvedValueOnce(makeDomains('推理', 'https://github.com/team/myrepo')) + .mockResolvedValueOnce(makeDomains('推理', 'https://github.com/team/myrepo')); + + await driftCmd({ + applyAll: true, + threshold: '0.7', + skipAggregate: true, + json: true, + }); + + // item1 失败(旧域不存在),item2 成功 + expect(saveDomains).toHaveBeenCalledTimes(1); + expect(removePendingReview).toHaveBeenCalledWith(expect.anything(), 'id-2'); + expect(removePendingReview).not.toHaveBeenCalledWith(expect.anything(), 'id-1'); + }); +}); diff --git a/src/__tests__/env-commands.test.ts b/src/__tests__/env-commands.test.ts index b6c4f05..233f96e 100644 --- a/src/__tests__/env-commands.test.ts +++ b/src/__tests__/env-commands.test.ts @@ -107,7 +107,7 @@ scope: 'user', expect(log.info).toHaveBeenCalledWith('No env variables defined'); }); - it('should list variables', async () => { + it('should list variables with masked values by default', async () => { await fse.writeFile( path.join(repoPath, 'env', 'env.yaml'), YAML.stringify({ @@ -120,6 +120,27 @@ scope: 'user', await envList({}); + const allOutput = consoleSpy.mock.calls.map(c => c[0]).join('\n'); + expect(allOutput).toContain('Team env variables (2)'); + // Default: values should be masked + expect(allOutput).toContain('API_URL=ht****'); + expect(allOutput).toContain('TOKEN=se****'); + expect(allOutput).not.toContain('https://api.example.com'); + }); + + it('should reveal plaintext values when reveal=true', async () => { + await fse.writeFile( + path.join(repoPath, 'env', 'env.yaml'), + YAML.stringify({ + variables: [ + { key: 'API_URL', value: 'https://api.example.com', description: 'API endpoint' }, + { key: 'TOKEN', value: 'secret' }, + ], + }), + ); + + await envList({ reveal: true }); + const allOutput = consoleSpy.mock.calls.map(c => c[0]).join('\n'); expect(allOutput).toContain('Team env variables (2)'); expect(allOutput).toContain('API_URL=https://api.example.com'); diff --git a/src/__tests__/gf-org.test.ts b/src/__tests__/gf-org.test.ts new file mode 100644 index 0000000..a68badf --- /dev/null +++ b/src/__tests__/gf-org.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Mock } from 'vitest'; + +vi.mock('../../src/providers/tgit/gf-cli.js', () => ({ + gfGetOAuthToken: vi.fn(), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, +})); + +import { gfListOrgRepos } from '../providers/tgit/gf-org.js'; +import { gfGetOAuthToken } from '../providers/tgit/gf-cli.js'; + +function makeProject(overrides: Record = {}) { + return { + id: 1, + name: 'repo-one', + path_with_namespace: 'my-group/repo-one', + description: 'A test repo', + http_url_to_repo: 'https://git.woa.com/my-group/repo-one.git', + archived: false, + last_activity_at: '2024-01-01T00:00:00Z', + star_count: 5, + ...overrides, + }; +} + +function makeResponse(body: unknown, status = 200): Response { + const bodyText = JSON.stringify(body); + const encoder = new TextEncoder(); + const bytes = encoder.encode(bodyText); + let readerDone = false; + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(bodyText), + body: { + getReader: () => ({ + read: async () => { + if (!readerDone) { + readerDone = true; + return { done: false, value: bytes }; + } + return { done: true, value: undefined }; + }, + cancel: async () => {}, + }), + }, + } as unknown as Response; +} + +describe('gfListOrgRepos', () => { + let mockFetch: Mock; + + beforeEach(() => { + vi.resetAllMocks(); + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + (gfGetOAuthToken as Mock).mockReturnValue('test-token-abc'); + }); + + it('单页(< 100 条)— 一次调用拿全', async () => { + const projects = [ + makeProject(), + makeProject({ id: 2, name: 'repo-two', path_with_namespace: 'my-group/repo-two' }), + ]; + mockFetch.mockResolvedValueOnce(makeResponse(projects)); + + const result = await gfListOrgRepos('my-group'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(2); + expect(result[0].fullName).toBe('my-group/repo-one'); + expect(result[1].name).toBe('repo-two'); + }); + + it('多页(页1返回100条 → 页2返回23条)— 两次调用,正确合并', async () => { + const page1 = Array.from({ length: 100 }, (_, i) => + makeProject({ id: i + 1, name: `repo-${i}`, path_with_namespace: `grp/repo-${i}` }), + ); + const page2 = Array.from({ length: 23 }, (_, i) => + makeProject({ id: 200 + i, name: `repo-b-${i}`, path_with_namespace: `grp/repo-b-${i}` }), + ); + + mockFetch + .mockResolvedValueOnce(makeResponse(page1)) + .mockResolvedValueOnce(makeResponse(page2)); + + const result = await gfListOrgRepos('grp'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(123); + }); + + it('maxRepos=50 限制 — 只返回 50 条', async () => { + const page1 = Array.from({ length: 100 }, (_, i) => + makeProject({ id: i + 1, name: `repo-${i}`, path_with_namespace: `grp/repo-${i}` }), + ); + mockFetch.mockResolvedValueOnce(makeResponse(page1)); + + const result = await gfListOrgRepos('grp', { maxRepos: 50 }); + + expect(result).toHaveLength(50); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('404 — 抛 TGit group not found or no access', async () => { + mockFetch.mockResolvedValueOnce(makeResponse('Not Found', 404)); + + await expect(gfListOrgRepos('nonexistent-group')).rejects.toThrow( + 'TGit group nonexistent-group not found or no access', + ); + }); + + it('401 HTTP 错误 — 抛 TGit API HTTP 401', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve('Unauthorized'), + } as unknown as Response); + + await expect(gfListOrgRepos('my-group')).rejects.toThrow('TGit API HTTP 401'); + }); + + it('403 HTTP 错误 — 抛 TGit API HTTP 403', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: () => Promise.resolve('Forbidden'), + } as unknown as Response); + + await expect(gfListOrgRepos('my-group')).rejects.toThrow('TGit API HTTP 403'); + }); + + it('token 缺失 — 抛 TGit token unavailable', async () => { + (gfGetOAuthToken as Mock).mockReturnValue(null); + + await expect(gfListOrgRepos('my-group')).rejects.toThrow('TGit token unavailable'); + }); + + it('archived 字段缺失时默认 false', async () => { + const project = makeProject(); + delete (project as Record).archived; + mockFetch.mockResolvedValueOnce(makeResponse([project])); + + const result = await gfListOrgRepos('my-group'); + + expect(result[0].archived).toBe(false); + }); + + it('多级 group 路径 team/sub — URL 中 team%2Fsub', async () => { + mockFetch.mockResolvedValueOnce(makeResponse([])); + + await gfListOrgRepos('team/sub'); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('team%2Fsub'); + }); + + it('字段映射准确 — path_with_namespace → fullName,http_url_to_repo → url', async () => { + const project = makeProject({ + path_with_namespace: 'org/sub/my-repo', + http_url_to_repo: 'https://git.woa.com/org/sub/my-repo.git', + description: 'Test description', + star_count: 42, + last_activity_at: '2025-01-15T10:00:00Z', + }); + mockFetch.mockResolvedValueOnce(makeResponse([project])); + + const result = await gfListOrgRepos('org/sub'); + + expect(result[0].fullName).toBe('org/sub/my-repo'); + expect(result[0].url).toBe('https://git.woa.com/org/sub/my-repo.git'); + expect(result[0].description).toBe('Test description'); + expect(result[0].stars).toBe(42); + expect(result[0].pushedAt).toBe('2025-01-15T10:00:00Z'); + expect(result[0].primaryLanguage).toBeUndefined(); + }); +}); diff --git a/src/__tests__/gh-org.test.ts b/src/__tests__/gh-org.test.ts new file mode 100644 index 0000000..23e4b81 --- /dev/null +++ b/src/__tests__/gh-org.test.ts @@ -0,0 +1,195 @@ +// -*- coding: utf-8 -*- +/** + * gh-org private repo / fallback 场景测试 + * + * 验证: + * 1. 私有仓不被过滤(Blocker 2 修复) + * 2. /orgs/ 第一页返回 [] 时 fallback 到 /users/(Major 1 修复) + * 3. /orgs/ 404 时 fallback 到 /users/(既有路径,防回归) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ─── Mocks ────────────────────────────────────────────── + +vi.mock('../providers/github/gh-cli.js', () => ({ + isGhInstalled: vi.fn().mockReturnValue(false), + getGitHubToken: vi.fn().mockReturnValue('fake-token-xyz'), +})); + +// ─── Imports (after mocks) ─────────────────────────────── + +import { ghListOrgRepos } from '../providers/github/gh-org.js'; +import { isGhInstalled, getGitHubToken } from '../providers/github/gh-cli.js'; + +// ─── Helpers ──────────────────────────────────────────── + +function makeGhRepoItem(overrides: Record = {}): Record { + return { + clone_url: 'https://github.com/org/repo.git', + full_name: 'org/repo', + name: 'repo', + description: null, + language: 'TypeScript', + archived: false, + stargazers_count: 5, + pushed_at: '2024-01-01T00:00:00Z', + ...overrides, + }; +} + +// ─── Tests ────────────────────────────────────────────── + +describe('gh-org fetch 分支(使用 GITHUB_TOKEN)', () => { + beforeEach(() => { + vi.mocked(isGhInstalled).mockReturnValue(false); + vi.mocked(getGitHubToken).mockReturnValue('fake-token-xyz'); + vi.restoreAllMocks(); + // 每次重新 mock + vi.mocked(isGhInstalled).mockReturnValue(false); + vi.mocked(getGitHubToken).mockReturnValue('fake-token-xyz'); + }); + + it('包含私有仓的响应 → 私有仓出现在结果中(不被过滤)', async () => { + const items = [ + makeGhRepoItem({ full_name: 'org/public-repo', name: 'public-repo', clone_url: 'https://github.com/org/public-repo.git' }), + makeGhRepoItem({ full_name: 'org/private-repo', name: 'private-repo', clone_url: 'https://github.com/org/private-repo.git' }), + ]; + + const mockFetch = vi.fn().mockResolvedValue({ + status: 200, + ok: true, + body: { + getReader: () => { + const text = JSON.stringify(items); + const encoder = new TextEncoder(); + const bytes = encoder.encode(text); + let done = false; + return { + read: async () => { + if (!done) { + done = true; + return { done: false, value: bytes }; + } + return { done: true, value: undefined }; + }, + cancel: async () => {}, + }; + }, + }, + }); + + vi.stubGlobal('fetch', mockFetch); + + const result = await ghListOrgRepos('org'); + + const names = result.map((r: { name: string }) => r.name); + expect(names).toContain('public-repo'); + expect(names).toContain('private-repo'); + + vi.unstubAllGlobals(); + }); + + it('/orgs/ 第一页返回 [] → fallback 到 /users/ 并拿到非空结果', async () => { + const userRepos = [ + makeGhRepoItem({ full_name: 'myuser/my-repo', name: 'my-repo', clone_url: 'https://github.com/myuser/my-repo.git' }), + ]; + + let callCount = 0; + const mockFetch = vi.fn().mockImplementation(async (url: string) => { + callCount++; + if (url.includes('/orgs/')) { + // /orgs/ 第一页返回空数组 + const emptyBytes = new TextEncoder().encode('[]'); + return { + status: 200, + ok: true, + body: { + getReader: () => { + let done = false; + return { + read: async () => { + if (!done) { done = true; return { done: false, value: emptyBytes }; } + return { done: true, value: undefined }; + }, + cancel: async () => {}, + }; + }, + }, + }; + } + // /users/ 返回非空 + const bytes = new TextEncoder().encode(JSON.stringify(userRepos)); + return { + status: 200, + ok: true, + body: { + getReader: () => { + let done = false; + return { + read: async () => { + if (!done) { done = true; return { done: false, value: bytes }; } + return { done: true, value: undefined }; + }, + cancel: async () => {}, + }; + }, + }, + }; + }); + + vi.stubGlobal('fetch', mockFetch); + + const result = await ghListOrgRepos('myuser'); + + // 断言走了 fallback:/orgs/ 一次 + /users/ 至少一次 + expect(callCount).toBeGreaterThanOrEqual(2); + expect(result.length).toBeGreaterThan(0); + expect(result[0].name).toBe('my-repo'); + + vi.unstubAllGlobals(); + }); + + it('/orgs/ 404 → fallback 到 /users/', async () => { + const userRepos = [ + makeGhRepoItem({ full_name: 'fallback-user/repo1', name: 'repo1', clone_url: 'https://github.com/fallback-user/repo1.git' }), + ]; + + const mockFetch = vi.fn().mockImplementation(async (url: string) => { + if (url.includes('/orgs/')) { + // 模拟 404 → fetchApiPage 抛错 → tryUrl 捕获,page === 1 返回 false + return { + status: 404, + ok: false, + text: async () => 'Not Found', + body: { getReader: () => ({ read: async () => ({ done: true, value: undefined }), cancel: async () => {} }) }, + }; + } + const bytes = new TextEncoder().encode(JSON.stringify(userRepos)); + return { + status: 200, + ok: true, + body: { + getReader: () => { + let done = false; + return { + read: async () => { + if (!done) { done = true; return { done: false, value: bytes }; } + return { done: true, value: undefined }; + }, + cancel: async () => {}, + }; + }, + }, + }; + }); + + vi.stubGlobal('fetch', mockFetch); + + const result = await ghListOrgRepos('fallback-user'); + expect(result.length).toBeGreaterThan(0); + expect(result[0].name).toBe('repo1'); + + vi.unstubAllGlobals(); + }); +}); diff --git a/src/__tests__/hooks.test.ts b/src/__tests__/hooks.test.ts index 8a69f2b..82fe5b5 100644 --- a/src/__tests__/hooks.test.ts +++ b/src/__tests__/hooks.test.ts @@ -62,7 +62,7 @@ describe('hooks', () => { }); describe('inject — empty file', () => { - it('Claude format: injects 4 events with 13 hooks into empty settings.json', async () => { + it('Claude format: injects 4 events with 15 hooks into empty settings.json', async () => { await injectHooks('/test/settings.json', 'claude'); const result = mockFiles['/test/settings.json'] as { hooks: Record }; @@ -72,15 +72,15 @@ describe('hooks', () => { expect(events).toEqual(['SessionStart', 'Stop', 'PostToolUse', 'UserPromptSubmit']); // Stop has 3 hooks (update, dashboard-stop, contribute-check) - // PostToolUse has 6 hooks (track-skill, dashboard-tool, 4x auto-recall per tool) + // PostToolUse has 7 hooks (track-skill, dashboard-tool, 4x auto-recall per tool, todowrite-hint) // Others have 2 each - expect(result.hooks['SessionStart']).toHaveLength(2); + expect(result.hooks['SessionStart']).toHaveLength(3); expect(result.hooks['Stop']).toHaveLength(3); - expect(result.hooks['PostToolUse']).toHaveLength(6); + expect(result.hooks['PostToolUse']).toHaveLength(7); expect(result.hooks['UserPromptSubmit']).toHaveLength(2); }); - it('Cursor format: injects 4 events with 13 hooks into empty hooks.json', async () => { + it('Cursor format: injects 4 events with 15 hooks into empty hooks.json', async () => { await injectHooks('/test/hooks.json', 'cursor'); const result = mockFiles['/test/hooks.json'] as { version: number; hooks: Record }; @@ -91,10 +91,10 @@ describe('hooks', () => { expect(events).toEqual(['sessionStart', 'stop', 'postToolUse', 'beforeSubmitPrompt']); // stop has 3 hooks (update, dashboard-stop, contribute-check) - // postToolUse has 6 hooks (track, dashboard, 4x auto-recall per tool) - expect(result.hooks['sessionStart']).toHaveLength(2); + // postToolUse has 7 hooks (track, dashboard, 4x auto-recall per tool, todowrite-hint) + expect(result.hooks['sessionStart']).toHaveLength(3); expect(result.hooks['stop']).toHaveLength(3); - expect(result.hooks['postToolUse']).toHaveLength(6); + expect(result.hooks['postToolUse']).toHaveLength(7); expect(result.hooks['beforeSubmitPrompt']).toHaveLength(2); }); @@ -121,9 +121,9 @@ describe('hooks', () => { await injectHooks('/test/settings.json', 'claude'); const result = mockFiles['/test/settings.json'] as { hooks: Record }; - expect(result.hooks['SessionStart']).toHaveLength(2); + expect(result.hooks['SessionStart']).toHaveLength(3); expect(result.hooks['Stop']).toHaveLength(3); - expect(result.hooks['PostToolUse']).toHaveLength(6); + expect(result.hooks['PostToolUse']).toHaveLength(7); expect(result.hooks['UserPromptSubmit']).toHaveLength(2); }); @@ -132,9 +132,9 @@ describe('hooks', () => { await injectHooks('/test/hooks.json', 'cursor'); const result = mockFiles['/test/hooks.json'] as { hooks: Record }; - expect(result.hooks['sessionStart']).toHaveLength(2); + expect(result.hooks['sessionStart']).toHaveLength(3); expect(result.hooks['stop']).toHaveLength(3); - expect(result.hooks['postToolUse']).toHaveLength(6); + expect(result.hooks['postToolUse']).toHaveLength(7); expect(result.hooks['beforeSubmitPrompt']).toHaveLength(2); }); @@ -195,7 +195,7 @@ describe('hooks', () => { hooks: Record; language: string; }; - expect(result.hooks.SessionStart).toHaveLength(3); + expect(result.hooks.SessionStart).toHaveLength(4); expect(result.hooks.SessionStart[0]).toEqual(userHook); expect(result.language).toBe('en'); }); @@ -210,7 +210,7 @@ describe('hooks', () => { await injectHooks('/test/hooks.json', 'cursor'); const result = mockFiles['/test/hooks.json'] as { hooks: Record }; - expect(result.hooks.sessionStart).toHaveLength(3); + expect(result.hooks.sessionStart).toHaveLength(4); expect(result.hooks.sessionStart[0]).toEqual(userHook); }); }); diff --git a/src/__tests__/import-org.test.ts b/src/__tests__/import-org.test.ts new file mode 100644 index 0000000..9f22b86 --- /dev/null +++ b/src/__tests__/import-org.test.ts @@ -0,0 +1,201 @@ +// -*- coding: utf-8 -*- +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs-extra'; + +// ─── Mocks ────────────────────────────────────────────── + +vi.mock('../domains/cluster.js', () => ({ + clusterRepos: vi.fn(), +})); + +vi.mock('../domains/store.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + saveDomainsDraft: vi.fn().mockResolvedValue(undefined), + saveDomains: vi.fn().mockResolvedValue(undefined), + appendHistory: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../domains/review.js', () => ({ + reviewDomains: vi.fn(), +})); + +vi.mock('../import-repo-list.js', () => ({ + importFromRepoList: vi.fn(), +})); + +vi.mock('../providers/registry.js', () => ({ + getProvider: vi.fn(), + getProviderFromUrl: vi.fn().mockReturnValue({ name: 'github' }), +})); + +// ─── Imports (after mocks) ─────────────────────────────── + +import { importFromOrg } from '../import-org.js'; +import { clusterRepos } from '../domains/cluster.js'; +import { saveDomainsDraft, saveDomains, appendHistory } from '../domains/store.js'; +import { reviewDomains } from '../domains/review.js'; +import { importFromRepoList } from '../import-repo-list.js'; +import { getProvider } from '../providers/registry.js'; +import type { DomainsFile } from '../domains/index.js'; +import type { OrgRepoInfo } from '../providers/types.js'; + +// ─── Helpers ──────────────────────────────────────────── + +function makeRepo(overrides: Partial = {}): OrgRepoInfo { + return { + url: 'https://github.com/org/repo-a', + fullName: 'org/repo-a', + name: 'repo-a', + archived: false, + ...overrides, + }; +} + +function makeDomains(): DomainsFile { + return { + version: 1, + confidence_threshold: 0.6, + domains: [ + { + name: '基础设施', + description: '', + repos: [{ url: 'https://github.com/org/repo-a', locked: false }], + }, + ], + }; +} + +async function makeWorkdir(): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-import-org-test-')); + await fs.ensureDir(path.join(tmpDir, '.teamai')); + return tmpDir; +} + +// ─── Tests ────────────────────────────────────────────── + +describe('importFromOrg', () => { + let cwd: string; + let originalCwd: string; + + const mockListOrgRepos = vi.fn(); + const mockProvider = { + name: 'github', + listOrgRepos: mockListOrgRepos, + }; + + beforeEach(async () => { + cwd = await makeWorkdir(); + originalCwd = process.cwd(); + process.chdir(cwd); + vi.clearAllMocks(); + (getProvider as ReturnType).mockReturnValue(mockProvider); + (clusterRepos as ReturnType).mockResolvedValue(makeDomains()); + (reviewDomains as ReturnType).mockResolvedValue({ + result: makeDomains(), + finalize: 'save', + }); + (importFromRepoList as ReturnType).mockResolvedValue({ + succeeded: 1, + failed: [], + skipped: [], + }); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(cwd); + }); + + it('过滤 archived 仓库后传给 clusterRepos', async () => { + const repos: OrgRepoInfo[] = [ + makeRepo({ url: 'https://github.com/org/active', fullName: 'org/active', name: 'active', archived: false }), + makeRepo({ url: 'https://github.com/org/archived', fullName: 'org/archived', name: 'archived', + archived: true }), + ]; + mockListOrgRepos.mockResolvedValue(repos); + (clusterRepos as ReturnType).mockResolvedValue({ + ...makeDomains(), + domains: [{ + name: '基础设施', + description: '', + repos: [{ url: 'https://github.com/org/active', locked: false }], + }], + }); + + await importFromOrg({ org: 'github.com/org', skipImport: true, bootstrap: false, dryRun: true }); + + expect(clusterRepos).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ name: 'active' }), + ]), + ); + const callArg = (clusterRepos as ReturnType).mock.calls[0][0] as Array; + expect(callArg.some((r: unknown) => (r as { name: string }).name === 'archived')).toBe(false); + }); + + it('includePattern + excludePattern 共同生效', async () => { + const repos: OrgRepoInfo[] = [ + makeRepo({ url: 'https://github.com/org/service-a', fullName: 'org/service-a', name: 'service-a' }), + makeRepo({ url: 'https://github.com/org/service-b', fullName: 'org/service-b', name: 'service-b' }), + makeRepo({ url: 'https://github.com/org/tool-x', fullName: 'org/tool-x', name: 'tool-x' }), + ]; + mockListOrgRepos.mockResolvedValue(repos); + + await importFromOrg({ + org: 'github.com/org', + includePattern: 'service-', + excludePattern: 'service-b', + skipImport: true, + bootstrap: false, + dryRun: true, + }); + + const callArg = (clusterRepos as ReturnType).mock.calls[0][0] as Array; + expect(callArg).toHaveLength(1); + expect((callArg[0] as { name: string }).name).toBe('service-a'); + }); + + it('skipImport=true 跳过 importFromRepoList', async () => { + mockListOrgRepos.mockResolvedValue([makeRepo()]); + + await importFromOrg({ org: 'github.com/org', skipImport: true, bootstrap: false, dryRun: true }); + + expect(importFromRepoList).not.toHaveBeenCalled(); + }); + + it('bootstrap=false 仅写草稿不 review', async () => { + mockListOrgRepos.mockResolvedValue([makeRepo()]); + + await importFromOrg({ org: 'github.com/org', bootstrap: false, skipImport: true, dryRun: true }); + + expect(reviewDomains).not.toHaveBeenCalled(); + }); + + it('bootstrap=true 调用 reviewDomains 且 finalize=save 时写正式配置', async () => { + mockListOrgRepos.mockResolvedValue([makeRepo()]); + + await importFromOrg({ + org: 'github.com/org', + bootstrap: true, + skipImport: true, + dryRun: false, + }); + + expect(reviewDomains).toHaveBeenCalled(); + expect(saveDomains).toHaveBeenCalled(); + }); + + it('appendHistory 被调用两次(start + complete)', async () => { + mockListOrgRepos.mockResolvedValue([makeRepo()]); + + await importFromOrg({ org: 'github.com/org', skipImport: true, bootstrap: false, dryRun: true }); + + expect(appendHistory).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/__tests__/import-repo-drift-pending.test.ts b/src/__tests__/import-repo-drift-pending.test.ts new file mode 100644 index 0000000..3f4271b --- /dev/null +++ b/src/__tests__/import-repo-drift-pending.test.ts @@ -0,0 +1,192 @@ +// -*- coding: utf-8 -*- +/** + * import-repo-drift-pending.test.ts — detectDomainDrift 扩展测试。 + * + * 验证 drift 触发后同时写入 pending-review.jsonl, + * 以及 24h 去重逻辑(仅移除 24h 内的旧项,不移除更早的)。 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// ─── Mocks ────────────────────────────────────────────── + +vi.mock('../domains/recommend.js', () => ({ + recommendDomain: vi.fn(), +})); + +vi.mock('../domains/store.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + appendHistory: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../review-store.js', () => ({ + loadPendingReview: vi.fn(), + removePendingReview: vi.fn(), + appendPendingReview: vi.fn(), +})); + +// ─── Imports (after mocks) ─────────────────────────────── + +import { detectDomainDrift } from '../import-repo.js'; +import { recommendDomain } from '../domains/recommend.js'; +import { appendHistory } from '../domains/store.js'; +import { + loadPendingReview, + removePendingReview, + appendPendingReview, +} from '../review-store.js'; +import type { DomainsFile } from '../domains/index.js'; +import type { PendingReviewItem } from '../review-store.js'; + +// ─── Helpers ──────────────────────────────────────────── + +function buildDomains(repoUrl: string, domainName: string, repoConfidence: number): DomainsFile { + return { + version: 1, + confidence_threshold: 0.6, + domains: [ + { + name: domainName, + description: '', + confidence: 1.0, + repos: [ + { url: repoUrl, confidence: repoConfidence, signal: 'test', locked: false }, + ], + }, + ], + }; +} + +function makeDriftPendingItem(url: string, tsMs: number): PendingReviewItem { + return { + id: `drift-${tsMs}`, + ts: new Date(tsMs).toISOString(), + kind: 'domain-drift', + target: { file: '.teamai/domains.yaml' }, + payload: { url, oldDomain: '推理', newRecommendedDomain: '平台' }, + source: 'drift-detector', + risk: 'medium', + }; +} + +// ─── Tests ────────────────────────────────────────────── + +describe('detectDomainDrift + pending-review', () => { + const TEST_URL = 'https://github.com/owner/testrepo'; + const OLD_SHA = 'oldsha001234567890abcdef1234567890abcdef'; + const NEW_SHA = 'newsha001234567890abcdef1234567890abcdef'; + const newMeta = { url: TEST_URL, name: 'testrepo' }; + const domains = buildDomains(TEST_URL, '推理', 0.5); + + beforeEach(() => { + vi.mocked(appendHistory).mockClear(); + vi.mocked(recommendDomain).mockClear(); + vi.mocked(loadPendingReview).mockClear(); + vi.mocked(removePendingReview).mockClear(); + vi.mocked(appendPendingReview).mockClear(); + vi.mocked(appendPendingReview).mockResolvedValue({} as PendingReviewItem); + vi.mocked(removePendingReview).mockResolvedValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('drift 触发后 appendPendingReview 被调用,payload 包含正确字段', async () => { + vi.mocked(loadPendingReview).mockResolvedValue([]); + vi.mocked(recommendDomain).mockResolvedValue({ + domain: '平台', confidence: 0.95, signal: 'README changed', alternatives: [], + }); + + await detectDomainDrift({ + cwd: '/fake/cwd', url: TEST_URL, newMeta, domains, + oldSha: OLD_SHA, newSha: NEW_SHA, + }); + + expect(appendPendingReview).toHaveBeenCalledTimes(1); + const call = vi.mocked(appendPendingReview).mock.calls[0]!; + expect(call[1].kind).toBe('domain-drift'); + expect(call[1].payload['url']).toBe(TEST_URL); + expect(call[1].payload['oldDomain']).toBe('推理'); + expect(call[1].payload['newRecommendedDomain']).toBe('平台'); + expect(call[1].payload['newConfidence']).toBe(0.95); + expect(call[1].source).toBe('drift-detector'); + }); + + it('24h 去重:仅移除 24h 内的旧项,25h 前的不移除', async () => { + const now = Date.now(); + const item25hAgo = makeDriftPendingItem(TEST_URL, now - 25 * 3600 * 1000); + const item1hAgo = makeDriftPendingItem(TEST_URL, now - 1 * 3600 * 1000); + vi.mocked(loadPendingReview).mockResolvedValue([item25hAgo, item1hAgo]); + + vi.mocked(recommendDomain).mockResolvedValue({ + domain: '平台', confidence: 0.95, signal: 'test', alternatives: [], + }); + + await detectDomainDrift({ + cwd: '/fake/cwd', url: TEST_URL, newMeta, domains, + oldSha: OLD_SHA, newSha: NEW_SHA, + }); + + // 1h 内的旧项应被移除,25h 前的不应被移除 + expect(removePendingReview).toHaveBeenCalledWith('/fake/cwd', item1hAgo.id); + expect(removePendingReview).not.toHaveBeenCalledWith('/fake/cwd', item25hAgo.id); + + // 最终 appendPendingReview 依然被调用(新项写入) + expect(appendPendingReview).toHaveBeenCalledTimes(1); + }); + + it('24h 去重:不同 url 的旧项不被移除', async () => { + const now = Date.now(); + const itemOtherUrl = makeDriftPendingItem('https://github.com/other/repo', now - 1 * 3600 * 1000); + vi.mocked(loadPendingReview).mockResolvedValue([itemOtherUrl]); + + vi.mocked(recommendDomain).mockResolvedValue({ + domain: '平台', confidence: 0.95, signal: 'test', alternatives: [], + }); + + await detectDomainDrift({ + cwd: '/fake/cwd', url: TEST_URL, newMeta, domains, + oldSha: OLD_SHA, newSha: NEW_SHA, + }); + + // 不同 url 不应被移除 + expect(removePendingReview).not.toHaveBeenCalled(); + expect(appendPendingReview).toHaveBeenCalledTimes(1); + }); + + it('drift 未触发时 appendPendingReview 不被调用(同域)', async () => { + vi.mocked(recommendDomain).mockResolvedValue({ + domain: '推理', confidence: 0.9, signal: 'same domain', alternatives: [], + }); + + await detectDomainDrift({ + cwd: '/fake/cwd', url: TEST_URL, newMeta, domains, + oldSha: OLD_SHA, newSha: NEW_SHA, + }); + + expect(appendPendingReview).not.toHaveBeenCalled(); + expect(loadPendingReview).not.toHaveBeenCalled(); + }); + + it('appendPendingReview 抛错 → 不阻塞主流程(不抛错)', async () => { + vi.mocked(loadPendingReview).mockResolvedValue([]); + vi.mocked(appendPendingReview).mockRejectedValue(new Error('disk full')); + vi.mocked(recommendDomain).mockResolvedValue({ + domain: '平台', confidence: 0.95, signal: 'test', alternatives: [], + }); + + await expect( + detectDomainDrift({ + cwd: '/fake/cwd', url: TEST_URL, newMeta, domains, + oldSha: OLD_SHA, newSha: NEW_SHA, + }), + ).resolves.toBeUndefined(); + + // appendHistory 依然被调用(不因 pending-review 失败而跳过) + expect(appendHistory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/import-repo-incremental.test.ts b/src/__tests__/import-repo-incremental.test.ts new file mode 100644 index 0000000..04d8234 --- /dev/null +++ b/src/__tests__/import-repo-incremental.test.ts @@ -0,0 +1,202 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs-extra'; + +// ─── Mocks ────────────────────────────────────────────── + +vi.mock('../clone.js', () => ({ + shallowClone: vi.fn(), + shallowFetch: vi.fn(), +})); + +vi.mock('../codebase.js', () => ({ + generateCodebaseMd: vi.fn().mockResolvedValue('# Codebase\n\n生成的 codebase 文档内容\n'), +})); + +vi.mock('../domains/recommend.js', () => ({ + recommendDomain: vi.fn().mockResolvedValue({ + domain: '推理', + confidence: 0.84, + signal: 'README 含 "推理服务"', + alternatives: [], + }), +})); + +vi.mock('../utils/prompt.js', () => ({ + askQuestion: vi.fn().mockResolvedValue('y'), + askConfirmation: vi.fn().mockResolvedValue(true), +})); + +// ─── Imports (after mocks) ─────────────────────────────── + +import { importFromRepo } from '../import-repo.js'; +import { shallowClone, shallowFetch } from '../clone.js'; +import { loadDomains } from '../domains/store.js'; +import { generateCodebaseMd } from '../codebase.js'; +import { recommendDomain } from '../domains/recommend.js'; + +// ─── Constants ────────────────────────────────────────── + +const CLONE_SHA = 'deadbeef1234567890abcdef1234567890abcdef'; +const FETCH_SHA = 'cafebabe1234567890abcdef1234567890abcdef'; + +// ─── Helpers ──────────────────────────────────────────── + +async function makeWorkdir(): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-incremental-test-')); + await fs.ensureDir(path.join(tmpDir, '.teamai')); + return tmpDir; +} + +async function makeFakeCache( + baseDir: string, + provider: string, + owner: string, + repo: string, + sha: string, +): Promise { + const cacheDir = path.join(baseDir, 'cache', provider, owner, repo); + await fs.ensureDir(path.join(cacheDir, '.git')); + const isoTs = new Date().toISOString(); + await fs.writeFile(path.join(cacheDir, 'LAST_SYNC'), `${sha}\n${isoTs}\n`, 'utf8'); + return cacheDir; +} + +// ─── Tests ────────────────────────────────────────────── + +describe('importFromRepo — incremental mode', () => { + let workdir: string; + const FAKE_OLD_SHA = 'oldsha0001234567890abcdef1234567890abcdef'; + const TEST_URL = 'https://github.com/owner/testrepo'; + + beforeEach(async () => { + workdir = await makeWorkdir(); + vi.spyOn(process, 'cwd').mockReturnValue(workdir); + process.env.TEAMAI_CACHE_DIR = path.join(workdir, 'cache'); + + vi.mocked(shallowClone).mockImplementation(async (_url: string, localPath: string) => { + await fs.ensureDir(localPath); + return { sha: CLONE_SHA, branch: 'main', cloneMethod: 'https-token' as const }; + }); + + vi.mocked(shallowFetch).mockImplementation(async (localPath: string) => { + await fs.ensureDir(localPath); + return { sha: FETCH_SHA }; + }); + + vi.mocked(generateCodebaseMd).mockResolvedValue('# Codebase\n\n生成的 codebase 文档内容\n'); + + vi.mocked(recommendDomain).mockResolvedValue({ + domain: '推理', + confidence: 0.84, + signal: 'README 含 "推理服务"', + alternatives: [], + }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + delete process.env.TEAMAI_CACHE_DIR; + await fs.remove(workdir); + }); + + it('缓存不存在时走全量 clone,不调 shallowFetch', async () => { + await importFromRepo({ + url: TEST_URL, + incremental: true, + interactive: false, + }); + + expect(shallowClone).toHaveBeenCalledTimes(1); + expect(shallowFetch).not.toHaveBeenCalled(); + }); + + it('缓存存在 + LAST_SYNC + incremental=true → 走 fetch,不调 shallowClone', async () => { + await makeFakeCache(workdir, 'github', 'owner', 'testrepo', FAKE_OLD_SHA); + + await importFromRepo({ + url: TEST_URL, + incremental: true, + interactive: false, + }); + + expect(shallowFetch).toHaveBeenCalledTimes(1); + expect(shallowClone).not.toHaveBeenCalled(); + }); + + it('增量 fetch 失败时 fallback 到 shallowClone', async () => { + await makeFakeCache(workdir, 'github', 'owner', 'testrepo', FAKE_OLD_SHA); + vi.mocked(shallowFetch).mockRejectedValueOnce(new Error('network error')); + + await importFromRepo({ + url: TEST_URL, + incremental: true, + interactive: false, + }); + + expect(shallowFetch).toHaveBeenCalledTimes(1); + expect(shallowClone).toHaveBeenCalledTimes(1); + }); + + it('incremental=false 时即使有缓存也走全量 clone', async () => { + await makeFakeCache(workdir, 'github', 'owner', 'testrepo', FAKE_OLD_SHA); + + await importFromRepo({ + url: TEST_URL, + incremental: false, + interactive: false, + }); + + expect(shallowClone).toHaveBeenCalledTimes(1); + expect(shallowFetch).not.toHaveBeenCalled(); + }); + + it('全量 clone 后写入 LAST_SYNC', async () => { + await importFromRepo({ + url: TEST_URL, + incremental: false, + interactive: false, + }); + + const lastSyncPath = path.join(workdir, 'cache', 'github', 'owner', 'testrepo', 'LAST_SYNC'); + const exists = await fs.pathExists(lastSyncPath); + expect(exists).toBe(true); + const content = await fs.readFile(lastSyncPath, 'utf8'); + expect(content).toContain('deadbeef'); + }); + + it('增量模式下仓库已在域中:更新 LAST_SYNC 并返回', async () => { + const domainsYaml = [ + 'version: 1', + 'confidence_threshold: 0.6', + 'domains:', + ' - name: 推理', + ' description: ""', + ' repos:', + ` - url: "${TEST_URL}"`, + ' confidence: 0.84', + ' signal: test', + ' locked: false', + ].join('\n'); + await fs.writeFile(path.join(workdir, '.teamai', 'domains.yaml'), domainsYaml, 'utf8'); + await makeFakeCache(workdir, 'github', 'owner', 'testrepo', FAKE_OLD_SHA); + + await importFromRepo({ + url: TEST_URL, + incremental: true, + interactive: false, + }); + + expect(shallowFetch).toHaveBeenCalledTimes(1); + const lastSyncPath = path.join(workdir, 'cache', 'github', 'owner', 'testrepo', 'LAST_SYNC'); + const content = await fs.readFile(lastSyncPath, 'utf8'); + expect(content).toContain('cafebabe'); + + // domains.yaml 中不应新增条目 + const domains = await loadDomains(workdir); + const domainEntry = domains.domains.find((d) => d.name === '推理'); + expect(domainEntry?.repos).toHaveLength(1); + }); +}); diff --git a/src/__tests__/import-repo-list.test.ts b/src/__tests__/import-repo-list.test.ts new file mode 100644 index 0000000..4ea097b --- /dev/null +++ b/src/__tests__/import-repo-list.test.ts @@ -0,0 +1,158 @@ +// -*- coding: utf-8 -*- +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs-extra'; +import { stringify as yamlStringify } from 'yaml'; + +// ─── Mocks ────────────────────────────────────────────── + +vi.mock('../import-repo.js', () => ({ + importFromRepo: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../aggregate.js', () => ({ + regenerateAggregate: vi.fn().mockResolvedValue({ + domainFiles: [], + indexFile: '/mock/index.md', + }), +})); + +vi.mock('../domains/store.js', () => ({ + loadDomains: vi.fn().mockResolvedValue({ + version: 1, + confidence_threshold: 0.6, + domains: [], + }), +})); + +// ─── Imports(after mocks)────────────────────────────── + +import { importFromRepoList } from '../import-repo-list.js'; +import { importFromRepo } from '../import-repo.js'; +import { regenerateAggregate } from '../aggregate.js'; + +// ─── Tests ────────────────────────────────────────────── + +describe('importFromRepoList', () => { + let tmpDir: string; + let originalCwd: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-import-list-test-')); + originalCwd = process.cwd(); + process.chdir(tmpDir); + await fs.ensureDir(path.join(tmpDir, '.teamai')); + vi.clearAllMocks(); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(tmpDir); + vi.restoreAllMocks(); + }); + + async function writeYaml(fileName: string, content: unknown): Promise { + const filePath = path.join(tmpDir, fileName); + await fs.writeFile(filePath, yamlStringify(content), 'utf8'); + return filePath; + } + + it('加载 yaml → 调度 → 汇总数字正确(2 个成功)', async () => { + const filePath = await writeYaml('repos.yaml', { + version: 1, + repos: [ + { url: 'https://github.com/org/repo-1', domain: '推理' }, + { url: 'https://github.com/org/repo-2', domain: '训练' }, + ], + }); + + const result = await importFromRepoList({ listPath: filePath }); + + expect(importFromRepo).toHaveBeenCalledTimes(2); + expect(result.succeeded).toBe(2); + expect(result.failed).toHaveLength(0); + expect(result.skipped).toHaveLength(0); + }); + + it('org entry → skipped 数 +1,importFromRepo 不被调用', async () => { + const filePath = await writeYaml('repos.yaml', { + version: 1, + repos: [ + { org: 'https://github.com/myorg', default_domain: '平台' }, + { url: 'https://github.com/org/single-repo' }, + ], + }); + + const result = await importFromRepoList({ listPath: filePath }); + + expect(importFromRepo).toHaveBeenCalledTimes(1); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0].url).toBe('https://github.com/myorg'); + expect(result.succeeded).toBe(1); + }); + + it('单仓抛错 → failed +1,不中断其他', async () => { + vi.mocked(importFromRepo) + .mockRejectedValueOnce(new Error('克隆失败')) + .mockResolvedValue(undefined); + + const filePath = await writeYaml('repos.yaml', { + version: 1, + repos: [ + { url: 'https://github.com/org/fail-repo' }, + { url: 'https://github.com/org/success-repo' }, + ], + }); + + const result = await importFromRepoList({ listPath: filePath }); + + expect(result.failed).toHaveLength(1); + expect(result.failed[0].url).toBe('https://github.com/org/fail-repo'); + expect(result.failed[0].error).toContain('克隆失败'); + expect(result.succeeded).toBe(1); + }); + + it('skipAggregate=true → 不调用 regenerateAggregate', async () => { + const filePath = await writeYaml('repos.yaml', { + version: 1, + repos: [{ url: 'https://github.com/org/repo-x' }], + }); + + await importFromRepoList({ listPath: filePath, skipAggregate: true }); + + expect(regenerateAggregate).not.toHaveBeenCalled(); + }); + + it('默认 skipAggregate=false → 调用 regenerateAggregate', async () => { + const filePath = await writeYaml('repos.yaml', { + version: 1, + repos: [{ url: 'https://github.com/org/repo-y' }], + }); + + await importFromRepoList({ listPath: filePath }); + + expect(regenerateAggregate).toHaveBeenCalledTimes(1); + }); + + it('priority=high 条目优先排序:先于 normal', async () => { + const callOrder: string[] = []; + vi.mocked(importFromRepo).mockImplementation(async (opts) => { + callOrder.push(opts.url); + }); + + const filePath = await writeYaml('repos.yaml', { + version: 1, + repos: [ + { url: 'https://github.com/org/normal-repo', priority: 'normal' }, + { url: 'https://github.com/org/high-repo', priority: 'high' }, + ], + }); + + await importFromRepoList({ listPath: filePath, concurrency: 1 }); + + expect(callOrder[0]).toBe('https://github.com/org/high-repo'); + expect(callOrder[1]).toBe('https://github.com/org/normal-repo'); + }); +}); diff --git a/src/__tests__/import-repo-merge.test.ts b/src/__tests__/import-repo-merge.test.ts new file mode 100644 index 0000000..d328f22 --- /dev/null +++ b/src/__tests__/import-repo-merge.test.ts @@ -0,0 +1,175 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs-extra'; + +// ─── Mocks ────────────────────────────────────────────── + +vi.mock('../clone.js', () => ({ + shallowClone: vi.fn(), + shallowFetch: vi.fn(), +})); + +vi.mock('../domains/recommend.js', () => ({ + recommendDomain: vi.fn().mockResolvedValue({ + domain: '推理', + confidence: 0.84, + signal: 'test signal', + alternatives: [], + }), +})); + +vi.mock('../utils/prompt.js', () => ({ + askQuestion: vi.fn().mockResolvedValue('y'), + askConfirmation: vi.fn().mockResolvedValue(true), +})); + +vi.mock('../codebase.js', () => ({ + generateCodebaseMd: vi.fn().mockResolvedValue( + '---\ntitle: Test Repo\nlastUpdated: 2024-01-01T00:00:00.000Z\n---\n\n## 项目概述\n固定的项目概述内容,不会改变。\n\n## 技术栈\nTypeScript + vitest', + ), +})); + +// ─── Imports (after mocks) ────────────────────────────── + +import { importFromRepo } from '../import-repo.js'; +import { shallowClone } from '../clone.js'; +import { generateCodebaseMd } from '../codebase.js'; + +// ─── Constants ────────────────────────────────────────── + +const CLONE_SHA = 'deadbeef1234567890abcdef1234567890abcdef'; + +const FIXED_CODEBASE_MD = + '---\ntitle: Test Repo\nlastUpdated: 2024-01-01T00:00:00.000Z\n---\n\n## 项目概述\n固定的项目概述内容,不会改变。\n\n## 技术栈\nTypeScript + vitest'; + +// ─── Helpers ──────────────────────────────────────────── + +async function makeWorkdir(): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-import-merge-test-')); + await fs.ensureDir(path.join(tmpDir, '.teamai')); + return tmpDir; +} + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ─── Tests ────────────────────────────────────────────── + +describe('importFromRepo — section merge', () => { + let workdir: string; + const TEST_URL = 'https://github.com/owner/mergetest'; + + beforeEach(async () => { + workdir = await makeWorkdir(); + vi.spyOn(process, 'cwd').mockReturnValue(workdir); + process.env.TEAMAI_CACHE_DIR = path.join(workdir, 'cache'); + + vi.mocked(shallowClone).mockImplementation(async (_url: string, localPath: string) => { + await fs.ensureDir(localPath); + return { sha: CLONE_SHA, branch: 'main', cloneMethod: 'https-token' as const }; + }); + + vi.mocked(generateCodebaseMd).mockResolvedValue(FIXED_CODEBASE_MD); + }); + + afterEach(async () => { + vi.clearAllMocks(); + delete process.env.TEAMAI_CACHE_DIR; + await fs.remove(workdir); + }); + + it('第一次跑 import → 文件被创建、含锚点', async () => { + await importFromRepo({ + url: TEST_URL, + interactive: false, + }); + + const repoMdPath = path.join(workdir, 'docs', 'team-codebase', 'repos', 'github__owner__mergetest.md'); + const exists = await fs.pathExists(repoMdPath); + expect(exists).toBe(true); + + const content = await fs.readFile(repoMdPath, 'utf8'); + expect(content).toContain('', + '## 项目概述', + '旧内容,这是旧内容。', + // 故意缺少 闭锚 + ].join('\n'); + + await fs.writeFile(repoMdPath, unclosedOldFile, 'utf8'); + + // 执行 importFromRepo,此时 parseSections 会因未闭合锚点抛错 → fallback + await importFromRepo({ + url: TEST_URL, + interactive: false, + }); + + // 1. 验证备份文件存在且内容等于旧文件 + const bakPath = `${repoMdPath}.bak`; + expect(await fs.pathExists(bakPath)).toBe(true); + const bakContent = await fs.readFile(bakPath, 'utf8'); + expect(bakContent).toBe(unclosedOldFile); + + // 2. 验证产物文件包含新 codebase 内容(fallback 全量重写) + const newContent = await fs.readFile(repoMdPath, 'utf8'); + expect(newContent).toContain('项目概述'); + expect(newContent).toContain('固定的项目概述内容'); + }); +}); diff --git a/src/__tests__/import-repo.test.ts b/src/__tests__/import-repo.test.ts new file mode 100644 index 0000000..7dbf28c --- /dev/null +++ b/src/__tests__/import-repo.test.ts @@ -0,0 +1,300 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs-extra'; + +// ─── Mocks ────────────────────────────────────────────── + +vi.mock('../clone.js', () => ({ + shallowClone: vi.fn().mockResolvedValue({ + sha: 'deadbeef1234567890abcdef1234567890abcdef', + branch: 'main', + cloneMethod: 'https-token', + }), +})); + +vi.mock('../codebase.js', () => ({ + generateCodebaseMd: vi.fn().mockResolvedValue('# Codebase\n\n生成的 codebase 文档内容\n'), +})); + +vi.mock('../domains/recommend.js', () => ({ + recommendDomain: vi.fn().mockResolvedValue({ + domain: '推理', + confidence: 0.84, + signal: 'README 含 "推理服务"', + alternatives: [{ domain: '平台', confidence: 0.42 }], + }), +})); + +vi.mock('../utils/prompt.js', () => ({ + askQuestion: vi.fn().mockResolvedValue('y'), + askConfirmation: vi.fn().mockResolvedValue(true), +})); + +// ─── Imports(after mocks)────────────────────────────── + +import { importFromRepo, buildRepoMetaFromPath } from '../import-repo.js'; +import { loadDomains } from '../domains/store.js'; +import { shallowClone } from '../clone.js'; +import { generateCodebaseMd } from '../codebase.js'; +import { recommendDomain } from '../domains/recommend.js'; +import { askQuestion } from '../utils/prompt.js'; + +// ─── Helpers ──────────────────────────────────────────── + +async function makeWorkdir(): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-import-repo-test-')); + // 初始化 .teamai 目录(saveDomains 需要) + await fs.ensureDir(path.join(tmpDir, '.teamai')); + return tmpDir; +} + +async function makeCacheDir(tmpDir: string, provider: string, owner: string, repo: string): Promise { + const cacheDir = path.join(tmpDir, 'cache', provider, owner, repo); + await fs.ensureDir(cacheDir); + return cacheDir; +} + +// ─── Tests ────────────────────────────────────────────── + +describe('importFromRepo', () => { + let workdir: string; + let originalCwd: string; + let originalCacheDir: string | undefined; + + beforeEach(async () => { + workdir = await makeWorkdir(); + originalCwd = process.cwd(); + process.chdir(workdir); + + // 把缓存目录也放在 tmpDir 下,避免污染真实 ~/.teamai + originalCacheDir = process.env.TEAMAI_CACHE_DIR; + process.env.TEAMAI_CACHE_DIR = path.join(workdir, 'cache'); + + vi.clearAllMocks(); + + // 默认:shallowClone 成功后缓存目录会存在(importFromRepo 需要读取其中文件) + vi.mocked(shallowClone).mockImplementation(async (_url, localPath) => { + await fs.ensureDir(localPath); + return { sha: 'deadbeef1234567890abcdef', branch: 'main', cloneMethod: 'https-token' }; + }); + + vi.mocked(generateCodebaseMd).mockResolvedValue('# Codebase\n内容\n'); + + vi.mocked(recommendDomain).mockResolvedValue({ + domain: '推理', + confidence: 0.84, + signal: 'README 含推理服务', + alternatives: [], + }); + + // 默认用户回答 Y + vi.mocked(askQuestion).mockResolvedValue('y'); + + // 模拟 TTY + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + }); + + afterEach(async () => { + process.chdir(originalCwd); + if (originalCacheDir === undefined) { + delete process.env.TEAMAI_CACHE_DIR; + } else { + process.env.TEAMAI_CACHE_DIR = originalCacheDir; + } + await fs.remove(workdir); + vi.restoreAllMocks(); + }); + + it('显式 --domain 模式:跳过推荐,直接写入对应域', async () => { + await importFromRepo({ + url: 'https://github.com/org/inference-core', + explicitDomain: '推理', + }); + + expect(recommendDomain).not.toHaveBeenCalled(); + + const domains = await loadDomains(workdir); + const inferDomain = domains.domains.find((d) => d.name === '推理'); + expect(inferDomain).toBeDefined(); + expect(inferDomain!.repos).toHaveLength(1); + expect(inferDomain!.repos[0].url).toBe('https://github.com/org/inference-core'); + }); + + it('显式 --domain 指向不存在的域 → 自动新建该域', async () => { + await importFromRepo({ + url: 'https://github.com/org/new-service', + explicitDomain: '全新业务域', + }); + + const domains = await loadDomains(workdir); + const newDomain = domains.domains.find((d) => d.name === '全新业务域'); + expect(newDomain).toBeDefined(); + expect(newDomain!.repos[0].url).toBe('https://github.com/org/new-service'); + }); + + it('AI 推荐 + 用户接受 → 写入 RepoEntry', async () => { + vi.mocked(askQuestion).mockResolvedValue('y'); + + await importFromRepo({ url: 'https://github.com/org/ai-engine' }); + + expect(recommendDomain).toHaveBeenCalled(); + + const domains = await loadDomains(workdir); + const inferDomain = domains.domains.find((d) => d.name === '推理'); + expect(inferDomain).toBeDefined(); + expect(inferDomain!.repos[0].url).toBe('https://github.com/org/ai-engine'); + expect(inferDomain!.repos[0].confidence).toBeCloseTo(0.84); + }); + + it('AI 推荐 + 用户拒绝 (n) → 归入未分类并记录 reject_reason 到 history', async () => { + // 第一次调用 askQuestion 是确认框,第二次是 reject reason + vi.mocked(askQuestion) + .mockResolvedValueOnce('n') // 拒绝推荐 + .mockResolvedValueOnce('不符合该域'); // reject reason + + await importFromRepo({ url: 'https://github.com/org/rejected-repo' }); + + const domains = await loadDomains(workdir); + const unclassified = domains.domains.find((d) => d.name === '未分类'); + expect(unclassified).toBeDefined(); + expect(unclassified!.repos[0].url).toBe('https://github.com/org/rejected-repo'); + + // 验证 history 中有 reject 记录 + const historyPath = path.join(workdir, '.teamai', 'domains.history.jsonl'); + const historyContent = await fs.readFile(historyPath, 'utf8'); + const lines = historyContent.trim().split('\n').filter(Boolean); + const lastEvent = JSON.parse(lines[lines.length - 1]) as Record; + expect(lastEvent.action).toBe('reject'); + expect((lastEvent.details as Record).reject_reason).toBe('不符合该域'); + }); + + it('url 重复(已在其他域)→ warn + 跳过,不重复添加', async () => { + const existingUrl = 'https://github.com/org/existing-repo'; + + // 先正常导入一次 + vi.mocked(askQuestion).mockResolvedValue('y'); + await importFromRepo({ url: existingUrl, explicitDomain: '平台' }); + + const domainsAfterFirst = await loadDomains(workdir); + const repoCountAfterFirst = domainsAfterFirst.domains + .flatMap((d) => d.repos) + .filter((r) => r.url === existingUrl).length; + expect(repoCountAfterFirst).toBe(1); + + // 再次导入同一 url,应该跳过 + vi.clearAllMocks(); + vi.mocked(shallowClone).mockImplementation(async (_url, localPath) => { + await fs.ensureDir(localPath); + return { sha: 'deadbeef', branch: 'main', cloneMethod: 'https-anonymous' }; + }); + vi.mocked(generateCodebaseMd).mockResolvedValue('# Codebase\n'); + + await importFromRepo({ url: existingUrl, explicitDomain: '推理' }); + + const domainsAfterSecond = await loadDomains(workdir); + const repoCountAfterSecond = domainsAfterSecond.domains + .flatMap((d) => d.repos) + .filter((r) => r.url === existingUrl).length; + // 不应增加 + expect(repoCountAfterSecond).toBe(1); + }); + + it('dry-run 不写盘(domains.yaml 不变,产物文件不生成)', async () => { + await importFromRepo({ + url: 'https://github.com/org/dry-run-repo', + dryRun: true, + explicitDomain: '推理', + }); + + // domains.yaml 应不存在或为空(未写入) + const domainsPath = path.join(workdir, '.teamai', 'domains.yaml'); + const exists = await fs.pathExists(domainsPath); + expect(exists).toBe(false); + + // 产物文件不应生成 + const repoMdPath = path.join(workdir, 'docs', 'team-codebase', 'repos'); + const repoMdExists = await fs.pathExists(repoMdPath); + expect(repoMdExists).toBe(false); + }); + + it('非 TTY 直接归未分类(不调用 askQuestion)', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await importFromRepo({ url: 'https://github.com/org/non-tty-repo' }); + + // 非 TTY 下不应调用 prompt + expect(askQuestion).not.toHaveBeenCalled(); + + const domains = await loadDomains(workdir); + const unclassified = domains.domains.find((d) => d.name === '未分类'); + expect(unclassified).toBeDefined(); + expect(unclassified!.repos[0].url).toBe('https://github.com/org/non-tty-repo'); + }); +}); + +describe('buildRepoMetaFromPath', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-repo-meta-test-')); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + it('读取 README.md 首段(不含标题前缀)', async () => { + await fs.writeFile( + path.join(tmpDir, 'README.md'), + '# My Project\n\n这是项目描述。提供推理服务。\n\n## 安装\n\n略。\n', + 'utf8', + ); + + const meta = await buildRepoMetaFromPath(tmpDir, 'https://github.com/org/test', 'test'); + expect(meta.readme_excerpt).toContain('这是项目描述'); + expect(meta.readme_excerpt).not.toContain('# My Project'); + }); + + it('读取 package.json description 和 keywords', async () => { + await fs.writeJSON(path.join(tmpDir, 'package.json'), { + name: 'test-pkg', + description: '测试包描述', + keywords: ['ai', 'inference'], + }); + + const meta = await buildRepoMetaFromPath(tmpDir, 'https://github.com/org/test', 'test'); + expect(meta.description).toBe('测试包描述'); + expect(meta.keywords).toEqual(['ai', 'inference']); + }); + + it('无 README 和 package.json 时元数据为空但不报错', async () => { + const meta = await buildRepoMetaFromPath(tmpDir, 'https://github.com/org/empty', 'empty'); + expect(meta.url).toBe('https://github.com/org/empty'); + expect(meta.name).toBe('empty'); + expect(meta.readme_excerpt).toBeUndefined(); + expect(meta.description).toBeUndefined(); + }); + + it('Python 项目读取 setup.py description', async () => { + await fs.writeFile( + path.join(tmpDir, 'setup.py'), + 'setup(name="svc", description="Python 推理服务", version="1.0")\n', + 'utf8', + ); + + const meta = await buildRepoMetaFromPath(tmpDir, 'https://github.com/org/py-svc', 'py-svc'); + expect(meta.description).toBe('Python 推理服务'); + }); + + it('检测主要语言(TypeScript 文件最多)', async () => { + await fs.writeFile(path.join(tmpDir, 'a.ts'), ''); + await fs.writeFile(path.join(tmpDir, 'b.ts'), ''); + await fs.writeFile(path.join(tmpDir, 'c.ts'), ''); + await fs.writeFile(path.join(tmpDir, 'd.py'), ''); + + const meta = await buildRepoMetaFromPath(tmpDir, 'https://github.com/org/ts-proj', 'ts-proj'); + expect(meta.primary_language).toBe('TypeScript'); + }); +}); diff --git a/src/__tests__/iwiki-dual.test.ts b/src/__tests__/iwiki-dual.test.ts new file mode 100644 index 0000000..c3b5153 --- /dev/null +++ b/src/__tests__/iwiki-dual.test.ts @@ -0,0 +1,160 @@ +// -*- coding: utf-8 -*- +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs-extra'; + +// ─── Mocks ────────────────────────────────────────────── + +vi.mock('../utils/ai-client.js', () => ({ + callClaude: vi.fn(), +})); + +vi.mock('../utils/iwiki-client.js', () => ({ + IWikiClient: vi.fn().mockImplementation(() => ({ + fetchAllPages: vi.fn().mockResolvedValue([ + { docid: '123', title: 'Test Page' }, + ]), + getDocument: vi.fn().mockResolvedValue({ + docid: '123', + title: 'Test Page', + content: '这是测试内容,包含一些 API 接口和术语。', + }), + })), +})); + +vi.mock('../review-store.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + appendPendingReview: vi.fn().mockImplementation( + async (_cwd: string, partial: Record) => ({ + id: 'mockedid00001', + ts: new Date().toISOString(), + ...partial, + risk: 'medium', + }), + ), + }; +}); + +// ─── Imports (after mocks) ─────────────────────────────── + +import { importFromIWikiDual } from '../iwiki-dual.js'; +import { callClaude } from '../utils/ai-client.js'; +import { appendPendingReview } from '../review-store.js'; + +// ─── 辅助 ──────────────────────────────────────────────── + +const VALID_AI_OUTPUT = JSON.stringify({ + 'business-api': '## 业务接口\n接口列表...', + 'external-knowledge': '## 外部知识\n知识列表...', + 'glossary': '| 术语 | 说明 |\n|------|------|\n| foo | bar |', +}); + +async function makeWorkdir(): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-iwiki-dual-test-')); + return tmpDir; +} + +// ─── Tests ────────────────────────────────────────────── + +describe('importFromIWikiDual', () => { + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + cwd = await makeWorkdir(); + originalCwd = process.cwd(); + process.chdir(cwd); + vi.clearAllMocks(); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(cwd); + }); + + it('首次创建写出三章节骨架', async () => { + (callClaude as ReturnType).mockResolvedValue(VALID_AI_OUTPUT); + + await importFromIWikiDual({ input: '12345', token: 'test-token' }); + + const filePath = path.join(cwd, 'docs/team-codebase/external-knowledge.md'); + expect(await fs.pathExists(filePath)).toBe(true); + const content = await fs.readFile(filePath, 'utf8'); + expect(content).toContain('## 业务接口'); + expect(content).toContain('## 外部知识源'); + expect(content).toContain('## 术语表'); + }); + + it('二次调用按锚点替换,未指定的章节不动', async () => { + // 第一次:写全部三章节 + (callClaude as ReturnType).mockResolvedValue(VALID_AI_OUTPUT); + await importFromIWikiDual({ input: '12345', token: 'test-token' }); + + const filePath = path.join(cwd, 'docs/team-codebase/external-knowledge.md'); + const firstContent = await fs.readFile(filePath, 'utf8'); + + // 第二次:只更新 business-api + const updatedOutput = JSON.stringify({ + 'business-api': '## 更新后的接口', + 'external-knowledge': '', + 'glossary': '', + }); + (callClaude as ReturnType).mockResolvedValue(updatedOutput); + + await importFromIWikiDual({ + input: '12345', + token: 'test-token', + sections: ['business-api'], + }); + + const secondContent = await fs.readFile(filePath, 'utf8'); + // business-api 已更新 + expect(secondContent).toContain('更新后的接口'); + // glossary 未被清空(来自第一次写入) + expect(secondContent).toContain('| 术语 | 说明 |'); + // external-knowledge 区域存在(来自第一次写入) + expect(secondContent).toContain('外部知识'); + // 长度与第一次相比发生了变化(business-api 被替换) + expect(secondContent).not.toEqual(firstContent); + }); + + it('AI 输出非 JSON → warn 并不写', async () => { + (callClaude as ReturnType).mockResolvedValue('这不是 JSON 内容'); + + const result = await importFromIWikiDual({ input: '12345', token: 'test-token' }); + + expect(result.sectionsUpdated).toHaveLength(0); + const filePath = path.join(cwd, 'docs/team-codebase/external-knowledge.md'); + // 不写文件(因为 AI 输出无效) + expect(await fs.pathExists(filePath)).toBe(false); + }); + + it('requireReview=true → 调 appendPendingReview 且不动 external-knowledge.md', async () => { + (callClaude as ReturnType).mockResolvedValue(VALID_AI_OUTPUT); + + const result = await importFromIWikiDual({ + input: '12345', + token: 'test-token', + requireReview: true, + }); + + expect(result.pendingReview).toBe(true); + + // external-knowledge.md 不应被创建 + const filePath = path.join(cwd, 'docs/team-codebase/external-knowledge.md'); + expect(await fs.pathExists(filePath)).toBe(false); + + // appendPendingReview 应被调用(每个章节一次) + expect(appendPendingReview).toHaveBeenCalled(); + const firstCall = (appendPendingReview as ReturnType).mock.calls[0][1] as { + kind: string; + payload: { content: string }; + }; + expect(firstCall.kind).toBe('codebase-section'); + expect(typeof firstCall.payload.content).toBe('string'); + }); +}); diff --git a/src/__tests__/iwiki-review-apply.test.ts b/src/__tests__/iwiki-review-apply.test.ts new file mode 100644 index 0000000..8184b5c --- /dev/null +++ b/src/__tests__/iwiki-review-apply.test.ts @@ -0,0 +1,167 @@ +// -*- coding: utf-8 -*- +/** + * iwiki review apply 闭环 e2e 测试 + * + * 验证从 importFromIWikiDual(requireReview:true) 写入 pending-review.jsonl, + * 到 reviewCmd(apply:true) 将章节 patch 进 external-knowledge.md 的完整链路。 + * 关键点:patchManagedSection 必须能识别 --from-iwiki 锚点(Blocker 1 修复验证)。 + */ + +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs-extra'; + +// ─── Mocks ────────────────────────────────────────────── + +vi.mock('../utils/ai-client.js', () => ({ + callClaude: vi.fn(), +})); + +vi.mock('../utils/iwiki-client.js', () => ({ + IWikiClient: vi.fn().mockImplementation(() => ({ + fetchAllPages: vi.fn().mockResolvedValue([ + { docid: '456', title: 'Test Wiki Page' }, + ]), + getDocument: vi.fn().mockResolvedValue({ + docid: '456', + title: 'Test Wiki Page', + content: '这是测试内容,包含业务接口和外部知识', + }), + })), +})); + +vi.mock('../utils/prompt.js', () => ({ + askQuestion: vi.fn().mockResolvedValue('y'), + askConfirmation: vi.fn().mockResolvedValue(true), +})); + +vi.mock('../domains/store.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + appendHistory: vi.fn().mockResolvedValue(undefined), + }; +}); + +// ─── Imports (after mocks) ─────────────────────────────── + +import { importFromIWikiDual } from '../iwiki-dual.js'; +import { reviewCmd } from '../review-cmd.js'; +import { loadPendingReview } from '../review-store.js'; +import { callClaude } from '../utils/ai-client.js'; + +// ─── 常量 ──────────────────────────────────────────────── + +const AI_OUTPUT = JSON.stringify({ + 'business-api': '## 业务接口\n已更新的业务接口内容', + 'external-knowledge': '## 外部知识\n已更新的外部知识内容,由 iwiki 导入', + 'glossary': '| 术语 | 说明 |\n|------|------|\n| alpha | 测试术语 |', +}); + +/** 含 --from-iwiki 锚点的 external-knowledge.md 骨架内容 */ +function buildSkeletonMd(): string { + return [ + '# 外部知识源', + '', + '本文档由 `teamai import --from-iwiki --iwiki-dual` 自动维护。', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join('\n'); +} + +// ─── 辅助 ──────────────────────────────────────────────── + +async function makeWorkdir(): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-iwiki-apply-e2e-')); + await fs.ensureDir(path.join(tmpDir, '.teamai')); + return tmpDir; +} + +// ─── Tests ────────────────────────────────────────────── + +describe('iwiki review apply 闭环 e2e', () => { + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + cwd = await makeWorkdir(); + originalCwd = process.cwd(); + process.chdir(cwd); + vi.clearAllMocks(); + (callClaude as ReturnType).mockResolvedValue(AI_OUTPUT); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(cwd); + }); + + it('importFromIWikiDual(requireReview:true) 写入 pending-review.jsonl', async () => { + const result = await importFromIWikiDual({ + input: '456', + token: 'fake-token', + sections: ['external-knowledge'], + requireReview: true, + }); + + expect(result.pendingReview).toBe(true); + + const items = await loadPendingReview(cwd); + expect(items.length).toBeGreaterThan(0); + + const item = items.find((i) => i.target.section === 'external-knowledge'); + expect(item).toBeDefined(); + expect(item?.kind).toBe('codebase-section'); + expect(item?.source).toContain('iwiki://456'); + expect(item?.payload['content']).toContain('外部知识'); + }); + + it('reviewCmd(apply:true) 成功 patch --from-iwiki 锚点并写入 body', async () => { + // 1. 准备带 --from-iwiki 锚点的 external-knowledge.md + const ekDir = path.join(cwd, 'docs', 'team-codebase'); + await fs.ensureDir(ekDir); + const ekPath = path.join(ekDir, 'external-knowledge.md'); + await fs.writeFile(ekPath, buildSkeletonMd(), 'utf8'); + + // 2. 写入 pending-review 条目(模拟 importFromIWikiDual requireReview 的产物) + await importFromIWikiDual({ + input: '456', + token: 'fake-token', + sections: ['external-knowledge'], + requireReview: true, + }); + + const items = await loadPendingReview(cwd); + const item = items.find((i) => i.target.section === 'external-knowledge'); + expect(item).toBeDefined(); + const itemId = item!.id; + + // 3. 执行 review --apply + await reviewCmd({ idArg: itemId, apply: true }); + + // 4. 断言 external-knowledge.md 的 body 确实被 patch(内容包含新文本) + const patched = await fs.readFile(ekPath, 'utf8'); + expect(patched).toContain('已更新的外部知识内容'); + expect(patched).toContain('由 iwiki 导入'); + + // 5. 断言锚点前缀仍保留 --from-iwiki(写入侧锚点不被 patch 成 --from-repo) + // patchManagedSection 会用 meta.source 重建开锚;此处来源是 iwiki://456 + expect(patched).toContain('--from-iwiki'); + + // 6. 断言条目已从 pending-review 移除 + const remaining = await loadPendingReview(cwd); + expect(remaining.find((i) => i.id === itemId)).toBeUndefined(); + }); +}); diff --git a/src/__tests__/mr-hint.test.ts b/src/__tests__/mr-hint.test.ts new file mode 100644 index 0000000..91042d2 --- /dev/null +++ b/src/__tests__/mr-hint.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { parseRemoteToRepo, buildHintMessage, getGitRemote } from '../mr-hint.js'; +import type { MRSummary } from '../mr-hint.js'; + +describe('parseRemoteToRepo', () => { + it('parses TGit HTTPS URL with simple path', () => { + const result = parseRemoteToRepo('https://git.woa.com/owner/repo.git'); + expect(result).toEqual({ provider: 'tgit', owner: 'owner', repo: 'repo' }); + }); + + it('parses TGit HTTPS URL with group path', () => { + const result = parseRemoteToRepo('https://git.woa.com/group/subgroup/repo.git'); + expect(result).toEqual({ provider: 'tgit', owner: 'group/subgroup', repo: 'repo' }); + }); + + it('parses TGit SSH URL', () => { + const result = parseRemoteToRepo('git@git.woa.com:group/repo.git'); + expect(result).toEqual({ provider: 'tgit', owner: 'group', repo: 'repo' }); + }); + + it('parses GitHub HTTPS URL', () => { + const result = parseRemoteToRepo('https://github.com/myorg/myrepo.git'); + expect(result).toEqual({ provider: 'github', owner: 'myorg', repo: 'myrepo' }); + }); + + it('parses GitHub SSH URL', () => { + const result = parseRemoteToRepo('git@github.com:myorg/myrepo.git'); + expect(result).toEqual({ provider: 'github', owner: 'myorg', repo: 'myrepo' }); + }); + + it('parses URL without .git suffix', () => { + const result = parseRemoteToRepo('https://git.woa.com/owner/repo'); + expect(result).toEqual({ provider: 'tgit', owner: 'owner', repo: 'repo' }); + }); + + it('returns null for unrecognized URL', () => { + expect(parseRemoteToRepo('https://gitlab.com/owner/repo.git')).toBeNull(); + expect(parseRemoteToRepo('')).toBeNull(); + expect(parseRemoteToRepo('not-a-url')).toBeNull(); + }); +}); + +describe('buildHintMessage', () => { + const sampleMRs: MRSummary[] = [ + { + id: '42', + title: 'feat: add new feature', + url: 'https://git.woa.com/owner/repo/merge_requests/42', + mergedAt: '2024-06-01T10:00:00Z', + }, + ]; + + it('includes MR title in hint', () => { + const msg = buildHintMessage(sampleMRs); + expect(msg).toContain('feat: add new feature'); + }); + + it('includes teamai import command', () => { + const msg = buildHintMessage(sampleMRs); + expect(msg).toContain('teamai import --from-mr'); + expect(msg).toContain('https://git.woa.com/owner/repo/merge_requests/42'); + }); + + it('mentions MR count', () => { + const msg = buildHintMessage(sampleMRs); + expect(msg).toContain('1'); + }); + + it('includes the [teamai:mr-hint] prefix', () => { + const msg = buildHintMessage(sampleMRs); + expect(msg).toContain('[teamai:mr-hint]'); + }); + + it('handles multiple MRs', () => { + const mrs: MRSummary[] = [ + { id: '1', title: 'MR One', url: 'https://git.woa.com/a/b/merge_requests/1', mergedAt: '2024-06-01T00:00:00Z' }, + { id: '2', title: 'MR Two', url: 'https://git.woa.com/a/b/merge_requests/2', mergedAt: '2024-06-02T00:00:00Z' }, + ]; + const msg = buildHintMessage(mrs); + expect(msg).toContain('2'); + expect(msg).toContain('MR One'); + expect(msg).toContain('MR Two'); + }); +}); + +describe('getGitRemote', () => { + it('returns null for non-git directory', () => { + const result = getGitRemote('/tmp'); + expect(result).toBeNull(); + }); +}); diff --git a/src/__tests__/path-safety.test.ts b/src/__tests__/path-safety.test.ts new file mode 100644 index 0000000..831aff8 --- /dev/null +++ b/src/__tests__/path-safety.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import { assertSafePath, assertSafeResourceName, defaultAllowedRoots } from '../utils/path-safety.js'; + +describe('assertSafePath', () => { + const home = os.homedir(); + const cwd = process.cwd(); + + it('allows a path inside home directory', () => { + expect(() => assertSafePath(path.join(home, '.teamai', 'file.md'), [home])).not.toThrow(); + }); + + it('allows a path equal to the allowed root', () => { + expect(() => assertSafePath(home, [home])).not.toThrow(); + }); + + it('allows a path inside cwd', () => { + expect(() => assertSafePath(path.join(cwd, 'src', 'file.ts'), [cwd])).not.toThrow(); + }); + + it('throws for a path outside all allowed roots', () => { + expect(() => assertSafePath('/etc/passwd', [home, cwd])).toThrow('Path traversal detected'); + }); + + it('throws for /tmp when not in allowedRoots', () => { + expect(() => assertSafePath('/tmp/evil', [home])).toThrow('Path traversal detected'); + }); + + it('does not allow sibling path confusion (prefix-only check)', () => { + // e.g., /home/userX should not be allowed when root is /home/user + const root = path.join(home, 'safe-dir'); + const tricky = home + '-malicious/file.txt'; + expect(() => assertSafePath(tricky, [root])).toThrow('Path traversal detected'); + }); +}); + +describe('defaultAllowedRoots', () => { + it('returns cwd and homedir', () => { + const roots = defaultAllowedRoots(); + expect(roots).toContain(process.cwd()); + expect(roots).toContain(os.homedir()); + }); +}); + +describe('assertSafeResourceName', () => { + // ── 合法名称 ───────────────────────────────────────────────── + it('accepts a simple skill name', () => { + expect(() => assertSafeResourceName('my-skill')).not.toThrow(); + }); + + it('accepts name with dots and underscores mixed', () => { + expect(() => assertSafeResourceName('a.b_c-1')).not.toThrow(); + }); + + it('accepts a single character', () => { + expect(() => assertSafeResourceName('a')).not.toThrow(); + }); + + // ── 路径遍历拒绝 ────────────────────────────────────────────── + it('rejects path traversal "../etc"', () => { + expect(() => assertSafeResourceName('../etc')).toThrow('Invalid resource name'); + }); + + it('rejects double dot ".."', () => { + expect(() => assertSafeResourceName('..')).toThrow('Invalid resource name'); + }); + + it('rejects single dot "."', () => { + expect(() => assertSafeResourceName('.')).toThrow('Invalid resource name'); + }); + + it('rejects empty string', () => { + expect(() => assertSafeResourceName('')).toThrow('Invalid resource name'); + }); + + it('rejects name with forward slash "a/b"', () => { + expect(() => assertSafeResourceName('a/b')).toThrow('Invalid resource name'); + }); + + it('rejects name with backslash "a\\\\b"', () => { + expect(() => assertSafeResourceName('a\\b')).toThrow('Invalid resource name'); + }); + + // ── URL 编码绕过拒绝 ────────────────────────────────────────── + it('rejects percent-encoded double dot "%2e%2e"', () => { + expect(() => assertSafeResourceName('%2e%2e')).toThrow('Invalid resource name'); + }); + + it('rejects percent-encoded slash "%2fetc"', () => { + expect(() => assertSafeResourceName('%2fetc')).toThrow('Invalid resource name'); + }); + + // ── 特殊字符拒绝 ────────────────────────────────────────────── + it('rejects name with null byte', () => { + expect(() => assertSafeResourceName('a\0b')).toThrow('Invalid resource name'); + }); + + it('rejects name longer than 64 characters', () => { + const long = 'a'.repeat(65); + expect(() => assertSafeResourceName(long)).toThrow('Invalid resource name'); + }); + + it('rejects name containing Chinese characters', () => { + expect(() => assertSafeResourceName('技能')).toThrow('Invalid resource name'); + }); + + it('rejects name containing spaces', () => { + expect(() => assertSafeResourceName('my skill')).toThrow('Invalid resource name'); + }); + + it('rejects absolute path "/abs"', () => { + expect(() => assertSafeResourceName('/abs')).toThrow('Invalid resource name'); + }); + + // ── 非法 percent-encoding 拒绝 ──────────────────────────────── + it('rejects malformed percent-encoding like "%E0%A4%A"', () => { + expect(() => assertSafeResourceName('%E0%A4%A')).toThrow('Invalid resource name'); + }); +}); diff --git a/src/__tests__/recall-rules.test.ts b/src/__tests__/recall-rules.test.ts new file mode 100644 index 0000000..4804eeb --- /dev/null +++ b/src/__tests__/recall-rules.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + })), +})); + +import { compileRecallRulesBlock } from '../pull.js'; +import { injectClaudeMdSection } from '../utils/claudemd.js'; +import { TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END } from '../types.js'; + +describe('compileRecallRulesBlock', () => { + it('produces a marker-delimited block containing both required rules', () => { + const block = compileRecallRulesBlock(); + expect(block).toContain(TEAMAI_RECALL_RULES_START); + expect(block).toContain(TEAMAI_RECALL_RULES_END); + // Rule 1: must call teamai-recall before tasks + expect(block).toMatch(/teamai-recall/); + expect(block).toMatch(/Before/i); + // Rule 2: must declare referenced-doc-ids after task + expect(block).toContain('teamai:referenced-doc-ids'); + }); + + it('is idempotent (same input produces same output)', () => { + expect(compileRecallRulesBlock()).toBe(compileRecallRulesBlock()); + }); +}); + +describe('injectClaudeMdSection — recall rules block lifecycle', () => { + let tmpDir: string; + let claudeMdPath: string; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-recall-rules-')); + claudeMdPath = path.join(tmpDir, 'CLAUDE.md'); + }); + + afterEach(async () => { + await fse.remove(tmpDir); + }); + + it('injects the block into a fresh CLAUDE.md (file did not exist)', async () => { + const block = compileRecallRulesBlock(); + await injectClaudeMdSection(claudeMdPath, TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END, block); + + const content = await fse.readFile(claudeMdPath, 'utf8'); + expect(content).toContain(TEAMAI_RECALL_RULES_START); + expect(content).toContain(TEAMAI_RECALL_RULES_END); + expect(content).toContain('teamai-recall'); + }); + + it('appends the block when CLAUDE.md exists but has no marker', async () => { + await fse.writeFile(claudeMdPath, '# My Project\n\nUser-written instructions.\n'); + const block = compileRecallRulesBlock(); + await injectClaudeMdSection(claudeMdPath, TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END, block); + + const content = await fse.readFile(claudeMdPath, 'utf8'); + // User content preserved + expect(content).toContain('# My Project'); + expect(content).toContain('User-written instructions.'); + // Recall block appended + expect(content).toContain(TEAMAI_RECALL_RULES_START); + expect(content).toContain(TEAMAI_RECALL_RULES_END); + }); + + it('replaces ONLY the marker region on subsequent injections', async () => { + const before = `# My Project + +Custom user content above. + +${TEAMAI_RECALL_RULES_START} +old block — to be replaced +${TEAMAI_RECALL_RULES_END} + +Custom user content below. +`; + await fse.writeFile(claudeMdPath, before); + + const block = compileRecallRulesBlock(); + await injectClaudeMdSection(claudeMdPath, TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END, block); + + const content = await fse.readFile(claudeMdPath, 'utf8'); + // Outside-marker user content preserved + expect(content).toContain('Custom user content above.'); + expect(content).toContain('Custom user content below.'); + // Old block content gone + expect(content).not.toContain('old block — to be replaced'); + // New block present + expect(content).toContain('teamai-recall'); + // Only one occurrence of the markers + const startMatches = content.match(new RegExp(TEAMAI_RECALL_RULES_START.replace(/[\[\]\-]/g, '\\$&'), 'g')) ?? []; + expect(startMatches.length).toBe(1); + }); + + it('coexists with the legacy [teamai:claudemd] marker block (independent regions)', async () => { + const before = ` +some legacy injected content + +`; + await fse.writeFile(claudeMdPath, before); + + await injectClaudeMdSection( + claudeMdPath, + TEAMAI_RECALL_RULES_START, + TEAMAI_RECALL_RULES_END, + compileRecallRulesBlock(), + ); + + const content = await fse.readFile(claudeMdPath, 'utf8'); + expect(content).toContain(''); + expect(content).toContain('some legacy injected content'); + expect(content).toContain(TEAMAI_RECALL_RULES_START); + }); +}); diff --git a/src/__tests__/recall.test.ts b/src/__tests__/recall.test.ts index a754668..90f1ec0 100644 --- a/src/__tests__/recall.test.ts +++ b/src/__tests__/recall.test.ts @@ -67,6 +67,7 @@ describe('autoUpvote', () => { tags: [], tokens: [], votes: 0, + type: 'learnings', }, score: 5, }; diff --git a/src/__tests__/repo-cache.test.ts b/src/__tests__/repo-cache.test.ts new file mode 100644 index 0000000..9a5cf2f --- /dev/null +++ b/src/__tests__/repo-cache.test.ts @@ -0,0 +1,108 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; + +import { + getRepoCacheDir, + getRepoSlug, + writeLastSync, + readLastSync, + ensureCacheRoot, +} from '../utils/repo-cache.js'; + +describe('repo-cache', () => { + let tmpDir: string; + let originalCacheDir: string | undefined; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-repo-cache-test-')); + originalCacheDir = process.env.TEAMAI_CACHE_DIR; + process.env.TEAMAI_CACHE_DIR = tmpDir; + }); + + afterEach(async () => { + if (originalCacheDir === undefined) { + delete process.env.TEAMAI_CACHE_DIR; + } else { + process.env.TEAMAI_CACHE_DIR = originalCacheDir; + } + await fs.remove(tmpDir); + }); + + describe('getRepoCacheDir', () => { + it('拼接路径正确(简单 owner)', () => { + const result = getRepoCacheDir('github', 'myorg', 'myrepo'); + expect(result).toBe(path.join(tmpDir, 'github', 'myorg', 'myrepo')); + }); + + it('拼接路径正确(多级 owner)', () => { + const result = getRepoCacheDir('tgit', 'team/sub', 'service'); + expect(result).toBe(path.join(tmpDir, 'tgit', 'team/sub', 'service')); + }); + + it('不同 provider 产生不同路径', () => { + const github = getRepoCacheDir('github', 'org', 'repo'); + const tgit = getRepoCacheDir('tgit', 'org', 'repo'); + expect(github).not.toBe(tgit); + }); + }); + + describe('getRepoSlug', () => { + it('简单 owner 生成正确 slug', () => { + expect(getRepoSlug('github', 'myorg', 'myrepo')).toBe('github__myorg__myrepo'); + }); + + it('多级 owner 中 / 替换为 -', () => { + expect(getRepoSlug('tgit', 'team/sub', 'service')).toBe('tgit__team-sub__service'); + }); + + it('多层 group 全部替换', () => { + expect(getRepoSlug('tgit', 'a/b/c', 'repo')).toBe('tgit__a-b-c__repo'); + }); + }); + + describe('writeLastSync / readLastSync', () => { + it('往返写读一致', async () => { + const cacheDir = path.join(tmpDir, 'test-repo'); + await fs.ensureDir(cacheDir); + + const sha = 'abc123def456789012345678901234567890abcd'; + await writeLastSync(cacheDir, sha); + + const result = await readLastSync(cacheDir); + expect(result).not.toBeNull(); + expect(result!.sha).toBe(sha); + expect(result!.ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it('LAST_SYNC 文件不存在时返回 null', async () => { + const cacheDir = path.join(tmpDir, 'nonexistent-repo'); + const result = await readLastSync(cacheDir); + expect(result).toBeNull(); + }); + + it('多次写入取最后一次', async () => { + const cacheDir = path.join(tmpDir, 'test-repo-2'); + await fs.ensureDir(cacheDir); + + await writeLastSync(cacheDir, 'sha1111'); + await writeLastSync(cacheDir, 'sha2222'); + + const result = await readLastSync(cacheDir); + expect(result!.sha).toBe('sha2222'); + }); + }); + + describe('ensureCacheRoot', () => { + it('返回缓存根路径并确保目录存在', async () => { + const newTmpRoot = path.join(tmpDir, 'deep', 'nested', 'root'); + process.env.TEAMAI_CACHE_DIR = newTmpRoot; + + const result = await ensureCacheRoot(); + expect(result).toBe(newTmpRoot); + expect(await fs.pathExists(newTmpRoot)).toBe(true); + }); + }); +}); diff --git a/src/__tests__/repo-list-schema.test.ts b/src/__tests__/repo-list-schema.test.ts new file mode 100644 index 0000000..dbc74e3 --- /dev/null +++ b/src/__tests__/repo-list-schema.test.ts @@ -0,0 +1,91 @@ +// -*- coding: utf-8 -*- +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; +import { stringify as yamlStringify } from 'yaml'; + +import { loadRepoList } from '../repo-list/store.js'; +import { isOrgEntry, type RepoListFile } from '../repo-list/schema.js'; + +describe('loadRepoList', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-repo-list-test-')); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + it('加载并校验合法的单仓 yaml', async () => { + const content: RepoListFile = { + version: 1, + repos: [ + { url: 'https://github.com/org/repo-a', domain: '推理', priority: 'high' }, + { url: 'https://github.com/org/repo-b', priority: 'normal' }, + ], + }; + const filePath = path.join(tmpDir, 'repos.yaml'); + await fs.writeFile(filePath, yamlStringify(content), 'utf8'); + + const loaded = await loadRepoList(filePath); + expect(loaded.version).toBe(1); + expect(loaded.repos).toHaveLength(2); + expect(loaded.repos[0]).toMatchObject({ url: 'https://github.com/org/repo-a', domain: '推理' }); + }); + + it('文件不存在时抛 Error 包含文件路径', async () => { + const missingPath = path.join(tmpDir, 'nonexistent.yaml'); + await expect(loadRepoList(missingPath)).rejects.toThrow(`Repo list not found: ${missingPath}`); + }); + + it('url 不合法时 zod 校验抛错', async () => { + const filePath = path.join(tmpDir, 'bad.yaml'); + await fs.writeFile(filePath, yamlStringify({ version: 1, repos: [{ url: 'not-a-url' }] }), 'utf8'); + await expect(loadRepoList(filePath)).rejects.toThrow(); + }); + + it('org entry 与 single entry 都被正确识别', async () => { + const filePath = path.join(tmpDir, 'mixed.yaml'); + await fs.writeFile(filePath, yamlStringify({ + version: 1, + repos: [ + { url: 'https://github.com/org/single-repo' }, + { org: 'https://github.com/myorg', default_domain: '平台' }, + ], + }), 'utf8'); + + const loaded = await loadRepoList(filePath); + expect(loaded.repos).toHaveLength(2); + + const orgItem = loaded.repos[1]; + expect(isOrgEntry(orgItem)).toBe(true); + if (isOrgEntry(orgItem)) { + expect(orgItem.org).toBe('https://github.com/myorg'); + } + + const singleItem = loaded.repos[0]; + expect(isOrgEntry(singleItem)).toBe(false); + }); + + it('version 字段缺失时默认为 1', async () => { + const filePath = path.join(tmpDir, 'no-version.yaml'); + await fs.writeFile(filePath, yamlStringify({ repos: [{ url: 'https://github.com/a/b' }] }), 'utf8'); + const loaded = await loadRepoList(filePath); + expect(loaded.version).toBe(1); + }); + + it('文件超过 10 MB 时抛出 size 超限错误', async () => { + const filePath = path.join(tmpDir, 'huge.yaml'); + // 写入 11 MB 内容(真实文件,非 mock fs.stat) + const chunk = 'a'.repeat(1024 * 1024); + let content = ''; + for (let i = 0; i < 11; i++) content += chunk; + await fs.writeFile(filePath, content, 'utf8'); + + await expect(loadRepoList(filePath)).rejects.toThrow('exceeds max allowed size 10MB'); + }); +}); diff --git a/src/__tests__/review-cmd.test.ts b/src/__tests__/review-cmd.test.ts new file mode 100644 index 0000000..7cfd45f --- /dev/null +++ b/src/__tests__/review-cmd.test.ts @@ -0,0 +1,219 @@ +// -*- coding: utf-8 -*- +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs-extra'; + +// ─── Mocks ────────────────────────────────────────────── + +vi.mock('../review-store.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPendingReview: vi.fn(), + savePendingReview: vi.fn(), + removePendingReview: vi.fn(), + }; +}); + +vi.mock('../section-patcher.js', () => ({ + patchManagedSection: vi.fn(), +})); + +vi.mock('../domains/index.js', () => ({ + appendHistory: vi.fn(), +})); + +// ─── Imports (after mocks) ─────────────────────────────── + +import { reviewCmd } from '../review-cmd.js'; +import { + loadPendingReview, + removePendingReview, + type PendingReviewItem, +} from '../review-store.js'; +import { patchManagedSection } from '../section-patcher.js'; +import { appendHistory } from '../domains/index.js'; + +// ─── 辅助 ──────────────────────────────────────────────── + +async function makeWorkdir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'teamai-review-cmd-test-')); +} + +function makeItem(overrides: Partial = {}): PendingReviewItem { + return { + id: 'abc123def456', + ts: '2024-01-01T00:00:00.000Z', + kind: 'codebase-section', + target: { + file: 'docs/team-codebase/external-knowledge.md', + section: 'glossary', + }, + payload: { content: '## 术语表\n| foo | bar |' }, + source: 'import --from-iwiki', + risk: 'medium', + ...overrides, + }; +} + +// ─── Tests ────────────────────────────────────────────── + +describe('review-cmd', () => { + let cwd: string; + let originalCwd: string; + let consoleSpy: ReturnType; + + beforeEach(async () => { + cwd = await makeWorkdir(); + originalCwd = process.cwd(); + process.chdir(cwd); + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(cwd); + consoleSpy.mockRestore(); + }); + + // ── list 模式 ───────────────────────────────────────── + + it('无 args → list 调用 loadPendingReview', async () => { + (loadPendingReview as ReturnType).mockResolvedValue([makeItem()]); + + await reviewCmd({}); + + expect(loadPendingReview).toHaveBeenCalledOnce(); + // 输出中含 ID + const output = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('abc123def456'); + }); + + it('--json → list 输出有效 JSON 数组', async () => { + const items = [makeItem(), makeItem({ id: 'def456abc123', risk: 'high' })]; + (loadPendingReview as ReturnType).mockResolvedValue(items); + + await reviewCmd({ json: true }); + + const output = consoleSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output) as PendingReviewItem[]; + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBe(2); + }); + + // ── show 模式 ───────────────────────────────────────── + + it('show 模式正确渲染单条', async () => { + const item = makeItem(); + (loadPendingReview as ReturnType).mockResolvedValue([item]); + + await reviewCmd({ idArg: 'abc123def456' }); + + const output = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('abc123def456'); + expect(output).toContain('codebase-section'); + expect(output).toContain('glossary'); + }); + + // ── apply 模式 ──────────────────────────────────────── + + it('apply 单条 codebase-section → 调 patchManagedSection + removeItem + appendHistory', async () => { + const item = makeItem(); + const targetFile = path.join(cwd, item.target.file!); + await fs.ensureDir(path.dirname(targetFile)); + await fs.writeFile(targetFile, '# doc\n## glossary\n', 'utf8'); + + (loadPendingReview as ReturnType).mockResolvedValue([item]); + (patchManagedSection as ReturnType).mockReturnValue('# doc patched'); + (removePendingReview as ReturnType).mockResolvedValue(true); + (appendHistory as ReturnType).mockResolvedValue(undefined); + + await reviewCmd({ idArg: 'abc123def456', apply: true }); + + expect(patchManagedSection).toHaveBeenCalledOnce(); + expect(removePendingReview).toHaveBeenCalledWith(expect.stringContaining('teamai-review-cmd-test-'), 'abc123def456'); + expect(appendHistory).toHaveBeenCalledOnce(); + const histCall = (appendHistory as ReturnType).mock.calls[0][1]; + expect(histCall.action).toBe('accept'); + }); + + it('apply kind=domain-drift → 不调 patchManagedSection,jsonl 不变', async () => { + const item = makeItem({ kind: 'domain-drift' }); + (loadPendingReview as ReturnType).mockResolvedValue([item]); + (removePendingReview as ReturnType).mockResolvedValue(false); + + await reviewCmd({ idArg: 'abc123def456', apply: true }); + + expect(patchManagedSection).not.toHaveBeenCalled(); + expect(removePendingReview).not.toHaveBeenCalled(); + + const output = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('不支持'); + }); + + // ── reject 模式 ─────────────────────────────────────── + + it('reject 单条 → removePendingReview + appendHistory action=reject', async () => { + const item = makeItem(); + (loadPendingReview as ReturnType).mockResolvedValue([item]); + (removePendingReview as ReturnType).mockResolvedValue(true); + (appendHistory as ReturnType).mockResolvedValue(undefined); + + await reviewCmd({ idArg: 'abc123def456', reject: true, reason: '内容不准确' }); + + expect(removePendingReview).toHaveBeenCalledWith(expect.stringContaining('teamai-review-cmd-test-'), 'abc123def456'); + expect(appendHistory).toHaveBeenCalledOnce(); + const histCall = (appendHistory as ReturnType).mock.calls[0][1]; + expect(histCall.action).toBe('reject'); + expect(histCall.details['reason']).toBe('内容不准确'); + }); + + // ── --all-apply 模式 ────────────────────────────────── + + it('--all-apply --max-risk medium → 只应用 medium/low 的 codebase-section;high 项跳过', async () => { + const highItem = makeItem({ id: 'highriskitem1', risk: 'high', target: { file: 'docs/a.md', section: 'sec-a' } }); + const mediumItem = makeItem({ id: 'mediumitem001', risk: 'medium', target: { file: 'docs/b.md', section: 'sec-b' } }); + + const targetFile = path.join(cwd, 'docs/b.md'); + await fs.ensureDir(path.dirname(targetFile)); + await fs.writeFile(targetFile, '# doc', 'utf8'); + + (loadPendingReview as ReturnType).mockResolvedValue([highItem, mediumItem]); + (patchManagedSection as ReturnType).mockReturnValue('# doc patched'); + (removePendingReview as ReturnType).mockResolvedValue(true); + (appendHistory as ReturnType).mockResolvedValue(undefined); + + await reviewCmd({ allApply: true, maxRisk: 'medium' }); + + // 只对 mediumItem 调用 patchManagedSection + expect(patchManagedSection).toHaveBeenCalledOnce(); + expect(removePendingReview).toHaveBeenCalledWith(expect.stringContaining('teamai-review-cmd-test-'), 'mediumitem001'); + // highItem 不应该被移除 + expect(removePendingReview).not.toHaveBeenCalledWith(expect.stringContaining('teamai-review-cmd-test-'), 'highriskitem1'); + + const output = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(output).toContain('跳过'); + }); + + it('--json 输出有效 JSON(apply 模式)', async () => { + const item = makeItem(); + const targetFile = path.join(cwd, item.target.file!); + await fs.ensureDir(path.dirname(targetFile)); + await fs.writeFile(targetFile, '# doc', 'utf8'); + + (loadPendingReview as ReturnType).mockResolvedValue([item]); + (patchManagedSection as ReturnType).mockReturnValue('# doc patched'); + (removePendingReview as ReturnType).mockResolvedValue(true); + (appendHistory as ReturnType).mockResolvedValue(undefined); + + await reviewCmd({ idArg: 'abc123def456', apply: true, json: true }); + + const output = consoleSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output) as { ok: boolean; id: string }; + expect(typeof parsed.ok).toBe('boolean'); + expect(parsed.id).toBe('abc123def456'); + }); +}); diff --git a/src/__tests__/review-store.test.ts b/src/__tests__/review-store.test.ts new file mode 100644 index 0000000..dd4faf6 --- /dev/null +++ b/src/__tests__/review-store.test.ts @@ -0,0 +1,250 @@ +// -*- coding: utf-8 -*- +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs-extra'; + +import { + loadPendingReview, + savePendingReview, + appendPendingReview, + removePendingReview, + computeReviewId, + inferRisk, + getPendingReviewPath, + type PendingReviewItem, +} from '../review-store.js'; + +// ─── 辅助 ──────────────────────────────────────────────── + +async function makeWorkdir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'teamai-review-store-test-')); +} + +function makeItem(overrides: Partial = {}): PendingReviewItem { + return { + id: 'abc123def456', + ts: '2024-01-01T00:00:00.000Z', + kind: 'codebase-section', + target: { file: 'docs/team-codebase/external-knowledge.md', section: 'glossary' }, + payload: { content: '## 术语表\n| foo | bar |' }, + source: 'import --from-iwiki', + risk: 'medium', + ...overrides, + }; +} + +// ─── Tests ────────────────────────────────────────────── + +describe('review-store', () => { + let cwd: string; + + beforeEach(async () => { + cwd = await makeWorkdir(); + }); + + afterEach(async () => { + await fs.remove(cwd); + }); + + // ── loadPendingReview ──────────────────────────────── + + it('文件不存在 → 返回空数组', async () => { + const items = await loadPendingReview(cwd); + expect(items).toEqual([]); + }); + + it('旧 schema 条目(type/file/section/content)能被归一化', async () => { + const legacyRecord = { + ts: '2024-01-01T00:00:00.000Z', + type: 'codebase-section', + file: 'docs/team-codebase/external-knowledge.md', + section: 'glossary', + source: 'iwiki://12345', + content: '## 术语表\n| foo | bar |', + }; + + const filePath = getPendingReviewPath(cwd); + await fs.ensureDir(path.dirname(filePath)); + await fs.appendFile(filePath, JSON.stringify(legacyRecord) + '\n', 'utf8'); + + const items = await loadPendingReview(cwd); + expect(items).toHaveLength(1); + + const item = items[0]; + expect(item.kind).toBe('codebase-section'); + expect(item.target.file).toBe('docs/team-codebase/external-knowledge.md'); + expect(item.target.section).toBe('glossary'); + expect(item.payload['content']).toBe('## 术语表\n| foo | bar |'); + expect(item.source).toBe('iwiki://12345'); + // id 应该被自动计算 + expect(item.id).toBeTruthy(); + expect(item.id).toHaveLength(12); + // risk 应该被推断(路径含 external-knowledge → high) + expect(item.risk).toBe('high'); + }); + + it('新 schema 条目正确读出', async () => { + const newItem = makeItem(); + const filePath = getPendingReviewPath(cwd); + await fs.ensureDir(path.dirname(filePath)); + await fs.appendFile(filePath, JSON.stringify(newItem) + '\n', 'utf8'); + + const items = await loadPendingReview(cwd); + expect(items).toHaveLength(1); + expect(items[0].id).toBe('abc123def456'); + expect(items[0].kind).toBe('codebase-section'); + expect(items[0].risk).toBe('medium'); + }); + + it('损坏的 JSON 行被跳过,其他行正常返回', async () => { + const filePath = getPendingReviewPath(cwd); + await fs.ensureDir(path.dirname(filePath)); + + const good = makeItem({ id: 'gooditem0001' }); + await fs.appendFile(filePath, JSON.stringify(good) + '\n', 'utf8'); + await fs.appendFile(filePath, 'this is not valid json\n', 'utf8'); + await fs.appendFile(filePath, JSON.stringify(makeItem({ id: 'gooditem0002' })) + '\n', 'utf8'); + + const items = await loadPendingReview(cwd); + expect(items).toHaveLength(2); + expect(items[0].id).toBe('gooditem0001'); + expect(items[1].id).toBe('gooditem0002'); + }); + + // ── appendPendingReview ────────────────────────────── + + it('appendPendingReview:缺 id/ts/risk 自动填充', async () => { + const item = await appendPendingReview(cwd, { + kind: 'codebase-section', + target: { file: 'docs/foo.md', section: 'bar' }, + payload: { content: 'hello' }, + source: 'test', + }); + + expect(item.id).toBeTruthy(); + expect(item.id).toHaveLength(12); + expect(item.ts).toBeTruthy(); + expect(item.risk).toBe('medium'); + + // 验证落盘 + const loaded = await loadPendingReview(cwd); + expect(loaded).toHaveLength(1); + expect(loaded[0].id).toBe(item.id); + }); + + it('appendPendingReview:返回值含完整字段', async () => { + const item = await appendPendingReview(cwd, { + kind: 'codebase-section', + target: { file: 'docs/foo.md', section: '架构' }, + payload: { content: 'body' }, + source: 'test', + }); + + expect(item.risk).toBe('high'); // 高风险章节 + expect(item.kind).toBe('codebase-section'); + expect(item.target.section).toBe('架构'); + }); + + // ── removePendingReview ────────────────────────────── + + it('removePendingReview:存在 → 返回 true,文件少一行', async () => { + const item1 = await appendPendingReview(cwd, { + kind: 'codebase-section', + target: { file: 'docs/a.md', section: 'sec1' }, + payload: {}, + source: 'test', + }); + const item2 = await appendPendingReview(cwd, { + kind: 'codebase-section', + target: { file: 'docs/b.md', section: 'sec2' }, + payload: {}, + source: 'test', + }); + + const removed = await removePendingReview(cwd, item1.id); + expect(removed).toBe(true); + + const remaining = await loadPendingReview(cwd); + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe(item2.id); + }); + + it('removePendingReview:不存在 → 返回 false', async () => { + const removed = await removePendingReview(cwd, 'nonexistent0'); + expect(removed).toBe(false); + }); + + // ── inferRisk ──────────────────────────────────────── + + it('inferRisk:高风险章节 → high', () => { + expect(inferRisk({ file: 'docs/foo.md', section: '架构' })).toBe('high'); + expect(inferRisk({ file: 'docs/foo.md', section: 'architecture' })).toBe('high'); + expect(inferRisk({ file: 'docs/foo.md', section: 'external-knowledge' })).toBe('high'); + expect(inferRisk({ file: 'docs/foo.md', section: '架构决策与权衡' })).toBe('high'); + }); + + it('inferRisk:包含 external-knowledge 路径 → high', () => { + expect(inferRisk({ file: 'docs/team-codebase/external-knowledge.md' })).toBe('high'); + }); + + it('inferRisk:普通章节 → medium', () => { + expect(inferRisk({ file: 'docs/foo.md', section: 'glossary' })).toBe('medium'); + expect(inferRisk({ file: 'docs/readme.md' })).toBe('medium'); + }); + + // ── computeReviewId ────────────────────────────────── + + it('computeReviewId:相同输入产生相同 ID', () => { + const id1 = computeReviewId('docs/foo.md', 'bar', '2024-01-01T00:00:00.000Z'); + const id2 = computeReviewId('docs/foo.md', 'bar', '2024-01-01T00:00:00.000Z'); + expect(id1).toBe(id2); + expect(id1).toHaveLength(12); + }); + + it('computeReviewId:不同输入产生不同 ID', () => { + const id1 = computeReviewId('docs/foo.md', 'bar', '2024-01-01T00:00:00.000Z'); + const id2 = computeReviewId('docs/baz.md', 'bar', '2024-01-01T00:00:00.000Z'); + expect(id1).not.toBe(id2); + }); + + // ── savePendingReview 原子性 ───────────────────────── + + it('savePendingReview 原子性:rename 失败时不留 .tmp 残留', async () => { + const items = [makeItem()]; + const filePath = getPendingReviewPath(cwd); + const tmpPath = `${filePath}.tmp`; + + await fs.ensureDir(path.dirname(filePath)); + + // mock fs.rename 抛错 + const renameSpy = vi.spyOn(fs, 'rename').mockRejectedValueOnce(new Error('rename failed')); + + await expect(savePendingReview(cwd, items)).rejects.toThrow('rename failed'); + + renameSpy.mockRestore(); + + // .tmp 文件应该存在(因为 rename 失败前已写入) + // 但主文件不应存在(rename 失败) + expect(await fs.pathExists(filePath)).toBe(false); + expect(await fs.pathExists(tmpPath)).toBe(true); + + // 清理 + await fs.remove(tmpPath); + }); + + describe('loadPendingReview — 文件大小限制', () => { + it('pending-review.jsonl 超过 10 MB 时抛出 size 超限错误', async () => { + const filePath = getPendingReviewPath(cwd); + await fs.ensureDir(path.dirname(filePath)); + // 写入 11 MB 内容(真实文件,非 mock fs.stat) + const chunk = 'x'.repeat(1024 * 1024); + let content = ''; + for (let i = 0; i < 11; i++) content += chunk; + await fs.writeFile(filePath, content, 'utf8'); + + await expect(loadPendingReview(cwd)).rejects.toThrow('exceeds max allowed size 10MB'); + }); + }); +}); diff --git a/src/__tests__/search-domain-weighting.test.ts b/src/__tests__/search-domain-weighting.test.ts new file mode 100644 index 0000000..3feb64d --- /dev/null +++ b/src/__tests__/search-domain-weighting.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; +import { buildIndex, loadIndex, search } from '../utils/search-index.js'; + +// --------------------------------------------------------------------------- +// P1.4: Domain-weighted search ranking integration tests +// +// These tests build a real on-disk search index from fixture files and verify +// that the domain × type multipliers produce the expected ranking order. +// --------------------------------------------------------------------------- + +let tmpDir: string; +let indexPath: string; + +beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-domain-test-')); + indexPath = path.join(tmpDir, 'search-index.json'); +}); + +afterEach(async () => { + await fse.remove(tmpDir); +}); + +describe('domain-weighted search scoring', () => { + it('technical entry outranks ops entry with the same raw title/tag score', async () => { + // Both entries have the same title keyword ("timeout") and one matching tag. + // The technical entry should rank higher due to DOMAIN_WEIGHT.technical (1.0) + // vs DOMAIN_WEIGHT.ops (0.5). + const learningsDir = path.join(tmpDir, 'learnings'); + await fse.ensureDir(learningsDir); + + await fse.writeFile( + path.join(learningsDir, 'api-timeout-technical.md'), + '---\ntitle: "API timeout fix"\ntags: [api]\n---\nUse retry backoff.\n', + ); + await fse.writeFile( + path.join(learningsDir, 'k8s-timeout-ops.md'), + '---\ntitle: "k8s timeout fix"\ntags: [k8s]\n---\nAdjust probe timeout.\n', + ); + + await buildIndex({ learningsDir, indexPath }); + const index = await loadIndex(indexPath); + expect(index).not.toBeNull(); + + const results = search('timeout', index!); + expect(results.length).toBe(2); + + const technicalEntry = results.find((r) => r.entry.domain === 'technical'); + const opsEntry = results.find((r) => r.entry.domain === 'ops'); + + expect(technicalEntry).toBeDefined(); + expect(opsEntry).toBeDefined(); + + // technical score should be higher than ops score + expect(technicalEntry!.score).toBeGreaterThan(opsEntry!.score); + // First result should be the technical entry + expect(results[0].entry.domain).toBe('technical'); + }); + + it('ops entry is still returned in results (downweighted, not excluded)', async () => { + const learningsDir = path.join(tmpDir, 'learnings'); + await fse.ensureDir(learningsDir); + + await fse.writeFile( + path.join(learningsDir, 'k8s-rolling-upgrade.md'), + '---\ntitle: "k8s rolling upgrade"\ntags: [k8s, sop]\n---\nRolling upgrade steps.\n', + ); + + await buildIndex({ learningsDir, indexPath }); + const index = await loadIndex(indexPath); + const results = search('k8s', index!); + + // The ops entry must still be present — just with a lower score + expect(results.length).toBeGreaterThan(0); + expect(results[0].entry.domain).toBe('ops'); + expect(results[0].score).toBeGreaterThan(0); + }); + + it('skills type gets TYPE_BONUS (×1.1) over a same-domain learnings entry', async () => { + const learningsDir = path.join(tmpDir, 'learnings'); + const skillsDir = path.join(tmpDir, 'skills'); + const mySkillDir = path.join(skillsDir, 'code-review'); + await fse.ensureDir(learningsDir); + await fse.ensureDir(mySkillDir); + + // Both have identical title/tag content but one is a skill (type bonus ×1.1) + await fse.writeFile( + path.join(learningsDir, 'code-review-tips.md'), + '---\ntitle: "code review tips"\ntags: [api, refactor]\n---\nReview code carefully.\n', + ); + await fse.writeFile( + path.join(mySkillDir, 'SKILL.md'), + '---\nname: code-review\ndescription: code review tips\ntags: [api, refactor]\n---\nReview code carefully.\n', + ); + + await buildIndex({ learningsDir, skillsDir, indexPath }); + const index = await loadIndex(indexPath); + const results = search('code review', index!); + + expect(results.length).toBe(2); + + const skillResult = results.find((r) => r.entry.type === 'skills'); + const learningResult = results.find((r) => r.entry.type === 'learnings'); + + expect(skillResult).toBeDefined(); + expect(learningResult).toBeDefined(); + + // skills ×1.1 on technical domain → 1.0 × 1.1 = 1.1 multiplier + // learnings ×1.0 on technical domain → 1.0 × 1.0 = 1.0 multiplier + expect(skillResult!.score).toBeGreaterThan(learningResult!.score); + }); + + it('frontmatter domain:technical overrides tag-inferred ops and boosts ranking', async () => { + // Entry A has ops tags but declares domain:technical in frontmatter + // Entry B has ops tags with no frontmatter override → inferred ops + // Query uses a technical keyword ("timeout") so the query domain is + // inferred as technical → technical entries are ranked above ops entries. + // Entry A (frontmatter technical) should therefore outrank Entry B (ops). + const learningsDir = path.join(tmpDir, 'learnings'); + await fse.ensureDir(learningsDir); + + await fse.writeFile( + path.join(learningsDir, 'deploy-override.md'), + '---\ntitle: "deploy timeout"\ndomain: technical\ntags: [deploy, timeout]\n---\nDeploy steps with technical context.\n', + ); + await fse.writeFile( + path.join(learningsDir, 'deploy-normal.md'), + '---\ntitle: "deploy timeout"\ntags: [deploy]\n---\nDeploy steps.\n', + ); + + await buildIndex({ learningsDir, indexPath }); + const index = await loadIndex(indexPath); + // "timeout" is in TECHNICAL_TAGS → query domain inferred as technical + const results = search('deploy timeout', index!); + + expect(results.length).toBe(2); + + // Entry with domain:technical should rank higher than ops-inferred entry + const overrideResult = results.find((r) => r.entry.filename === 'deploy-override.md'); + const normalResult = results.find((r) => r.entry.filename === 'deploy-normal.md'); + + expect(overrideResult).toBeDefined(); + expect(normalResult).toBeDefined(); + + expect(overrideResult!.entry.domain).toBe('technical'); + expect(normalResult!.entry.domain).toBe('ops'); + expect(overrideResult!.score).toBeGreaterThan(normalResult!.score); + }); + + it('built index carries domain field on every entry (version 4)', async () => { + const learningsDir = path.join(tmpDir, 'learnings'); + await fse.ensureDir(learningsDir); + + await fse.writeFile( + path.join(learningsDir, 'some-learning.md'), + '---\ntitle: "some learning"\ntags: [api]\n---\nBody.\n', + ); + + await buildIndex({ learningsDir, indexPath }); + const index = await loadIndex(indexPath); + + expect(index).not.toBeNull(); + expect(index!.version).toBe(4); + + for (const entry of index!.entries) { + expect(entry.domain).toBeDefined(); + expect(['technical', 'ops', 'support', 'neutral']).toContain(entry.domain); + } + }); +}); diff --git a/src/__tests__/search-index-multi.test.ts b/src/__tests__/search-index-multi.test.ts new file mode 100644 index 0000000..2a5068a --- /dev/null +++ b/src/__tests__/search-index-multi.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, +})); + +import { buildIndex, loadIndex, isLegacyIndex, search } from '../utils/search-index.js'; +import { SEARCH_INDEX_VERSION } from '../types.js'; + +describe('buildIndex — Phase 1 multi-category', () => { + let tmpDir: string; + let indexPath: string; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-index-multi-')); + indexPath = path.join(tmpDir, 'search-index.json'); + }); + + afterEach(async () => { + await fse.remove(tmpDir); + }); + + it('indexes learnings + docs + rules + skills together with correct types', async () => { + const learningsDir = path.join(tmpDir, 'learnings'); + const docsDir = path.join(tmpDir, 'docs'); + const rulesDir = path.join(tmpDir, 'rules'); + const skillsDir = path.join(tmpDir, 'skills'); + + await fse.ensureDir(learningsDir); + await fse.ensureDir(docsDir); + await fse.ensureDir(path.join(rulesDir, 'common')); + await fse.ensureDir(path.join(skillsDir, 'sample-skill')); + + await fse.writeFile( + path.join(learningsDir, 'l1.md'), + '---\ntitle: learning entry\ntags: [api, retry]\n---\nbody about api', + ); + await fse.writeFile( + path.join(docsDir, 'overview.md'), + '---\ntitle: docs entry\ntags: [api]\n---\ndocs body', + ); + await fse.writeFile( + path.join(rulesDir, 'common', 'coding-style.md'), + '---\ntitle: rules entry\ntags: [style]\n---\nrules body', + ); + await fse.writeFile( + path.join(skillsDir, 'sample-skill', 'SKILL.md'), + '---\nname: sample-skill\ndescription: skills entry test\ntags: [skills]\n---\nskill body', + ); + + await buildIndex({ learningsDir, docsDir, rulesDir, skillsDir, indexPath }); + const index = await loadIndex(indexPath); + expect(index).not.toBeNull(); + expect(index!.version).toBe(SEARCH_INDEX_VERSION); + + const types = index!.entries.map((e) => e.type).sort(); + expect(types).toEqual(['docs', 'learnings', 'rules', 'skills']); + + // Each entry carries an absolute file path + for (const e of index!.entries) { + expect(e.path).toBeTruthy(); + expect(path.isAbsolute(e.path!)).toBe(true); + } + + // Recursive subdirectory paths preserved as filename id (rules/common/...) + const rulesEntry = index!.entries.find((e) => e.type === 'rules'); + expect(rulesEntry?.filename).toBe(path.join('common', 'coding-style.md')); + + // Skill entry uses skill name as id + const skillEntry = index!.entries.find((e) => e.type === 'skills'); + expect(skillEntry?.filename).toBe('sample-skill.md'); + }); + + it('truncates oversized files (>50KB) instead of dropping them', async () => { + const docsDir = path.join(tmpDir, 'docs'); + await fse.ensureDir(docsDir); + const huge = '---\ntitle: huge\n---\n' + 'a'.repeat(60 * 1024); + await fse.writeFile(path.join(docsDir, 'huge.md'), huge); + + await buildIndex({ docsDir, indexPath }); + const index = await loadIndex(indexPath); + expect(index!.entries.length).toBe(1); + expect(index!.entries[0].type).toBe('docs'); + }); + + it('skips categories whose source dir does not exist', async () => { + const learningsDir = path.join(tmpDir, 'learnings'); + await fse.ensureDir(learningsDir); + await fse.writeFile( + path.join(learningsDir, 'only.md'), + '---\ntitle: only\n---\nonly body', + ); + + // Pass paths that don't exist for docs/rules/skills + await buildIndex({ + learningsDir, + docsDir: path.join(tmpDir, 'no-docs'), + rulesDir: path.join(tmpDir, 'no-rules'), + skillsDir: path.join(tmpDir, 'no-skills'), + indexPath, + }); + + const index = await loadIndex(indexPath); + expect(index!.entries.length).toBe(1); + expect(index!.entries[0].type).toBe('learnings'); + }); + + it('produces tokens that include a type: marker', async () => { + const docsDir = path.join(tmpDir, 'docs'); + await fse.ensureDir(docsDir); + await fse.writeFile( + path.join(docsDir, 'a.md'), + '---\ntitle: alpha\n---\nbody', + ); + + await buildIndex({ docsDir, indexPath }); + const index = await loadIndex(indexPath); + expect(index!.entries[0].tokens).toContain('type:docs'); + }); +}); + +describe('isLegacyIndex', () => { + it('returns false for null / missing index (caller should not rebuild)', () => { + expect(isLegacyIndex(null)).toBe(false); + }); + + it('detects pre-Phase-1 indexes (no version field)', () => { + const legacy = { + builtAt: '2026-01-01T00:00:00Z', + elapsedMs: 10, + entries: [ + { + filename: 'old.md', + title: 'old', + author: '', + date: '', + tags: [], + tokens: ['old'], + votes: 0, + } as unknown as import('../types.js').SearchIndexEntry, + ], + }; + expect(isLegacyIndex(legacy)).toBe(true); + }); + + it('detects v2 indexes whose entries are missing type field', () => { + // A v3-version index (SEARCH_INDEX_VERSION) that still lacks 'type' — treated as legacy + const partial = { + version: SEARCH_INDEX_VERSION, + builtAt: '2026-01-01T00:00:00Z', + elapsedMs: 10, + entries: [ + { + filename: 'no-type.md', + title: 'no type', + author: '', + date: '', + tags: [], + tokens: [], + votes: 0, + // type missing + } as unknown as import('../types.js').SearchIndexEntry, + ], + }; + expect(isLegacyIndex(partial)).toBe(true); + }); + + it('detects old v2 (Phase 1.3) indexes missing domain field as legacy', () => { + // Simulates an index built before P1.4 (version=2, has type but no domain). + // isLegacyIndex() must return true so teamai pull rebuilds the index. + const v2Index = { + version: 2, // old pre-P1.4 version + builtAt: '2026-01-01T00:00:00Z', + elapsedMs: 10, + entries: [ + { + filename: 'has-type-no-domain.md', + title: 'some learning', + author: '', + date: '', + tags: [], + tokens: ['type:learnings'], + votes: 0, + type: 'learnings' as const, + // domain: undefined ← missing, as in pre-P1.4 indexes + } as unknown as import('../types.js').SearchIndexEntry, + ], + }; + expect(isLegacyIndex(v2Index)).toBe(true); + }); + + it('returns false for fully populated v3 index (type + domain present)', () => { + const current = { + version: SEARCH_INDEX_VERSION, + builtAt: '2026-01-01T00:00:00Z', + elapsedMs: 10, + entries: [ + { + filename: 'fresh.md', + title: 'fresh', + author: '', + date: '', + tags: [], + tokens: ['type:learnings'], + votes: 0, + type: 'learnings' as const, + domain: 'technical' as const, // P1.4 domain field present + }, + ], + df: {}, // v4: df map required + }; + expect(isLegacyIndex(current)).toBe(false); + }); +}); + +describe('search — type field surfaces on results', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-index-search-')); + }); + + afterEach(async () => { + await fse.remove(tmpDir); + }); + + it('returns the category type on each search result entry', async () => { + const docsDir = path.join(tmpDir, 'docs'); + const learningsDir = path.join(tmpDir, 'learnings'); + await fse.ensureDir(docsDir); + await fse.ensureDir(learningsDir); + await fse.writeFile( + path.join(docsDir, 'api.md'), + '---\ntitle: api timeout\ntags: [api]\n---\ndocs body', + ); + await fse.writeFile( + path.join(learningsDir, 'api-fix.md'), + '---\ntitle: api timeout fix\ntags: [api]\n---\nlearning body', + ); + + const indexPath = path.join(tmpDir, 'idx.json'); + await buildIndex({ docsDir, learningsDir, indexPath }); + const index = await loadIndex(indexPath); + const results = search('api', index!); + expect(results.length).toBeGreaterThan(0); + const types = results.map((r) => r.entry.type).sort(); + expect(types).toContain('docs'); + expect(types).toContain('learnings'); + }); +}); diff --git a/src/__tests__/search-index.test.ts b/src/__tests__/search-index.test.ts index 43f7397..b0fe79e 100644 --- a/src/__tests__/search-index.test.ts +++ b/src/__tests__/search-index.test.ts @@ -372,3 +372,91 @@ describe('buildIndex with votes', () => { expect(entry.votes).toBe(2); }); }); + +// ─── codebase-index.md 跳过与权重 boost ────────────────────── + +describe('codebase-index skip and weight boost', () => { + let tmpDir: string; + let docsDir: string; + const originalHome = process.env.HOME; + + const CODEBASE_FULL_CONTENT = `--- +title: "Codebase Full Document" +tags: [codebase, architecture] +--- + +## Overview +This is the full codebase documentation with all details. +`; + + const CODEBASE_INDEX_CONTENT = `--- +title: "Codebase Index" +tags: [codebase, architecture] +--- + +## Overview +This is the codebase index with chapter summaries. +`; + + const NORMAL_DOC_CONTENT = `--- +title: "Normal Architecture Doc" +tags: [codebase, architecture] +--- + +## Overview +This is a normal documentation file about architecture. +`; + + beforeEach(() => { + tmpDir = makeTmpDir(); + docsDir = path.join(tmpDir, 'docs'); + fs.mkdirSync(docsDir, { recursive: true }); + process.env.HOME = tmpDir; + }); + + afterEach(() => { + process.env.HOME = originalHome; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('skips codebase.md when codebase-index.md exists in same directory', async () => { + fs.writeFileSync(path.join(docsDir, 'codebase.md'), CODEBASE_FULL_CONTENT); + fs.writeFileSync(path.join(docsDir, 'codebase-index.md'), CODEBASE_INDEX_CONTENT); + + await buildIndex({ docsDir }); + const index = await loadIndex(); + expect(index).not.toBeNull(); + + const filenames = index!.entries.map((e) => e.filename); + expect(filenames).not.toContain('codebase.md'); + expect(filenames).toContain('codebase-index.md'); + }); + + it('includes codebase.md when codebase-index.md does not exist', async () => { + fs.writeFileSync(path.join(docsDir, 'codebase.md'), CODEBASE_FULL_CONTENT); + + await buildIndex({ docsDir }); + const index = await loadIndex(); + expect(index).not.toBeNull(); + + const filenames = index!.entries.map((e) => e.filename); + expect(filenames).toContain('codebase.md'); + }); + + it('codebase-index.md scores higher than a normal docs file with same query', async () => { + fs.writeFileSync(path.join(docsDir, 'codebase-index.md'), CODEBASE_INDEX_CONTENT); + fs.writeFileSync(path.join(docsDir, 'normal-doc.md'), NORMAL_DOC_CONTENT); + + await buildIndex({ docsDir }); + const index = await loadIndex(); + expect(index).not.toBeNull(); + + const results = search('codebase architecture', index!, 10); + const indexEntry = results.find((r) => r.entry.filename === 'codebase-index.md'); + const normalEntry = results.find((r) => r.entry.filename === 'normal-doc.md'); + + expect(indexEntry).toBeDefined(); + expect(normalEntry).toBeDefined(); + expect(indexEntry!.score).toBeGreaterThan(normalEntry!.score); + }); +}); diff --git a/src/__tests__/section-merge.test.ts b/src/__tests__/section-merge.test.ts new file mode 100644 index 0000000..5330a95 --- /dev/null +++ b/src/__tests__/section-merge.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from 'vitest'; + +import { mergeWithAnchors, splitToSections, joinSections } from '../section-patcher.js'; + +const META = { source: 'https://github.com/org/repo@deadbeef', syncedAt: '2024-01-01T00:00:00.000Z' }; +const META2 = { source: 'https://github.com/org/repo@cafebabe', syncedAt: '2024-06-01T00:00:00.000Z' }; + +const FRESH_MD = [ + '---', + 'title: Test Repo', + 'lastUpdated: 2024-01-01T00:00:00.000Z', + '---', + '', + '## 项目概述', + '这是项目概述。', + '', + '## 技术栈', + 'TypeScript + Node.js', +].join('\n'); + +describe('mergeWithAnchors', () => { + it('oldFile=null(首次)→ 全部 added,无 changed/removed', () => { + const result = mergeWithAnchors(null, FRESH_MD, META); + + expect(result.addedSlugs).toHaveLength(2); + expect(result.changedSlugs).toHaveLength(0); + expect(result.removedSlugs).toHaveLength(0); + expect(result.keptSlugs).toHaveLength(0); + expect(result.mergedMd).toContain(''); + }); + + it('fresh 中新增 section → added 列表', () => { + const first = mergeWithAnchors(null, FRESH_MD, META); + + const freshMd2 = [ + '---', + 'title: Test Repo', + 'lastUpdated: 2024-06-01T00:00:00.000Z', + '---', + '', + '## 项目概述', + '这是项目概述。', + '', + '## 技术栈', + 'TypeScript + Node.js', + '', + '## 部署方式', // 新增 + 'Docker + K8s', + ].join('\n'); + + const second = mergeWithAnchors(first.mergedMd, freshMd2, META2); + + expect(second.addedSlugs).toContain('部署方式'); + expect(second.mergedMd).toContain('部署方式'); + }); + + it('prelude 不同(frontmatter lastUpdated 变化)→ 全 kept 时保留旧 prelude', () => { + const first = mergeWithAnchors(null, FRESH_MD, META); + + // freshMd 完全相同内容但 lastUpdated 不同 + const freshMd2 = FRESH_MD.replace('2024-01-01T00:00:00.000Z', '2099-12-31T00:00:00.000Z'); + const second = mergeWithAnchors(first.mergedMd, freshMd2, META); + + // 全部 kept,应保留旧 prelude(旧 lastUpdated) + expect(second.mergedMd).toBe(first.mergedMd); + expect(second.changedSlugs).toHaveLength(0); + }); + + it('有 section 改变时 prelude 用 fresh 的', () => { + const first = mergeWithAnchors(null, FRESH_MD, META); + + const freshMd2 = [ + '---', + 'title: Test Repo', + 'lastUpdated: 2099-12-31T00:00:00.000Z', + '---', + '', + '## 项目概述', + '内容已改变!', + '', + '## 技术栈', + 'TypeScript + Node.js', + ].join('\n'); + + const second = mergeWithAnchors(first.mergedMd, freshMd2, META2); + + expect(second.changedSlugs).toContain('项目概述'); + // fresh prelude 被使用(含新 lastUpdated) + expect(second.mergedMd).toContain('2099-12-31T00:00:00.000Z'); + }); +}); diff --git a/src/__tests__/section-patcher.test.ts b/src/__tests__/section-patcher.test.ts new file mode 100644 index 0000000..472efb5 --- /dev/null +++ b/src/__tests__/section-patcher.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect } from 'vitest'; + +import { + splitToSections, + joinSections, + parseSections, + patchManagedSection, + hashBody, + mergeWithAnchors, +} from '../section-patcher.js'; + +// ─── hashBody ──────────────────────────────────────────── + +describe('hashBody', () => { + it('相同输入产生相同 hash', () => { + expect(hashBody('hello world')).toBe(hashBody('hello world')); + }); + + it('trailing whitespace 不影响 hash', () => { + expect(hashBody('line1\nline2 \nline3')).toBe(hashBody('line1\nline2\nline3')); + }); + + it('前后空行不影响 hash', () => { + expect(hashBody('\n\nhello\n\n')).toBe(hashBody('hello')); + }); + + it('返回 16 位 hex 字符串', () => { + const h = hashBody('test'); + expect(h).toMatch(/^[0-9a-f]{16}$/); + }); +}); + +// ─── splitToSections ────────────────────────────────────── + +describe('splitToSections', () => { + it('纯 frontmatter + 多个 section → 正确切分', () => { + const md = [ + '---', + 'title: Test', + 'lastUpdated: 2024-01-01', + '---', + '', + '## 项目概述', + '这是项目概述内容。', + '', + '## 技术栈', + 'TypeScript + Node.js', + ].join('\n'); + + const { prelude, sections } = splitToSections(md); + + expect(prelude).toContain('title: Test'); + expect(sections).toHaveLength(2); + expect(sections[0].title).toBe('项目概述'); + expect(sections[0].slug).toBe('项目概述'); + expect(sections[0].body).toContain('这是项目概述内容'); + expect(sections[1].title).toBe('技术栈'); + expect(sections[1].slug).toBe('技术栈'); + }); + + it('无 frontmatter → prelude 为空字符串(或只含 ## 前的内容)', () => { + const md = '## 第一章\n内容一\n\n## 第二章\n内容二\n'; + const { prelude, sections } = splitToSections(md); + + expect(prelude.trim()).toBe(''); + expect(sections).toHaveLength(2); + expect(sections[0].title).toBe('第一章'); + }); + + it('标题重复 → 第二个 slug 加 -2', () => { + const md = '## Overview\n内容1\n\n## Overview\n内容2\n'; + const { sections } = splitToSections(md); + + expect(sections).toHaveLength(2); + expect(sections[0].slug).toBe('Overview'); + expect(sections[1].slug).toBe('Overview-2'); + }); + + it('无任何 ## 标题 → sections 为空,prelude 为全文', () => { + const md = '# 一级标题\n\n普通段落\n'; + const { prelude, sections } = splitToSections(md); + + expect(sections).toHaveLength(0); + expect(prelude).toBe(md); + }); + + it('标题中含空格 → slug 用 - 替换空格', () => { + const md = '## My Section Title\n内容\n'; + const { sections } = splitToSections(md); + + expect(sections[0].slug).toBe('My-Section-Title'); + }); +}); + +// ─── joinSections × splitToSections 往返 ────────────────── + +describe('joinSections × splitToSections 往返', () => { + it('split 后 join 再 split 保持一致', () => { + const md = [ + '---', + 'title: Demo', + '---', + '', + '## Alpha', + 'alpha content', + '', + '## Beta', + 'beta content', + ].join('\n'); + + const { prelude, sections } = splitToSections(md); + const joined = joinSections(prelude, sections); + const { sections: sections2 } = parseSections(joined); + + expect(sections2).toHaveLength(2); + expect(sections2[0].title).toBe('Alpha'); + expect(sections2[0].body.trim()).toBe('alpha content'); + expect(sections2[1].title).toBe('Beta'); + expect(sections2[1].body.trim()).toBe('beta content'); + }); +}); + +// ─── parseSections ──────────────────────────────────────── + +describe('parseSections', () => { + it('从含锚点的 md 中读出 section + 元数据', () => { + const md = [ + '---', + 'title: Test', + '---', + '', + '', + '## Introduction', + 'Some intro text.', + '', + '', + '', + '## Setup', + 'Setup instructions.', + '', + ].join('\n'); + + const { prelude, sections } = parseSections(md); + + expect(prelude).toContain('title: Test'); + expect(sections).toHaveLength(2); + expect(sections[0].slug).toBe('intro'); + expect(sections[0].title).toBe('Introduction'); + expect(sections[0].body.trim()).toBe('Some intro text.'); + expect(sections[0].source).toBe('https://github.com/a/b@deadbeef'); + expect(sections[0].syncedAt).toBe('2024-01-01T00:00:00.000Z'); + expect(sections[1].slug).toBe('setup'); + }); + + it('未配对的开锚抛 "unclosed anchor: "', () => { + const md = [ + '', + '## Orphan', + 'content', + // 没有闭锚 + ].join('\n'); + + expect(() => parseSections(md)).toThrow('unclosed anchor: orphan'); + }); + + it('完全无锚点 → sections=[], prelude=full md', () => { + const md = '# Title\n\nSome content\n\n## Section\nBody text\n'; + const { prelude, sections } = parseSections(md); + + expect(sections).toHaveLength(0); + expect(prelude).toBe(md); + }); +}); + +// ─── patchManagedSection ───────────────────────────────── + +describe('patchManagedSection', () => { + const md = [ + '', + '## Alpha', + 'Old alpha content.', + '', + '', + '', + '## Beta', + 'Beta content stays.', + '', + ].join('\n'); + + it('替换 body 不影响其他 section', () => { + const result = patchManagedSection(md, 'alpha', 'New alpha content.', { + source: 'repo@newsha', + syncedAt: '2024-06-01T00:00:00.000Z', + }); + + expect(result).toContain('New alpha content.'); + expect(result).toContain('Beta content stays.'); + expect(result).not.toContain('Old alpha content.'); + }); + + it('找不到 slug 时抛错', () => { + expect(() => patchManagedSection(md, 'nonexistent', 'body', {})).toThrow( + 'section not found: nonexistent', + ); + }); +}); + +// ─── --from-iwiki 锚点兼容性(Blocker 1 修复验证)──────── + +describe('parseSections — 接受 --from-iwiki 锚点', () => { + const iwikiMd = [ + '# 外部知识源', + '', + '', + '## 外部知识', + 'iwiki 章节内容', + '', + '', + '', + '## 术语表', + '| 术语 | 说明 |', + '', + ].join('\n'); + + it('parseSections 能正确解析含 --from-iwiki 锚点的 md', () => { + const { prelude, sections } = parseSections(iwikiMd); + + expect(prelude).toContain('外部知识源'); + expect(sections).toHaveLength(2); + expect(sections[0].slug).toBe('external-knowledge'); + expect(sections[0].body).toContain('iwiki 章节内容'); + expect(sections[1].slug).toBe('glossary'); + }); + + it('patchManagedSection 在含 --from-iwiki 锚点的 md 上 patch 成功', () => { + const result = patchManagedSection( + iwikiMd, + 'external-knowledge', + '## 外部知识\n已 patch 的新内容', + { source: 'iwiki://456', syncedAt: '2024-06-01T00:00:00.000Z' }, + ); + + expect(result).toContain('已 patch 的新内容'); + expect(result).not.toContain('iwiki 章节内容'); + // 术语表章节不受影响 + expect(result).toContain('| 术语 | 说明 |'); + }); +}); + diff --git a/src/__tests__/source-conflict.test.ts b/src/__tests__/source-conflict.test.ts new file mode 100644 index 0000000..6a10cff --- /dev/null +++ b/src/__tests__/source-conflict.test.ts @@ -0,0 +1,109 @@ +// -*- coding: utf-8 -*- +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; + +import { recordSourceUpdate } from '../utils/source-conflict.js'; +import type { SourceMark } from '../utils/source-conflict.js'; + +// ─── Helpers ──────────────────────────────────────────── + +async function makeWorkdir(): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-source-conflict-test-')); + await fs.ensureDir(path.join(tmpDir, '.teamai')); + return tmpDir; +} + +function makeMark( + source: SourceMark['source'], + sourceId: string, + syncedAt: string, +): SourceMark { + return { source, sourceId, syncedAt }; +} + +// ─── Tests ────────────────────────────────────────────── + +describe('recordSourceUpdate', () => { + let cwd: string; + let originalCwd: string; + const TEST_FILE = '/tmp/fake/external-knowledge.md'; + const TEST_SECTION = 'business-api'; + + beforeEach(async () => { + cwd = await makeWorkdir(); + originalCwd = process.cwd(); + process.chdir(cwd); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(cwd); + }); + + it('首次记录返回 conflict=false', async () => { + const mark = makeMark('iwiki', 'page-123', new Date().toISOString()); + const result = await recordSourceUpdate(cwd, TEST_FILE, TEST_SECTION, mark); + + expect(result.conflict).toBe(false); + expect(result.previousSources).toHaveLength(0); + + // 验证记录已写入 + const marksPath = path.join(cwd, '.teamai/source-marks.jsonl'); + expect(await fs.pathExists(marksPath)).toBe(true); + }); + + it('24 小时内不同 source 返回 conflict=true', async () => { + const now = Date.now(); + const mark1 = makeMark('iwiki', 'page-123', new Date(now - 3600_000).toISOString()); + const mark2 = makeMark('mr', 'https://github.com/org/repo/pull/1', new Date(now).toISOString()); + + // 先写 iwiki 记录 + await recordSourceUpdate(cwd, TEST_FILE, TEST_SECTION, mark1); + + // 再写 mr 记录 → 应检测到冲突 + const result = await recordSourceUpdate(cwd, TEST_FILE, TEST_SECTION, mark2); + + expect(result.conflict).toBe(true); + expect(result.previousSources).toHaveLength(1); + expect(result.previousSources[0]?.source).toBe('iwiki'); + }); + + it('相同 source + sourceId 不冲突', async () => { + const now = Date.now(); + const mark1 = makeMark('iwiki', 'page-123', new Date(now - 3600_000).toISOString()); + const mark2 = makeMark('iwiki', 'page-123', new Date(now).toISOString()); + + await recordSourceUpdate(cwd, TEST_FILE, TEST_SECTION, mark1); + const result = await recordSourceUpdate(cwd, TEST_FILE, TEST_SECTION, mark2); + + expect(result.conflict).toBe(false); + }); + + it('24 小时外的旧记录被忽略', async () => { + const now = Date.now(); + // 写一条 25 小时前的 iwiki 记录 + const oldMark = makeMark('iwiki', 'page-123', new Date(now - 25 * 3600_000).toISOString()); + await recordSourceUpdate(cwd, TEST_FILE, TEST_SECTION, oldMark); + + // 现在写 mr 记录 + const newMark = makeMark('mr', 'https://github.com/org/repo/pull/1', new Date(now).toISOString()); + const result = await recordSourceUpdate(cwd, TEST_FILE, TEST_SECTION, newMark); + + // 25 小时前的 iwiki 记录超出窗口,不触发冲突 + expect(result.conflict).toBe(false); + }); + + it('不同 file 的记录不互相影响', async () => { + const now = Date.now(); + const mark1 = makeMark('iwiki', 'page-111', new Date(now - 3600_000).toISOString()); + const mark2 = makeMark('mr', 'https://mr/1', new Date(now).toISOString()); + + await recordSourceUpdate(cwd, '/file-a.md', TEST_SECTION, mark1); + const result = await recordSourceUpdate(cwd, '/file-b.md', TEST_SECTION, mark2); + + expect(result.conflict).toBe(false); + }); +}); diff --git a/src/__tests__/status-agent-flag.test.ts b/src/__tests__/status-agent-flag.test.ts new file mode 100644 index 0000000..e8fe46f --- /dev/null +++ b/src/__tests__/status-agent-flag.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock heavy dependencies before importing the module under test +vi.mock('../config.js', () => ({ + autoDetectInit: vi.fn().mockResolvedValue({ + localConfig: { repo: { localPath: '/tmp/fake-repo' } }, + teamConfig: {}, + }), + loadStateForScope: vi.fn().mockResolvedValue({}), +})); + +vi.mock('../known-agents.js', () => ({ + detectInstalledAgents: vi.fn().mockResolvedValue([]), + filterAgents: vi.fn().mockReturnValue([]), +})); + +vi.mock('../utils/git.js', () => ({ getRepoStatus: vi.fn().mockResolvedValue({}) })); +vi.mock('../resources/index.js', () => ({ getAllHandlers: vi.fn().mockReturnValue([]) })); +vi.mock('../agent-skills.js', () => ({ + buildClassifyContext: vi.fn().mockReturnValue({}), + classifySkill: vi.fn().mockReturnValue('local'), + formatSkillSource: vi.fn().mockReturnValue(''), + scanAgentSkills: vi.fn().mockResolvedValue([]), + truncate: vi.fn((s: string) => s), +})); + +import { list } from '../status.js'; + +describe('list() --agent flag path-safety validation', () => { + let originalExitCode: number | undefined; + + beforeEach(() => { + originalExitCode = process.exitCode as number | undefined; + process.exitCode = undefined; + }); + + afterEach(() => { + process.exitCode = originalExitCode; + vi.clearAllMocks(); + }); + + it('rejects --agent with path traversal (../foo) and sets exitCode=2', async () => { + const errorSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + await list('skills', { agent: '../foo' }); + expect(process.exitCode).toBe(2); + errorSpy.mockRestore(); + }); + + it('rejects empty --agent string and sets exitCode=2', async () => { + const errorSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + await list('skills', { agent: '' }); + expect(process.exitCode).toBe(2); + errorSpy.mockRestore(); + }); + + it('allows valid --agent "claude" and proceeds past validation', async () => { + // With mocked downstream, the call should NOT set exitCode=2 for a valid agent name. + await list('skills', { agent: 'claude' }); + expect(process.exitCode).not.toBe(2); + }); +}); diff --git a/src/__tests__/team-codebase-paths.test.ts b/src/__tests__/team-codebase-paths.test.ts new file mode 100644 index 0000000..5f0623c --- /dev/null +++ b/src/__tests__/team-codebase-paths.test.ts @@ -0,0 +1,79 @@ +// -*- coding: utf-8 -*- +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; + +import { getTeamCodebasePaths, safeDomainSlug, TEAM_CODEBASE_DIR } from '../utils/team-codebase-paths.js'; + +describe('getTeamCodebasePaths', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-paths-test-')); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + it('默认路径:root = /docs/team-codebase', () => { + const paths = getTeamCodebasePaths(tmpDir); + expect(paths.root).toBe(path.join(tmpDir, 'docs', TEAM_CODEBASE_DIR)); + expect(paths.index).toBe(path.join(tmpDir, 'docs', TEAM_CODEBASE_DIR, 'index.md')); + expect(paths.domainsDir).toBe(path.join(tmpDir, 'docs', TEAM_CODEBASE_DIR, 'domains')); + expect(paths.reposDir).toBe(path.join(tmpDir, 'docs', TEAM_CODEBASE_DIR, 'repos')); + }); + + it('output 覆盖时 root 直接使用 output', () => { + const customOutput = path.join(tmpDir, 'custom-output'); + const paths = getTeamCodebasePaths(tmpDir, customOutput); + expect(paths.root).toBe(customOutput); + expect(paths.index).toBe(path.join(customOutput, 'index.md')); + expect(paths.domainsDir).toBe(path.join(customOutput, 'domains')); + expect(paths.reposDir).toBe(path.join(customOutput, 'repos')); + }); + + it('TEAM_CODEBASE_DIR 常量值为 team-codebase', () => { + expect(TEAM_CODEBASE_DIR).toBe('team-codebase'); + }); +}); + +describe('safeDomainSlug', () => { + it('普通中文域名直接保留', () => { + expect(safeDomainSlug('推理')).toBe('推理'); + }); + + it('含 / 的域名替换为 _', () => { + expect(safeDomainSlug('推理/训练')).toBe('推理_训练'); + }); + + it('含 \\ 的域名替换为 _', () => { + expect(safeDomainSlug('推理\\训练')).toBe('推理_训练'); + }); + + it('含 : 的域名替换为 _', () => { + expect(safeDomainSlug('推理:训练')).toBe('推理_训练'); + }); + + it('空字符串 → unnamed', () => { + expect(safeDomainSlug('')).toBe('unnamed'); + }); + + it('纯空白 → unnamed', () => { + expect(safeDomainSlug(' ')).toBe('unnamed'); + }); + + it('带前后空白 → trim 后的结果', () => { + expect(safeDomainSlug(' 推理 ')).toBe('推理'); + }); + + it('普通英文域名不变', () => { + expect(safeDomainSlug('inference')).toBe('inference'); + }); + + it('混合特殊字符全部替换', () => { + expect(safeDomainSlug('a/b\\c:d')).toBe('a_b_c_d'); + }); +}); diff --git a/src/__tests__/todowrite-hint.test.ts b/src/__tests__/todowrite-hint.test.ts new file mode 100644 index 0000000..409f1dc --- /dev/null +++ b/src/__tests__/todowrite-hint.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, +})); + +import { + buildHintMessage, + shouldSkipTodoWriteHint, + getTodoWriteHintCachePath, +} from '../todowrite-hint.js'; + +describe('buildHintMessage', () => { + it('contains the recall subagent reference and the [teamai:] prefix', () => { + const msg = buildHintMessage(); + expect(msg).toContain('[teamai:todowrite-hint]'); + expect(msg).toContain('teamai-recall'); + }); + + it('is bilingual (Chinese + English) so the agent has the strongest cue', () => { + const msg = buildHintMessage(); + // Chinese prompt body + expect(msg).toMatch(/任务/); + // English prompt body + expect(msg).toMatch(/Task plan detected/); + }); +}); + +describe('shouldSkipTodoWriteHint — session deduplication', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-todowrite-test-')); + await fse.ensureDir(path.join(tmpHome, '.teamai', 'sessions')); + vi.stubEnv('HOME', tmpHome); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpHome); + }); + + it('returns false on the first call (no prior hint)', () => { + expect(shouldSkipTodoWriteHint('session-A')).toBe(false); + }); + + it('returns true on the second call (already hinted)', () => { + shouldSkipTodoWriteHint('session-B'); + expect(shouldSkipTodoWriteHint('session-B')).toBe(true); + }); + + it('treats different sessions independently', () => { + shouldSkipTodoWriteHint('session-C'); + expect(shouldSkipTodoWriteHint('session-D')).toBe(false); + }); + + it('writes the cache file under ~/.teamai/sessions/-todowrite-hint.json', () => { + shouldSkipTodoWriteHint('session-path-test'); + const expectedPath = getTodoWriteHintCachePath('session-path-test'); + expect(expectedPath).toContain(path.join('.teamai', 'sessions')); + expect(expectedPath).toContain('session-path-test-todowrite-hint.json'); + expect(fse.pathExistsSync(expectedPath)).toBe(true); + }); +}); + +describe('hooks.ts — TodoWrite hint registration', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-hooks-todowrite-')); + vi.stubEnv('HOME', tmpHome); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpHome); + }); + + it('injects TodoWrite hint into Claude settings.json with matcher=TodoWrite', async () => { + const { injectHooks } = await import('../hooks.js'); + const settingsPath = path.join(tmpHome, '.claude', 'settings.json'); + await injectHooks(settingsPath, 'claude'); + + const settings = await fse.readJson(settingsPath); + const postToolUse = settings.hooks?.PostToolUse ?? []; + const hint = postToolUse.find((h: { description?: string }) => + h.description?.includes('TodoWrite hint'), + ); + expect(hint).toBeDefined(); + expect(hint.matcher).toBe('TodoWrite'); + expect(hint.hooks?.[0]?.command).toContain('teamai todowrite-hint'); + expect(hint.hooks?.[0]?.command).toContain('--tool claude'); + }); + + it('injects TodoWrite hint into CodeBuddy settings.json (PascalCase, same shape as Claude)', async () => { + const { injectHooks } = await import('../hooks.js'); + const settingsPath = path.join(tmpHome, '.codebuddy', 'settings.json'); + await injectHooks(settingsPath, 'codebuddy'); + + const settings = await fse.readJson(settingsPath); + const postToolUse = settings.hooks?.PostToolUse ?? []; + const hint = postToolUse.find((h: { description?: string }) => + h.description?.includes('TodoWrite hint'), + ); + expect(hint).toBeDefined(); + expect(hint.matcher).toBe('TodoWrite'); + expect(hint.hooks?.[0]?.command).toContain('--tool codebuddy'); + }); + + it('injects TodoWrite hint into Cursor hooks.json (camelCase event keys)', async () => { + const { injectHooks } = await import('../hooks.js'); + const hooksPath = path.join(tmpHome, '.cursor', 'hooks.json'); + await injectHooks(hooksPath, 'cursor'); + + const hooksJson = await fse.readJson(hooksPath); + const postToolUse = hooksJson.hooks?.postToolUse ?? []; + const hint = postToolUse.find( + (h: { command: string; matcher?: string }) => + h.command.includes('teamai todowrite-hint') && h.matcher === 'TodoWrite', + ); + expect(hint).toBeDefined(); + expect(hint.command).toContain('--tool cursor'); + }); + + it('does NOT duplicate TodoWrite hint when injected twice', async () => { + const { injectHooks } = await import('../hooks.js'); + const settingsPath = path.join(tmpHome, '.claude', 'settings.json'); + await injectHooks(settingsPath, 'claude'); + await injectHooks(settingsPath, 'claude'); + + const settings = await fse.readJson(settingsPath); + const postToolUse = settings.hooks?.PostToolUse ?? []; + const hits = postToolUse.filter( + (h: { description?: string }) => h.description?.includes('TodoWrite hint'), + ); + expect(hits.length).toBe(1); + }); +}); diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts index f19be36..9f440cb 100644 --- a/src/__tests__/types.test.ts +++ b/src/__tests__/types.test.ts @@ -61,6 +61,7 @@ describe('TeamaiConfigSchema', () => { expect(result.toolPaths.codebuddy).toEqual({ skills: '.codebuddy/skills', rules: '.codebuddy/rules', + agents: '.codebuddy/agents', settings: '.codebuddy/settings.json', claudemd: '.codebuddy/CODEBUDDY.md', }); @@ -97,6 +98,7 @@ describe('TeamaiConfigSchema', () => { expect(result.toolPaths['codex-internal']).toEqual({ skills: '.codex-internal/skills', rules: '.codex-internal/rules', + agents: '.codex-internal/agents', }); }); diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index 3a5e66d..e8454ef 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -1208,8 +1208,8 @@ describe('hook command strings', () => { const result = JSON.parse(await fs.promises.readFile(settingsPath, 'utf-8')); // Legacy duplicates should be cleaned, replaced by proper hooks with description - // SessionStart has 2 hooks: Auto-pull + Dashboard report - expect(result.hooks.SessionStart).toHaveLength(2); + // SessionStart has 3 hooks: Auto-pull + MR hint + Dashboard report + expect(result.hooks.SessionStart).toHaveLength(3); expect(result.hooks.SessionStart.every((h: { description?: string }) => h.description)).toBe(true); // Stop has 3 hooks: Auto-update + Dashboard stop + Contribute check diff --git a/src/aggregate.ts b/src/aggregate.ts new file mode 100644 index 0000000..73c8c85 --- /dev/null +++ b/src/aggregate.ts @@ -0,0 +1,278 @@ +// -*- coding: utf-8 -*- +import path from 'node:path'; + +import fs from 'fs-extra'; +import matter from 'gray-matter'; + +import type { DomainsFile } from './domains/index.js'; +import type { TeamCodebasePaths } from './utils/team-codebase-paths.js'; +import { safeDomainSlug } from './utils/team-codebase-paths.js'; + +/** regenerateAggregate 入参。 */ +export interface AggregateOptions { + paths: TeamCodebasePaths; + domains: DomainsFile; +} + +/** 从 slug.md 文件解析出的仓库摘要信息。 */ +interface RepoSummary { + slug: string; + /** 仓库 URL(来自 frontmatter.repo_url 或 frontmatter.url) */ + url: string; + /** 仓库名称(来自 frontmatter.repo_name 或 frontmatter 第一个 # 标题) */ + name: string; + /** 主语言 */ + primaryLanguage: string; + /** 代码行数 */ + lineCount: string; + /** 最后同步时间(ISO 或 N/A) */ + lastSynced: string; + /** 摘要段落(≤200 字) */ + excerpt: string; +} + +/** + * 解析 repos/.md 文件,提取仓库摘要。 + * + * @param filePath slug.md 绝对路径 + * @param slug 文件名(不含扩展名) + * @returns RepoSummary 对象 + */ +async function parseRepoMd(filePath: string, slug: string): Promise { + const raw = await fs.readFile(filePath, 'utf8'); + const { data, content } = matter(raw); + + const fm = data as Record; + + const url = typeof fm.repo_url === 'string' ? fm.repo_url + : typeof fm.url === 'string' ? fm.url + : ''; + + // 仓库名:frontmatter.repo_name 或首个 # 标题 + let name = typeof fm.repo_name === 'string' ? fm.repo_name : ''; + if (!name) { + const titleMatch = content.match(/^#\s+(.+)/m); + name = titleMatch ? titleMatch[1].trim() : slug; + } + + const primaryLanguage = typeof fm.primary_language === 'string' ? fm.primary_language : 'N/A'; + const lineCount = fm.line_count != null ? String(fm.line_count) : 'N/A'; + const lastSynced = typeof fm.last_synced === 'string' ? fm.last_synced + : typeof fm.generated_at === 'string' ? fm.generated_at + : 'N/A'; + + // 摘要:去掉标题行,取首段前 200 字 + const bodyWithoutTitle = content.replace(/^#[^\n]*\n/m, '').trim(); + const firstPara = bodyWithoutTitle.split(/\n\n+/)[0] ?? ''; + const excerpt = firstPara.slice(0, 200); + + return { slug, url, name, primaryLanguage, lineCount, lastSynced, excerpt }; +} + +/** + * 读取 paths.reposDir 下的所有 .md,按 domains 中的 repo→domain 映射 + * 重新生成所有 domains/domain-.md 与 index.md。 + * + * 不调用 AI,纯模板拼接。 + * 写出前先清理不再有仓库的旧 domain-*.md 文件。 + * + * @param opts AggregateOptions + * @returns 写出文件路径列表 + */ +export async function regenerateAggregate(opts: AggregateOptions): Promise<{ + domainFiles: string[]; + indexFile: string; +}> { + const { paths, domains } = opts; + + // 确保目录存在 + await fs.ensureDir(paths.domainsDir); + await fs.ensureDir(paths.reposDir); + + // 1. 读取所有 repos/.md + let repoFiles: string[] = []; + try { + const entries = await fs.readdir(paths.reposDir); + repoFiles = entries.filter((f) => f.endsWith('.md')); + } catch { + // reposDir 不存在或为空 + } + + // slug → RepoSummary + const repoMap = new Map(); + for (const file of repoFiles) { + const slug = file.replace(/\.md$/, ''); + try { + const summary = await parseRepoMd(path.join(paths.reposDir, file), slug); + repoMap.set(slug, summary); + } catch { + // 解析失败跳过 + } + } + + // 2. 构建 domain → slug[] 映射(基于 domains.yaml 中每个域的 repos[].url) + // 建立 url → slug 反查表(从 repoMap) + const urlToSlug = new Map(); + for (const [slug, summary] of repoMap) { + if (summary.url) { + urlToSlug.set(summary.url, slug); + } + } + + // 收集每个域的 slugs + const domainToSlugs = new Map(); + for (const domain of domains.domains) { + const slugs: string[] = []; + for (const repo of domain.repos) { + const slug = urlToSlug.get(repo.url); + if (slug) { + slugs.push(slug); + } + } + if (slugs.length > 0) { + domainToSlugs.set(domain.name, slugs); + } + } + + // 未归类:在 reposDir 有文件但 domains.yaml 中没有任何域声明该 url + const assignedSlugs = new Set( + [...domainToSlugs.values()].flat(), + ); + const unclassifiedSlugs = [...repoMap.keys()].filter((s) => !assignedSlugs.has(s)); + if (unclassifiedSlugs.length > 0) { + domainToSlugs.set('未分类', unclassifiedSlugs); + } + + // 3. 清理不再有仓库的旧 domain-*.md + const existingDomainFiles = (await fs.readdir(paths.domainsDir)) + .filter((f) => /^domain-.+\.md$/.test(f)); + + const newDomainFileNames = new Set( + [...domainToSlugs.keys()].map((name) => `domain-${safeDomainSlug(name)}.md`), + ); + + for (const oldFile of existingDomainFiles) { + if (!newDomainFileNames.has(oldFile)) { + await fs.remove(path.join(paths.domainsDir, oldFile)); + } + } + + // 4. 生成 domain-.md + const now = new Date().toISOString(); + const domainFiles: string[] = []; + + for (const [domainName, slugs] of domainToSlugs) { + const domainEntry = domains.domains.find((d) => d.name === domainName); + const description = domainEntry?.description ?? ''; + const safeSlug = safeDomainSlug(domainName); + const outputPath = path.join(paths.domainsDir, `domain-${safeSlug}.md`); + + const tableRows = slugs.map((slug) => { + const s = repoMap.get(slug); + if (!s) return ''; + const repoName = s.name || slug; + const url = s.url || 'N/A'; + const lang = s.primaryLanguage; + const lines = s.lineCount; + const synced = s.lastSynced.slice(0, 10); + return `| ${repoName} | ${url} | ${lang} | ~${lines} | ${synced} |`; + }).filter(Boolean).join('\n'); + + const repoSections = slugs.map((slug) => { + const s = repoMap.get(slug); + if (!s) return ''; + const repoName = s.name || slug; + const excerpt = s.excerpt || '(暂无摘要)'; + return [ + `### ${repoName}`, + '', + `> ${excerpt}`, + '', + `[完整视图 → repos/${slug}.md](../repos/${slug}.md)`, + '', + ].join('\n'); + }).filter(Boolean).join('\n'); + + const content = [ + '---', + `domain: ${domainName}`, + `description: ${description}`, + `repo_count: ${slugs.length}`, + `last_synced: ${now}`, + 'generator: teamai import (P5.2 aggregate)', + '---', + '', + `# 业务域:${domainName}`, + '', + description ? `> ${description}` : '', + '', + '## 仓库列表', + '', + '| 仓库 | URL | 主语言 | 行数 | 最后同步 |', + '|---|---|---|---|---|', + tableRows, + '', + '## 仓库摘要', + '', + repoSections, + ].filter((line) => line !== null).join('\n'); + + await fs.writeFile(outputPath, content, 'utf8'); + domainFiles.push(outputPath); + } + + // 5. 生成 index.md + const totalRepos = [...domainToSlugs.values()].reduce((acc, arr) => acc + arr.length, 0); + const domainCount = domainToSlugs.size; + + const domainMapRows = [...domainToSlugs.entries()] + .map(([name, slugs]) => { + const safeSlug = safeDomainSlug(name); + return `| ${name} | ${slugs.length} | [domain-${safeSlug}](domains/domain-${safeSlug}.md) |`; + }) + .join('\n'); + + const allRepoRows = [...domainToSlugs.entries()] + .flatMap(([domainName, slugs]) => + slugs.map((slug) => { + const s = repoMap.get(slug); + const repoName = s?.name ?? slug; + return `| ${repoName} | ${domainName} | [详情](repos/${slug}.md) |`; + }), + ) + .join('\n'); + + const indexContent = [ + '---', + 'generator: teamai import (P5.2 aggregate)', + `last_generated: ${now}`, + `domain_count: ${domainCount}`, + `repo_count: ${totalRepos}`, + 'schemaVersion: 1', + '---', + '', + '# 团队 Codebase 索引', + '', + '## 业务域地图', + '', + '| 业务域 | 仓库数 | 入口 |', + '|---|---|---|', + domainMapRows, + '', + '## 全部仓库索引', + '', + '| 仓库 | 业务域 | 详细视图 |', + '|---|---|---|', + allRepoRows, + '', + '## 维护说明', + '', + '由 `teamai import --from-repo-list` 自动生成。请勿手工编辑本文件,', + '对单仓内容的修改请到对应 `repos/.md`。', + '', + ].join('\n'); + + await fs.writeFile(paths.index, indexContent, 'utf8'); + + return { domainFiles, indexFile: paths.index }; +} diff --git a/src/builtin-agents.ts b/src/builtin-agents.ts new file mode 100644 index 0000000..03dd1a7 --- /dev/null +++ b/src/builtin-agents.ts @@ -0,0 +1,109 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { ensureDir, pathExists, copyFile } from './utils/fs.js'; +import { log } from './utils/logger.js'; +import type { TeamaiConfig, LocalConfig } from './types.js'; +import { resolveBaseDir } from './types.js'; +import { ResourceHandler } from './resources/base.js'; + +// ─── Built-in agents deployment ────────────────────────── +// +// CLI ships with built-in subagent definitions (e.g. teamai-recall). +// These are bundled in the npm package under agents/. +// On each `teamai pull`, we copy them to local AI tool +// agents directories so they're always available and +// stay in sync with the CLI version. +// +// npm package +// agents/teamai-recall.md +// │ +// ▼ (teamai pull) +// ~/.claude/agents/teamai-recall.md +// ~/.claude-internal/agents/teamai-recall.md +// ~/.codebuddy/agents/teamai-recall.md +// + +/** + * Names of CLI built-in agents. Used by `AgentsHandler.scanLocalForPush` + * to exclude them from team repo push (they are CLI-managed, not team-managed). + */ +export const BUILTIN_AGENT_NAMES = new Set(['teamai-recall']); + +/** + * Resolve the path to the built-in agents directory bundled with the CLI. + * Mirrors getBuiltinSkillsDir() — `dist/` lives one level below the + * package root, so we walk up to find `agents/`. + */ +function getBuiltinAgentsDir(): string { + const distDir = path.dirname(new URL(import.meta.url).pathname); + return path.join(distDir, '..', 'agents'); +} + +/** + * Deploy CLI built-in agent .md files to every installed tool's agents + * directory. + * + * Silently skips: + * - Built-in directory missing (dev environment without build step) + * - Tool whose toolPaths..agents is unset (Tier-2/3/4 tools) + * - Tool not yet installed on the user's machine + * + * Per-tool failures only log a warning and do not abort other tools. + * + * @returns Total number of (agent × tool) deployments performed + */ +export async function deployBuiltinAgents( + teamConfig: TeamaiConfig, + localConfig?: LocalConfig, +): Promise { + const builtinDir = getBuiltinAgentsDir(); + if (!await pathExists(builtinDir)) { + log.debug('No built-in agents directory found, skipping deployment'); + return 0; + } + + let entries: string[]; + try { + entries = await fs.promises.readdir(builtinDir); + } catch { + return 0; + } + + const agentFiles = entries.filter((f) => f.endsWith('.md') && !f.startsWith('.')); + if (agentFiles.length === 0) return 0; + + const baseDir = localConfig ? resolveBaseDir(localConfig) : (process.env.HOME ?? ''); + let deployed = 0; + + for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + if (!toolPath.agents) { + log.debug(`Skipping built-in agent deployment for ${tool}: no agents path`); + continue; + } + if (!await ResourceHandler.isToolInstalled(toolPath.agents, baseDir)) { + log.debug(`Skipping built-in agent deployment for ${tool}: tool not installed`); + continue; + } + + const targetAgentsDir = path.join(baseDir, toolPath.agents); + try { + await ensureDir(targetAgentsDir); + } catch (e) { + log.warn(`Failed to create agents dir for ${tool}: ${(e as Error).message}`); + continue; + } + + for (const file of agentFiles) { + const src = path.join(builtinDir, file); + const dest = path.join(targetAgentsDir, file); + try { + await copyFile(src, dest); + deployed++; + } catch (e) { + log.warn(`Failed to deploy built-in agent ${file} to ${tool}: ${(e as Error).message}`); + } + } + } + + return deployed; +} diff --git a/src/cache-cmd.ts b/src/cache-cmd.ts new file mode 100644 index 0000000..e37a5c1 --- /dev/null +++ b/src/cache-cmd.ts @@ -0,0 +1,188 @@ +import chalk from 'chalk'; + +import type { GlobalOptions } from './types.js'; +import { getCacheStatus, gcCache } from './utils/cache-index.js'; +import { log } from './utils/logger.js'; + +// ─── Types ─────────────────────────────────────────────── + +export interface CacheCmdOptions extends GlobalOptions { + status?: boolean; + gc?: boolean; + maxBytes?: string; + staleDays?: string; + dryRun?: boolean; + json?: boolean; +} + +// ─── Helpers ──────────────────────────────────────────── + +/** + * 将字节数格式化为人类可读字符串(B / KB / MB / GB)。 + * + * @param bytes 字节数 + */ +function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + if (bytes >= 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + } + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(2)} KB`; + } + return `${bytes} B`; +} + +/** + * 截断 SHA 到 8 位短格式。 + * + * @param sha 完整 SHA 或 undefined + */ +function shortSha(sha?: string): string { + if (!sha) return '-'; + return sha.slice(0, 8); +} + +// ─── Command ────────────────────────────────────────────── + +/** + * teamai cache 命令入口。 + * + * 支持 --status(默认)和 --gc 两种操作模式,配合 --json 输出机器可读格式。 + * + * @param opts 命令行选项 + */ +export async function cacheCmd(opts: CacheCmdOptions): Promise { + const isGc = opts.gc === true; + + if (isGc) { + await runGc(opts); + } else { + await runStatus(opts); + } +} + +async function runStatus(opts: CacheCmdOptions): Promise { + const result = await getCacheStatus(); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(''); + console.log(chalk.bold('Cache root:'), result.root); + console.log(''); + + if (result.entryCount === 0) { + console.log(chalk.gray('(无缓存条目)')); + return; + } + + // 表头 + const colKey = 50; + const colSize = 12; + const colUsed = 26; + const colSha = 10; + + const header = [ + 'KEY'.padEnd(colKey), + 'SIZE'.padStart(colSize), + 'LAST_USED'.padEnd(colUsed), + 'SHA', + ].join(' '); + + console.log(chalk.underline(header)); + + for (const entry of result.entries) { + const keyTrunc = entry.key.length > colKey ? `…${entry.key.slice(-(colKey - 1))}` : entry.key; + const row = [ + keyTrunc.padEnd(colKey), + formatBytes(entry.size_bytes).padStart(colSize), + entry.last_used.padEnd(colUsed), + shortSha(entry.last_synced_sha), + ].join(' '); + console.log(row); + } + + console.log(''); + console.log( + chalk.bold(`总计: ${result.entryCount} 个仓库,占用 ${formatBytes(result.totalBytes)}`), + ); + console.log(''); +} + +async function runGc(opts: CacheCmdOptions): Promise { + let maxBytes: number | undefined; + if (opts.maxBytes !== undefined) { + const parsed = parseInt(opts.maxBytes, 10); + if (!isNaN(parsed) && parsed > 0) { + maxBytes = parsed; + } else { + log.warn(`--max-bytes 值无效: ${opts.maxBytes},将使用默认值`); + } + } + + let staleDays: number | undefined; + if (opts.staleDays !== undefined) { + const parsed = parseInt(opts.staleDays, 10); + if (!isNaN(parsed) && parsed > 0) { + staleDays = parsed; + } else { + log.warn(`--stale-days 值无效: ${opts.staleDays},将使用默认值`); + } + } + + const gcOpts = { + ...(maxBytes !== undefined ? { maxBytes } : {}), + ...(staleDays !== undefined ? { staleDays } : {}), + dryRun: opts.dryRun, + }; + + const result = await gcCache(gcOpts); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + if (result.skipped.length > 0) { + process.exit(1); + } + return; + } + + const dryRunTag = opts.dryRun ? chalk.yellow('[dry-run] ') : ''; + + console.log(''); + console.log(chalk.bold(`${dryRunTag}GC 执行结果`)); + console.log(''); + console.log( + `前: ${result.before.entryCount} 个仓库,${formatBytes(result.before.totalBytes)}`, + ); + console.log( + `后: ${result.after.entryCount} 个仓库,${formatBytes(result.after.totalBytes)}`, + ); + console.log(''); + + if (result.removed.length === 0) { + console.log(chalk.green('无需清理')); + } else { + console.log(chalk.bold(`清理列表(${result.removed.length} 项):`)); + for (const item of result.removed) { + const tag = item.reason === 'stale' ? chalk.yellow('[stale]') : chalk.red('[over-cap]'); + console.log(` ${tag} ${item.key} (${formatBytes(item.size_bytes)})`); + } + } + + if (result.skipped.length > 0) { + console.log(''); + console.log(chalk.bold(chalk.red(`跳过列表(${result.skipped.length} 项,需人工排查):`))); + for (const item of result.skipped) { + console.log(` ${chalk.red('[skip]')} ${item.key}: ${item.reason}`); + } + console.log(''); + process.exit(1); + } + + console.log(''); +} diff --git a/src/clone.ts b/src/clone.ts new file mode 100644 index 0000000..a8880e2 --- /dev/null +++ b/src/clone.ts @@ -0,0 +1,246 @@ +import { spawn } from 'node:child_process'; + +import fs from 'fs-extra'; + +import { getGitHubToken } from './providers/github/gh-cli.js'; +import { log } from './utils/logger.js'; + +// ─── Types ────────────────────────────────────────────── + +export interface CloneOpts { + /** Shallow clone depth,默认 1 */ + depth?: number; + /** 强制走 SSH,即使 HTTPS token 可用 */ + forceSsh?: boolean; + /** 强制匿名 HTTPS,即使 token 可用(per-repo auth: public) */ + forceAnonymous?: boolean; + /** 超时毫秒数,默认 180_000 */ + timeoutMs?: number; +} + +export interface CloneResult { + /** clone 完成后的 HEAD commit SHA */ + sha: string; + /** 默认分支名 */ + branch: string; + /** 实际使用的认证方式 */ + cloneMethod: 'https-token' | 'https-anonymous' | 'ssh'; +} + +// ─── Helpers ──────────────────────────────────────────── + +/** + * 判断 url 是否是 SSH 形式(git@ 开头或包含 : 且不含 ://)。 + */ +function isSshUrl(url: string): boolean { + return url.startsWith('git@') || (!url.includes('://') && url.includes(':')); +} + +/** + * 将 URL 中的认证信息脱敏,用于日志和错误消息。 + * 替换 https://[anything]@ 为 https://***@ + * + * @param msg 可能含有 token 的字符串 + * @returns 脱敏后的字符串 + */ +export function sanitizeGitUrl(msg: string): string { + return msg.replace(/https?:\/\/[^@\s]+@/g, 'https://***@'); +} + +/** + * 对日志/错误信息中的 token 进行脱敏。 + */ +function redactToken(msg: string): string { + return sanitizeGitUrl(msg); +} + +/** + * 构建携带 GitHub token 的 git -c http.extraHeader 参数值。 + * 避免将 token 嵌入 URL,防止其出现在进程列表或日志中。 + * + * @param token GitHub token + * @returns Authorization header 值,格式为 `Authorization: Basic ` + */ +function buildAuthHeader(token: string): string { + const encoded = Buffer.from(`x-access-token:${token}`).toString('base64'); + return `Authorization: Basic ${encoded}`; +} + +/** + * 包装 spawn 为 Promise,返回 stdout/stderr/exitCode。 + */ +function runCommand( + cmd: string, + args: string[], + opts: { cwd?: string; timeoutMs: number }, +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: opts.cwd, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); + + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`Command timed out after ${opts.timeoutMs}ms: ${cmd} ${args.join(' ')}`)); + }, opts.timeoutMs); + + child.on('close', (code) => { + clearTimeout(timer); + resolve({ stdout, stderr, code: code ?? 1 }); + }); + + child.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +/** + * 在给定目录执行 git 命令,返回 stdout(trim)。 + */ +async function gitCmd( + args: string[], + cwd: string, + timeoutMs: number = 30_000, +): Promise { + const { stdout, stderr, code } = await runCommand('git', args, { cwd, timeoutMs }); + if (code !== 0) { + throw new Error(`git ${args[0]} failed (exit ${code}): ${redactToken(stderr.trim())}`); + } + return stdout.trim(); +} + +// ─── Public API ───────────────────────────────────────── + +/** + * Shallow clone 远端仓库到指定本地目录。 + * + * 三层认证策略: + * 1. forceSsh=true 或 url 是 SSH 形式 → 直接走 SSH + * 2. github 且能拿到 token → HTTPS + x-access-token 注入 + * 3. tgit 走 ~/.netrc(git 自身处理);github 无 token 则匿名 HTTPS + * + * @param url 仓库 URL(https/ssh 任一) + * @param localPath 目标目录(存在则先 rm 再 clone) + * @param provider 'github' | 'tgit' + * @param opts 克隆选项 + */ +export async function shallowClone( + url: string, + localPath: string, + provider: string, + opts?: CloneOpts, +): Promise { + const depth = opts?.depth ?? 1; + const forceSsh = opts?.forceSsh ?? false; + const forceAnonymous = opts?.forceAnonymous ?? false; + const timeoutMs = opts?.timeoutMs ?? 180_000; + + // 清理已存在目录 + if (await fs.pathExists(localPath)) { + await fs.remove(localPath); + } + await fs.ensureDir(localPath); + + // 确定克隆 URL 和认证方式 + let cloneUrl = url; + let cloneMethod: CloneResult['cloneMethod']; + let githubToken: string | undefined; + + if (forceSsh || isSshUrl(url)) { + cloneUrl = url; + cloneMethod = 'ssh'; + log.debug(`shallowClone: 使用 SSH 克隆 ${url}`); + } else if (forceAnonymous) { + cloneUrl = url; + cloneMethod = 'https-anonymous'; + log.debug(`shallowClone: forceAnonymous=true,匿名 HTTPS 克隆 ${url}`); + } else if (provider === 'github') { + const token = getGitHubToken(); + if (token) { + cloneUrl = url; + githubToken = token; + cloneMethod = 'https-token'; + log.debug(`shallowClone: 使用 HTTPS+token 克隆 github 仓库`); + } else { + cloneUrl = url; + cloneMethod = 'https-anonymous'; + log.debug(`shallowClone: 使用匿名 HTTPS 克隆 github 仓库`); + } + } else { + // tgit 或其他 provider,依赖 ~/.netrc + cloneUrl = url; + cloneMethod = 'https-anonymous'; + log.debug(`shallowClone: 使用 HTTPS (~/.netrc) 克隆 ${provider} 仓库`); + } + + // 构建 clone 参数:若有 token 则通过 http.extraHeader 注入,避免 token 出现在 URL 中 + const cloneArgs: string[] = []; + if (githubToken) { + cloneArgs.push('-c', `http.extraHeader=${buildAuthHeader(githubToken)}`); + } + cloneArgs.push( + 'clone', + `--depth=${depth}`, + '--single-branch', + cloneUrl, + localPath, + ); + + try { + const { code, stderr } = await runCommand('git', cloneArgs, { timeoutMs }); + if (code !== 0) { + // 清理失败的目录 + await fs.remove(localPath).catch(() => undefined); + throw new Error(`git clone failed (exit ${code}): ${redactToken(stderr.trim())}`); + } + } catch (err) { + if (err instanceof Error && err.message.startsWith('git clone failed')) { + throw err; + } + await fs.remove(localPath).catch(() => undefined); + throw err; + } + + // 获取 HEAD SHA 和分支名 + const sha = await gitCmd(['rev-parse', 'HEAD'], localPath); + let branch: string; + try { + branch = await gitCmd(['rev-parse', '--abbrev-ref', 'HEAD'], localPath); + } catch { + branch = 'HEAD'; + } + + log.debug(`shallowClone 完成:sha=${sha.slice(0, 8)}, branch=${branch}, method=${cloneMethod}`); + return { sha, branch, cloneMethod }; +} + +/** + * 在已有 clone 目录上执行 git fetch 并 reset 到最新 HEAD(用于 P5.3 增量;P5.1 暂不调用)。 + * + * @param localPath 本地 clone 目录 + * @param opts 选项 + */ +export async function shallowFetch( + localPath: string, + opts?: { timeoutMs?: number }, +): Promise<{ sha: string }> { + const timeoutMs = opts?.timeoutMs ?? 180_000; + + // 获取当前分支 + const branch = await gitCmd(['rev-parse', '--abbrev-ref', 'HEAD'], localPath, timeoutMs); + + await gitCmd(['fetch', '--depth=50', 'origin'], localPath, timeoutMs); + await gitCmd(['reset', '--hard', `origin/${branch}`], localPath, timeoutMs); + + const sha = await gitCmd(['rev-parse', 'HEAD'], localPath); + return { sha }; +} diff --git a/src/codebase-cmd.ts b/src/codebase-cmd.ts new file mode 100644 index 0000000..2633fa8 --- /dev/null +++ b/src/codebase-cmd.ts @@ -0,0 +1,147 @@ +import chalk from 'chalk'; + +import type { GlobalOptions } from './types.js'; +import { + lintTeamCodebase, + formatLintReport, + fixTeamCodebase, +} from './codebase-lint.js'; +import type { Severity, LintReport, FixResult } from './codebase-lint.js'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface CodebaseCmdOptions extends GlobalOptions { + lint?: boolean; + fix?: boolean; + severity?: Severity; + staleDays?: string; + pendingReviewThreshold?: string; + json?: boolean; + output?: string; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function formatFixResult(result: FixResult): string { + const lines: string[] = []; + if (result.applied.length > 0) { + lines.push(chalk.green(`[fix] 已应用 ${result.applied.length} 项修复:`)); + for (const item of result.applied) { + lines.push(chalk.green(` ✓ [${item.category}] ${item.location}`)); + lines.push(` ${item.description}`); + } + } + if (result.skipped.length > 0) { + lines.push(chalk.yellow(`[fix] 跳过 ${result.skipped.length} 项:`)); + for (const item of result.skipped) { + lines.push(chalk.yellow(` - [${item.category}] ${item.location}`)); + lines.push(` ${item.reason}`); + } + } + return lines.join('\n'); +} + +function hasHighIssues(report: LintReport): boolean { + return report.summary.bySeverity.high > 0; +} + +// ─── Command handler ───────────────────────────────────────────────────────── + +/** + * codebase 子命令处理函数。 + * + * 支持 --lint(全局一致性检查)、--fix(低风险机械修复)、--json(CI 机器可读输出)。 + * + * @param opts 命令选项(含全局选项) + */ +export async function codebaseCmd(opts: CodebaseCmdOptions): Promise { + const cwd = process.cwd(); + + if (!opts.lint) { + console.log('teamai codebase — 团队 codebase 文档健康度管理'); + console.log(''); + console.log('用法:'); + console.log(' teamai codebase --lint 运行全局一致性检查'); + console.log(' teamai codebase --lint --fix 检查并自动修复低风险问题'); + console.log(' teamai codebase --lint --json 输出 JSON 报告(适合 CI)'); + console.log(' teamai codebase --lint --severity high 只报告 high 级别问题'); + return; + } + + const staleDays = opts.staleDays ? parseInt(opts.staleDays, 10) : 60; + const pendingThreshold = opts.pendingReviewThreshold + ? parseInt(opts.pendingReviewThreshold, 10) + : 10; + const severity = opts.severity ?? 'info'; + + if (opts.fix) { + // lint → fix → re-lint + const initialReport = await lintTeamCodebase({ + cwd, + output: opts.output, + severity, + staleDays, + pendingReviewThreshold: pendingThreshold, + }); + + const fixResult = await fixTeamCodebase({ + cwd, + output: opts.output, + dryRun: opts.dryRun, + }); + + if (opts.json) { + // Re-run lint after fix to get final state + const finalReport = await lintTeamCodebase({ + cwd, + output: opts.output, + severity, + staleDays, + pendingReviewThreshold: pendingThreshold, + }); + console.log(JSON.stringify({ fixResult, finalReport }, null, 2)); + if (hasHighIssues(finalReport)) { + process.exitCode = 1; + } + } else { + console.log(formatFixResult(fixResult)); + console.log(''); + + // Show remaining issues + const finalReport = await lintTeamCodebase({ + cwd, + output: opts.output, + severity, + staleDays, + pendingReviewThreshold: pendingThreshold, + }); + console.log('── 修复后剩余问题 ──'); + console.log(formatLintReport(finalReport)); + + if (hasHighIssues(finalReport)) { + process.exitCode = 1; + } + } + // Suppress unused variable warning for initialReport + void initialReport; + } else { + // lint only + const report = await lintTeamCodebase({ + cwd, + output: opts.output, + severity, + staleDays, + pendingReviewThreshold: pendingThreshold, + }); + + if (opts.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(formatLintReport(report)); + } + + if (hasHighIssues(report)) { + process.exitCode = 1; + } + } +} diff --git a/src/codebase-lint.ts b/src/codebase-lint.ts new file mode 100644 index 0000000..6e219dc --- /dev/null +++ b/src/codebase-lint.ts @@ -0,0 +1,929 @@ +import path from 'node:path'; +import os from 'node:os'; + +import fs from 'fs-extra'; +import matter from 'gray-matter'; +import chalk from 'chalk'; + +import { getTeamCodebasePaths } from './utils/team-codebase-paths.js'; +import { loadDomains } from './domains/index.js'; +import { loadRepoList } from './repo-list/store.js'; +import { getRepoSlug } from './utils/repo-cache.js'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type Severity = 'high' | 'medium' | 'low' | 'info'; + +export type LintCategory = + | 'anchor-unclosed' + | 'orphan-repo' + | 'orphan-md' + | 'source-invalid' + | 'whitelist-missing' + | 'whitelist-only' + | 'sync-stale' + | 'index-mismatch' + | 'aggregate-row-mismatch' + | 'frontmatter-missing' + | 'pending-review-backlog' + | 'multi-source-conflict'; + +export interface LintIssue { + severity: Severity; + category: LintCategory; + location: string; + description: string; + suggestion?: string; + fixable: boolean; +} + +export interface LintReport { + issues: LintIssue[]; + summary: { + total: number; + bySeverity: Record; + byCategory: Record; + }; + scanned: { + domainsFile: boolean; + repoListFile: boolean; + indexFile: boolean; + domainFiles: number; + repoFiles: number; + externalKnowledgeFile: boolean; + }; +} + +export interface LintOptions { + cwd: string; + output?: string; + severity?: Severity; + staleDays?: number; + pendingReviewThreshold?: number; +} + +export interface FixOptions { + cwd: string; + output?: string; + dryRun?: boolean; +} + +export interface FixResult { + applied: Array<{ category: LintCategory; location: string; description: string }>; + skipped: Array<{ category: LintCategory; location: string; reason: string }>; +} + +// ─── Severity ordering ─────────────────────────────────────────────────────── + +const SEVERITY_ORDER: Record = { high: 3, medium: 2, low: 1, info: 0 }; + +function severityAtLeast(issue: Severity, threshold: Severity): boolean { + return SEVERITY_ORDER[issue] >= SEVERITY_ORDER[threshold]; +} + +// ─── URL → slug helper ─────────────────────────────────────────────────────── + +/** + * 从仓库 URL 解析出 slug(与 import-repo 中逻辑保持一致)。 + * + * @param url 仓库 HTTP/SSH URL + * @returns slug 字符串,解析失败返回 null + */ +function urlToSlug(url: string): string | null { + let provider: string | null = null; + if (/github\.com/i.test(url)) { + provider = 'github'; + } else if (/git\.woa\.com/i.test(url) || /tgit/i.test(url)) { + provider = 'tgit'; + } else if (/gitlab\./i.test(url)) { + provider = 'gitlab'; + } else { + // 通用 fallback:取域名去 www. 前缀 + const domainMatch = url.match(/https?:\/\/([^/]+)/); + if (domainMatch) { + provider = domainMatch[1].replace(/^www\./, '').replace(/\./g, '-'); + } + } + if (!provider) return null; + + const httpsMatch = url.match(/https?:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/.*)?$/); + const sshMatch = url.match(/git@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/); + let owner: string; + let repo: string; + if (httpsMatch) { + owner = httpsMatch[1]; + repo = httpsMatch[2]; + } else if (sshMatch) { + owner = sshMatch[1]; + repo = sshMatch[2]; + } else { + return null; + } + return getRepoSlug(provider, owner, repo); +} + +// ─── Parse source-marks.jsonl ──────────────────────────────────────────────── + +interface SourceMark { + file?: string; + section?: string; + source?: string; + ts?: string; + [key: string]: unknown; +} + +function parseJsonlLines(content: string): SourceMark[] { + return content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + try { + return JSON.parse(line) as SourceMark; + } catch { + return null; + } + }) + .filter((item): item is SourceMark => item !== null); +} + +// ─── Check functions ───────────────────────────────────────────────────────── + +function checkAnchorUnclosed(content: string, filePath: string): LintIssue[] { + const issues: LintIssue[] = []; + const openRegex = //g; + const closeRegex = //g; + + const openSections = new Set(); + let match: RegExpExecArray | null; + while ((match = openRegex.exec(content)) !== null) { + openSections.add(match[1]); + } + const closedSections = new Set(); + while ((match = closeRegex.exec(content)) !== null) { + closedSections.add(match[1]); + } + for (const section of openSections) { + if (!closedSections.has(section)) { + issues.push({ + severity: 'high', + category: 'anchor-unclosed', + location: `${filePath}:${section}`, + description: `开始锚点 section="${section}" 未找到对应闭合标签`, + suggestion: '检查最近一次 --from-iwiki 是否中断,手动补全 ', + fixable: false, + }); + } + } + return issues; +} + +function checkSyncStaleSections( + content: string, + filePath: string, + staleDays: number +): LintIssue[] { + const issues: LintIssue[] = []; + const syncedAtRegex = + //g; + let match: RegExpExecArray | null; + const now = Date.now(); + const thresholdMs = staleDays * 24 * 60 * 60 * 1000; + while ((match = syncedAtRegex.exec(content)) !== null) { + const section = match[1]; + const syncedAt = match[2]; + const syncedDate = new Date(syncedAt); + if (!isNaN(syncedDate.getTime()) && now - syncedDate.getTime() > thresholdMs) { + issues.push({ + severity: 'medium', + category: 'sync-stale', + location: `${filePath}:${section}`, + description: `section="${section}" syncedAt=${syncedAt} 超过 ${staleDays} 天未同步`, + suggestion: '重新运行 teamai import --from-iwiki 更新该 section', + fixable: false, + }); + } + } + return issues; +} + +function checkRepoFrontmatterRequired( + fm: Record, + filePath: string +): LintIssue[] { + const issues: LintIssue[] = []; + const required = ['title', 'generator', 'schemaVersion'] as const; + for (const field of required) { + if (fm[field] === undefined || fm[field] === null || fm[field] === '') { + issues.push({ + severity: field === 'schemaVersion' ? 'medium' : 'high', + category: 'frontmatter-missing', + location: filePath, + description: `repos/*.md 缺少必需 frontmatter 字段: ${field}`, + suggestion: `在 frontmatter 中补充 ${field}`, + fixable: field === 'schemaVersion', + }); + } + } + return issues; +} + +function checkDomainFrontmatterRequired( + fm: Record, + filePath: string +): LintIssue[] { + const issues: LintIssue[] = []; + const required = ['domain', 'generator'] as const; + for (const field of required) { + if (fm[field] === undefined || fm[field] === null || fm[field] === '') { + issues.push({ + severity: 'high', + category: 'frontmatter-missing', + location: filePath, + description: `domains/*.md 缺少必需 frontmatter 字段: ${field}`, + suggestion: `在 frontmatter 中补充 ${field}`, + fixable: false, + }); + } + } + return issues; +} + +function checkIndexFrontmatterRequired( + fm: Record, + filePath: string +): LintIssue[] { + const issues: LintIssue[] = []; + const required = ['generator', 'schemaVersion'] as const; + for (const field of required) { + if (fm[field] === undefined || fm[field] === null || fm[field] === '') { + issues.push({ + severity: field === 'schemaVersion' ? 'medium' : 'high', + category: 'frontmatter-missing', + location: filePath, + description: `index.md 缺少必需 frontmatter 字段: ${field}`, + suggestion: `在 frontmatter 中补充 ${field}`, + fixable: field === 'schemaVersion', + }); + } + } + return issues; +} + +function parseAggregateTableRows(content: string): number { + // 查找 ## 仓库列表 下的 markdown 表格行数(去掉表头行和分隔行) + const sectionMatch = content.match(/##\s+仓库列表\s*\n([\s\S]*?)(?:\n##\s|\n#\s|$)/); + if (!sectionMatch) return 0; + const tableText = sectionMatch[1]; + const lines = tableText.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('|')); + // 去掉表头行(第一行)和分隔行(含 ---) + const dataRows = lines.filter((l) => !l.includes('---') && lines.indexOf(l) > 0); + return dataRows.length; +} + +function checkMultiSourceConflict(marks: SourceMark[], now: number): LintIssue[] { + const issues: LintIssue[] = []; + const within24h = now - 24 * 60 * 60 * 1000; + // group by file+section + const groups = new Map>(); + for (const mark of marks) { + if (!mark.file || !mark.section || !mark.source || !mark.ts) continue; + const ts = new Date(mark.ts).getTime(); + if (isNaN(ts) || ts < within24h) continue; + const key = `${mark.file}:${mark.section}`; + const sources = groups.get(key) ?? new Set(); + sources.add(mark.source); + groups.set(key, sources); + } + for (const [key, sources] of groups) { + if (sources.size >= 2) { + issues.push({ + severity: 'medium', + category: 'multi-source-conflict', + location: key, + description: `近 24h 内 ${key} 出现 ${sources.size} 个不同 source:${[...sources].join(', ')}`, + suggestion: '检查是否有并发 import 任务,确认最终 source 的优先级', + fixable: false, + }); + } + } + return issues; +} + +// ─── Main lint entry ───────────────────────────────────────────────────────── + +/** + * 全局 lint 主入口。 + * + * 扫描 docs/team-codebase/ 及 .teamai/ 下各产物,返回完整报告。 + * 底层 IO/解析失败转为 lint issue,不抛错。 + * + * @param opts lint 选项 + * @returns 完整 LintReport + */ +export async function lintTeamCodebase(opts: LintOptions): Promise { + const staleDays = opts.staleDays ?? 60; + const pendingThreshold = opts.pendingReviewThreshold ?? 10; + const severityFilter = opts.severity ?? 'info'; + const paths = getTeamCodebasePaths(opts.cwd, opts.output); + + const issues: LintIssue[] = []; + const scanned = { + domainsFile: false, + repoListFile: false, + indexFile: false, + domainFiles: 0, + repoFiles: 0, + externalKnowledgeFile: false, + }; + + // 1. Load domains.yaml + let domainsData: Awaited> | null = null; + try { + domainsData = await loadDomains(opts.cwd); + scanned.domainsFile = true; + } catch { + issues.push({ + severity: 'high', + category: 'frontmatter-missing', + location: '.teamai/domains.yaml', + description: '无法加载 .teamai/domains.yaml(文件不存在或格式错误)', + suggestion: '运行 teamai import 重新生成 domains.yaml', + fixable: false, + }); + } + + // 2. Load repo-whitelist.yaml + const whitelistPath = path.join(opts.cwd, '.teamai', 'repo-whitelist.yaml'); + let whitelistData: Awaited> | null = null; + try { + whitelistData = await loadRepoList(whitelistPath); + scanned.repoListFile = true; + } catch { + // 不致命,降级为 low + issues.push({ + severity: 'low', + category: 'frontmatter-missing', + location: '.teamai/repo-whitelist.yaml', + description: '无法加载 .teamai/repo-whitelist.yaml(文件不存在或格式错误)', + suggestion: '运行 teamai import --from-repo-list 生成白名单文件', + fixable: false, + }); + } + + // 3. List repo files + let repoMdFiles: string[] = []; + if (await fs.pathExists(paths.reposDir)) { + const entries = await fs.readdir(paths.reposDir); + repoMdFiles = entries.filter((f) => f.endsWith('.md')); + scanned.repoFiles = repoMdFiles.length; + } + + // 4. List domain files + let domainMdFiles: string[] = []; + if (await fs.pathExists(paths.domainsDir)) { + const entries = await fs.readdir(paths.domainsDir); + domainMdFiles = entries.filter((f) => f.endsWith('.md')); + scanned.domainFiles = domainMdFiles.length; + } + + // 5. Check orphan-repo and orphan-md + if (domainsData) { + const allDomainUrls = domainsData.domains.flatMap((d) => d.repos.map((r) => r.url)); + const urlToSlugMap = new Map(); + for (const url of allDomainUrls) { + const slug = urlToSlug(url); + if (slug) urlToSlugMap.set(url, slug); + } + + // orphan-repo: domains.yaml url 对应的 md 不存在 + for (const url of allDomainUrls) { + const slug = urlToSlugMap.get(url); + if (!slug) continue; + const mdPath = path.join(paths.reposDir, `${slug}.md`); + if (!(await fs.pathExists(mdPath))) { + issues.push({ + severity: 'high', + category: 'orphan-repo', + location: `docs/team-codebase/repos/${slug}.md`, + description: `domains.yaml 中 url=${url} 在 repos/ 下找不到对应 ${slug}.md`, + suggestion: '运行 teamai import --from-repo 重新生成该仓库的 codebase 文档', + fixable: false, + }); + } + } + + // orphan-md: md 存在但 domains.yaml 无对应 + const slugsInDomains = new Set(urlToSlugMap.values()); + for (const mdFile of repoMdFiles) { + const slug = mdFile.replace(/\.md$/, ''); + if (!slugsInDomains.has(slug)) { + const relPath = `docs/team-codebase/repos/${mdFile}`; + issues.push({ + severity: 'high', + category: 'orphan-md', + location: relPath, + description: `${mdFile} 存在于 repos/ 但 domains.yaml 中无对应 url 条目`, + suggestion: '运行 teamai codebase --fix 将孤儿文件移到 .archived/ 目录', + fixable: true, + }); + } + } + } + + // 6. Check source-invalid (only when cache root exists) + const cacheRoot = path.join(os.homedir(), '.teamai', 'cache', 'repos'); + const cacheExists = await fs.pathExists(cacheRoot); + for (const mdFile of repoMdFiles) { + const mdPath = path.join(paths.reposDir, mdFile); + try { + const content = await fs.readFile(mdPath, 'utf8'); + const parsed = matter(content); + const fm = parsed.data as Record; + + // frontmatter-missing checks for repo files + issues.push(...checkRepoFrontmatterRequired(fm, `docs/team-codebase/repos/${mdFile}`)); + + // source-invalid check + const source = fm['source'] as string | undefined; + if (source) { + if (cacheExists) { + if (!(await fs.pathExists(source))) { + issues.push({ + severity: 'high', + category: 'source-invalid', + location: `docs/team-codebase/repos/${mdFile}`, + description: `frontmatter source="${source}" 指向的缓存路径已不存在`, + suggestion: '重新运行 teamai import --from-repo 刷新缓存', + fixable: false, + }); + } + } else { + issues.push({ + severity: 'info', + category: 'source-invalid', + location: `docs/team-codebase/repos/${mdFile}`, + description: `source-invalid 检查在本主机跳过:~/.teamai/cache 不存在(CI 环境)`, + fixable: false, + }); + } + } + + // sync-stale check for repo files + const lastUpdated = fm['lastUpdated'] as string | undefined; + if (lastUpdated) { + const d = new Date(lastUpdated); + if (!isNaN(d.getTime())) { + const ageMs = Date.now() - d.getTime(); + if (ageMs > staleDays * 24 * 60 * 60 * 1000) { + issues.push({ + severity: 'medium', + category: 'sync-stale', + location: `docs/team-codebase/repos/${mdFile}`, + description: `lastUpdated=${lastUpdated} 超过 ${staleDays} 天未同步`, + suggestion: '重新运行 teamai import --from-repo 刷新此仓库文档', + fixable: false, + }); + } + } + } + } catch { + issues.push({ + severity: 'medium', + category: 'frontmatter-missing', + location: `docs/team-codebase/repos/${mdFile}`, + description: `读取或解析 ${mdFile} 失败`, + fixable: false, + }); + } + } + + // 7. Check domain files + for (const domFile of domainMdFiles) { + const domPath = path.join(paths.domainsDir, domFile); + try { + const content = await fs.readFile(domPath, 'utf8'); + const parsed = matter(content); + const fm = parsed.data as Record; + + issues.push( + ...checkDomainFrontmatterRequired(fm, `docs/team-codebase/domains/${domFile}`) + ); + + // sync-stale check for domain files + const lastSynced = fm['last_synced'] as string | undefined; + if (lastSynced) { + const d = new Date(lastSynced); + if (!isNaN(d.getTime())) { + const ageMs = Date.now() - d.getTime(); + if (ageMs > staleDays * 24 * 60 * 60 * 1000) { + issues.push({ + severity: 'medium', + category: 'sync-stale', + location: `docs/team-codebase/domains/${domFile}`, + description: `last_synced=${lastSynced} 超过 ${staleDays} 天未同步`, + suggestion: '重新运行 teamai import --from-repo-list 更新域聚合文档', + fixable: false, + }); + } + } + } + + // aggregate-row-mismatch check + if (domainsData && fm['domain']) { + const domainName = fm['domain'] as string; + const domainEntry = domainsData.domains.find((d) => d.name === domainName); + if (domainEntry) { + const expectedCount = domainEntry.repos.length; + const actualCount = parseAggregateTableRows(parsed.content); + if (actualCount !== expectedCount) { + issues.push({ + severity: 'low', + category: 'aggregate-row-mismatch', + location: `docs/team-codebase/domains/${domFile}`, + description: + `仓库列表表格行数 ${actualCount} 与 domains.yaml 中 ` + + `"${domainName}" 域的 repos 数量 ${expectedCount} 不一致`, + suggestion: '重新运行 teamai import --from-repo-list 重新聚合', + fixable: false, + }); + } + } + } + } catch { + issues.push({ + severity: 'medium', + category: 'frontmatter-missing', + location: `docs/team-codebase/domains/${domFile}`, + description: `读取或解析 ${domFile} 失败`, + fixable: false, + }); + } + } + + // 8. Check index.md + if (await fs.pathExists(paths.index)) { + scanned.indexFile = true; + try { + const content = await fs.readFile(paths.index, 'utf8'); + const parsed = matter(content); + const fm = parsed.data as Record; + + issues.push(...checkIndexFrontmatterRequired(fm, 'docs/team-codebase/index.md')); + + // index-mismatch check + const fmRepoCnt = Number(fm['repo_count'] ?? -1); + const fmDomainCnt = Number(fm['domain_count'] ?? -1); + const actualRepoCnt = repoMdFiles.length; + const actualDomainCnt = domainMdFiles.length; + + if (!isNaN(fmRepoCnt) && fmRepoCnt >= 0 && fmRepoCnt !== actualRepoCnt) { + issues.push({ + severity: 'medium', + category: 'index-mismatch', + location: 'docs/team-codebase/index.md', + description: + `index.md frontmatter repo_count=${fmRepoCnt} 与实际 repos/ 文件数 ` + + `${actualRepoCnt} 不一致`, + suggestion: '运行 teamai codebase --fix 修正 index.md 中的计数', + fixable: true, + }); + } + if (!isNaN(fmDomainCnt) && fmDomainCnt >= 0 && fmDomainCnt !== actualDomainCnt) { + issues.push({ + severity: 'medium', + category: 'index-mismatch', + location: 'docs/team-codebase/index.md', + description: + `index.md frontmatter domain_count=${fmDomainCnt} 与实际 domains/ 文件数 ` + + `${actualDomainCnt} 不一致`, + suggestion: '运行 teamai codebase --fix 修正 index.md 中的计数', + fixable: true, + }); + } + } catch { + issues.push({ + severity: 'medium', + category: 'frontmatter-missing', + location: 'docs/team-codebase/index.md', + description: '读取或解析 index.md 失败', + fixable: false, + }); + } + } + + // 9. Check external-knowledge.md + const extKnowledgePath = path.join(paths.root, 'external-knowledge.md'); + if (await fs.pathExists(extKnowledgePath)) { + scanned.externalKnowledgeFile = true; + try { + const content = await fs.readFile(extKnowledgePath, 'utf8'); + issues.push(...checkAnchorUnclosed(content, 'docs/team-codebase/external-knowledge.md')); + issues.push( + ...checkSyncStaleSections( + content, + 'docs/team-codebase/external-knowledge.md', + staleDays + ) + ); + } catch { + issues.push({ + severity: 'medium', + category: 'frontmatter-missing', + location: 'docs/team-codebase/external-knowledge.md', + description: '读取 external-knowledge.md 失败', + fixable: false, + }); + } + } + + // 10. Whitelist cross-check + if (domainsData && whitelistData) { + const domainUrls = new Set( + domainsData.domains.flatMap((d) => d.repos.map((r) => r.url)) + ); + const whitelistUrls = new Set( + whitelistData.repos + .filter((r) => 'url' in r) + .map((r) => (r as { url: string }).url) + ); + + for (const url of domainUrls) { + if (!whitelistUrls.has(url)) { + issues.push({ + severity: 'medium', + category: 'whitelist-missing', + location: '.teamai/repo-whitelist.yaml', + description: `domains.yaml 中 url=${url} 不在 repo-whitelist.yaml 中`, + suggestion: '将该 url 加入 .teamai/repo-whitelist.yaml 的 repos 列表', + fixable: false, + }); + } + } + for (const url of whitelistUrls) { + if (!domainUrls.has(url)) { + issues.push({ + severity: 'medium', + category: 'whitelist-only', + location: '.teamai/repo-whitelist.yaml', + description: `repo-whitelist.yaml 中 url=${url} 未出现在 domains.yaml 中`, + suggestion: '运行 teamai import 将该仓库归入某个业务域', + fixable: false, + }); + } + } + } + + // 11. Check pending-review.jsonl + const pendingPath = path.join(opts.cwd, '.teamai', 'pending-review.jsonl'); + if (await fs.pathExists(pendingPath)) { + try { + const content = await fs.readFile(pendingPath, 'utf8'); + const lines = content.split('\n').filter((l) => l.trim().length > 0); + if (lines.length > pendingThreshold) { + issues.push({ + severity: 'info', + category: 'pending-review-backlog', + location: '.teamai/pending-review.jsonl', + description: `pending-review.jsonl 有 ${lines.length} 条待审记录,超过阈值 ${pendingThreshold}`, + suggestion: '运行 teamai import --require-review 处理积压的待审条目', + fixable: false, + }); + } + } catch { + // 读取失败,跳过 + } + } + + // 12. Check source-marks.jsonl for multi-source-conflict + const sourceMarksPath = path.join(opts.cwd, '.teamai', 'source-marks.jsonl'); + if (await fs.pathExists(sourceMarksPath)) { + try { + const content = await fs.readFile(sourceMarksPath, 'utf8'); + const marks = parseJsonlLines(content); + issues.push(...checkMultiSourceConflict(marks, Date.now())); + } catch { + // 读取失败,跳过 + } + } + + // Filter by severity + const filteredIssues = issues.filter((i) => severityAtLeast(i.severity, severityFilter)); + + // Build summary + const bySeverity: Record = { high: 0, medium: 0, low: 0, info: 0 }; + const byCategory: Record = {}; + for (const issue of filteredIssues) { + bySeverity[issue.severity]++; + byCategory[issue.category] = (byCategory[issue.category] ?? 0) + 1; + } + + return { + issues: filteredIssues, + summary: { + total: filteredIssues.length, + bySeverity, + byCategory, + }, + scanned, + }; +} + +// ─── Format report ─────────────────────────────────────────────────────────── + +/** + * 把报告渲染为带 chalk 颜色的人类可读文本。 + * + * @param report lint 报告 + * @param opts 渲染选项(color 默认为 true) + * @returns 可打印的字符串 + */ +export function formatLintReport(report: LintReport, opts?: { color?: boolean }): string { + const useColor = opts?.color !== false; + const { total, bySeverity } = report.summary; + + const colorize = (severity: Severity, text: string): string => { + if (!useColor) return text; + switch (severity) { + case 'high': + return chalk.red(text); + case 'medium': + return chalk.yellow(text); + case 'low': + return chalk.cyan(text); + case 'info': + return chalk.gray(text); + } + }; + + const lines: string[] = []; + const summaryParts = (['high', 'medium', 'low', 'info'] as Severity[]) + .filter((s) => bySeverity[s] > 0) + .map((s) => colorize(s, `${s}: ${bySeverity[s]}`)); + lines.push(`[lint] 共 ${total} 个问题(${summaryParts.join(', ')})`); + + if (total === 0) { + lines.push(useColor ? chalk.green(' ✓ 无问题') : ' ✓ 无问题'); + return lines.join('\n'); + } + + // Sort: high first + const sorted = [...report.issues].sort( + (a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] + ); + + for (const issue of sorted) { + const tag = colorize(issue.severity, `[${issue.severity}]`); + const cat = issue.category.padEnd(28); + lines.push(`${tag} ${cat} ${issue.location}`); + lines.push(` ${issue.description}`); + if (issue.suggestion) { + lines.push(` ${useColor ? chalk.gray(`建议:${issue.suggestion}`) : `建议:${issue.suggestion}`}`); + } + } + return lines.join('\n'); +} + +// ─── Fix entry ─────────────────────────────────────────────────────────────── + +/** + * 自动修复仅限低风险机械动作: + * - orphan-md:把孤儿 repos/*.md 移动到 repos/.archived/.md(不删除) + * - frontmatter-missing:补齐 schemaVersion: 1 默认值 + * - index-mismatch:重新写入正确的 repo_count/domain_count + * + * @param opts fix 选项 + * @returns 修复结果 + */ +export async function fixTeamCodebase(opts: FixOptions): Promise { + const report = await lintTeamCodebase({ cwd: opts.cwd, output: opts.output }); + const paths = getTeamCodebasePaths(opts.cwd, opts.output); + + const applied: FixResult['applied'] = []; + const skipped: FixResult['skipped'] = []; + + for (const issue of report.issues) { + if (!issue.fixable) { + skipped.push({ + category: issue.category, + location: issue.location, + reason: '该类别不支持自动修复', + }); + continue; + } + + if (issue.category === 'orphan-md') { + const mdFile = path.basename(issue.location); + const srcPath = path.join(paths.reposDir, mdFile); + const archivedDir = path.join(paths.reposDir, '.archived'); + const dstPath = path.join(archivedDir, mdFile); + if (opts.dryRun) { + applied.push({ + category: issue.category, + location: issue.location, + description: `[dry-run] 移动 ${srcPath} → ${dstPath}`, + }); + } else { + try { + await fs.ensureDir(archivedDir); + await fs.move(srcPath, dstPath, { overwrite: true }); + applied.push({ + category: issue.category, + location: issue.location, + description: `移动 ${srcPath} → ${dstPath}`, + }); + } catch (err) { + skipped.push({ + category: issue.category, + location: issue.location, + reason: `移动失败:${String(err)}`, + }); + } + } + } else if (issue.category === 'frontmatter-missing') { + // 只补 schemaVersion: 1 + const relPath = issue.location; + const absPath = path.join(opts.cwd, relPath); + if (opts.dryRun) { + applied.push({ + category: issue.category, + location: issue.location, + description: '[dry-run] 补齐 schemaVersion: 1', + }); + } else { + try { + const content = await fs.readFile(absPath, 'utf8'); + const parsed = matter(content); + if (!parsed.data['schemaVersion']) { + parsed.data['schemaVersion'] = 1; + const newContent = matter.stringify(parsed.content, parsed.data); + await fs.writeFile(absPath, newContent, 'utf8'); + applied.push({ + category: issue.category, + location: issue.location, + description: '已补齐 schemaVersion: 1', + }); + } else { + skipped.push({ + category: issue.category, + location: issue.location, + reason: 'schemaVersion 已存在,无需修复', + }); + } + } catch (err) { + skipped.push({ + category: issue.category, + location: issue.location, + reason: `读写失败:${String(err)}`, + }); + } + } + } else if (issue.category === 'index-mismatch') { + if (opts.dryRun) { + const repoFiles = await fs.readdir(paths.reposDir).catch(() => [] as string[]); + const domainFiles = await fs.readdir(paths.domainsDir).catch(() => [] as string[]); + const repoCnt = repoFiles.filter((f) => f.endsWith('.md')).length; + const domainCnt = domainFiles.filter((f) => f.endsWith('.md')).length; + applied.push({ + category: issue.category, + location: issue.location, + description: `[dry-run] 将 index.md repo_count 设为 ${repoCnt},domain_count 设为 ${domainCnt}`, + }); + } else { + try { + const indexContent = await fs.readFile(paths.index, 'utf8'); + const parsed = matter(indexContent); + + const repoFiles = await fs.readdir(paths.reposDir).catch(() => [] as string[]); + const domainFiles = await fs.readdir(paths.domainsDir).catch(() => [] as string[]); + const repoCnt = repoFiles.filter((f) => f.endsWith('.md')).length; + const domainCnt = domainFiles.filter((f) => f.endsWith('.md')).length; + + parsed.data['repo_count'] = repoCnt; + parsed.data['domain_count'] = domainCnt; + const newContent = matter.stringify(parsed.content, parsed.data); + await fs.writeFile(paths.index, newContent, 'utf8'); + applied.push({ + category: issue.category, + location: issue.location, + description: `已更新 index.md: repo_count=${repoCnt}, domain_count=${domainCnt}`, + }); + } catch (err) { + skipped.push({ + category: issue.category, + location: issue.location, + reason: `更新 index.md 失败:${String(err)}`, + }); + } + } + } else { + skipped.push({ + category: issue.category, + location: issue.location, + reason: '该类别不在自动修复范围内', + }); + } + } + + return { applied, skipped }; +} diff --git a/src/codebase.ts b/src/codebase.ts new file mode 100644 index 0000000..3901bc2 --- /dev/null +++ b/src/codebase.ts @@ -0,0 +1,529 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import matter from 'gray-matter'; + +import { callClaude } from './utils/ai-client.js'; +import { createGit } from './utils/git.js'; +import { log } from './utils/logger.js'; +import type { CodebaseSuggestion, LintIssue, LintReport } from './types.js'; + +/** 文件扫描截断上限(字符数)。 */ +const FILE_TREE_MAX_CHARS = 5000; + +/** 架构文档读取上限(字符数)。 */ +const DOC_MAX_CHARS = 2000; + +/** docs/ 目录下最多读取的 .md 文件数量。 */ +const DOCS_MAX_FILES = 3; + +/** git log 读取条数。 */ +const GIT_LOG_MAX_COUNT = 20; + +/** package.json / types 文件读取上限(字符数)。 */ +const META_MAX_CHARS = 2500; + +/** learnings 目录最多读取的 .md 文件数量。 */ +const LEARNINGS_MAX_FILES = 50; + +/** lint 报告中展示的高频 tag 数量上限。 */ +const TOP_TAGS_COUNT = 10; + +/** + * 收集 git 仓库上下文信息。 + * + * 包含:最近 commit 记录、文件树结构、package.json 依赖、 + * 入口文件命令注册、types 关键接口、README/ARCHITECTURE/docs 摘要。 + * + * @param repoPath 仓库根目录绝对路径 + * @returns 拼接好的上下文字符串 + */ +async function gatherRepoContext(repoPath: string): Promise { + const parts: string[] = []; + + // ── 最近 commit 记录 ──────────────────────────────────── + try { + const git = createGit(repoPath); + const logResult = await git.log({ maxCount: GIT_LOG_MAX_COUNT }); + const commitMessages = logResult.all + .map((c) => `- ${c.date.slice(0, 10)} ${c.message}`) + .join('\n'); + parts.push(`## 最近 ${GIT_LOG_MAX_COUNT} 条 Commit\n${commitMessages}`); + } catch (err) { + log.debug(`gatherRepoContext: git log 失败 — ${String(err)}`); + } + + // ── 文件树结构(加大深度,过滤噪音目录)────────────────── + try { + const rawTree = execSync( + 'find . -maxdepth 4' + + ' -not -path "*/.git/*"' + + ' -not -path "*/node_modules/*"' + + ' -not -path "*/__pycache__/*"' + + ' -not -path "*/dist/*"' + + ' -not -path "*/.claude/worktrees/*"' + + ' -not -name "*.js.map"', + { cwd: repoPath, encoding: 'utf-8' }, + ); + const truncated = + rawTree.length > FILE_TREE_MAX_CHARS + ? rawTree.slice(0, FILE_TREE_MAX_CHARS) + '\n…(已截断)' + : rawTree; + parts.push(`## 文件树(maxdepth=4,已过滤 dist/node_modules)\n${truncated}`); + } catch (err) { + log.debug(`gatherRepoContext: find 失败 — ${String(err)}`); + } + + // ── package.json:获取依赖和 scripts ──────────────────── + const pkgPath = path.join(repoPath, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const raw = fs.readFileSync(pkgPath, 'utf-8'); + const excerpt = raw.length > META_MAX_CHARS ? raw.slice(0, META_MAX_CHARS) + '\n…' : raw; + parts.push(`## package.json\n\`\`\`json\n${excerpt}\n\`\`\``); + } catch (err) { + log.debug(`gatherRepoContext: 读取 package.json 失败 — ${String(err)}`); + } + } + + // ── 入口文件命令注册(index.ts / main.py 等)──────────── + for (const candidate of ['src/index.ts', 'src/main.ts', 'index.ts', 'main.py']) { + const entryPath = path.join(repoPath, candidate); + if (fs.existsSync(entryPath)) { + try { + const raw = fs.readFileSync(entryPath, 'utf-8'); + const excerpt = raw.length > META_MAX_CHARS ? raw.slice(0, META_MAX_CHARS) + '\n…' : raw; + parts.push(`## 入口文件:${candidate}\n\`\`\`typescript\n${excerpt}\n\`\`\``); + break; + } catch (err) { + log.debug(`gatherRepoContext: 读取 ${candidate} 失败 — ${String(err)}`); + } + } + } + + // ── 类型定义文件(types.ts)──────────────────────────── + for (const candidate of ['src/types.ts', 'src/types/index.ts', 'types.py']) { + const typesPath = path.join(repoPath, candidate); + if (fs.existsSync(typesPath)) { + try { + const raw = fs.readFileSync(typesPath, 'utf-8'); + const excerpt = raw.length > META_MAX_CHARS ? raw.slice(0, META_MAX_CHARS) + '\n…' : raw; + parts.push(`## 类型定义:${candidate}\n\`\`\`typescript\n${excerpt}\n\`\`\``); + break; + } catch (err) { + log.debug(`gatherRepoContext: 读取 ${candidate} 失败 — ${String(err)}`); + } + } + } + + // ── 架构文档摘要 ──────────────────────────────────────── + const docCandidates: string[] = [ + path.join(repoPath, 'README.md'), + path.join(repoPath, 'ARCHITECTURE.md'), + ]; + + // 扫描 docs/ 下最多 DOCS_MAX_FILES 个 .md 文件 + const docsDir = path.join(repoPath, 'docs'); + if (fs.existsSync(docsDir)) { + try { + const entries = fs.readdirSync(docsDir); + let count = 0; + for (const entry of entries) { + if (count >= DOCS_MAX_FILES) break; + if (entry.endsWith('.md')) { + docCandidates.push(path.join(docsDir, entry)); + count++; + } + } + } catch (err) { + log.debug(`gatherRepoContext: 读取 docs/ 失败 — ${String(err)}`); + } + } + + for (const docPath of docCandidates) { + if (!fs.existsSync(docPath)) continue; + try { + const raw = fs.readFileSync(docPath, 'utf-8'); + const excerpt = + raw.length > DOC_MAX_CHARS ? raw.slice(0, DOC_MAX_CHARS) + '\n…(已截断)' : raw; + const relPath = path.relative(repoPath, docPath); + parts.push(`## 文档摘要:${relPath}\n${excerpt}`); + } catch (err) { + log.debug(`gatherRepoContext: 读取 ${docPath} 失败 — ${String(err)}`); + } + } + + return parts.join('\n\n'); +} + +/** + * 聚合 learnings 相关上下文,用于注入 codebase.md 生成 prompt。 + * + * 若有 learningsSuggestions,则拼出最近 MR 建议小节; + * 若有 learningsDir 且目录存在,则统计 frontmatter tags 高频词。 + * + * @param opts.learningsSuggestions 来自 P4.4 的 codebase suggestions + * @param opts.learningsDir learnings 目录路径 + * @returns 拼接好的上下文段落,无内容时返回空字符串 + */ +async function gatherLearningsContext(opts: { + learningsSuggestions?: CodebaseSuggestion[]; + learningsDir?: string; +}): Promise { + const { learningsSuggestions, learningsDir } = opts; + + if (!learningsSuggestions?.length && !learningsDir) { + return ''; + } + + const parts: string[] = []; + + // ── 最近 MR 提炼建议 ──────────────────────────────────── + if (learningsSuggestions && learningsSuggestions.length > 0) { + const lines = learningsSuggestions.map( + (s) => `- [${s.action}] ${s.section}: ${s.content.slice(0, 200)}`, + ); + parts.push(`## 最近 MR 提炼建议(参考)\n${lines.join('\n')}`); + } + + // ── learnings 目录高频 tags ────────────────────────────── + if (learningsDir && fs.existsSync(learningsDir)) { + try { + const entries = fs.readdirSync(learningsDir); + const tagFreq: Record = {}; + let fileCount = 0; + + for (const entry of entries) { + if (fileCount >= LEARNINGS_MAX_FILES) break; + if (!entry.endsWith('.md')) continue; + + try { + const filePath = path.join(learningsDir, entry); + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = matter(raw); + const tags: unknown = parsed.data['tags']; + if (Array.isArray(tags)) { + for (const tag of tags) { + if (typeof tag === 'string') { + tagFreq[tag] = (tagFreq[tag] ?? 0) + 1; + } + } + } + fileCount++; + } catch (err) { + log.debug(`gatherLearningsContext: 解析 ${entry} 失败 — ${String(err)}`); + } + } + + const topTags = Object.entries(tagFreq) + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_TAGS_COUNT) + .map(([tag, count]) => `${tag}(${count})`) + .join(', '); + + if (topTags) { + parts.push(`## Learnings 高频标签\n高频标签:${topTags}`); + } + } catch (err) { + log.debug(`gatherLearningsContext: 读取 learningsDir 失败 — ${String(err)}`); + } + } + + return parts.join('\n\n'); +} + +/** + * 生成 codebase.md 的 YAML frontmatter 头部。 + * + * @param repoPath 仓库根目录绝对路径 + * @returns frontmatter 字符串(含尾部换行) + */ +function buildFrontmatter(repoPath: string): string { + const now = new Date().toISOString(); + return [ + '---', + 'title: Codebase 概览', + `lastUpdated: ${now}`, + `source: ${repoPath}`, + 'generator: teamai-cli', + 'schemaVersion: 1', + '---', + '', + '', + ].join('\n'); +} + +/** + * 若 Markdown 内容顶部存在 frontmatter(以 `---\n` 开头),则剥离并返回正文。 + * + * @param md 原始 Markdown 字符串 + * @returns 剥离 frontmatter 后的正文 + */ +function stripExistingFrontmatter(md: string): string { + if (!md.startsWith('---\n')) { + return md; + } + // 找到第二个 `---` 行的结束位置 + const secondDash = md.indexOf('\n---\n', 4); + if (secondDash === -1) { + return md; + } + // 跳过 `\n---\n`(5 个字符),再跳过可能的空行 + const afterFrontmatter = md.slice(secondDash + 5); + return afterFrontmatter.replace(/^\n+/, ''); +} + +/** + * 扫描 git 仓库信息,用 AI 生成 codebase.md 初稿。 + * + * @param opts.repoPath 仓库根目录绝对路径 + * @param opts.existingCodebaseMd 已有 codebase.md 内容(存在时执行增量更新) + * @param opts.learningsSuggestions 来自 P4.4 的 codebase suggestions(已 apply 后的版本仍可作为提示) + * @param opts.learningsDir learnings 目录路径,函数会读取该目录下所有 .md 文件提取 frontmatter tags 做高频统计 + * @returns AI 生成的 codebase.md 完整内容(含 frontmatter) + */ +export async function generateCodebaseMd(opts: { + repoPath: string; + existingCodebaseMd?: string; + /** 来自 P4.4 的 codebase suggestions(已 apply 后的版本仍可作为提示) */ + learningsSuggestions?: CodebaseSuggestion[]; + /** learnings 目录路径,函数会读取该目录下所有 .md 文件提取 frontmatter tags 做高频统计 */ + learningsDir?: string; +}): Promise { + const { repoPath, existingCodebaseMd, learningsSuggestions, learningsDir } = opts; + + log.debug(`generateCodebaseMd: 收集仓库上下文,路径=${repoPath}`); + const context = await gatherRepoContext(repoPath); + + // 聚合 learnings 上下文(可能为空) + const learningsContext = await gatherLearningsContext({ learningsSuggestions, learningsDir }); + const learningsInjection = + learningsContext + ? `\n以下是最近 MR 提炼出的更新提示与团队关注点,请融合进文档相应章节:\n\n${learningsContext}\n\n` + : ''; + + let prompt: string; + + if (existingCodebaseMd) { + // 增量更新模式 + prompt = + `已有 codebase.md 如下,请根据新的仓库上下文更新它(保留已有内容,补充或修正变更部分):\n` + + `\n${existingCodebaseMd}\n\n\n` + + `新的仓库上下文:\n\n${context}\n\n` + + learningsInjection + + `\n输出完整更新后的 codebase.md,不要加额外说明。`; + } else { + // 全量生成模式:提供完整格式骨架,引导 AI 生成 A1 级别文档 + prompt = + `你是技术文档专家。根据以下 git 仓库信息,生成一份结构完整的 codebase.md 技术全景文档。\n` + + `【必须】用中文撰写,输出纯 Markdown(不要加额外说明)。\n\n` + + `== 格式骨架(严格按此结构生成,每个章节都必须包含)==\n\n` + + `# Codebase 概览\n\n` + + `## 项目概述\n` + + `(2-4 句描述项目是什么、做什么,然后列出核心能力 bullet list,每条带 emoji)\n` + + `核心能力:\n` + + `- 🔄 **功能名**:简短说明\n` + + `- 📥 **功能名**:简短说明\n\n` + + `## 技术栈\n` + + `(用表格,含版本信息)\n` + + `| 维度 | 技术 |\n` + + `|------|------|\n` + + `| 语言 | **语言** 版本+ |\n` + + `| 运行时 | **运行时** 版本 |\n` + + `(继续列出构建、测试、关键依赖库等)\n\n` + + `## 目录结构与模块职责\n` + + `(用带分组框的树形结构,相关文件归为一组,格式如下)\n` + + `\`\`\`\n` + + `项目根/\n` + + `├── src/\n` + + `│ ├── index.ts # CLI 入口,注册所有命令\n` + + `│ │\n` + + `│ ├── ┌─ 功能分组名 ────────────────────────────────┐\n` + + `│ ├── │ fileA.ts # 功能说明 │\n` + + `│ ├── │ fileB.ts # 功能说明 │\n` + + `│ ├── └─────────────────────────────────────────────────────┘\n` + + `│ │\n` + + `│ ├── ┌─ 另一个功能分组 ─────────────────────────────┐\n` + + `│ ├── │ dir/\n` + + `│ ├── │ ├── fileC.ts # 功能说明 │\n` + + `│ ├── └─────────────────────────────────────────────────────┘\n` + + `\`\`\`\n\n` + + `## 数据与配置\n` + + `(列出关键配置文件和运行时数据目录的路径树,说明每个目录/文件的用途)\n\n` + + `## 核心数据流\n` + + `(列出 2-4 条核心业务流程,每条用带缩进和 → 的流程图格式)\n` + + `### 1. 流程名称\n` + + `\`\`\`\n` + + `触发点(用户执行 xxx 命令)\n` + + ` │\n` + + ` ├─ 1. 步骤描述\n` + + ` │ └─ 子步骤\n` + + ` ├─ 2. 步骤描述 → 结果\n` + + ` └─ ✅ 完成\n` + + `\`\`\`\n\n` + + `## 关键接口与抽象\n` + + `(列出项目中最重要的 interface/abstract class,用代码块展示签名,并说明实现)\n\n` + + `## 配置系统\n` + + `(说明配置优先级、scope 检测逻辑、关键配置结构示例)\n\n` + + `## 性能与可靠性\n` + + `(表格列出关键性能设计:并发控制、超时、缓存、降级等)\n\n` + + `## 架构决策与权衡\n` + + `(列出 3-5 条主要设计选择的"为什么",格式如"为什么选择 X 而不是 Y:原因说明")\n\n` + + `## 已知限制与演进方向\n` + + `(列出 3-5 条当前实现的局限与下一步可能的优化)\n\n` + + `## 测试覆盖\n` + + `(表格列出测试层级、用例数、覆盖率)\n\n` + + `## 备注\n` + + `- ✅ 有文档佐证的信息\n` + + `- ⚠️ 基于代码结构推断的信息\n\n` + + `== 以上是格式骨架,根据实际仓库内容填充。若某章节确实无法从上下文推断,可简略但不得省略章节标题。==\n\n` + + `---\n` + + `以下是仓库上下文:\n` + + `\n${context}\n` + + learningsInjection; + } + + log.debug('generateCodebaseMd: 调用 AI 生成文档'); + const rawResult = await callClaude(prompt); + + // 剥离 AI 可能自行附加的 frontmatter,再 prepend 标准 frontmatter + const body = stripExistingFrontmatter(rawResult); + return buildFrontmatter(repoPath) + body; +} + +/** + * 基于 codebase.md 生成精简索引文档。 + * 索引让 LLM 跨会话快速定位章节,无需重读全文。 + * + * @param codebaseMd 完整 codebase.md 内容(包含 frontmatter) + * @returns Markdown 索引(含表格:章节 / 一句摘要 / 关键词) + */ +export async function generateCodebaseIndex(codebaseMd: string): Promise { + const prompt = + `请分析以下 codebase.md 文档,提取所有二级章节(## 开头的标题),` + + `为每个章节生成:一句摘要(≤30 字)和 3-5 个关键词。\n\n` + + `【输出格式要求】严格输出 JSON 数组,不要加任何额外说明:\n` + + `[{"section": "章节名", "summary": "摘要", "keywords": ["词1", "词2", "词3"]}]\n\n` + + `文档内容:\n\n${codebaseMd}\n`; + + log.debug('generateCodebaseIndex: 调用 AI 生成索引'); + const raw = await callClaude(prompt); + + const now = new Date().toISOString(); + const frontmatter = `---\ntitle: Codebase 索引\nlastUpdated: ${now}\n---\n\n`; + + interface IndexEntry { + section: string; + summary: string; + keywords: string[]; + } + + try { + // 从输出中提取 JSON(AI 可能包裹在代码块里) + const jsonMatch = raw.match(/\[[\s\S]*\]/); + if (!jsonMatch) { + throw new Error('未找到 JSON 数组'); + } + const entries: IndexEntry[] = JSON.parse(jsonMatch[0]); + + const tableRows = entries + .map((e) => `| ${e.section} | ${e.summary} | ${e.keywords.join(', ')} |`) + .join('\n'); + + return ( + frontmatter + + `# Codebase 索引\n\n` + + `| 章节 | 摘要 | 关键词 |\n` + + `| ---- | ---- | ------ |\n` + + tableRows + + '\n' + ); + } catch (err) { + log.debug(`generateCodebaseIndex: 解析 JSON 失败 — ${String(err)},原始输出:${raw.slice(0, 200)}`); + return ( + frontmatter + + `# Codebase 索引\n\n` + + `> ⚠️ 索引生成失败,请重新运行 \`teamai import --workspace\` 以重新生成。\n` + ); + } +} + +/** + * 健康检查:让 AI 检测 codebase.md 中的矛盾、过时声明、孤儿模块、缺失关键概念。 + * + * 不修改文档,只返回问题清单。 + * + * @param codebaseMd 完整 codebase.md 内容 + * @returns LintReport,含 issues 数组 + */ +export async function lintCodebaseMd(codebaseMd: string): Promise { + const prompt = + `请对以下 codebase.md 文档做健康检查,检测:\n` + + `1. 矛盾(contradiction):文档内部自相矛盾的陈述\n` + + `2. 过时(outdated):可能已经不准确的声明\n` + + `3. 孤儿(orphan):提到了但文档其他地方没有解释的模块或概念\n` + + `4. 缺失(missing):重要章节或关键概念未被覆盖\n\n` + + `【输出格式要求】严格输出 JSON,不要加任何额外说明:\n` + + `{"summary": "一句话总结", "issues": [` + + `{"severity": "high|medium|low", "category": "contradiction|outdated|orphan|missing", ` + + `"location": "章节名或行号区间", "description": "问题描述", "suggestion": "修复建议"}` + + `]}\n\n` + + `文档内容:\n\n${codebaseMd}\n`; + + log.debug('lintCodebaseMd: 调用 AI 做 lint 检查'); + + try { + const raw = await callClaude(prompt); + + // 从输出中提取 JSON 对象 + const jsonMatch = raw.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error('未找到 JSON 对象'); + } + const parsed = JSON.parse(jsonMatch[0]) as { summary?: string; issues?: LintIssue[] }; + return { + issues: Array.isArray(parsed.issues) ? parsed.issues : [], + summary: typeof parsed.summary === 'string' ? parsed.summary : '检查完成', + }; + } catch (err) { + log.debug(`lintCodebaseMd: 解析失败 — ${String(err)}`); + return { issues: [], summary: '解析失败,无法 lint' }; + } +} + +/** + * 将 MR 提炼的变更建议应用到现有 codebase.md 内容。 + * + * @param current 当前 codebase.md 完整内容 + * @param suggestions MR 提炼的变更建议列表 + * @returns AI 合并建议后的 codebase.md 完整内容 + */ +export async function applyCodebaseSuggestions( + current: string, + suggestions: CodebaseSuggestion[], +): Promise { + // 过滤掉 action='noop' 的建议 + const effectiveSuggestions = suggestions.filter((s) => s.action !== 'noop'); + + if (effectiveSuggestions.length === 0) { + log.debug('applyCodebaseSuggestions: 无有效建议,直接返回原内容'); + return current; + } + + const suggestionsJson = JSON.stringify(effectiveSuggestions, null, 2); + + const prompt = + `请将以下变更建议合并到 codebase.md 中,保持原有格式和风格:\n\n` + + `当前 codebase.md:\n\n${current}\n\n\n` + + `变更建议(JSON 列表):\n\n${suggestionsJson}\n\n\n` + + `【输出格式要求】\n` + + `- 直接输出完整的 Markdown 文档,从文档第一行(通常是 # 开头的标题)开始\n` + + `- 不要输出任何前缀说明、总结、"我已经..."、"更新内容包括..."等描述性文字\n` + + `- 保留原文档的所有已有内容,仅按建议新增或修改对应部分\n` + + `- 输出必须是可以直接写入文件的完整 codebase.md`; + + log.debug(`applyCodebaseSuggestions: 应用 ${effectiveSuggestions.length} 条建议`); + const result = await callClaude(prompt); + return result; +} diff --git a/src/domains/cluster.ts b/src/domains/cluster.ts new file mode 100644 index 0000000..bd53f78 --- /dev/null +++ b/src/domains/cluster.ts @@ -0,0 +1,165 @@ +import { callClaude } from '../utils/ai-client.js'; +import { DomainsFileSchema } from './schema.js'; +import type { DomainsFile, RepoMeta } from './schema.js'; + +/** AI 返回的域列表 JSON 结构(内部使用)。 */ +interface AiClusterOutput { + domains: Array<{ + name: string; + description: string; + confidence: number; + repos: Array<{ + url: string; + confidence: number; + signal: string; + }>; + }>; +} + +/** + * 从 AI 返回的文本中提取 JSON 字符串(去除可能的 ```json 代码围栏)。 + */ +function extractJson(text: string): string { + const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenceMatch) { + return fenceMatch[1].trim(); + } + // 尝试找到第一个 { 到最后一个 } 之间的内容 + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start !== -1 && end !== -1 && end > start) { + return text.slice(start, end + 1); + } + return text.trim(); +} + +/** + * 构建聚类 prompt,输入信号按权重排列:README > description/keywords > 仓库名 > 语言。 + */ +function buildClusterPrompt(repos: RepoMeta[], confidenceThreshold: number): string { + const repoList = repos.map((r) => { + const parts: string[] = [`- URL: ${r.url}`, ` 仓库名: ${r.name}`]; + if (r.readme_excerpt) { + parts.push(` README首段: ${r.readme_excerpt.slice(0, 500)}`); + } + if (r.description) { + parts.push(` 描述: ${r.description}`); + } + if (r.keywords && r.keywords.length > 0) { + parts.push(` 关键词: ${r.keywords.join(', ')}`); + } + if (r.primary_language) { + parts.push(` 主要语言: ${r.primary_language}`); + } + return parts.join('\n'); + }).join('\n\n'); + + return `你是一位技术架构师,请根据以下仓库信息进行业务域聚类分析。 + +## 仓库列表 + +${repoList} + +## 聚类要求 + +1. 把相关仓库归入同一个业务域。 +2. 置信度低于 ${confidenceThreshold} 的仓库必须放入名为「未分类」的域,不得放入其他域。 +3. 每个仓库只能出现在一个域中,不能重复。 +4. 域名用中文,简洁 2-4 字(如「基础设施」「前端应用」「数据处理」)。 +5. confidence 字段为 0-1 之间的小数,表示归类把握程度。 +6. signal 字段用一句话说明归类依据。 + +## 输出格式 + +请严格输出以下 JSON 格式,不要输出任何其他内容: + +{ + "domains": [ + { + "name": "域名(中文2-4字)", + "description": "域的功能描述", + "confidence": 0.9, + "repos": [ + { + "url": "仓库URL", + "confidence": 0.85, + "signal": "归类依据说明" + } + ] + } + ] +}`; +} + +/** + * 调用 AI 对仓库列表做业务域聚类。 + * + * @param repos 仓库元信息列表(≥ 1 个) + * @param opts.confidenceThreshold 默认 0.6;低于此阈值的仓必须进「未分类」域 + * @returns DomainsFile 草稿,generated_at/generator 已填好 + */ +export async function clusterRepos( + repos: RepoMeta[], + opts?: { confidenceThreshold?: number } +): Promise { + const confidenceThreshold = opts?.confidenceThreshold ?? 0.6; + const prompt = buildClusterPrompt(repos, confidenceThreshold); + + const rawOutput = await callClaude(prompt); + const jsonStr = extractJson(rawOutput); + + let aiOutput: AiClusterOutput; + try { + aiOutput = JSON.parse(jsonStr) as AiClusterOutput; + } catch (err) { + throw new Error(`AI cluster output invalid: failed to parse JSON — ${String(err)}`); + } + + // 用 zod 校验 AI 输出结构 + const partialSchema = DomainsFileSchema.pick({ domains: true }); + const validation = partialSchema.safeParse(aiOutput); + if (!validation.success) { + const issues = validation.error.issues + .map((i) => `${i.path.join('.')}: ${i.message}`) + .join('; '); + throw new Error(`AI cluster output invalid: ${issues}`); + } + + // 构造完整 DomainsFile + const domainsFile: DomainsFile = DomainsFileSchema.parse({ + version: 1, + generated_at: new Date().toISOString(), + generator: 'import --bootstrap-domains', + confidence_threshold: confidenceThreshold, + domains: aiOutput.domains, + }); + + // 后置校验:确保所有输入 repos 都被分配到某个域 + const assignedUrls = new Set( + domainsFile.domains.flatMap((d) => d.repos.map((r) => r.url)) + ); + + const missingRepos = repos.filter((r) => !assignedUrls.has(r.url)); + if (missingRepos.length > 0) { + // 将漏分配的仓库补入「未分类」域 + let unclassified = domainsFile.domains.find((d) => d.name === '未分类'); + if (!unclassified) { + unclassified = { + name: '未分类', + description: 'AI 未能归类的仓库', + repos: [], + }; + domainsFile.domains.push(unclassified); + } + for (const repo of missingRepos) { + unclassified.repos.push({ + url: repo.url, + confidence: 0, + signal: 'AI 聚类时未分配,自动补入未分类', + locked: false, + }); + } + } + + return domainsFile; +} diff --git a/src/domains/index.ts b/src/domains/index.ts new file mode 100644 index 0000000..e1a1459 --- /dev/null +++ b/src/domains/index.ts @@ -0,0 +1,5 @@ +export * from './schema.js'; +export * from './store.js'; +export * from './cluster.js'; +export * from './recommend.js'; +export * from './review.js'; diff --git a/src/domains/recommend.ts b/src/domains/recommend.ts new file mode 100644 index 0000000..2fc2fda --- /dev/null +++ b/src/domains/recommend.ts @@ -0,0 +1,127 @@ +import { z } from 'zod'; +import { callClaude } from '../utils/ai-client.js'; +import type { DomainsFile, RepoMeta } from './schema.js'; + +/** 单仓推荐结果。 */ +export interface RecommendResult { + domain: string; // 推荐域名(可能是「未分类」) + confidence: number; + signal: string; // 推荐依据 + alternatives: { domain: string; confidence: number }[]; // 备选 top-2 +} + +/** AI 返回的推荐 JSON 结构(内部使用)。 */ +const RecommendOutputSchema = z.object({ + domain: z.string().min(1), + confidence: z.number().min(0).max(1), + signal: z.string(), + alternatives: z.array( + z.object({ + domain: z.string(), + confidence: z.number().min(0).max(1), + }) + ).default([]), +}); + +/** + * 从 AI 返回文本中提取 JSON 字符串(去除代码围栏)。 + */ +function extractJson(text: string): string { + const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenceMatch) { + return fenceMatch[1].trim(); + } + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start !== -1 && end !== -1 && end > start) { + return text.slice(start, end + 1); + } + return text.trim(); +} + +/** + * 在已有域字典基础上,为单个新仓推荐归属。 + * + * @param repo 仓库元信息 + * @param existing 现有 domains.yaml(用于让 AI 选择已有域而非新造) + * @param opts.confidenceThreshold 默认沿用 existing.confidence_threshold + */ +export async function recommendDomain( + repo: RepoMeta, + existing: DomainsFile, + opts?: { confidenceThreshold?: number } +): Promise { + const threshold = opts?.confidenceThreshold ?? existing.confidence_threshold; + + // 构建已有域列表描述 + const domainList = existing.domains.map((d) => { + const desc = d.description ? `(${d.description})` : ''; + return `- ${d.name}${desc}`; + }).join('\n'); + + // 构建仓库描述 + const repoDesc: string[] = [`仓库名: ${repo.name}`, `URL: ${repo.url}`]; + if (repo.readme_excerpt) { + repoDesc.push(`README首段: ${repo.readme_excerpt.slice(0, 500)}`); + } + if (repo.description) { + repoDesc.push(`描述: ${repo.description}`); + } + if (repo.keywords && repo.keywords.length > 0) { + repoDesc.push(`关键词: ${repo.keywords.join(', ')}`); + } + if (repo.primary_language) { + repoDesc.push(`主要语言: ${repo.primary_language}`); + } + + const prompt = `你是一位技术架构师,请为以下新仓库推荐归属的业务域。 + +## 现有业务域 + +${domainList || '(暂无已有域)'} + +## 新仓库信息 + +${repoDesc.join('\n')} + +## 推荐要求 + +1. 优先从已有业务域中选择最合适的。 +2. 仅当没有任何已有域匹配,或置信度低于 ${threshold} 时,返回「未分类」作为推荐域。 +3. 提供最多 2 个备选域(alternatives),按置信度降序排列。 +4. signal 字段用一句话说明推荐依据。 + +## 输出格式 + +请严格输出以下 JSON 格式,不要输出任何其他内容: + +{ + "domain": "推荐域名", + "confidence": 0.85, + "signal": "推荐依据说明", + "alternatives": [ + { "domain": "备选域1", "confidence": 0.6 }, + { "domain": "备选域2", "confidence": 0.4 } + ] +}`; + + const rawOutput = await callClaude(prompt); + const jsonStr = extractJson(rawOutput); + + let parsed: unknown; + try { + parsed = JSON.parse(jsonStr); + } catch (err) { + throw new Error(`recommendDomain: failed to parse AI output JSON — ${String(err)}`); + } + + const validation = RecommendOutputSchema.safeParse(parsed); + if (!validation.success) { + const issues = validation.error.issues + .map((i) => `${i.path.join('.')}: ${i.message}`) + .join('; '); + throw new Error(`recommendDomain: AI output invalid — ${issues}`); + } + + return validation.data; +} diff --git a/src/domains/review.ts b/src/domains/review.ts new file mode 100644 index 0000000..69ce2fa --- /dev/null +++ b/src/domains/review.ts @@ -0,0 +1,391 @@ +import chalk from 'chalk'; +import { askQuestion, askConfirmation } from '../utils/prompt.js'; +import type { DomainsFile, DomainEntry, HistoryEvent } from './schema.js'; + +/** reviewDomains 的返回结果。 */ +export interface ReviewResult { + result: DomainsFile; + finalize: 'save' | 'draft' | 'abort'; +} + +/** + * 深拷贝一个 DomainsFile。 + */ +function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)) as T; +} + +/** + * 打印当前 domains 概要,用颜色区分置信度。 + */ +function printSummary(domains: DomainEntry[], threshold: number): void { + console.log('\n' + chalk.bold('=== 业务域概要 ===')); + if (domains.length === 0) { + console.log(chalk.gray('(暂无业务域)')); + return; + } + domains.forEach((d, idx) => { + const conf = d.confidence ?? 1; + let nameStr: string; + if (conf >= threshold) { + nameStr = chalk.green(`[${idx}] ${d.name}`); + } else if (conf >= threshold * 0.7) { + nameStr = chalk.yellow(`[${idx}] ${d.name}`); + } else { + nameStr = chalk.red(`[${idx}] ${d.name}`); + } + console.log(`${nameStr} (${d.repos.length} 个仓库)`); + d.repos.forEach((r, rIdx) => { + const rConf = r.confidence ?? 1; + let repoLine = ` [${rIdx}] ${r.url}`; + if (r.locked) { + repoLine += chalk.cyan(' [locked]'); + } + if (rConf < threshold) { + console.log(chalk.red(repoLine)); + } else if (rConf < 0.8) { + console.log(chalk.yellow(repoLine)); + } else { + console.log(repoLine); + } + if (r.signal) { + console.log(chalk.gray(` 信号: ${r.signal}`)); + } + }); + }); + console.log(); +} + +/** + * 打印帮助菜单。 + */ +function printHelp(): void { + console.log(chalk.bold('\n可用指令:')); + console.log(' a — 接受全部,直接保存'); + console.log(' r — 逐项 review 低置信仓库'); + console.log(' m — 合并域 N 与 M(N 吸收 M)'); + console.log(' s — 拆分域 N'); + console.log(' e — 重命名域 N'); + console.log(' l — 锁定域 N 中第 M 个仓'); + console.log(' x — 把域 N 的第 M 个仓重新分配到 newDomain'); + console.log(' h — 显示帮助'); + console.log(' q — 退出'); + console.log(); +} + +/** + * 解析用户输入指令,返回指令名和参数数组。 + */ +function parseCommand(input: string): { cmd: string; args: string[] } { + const parts = input.trim().split(/\s+/); + const cmd = (parts[0] ?? '').toLowerCase(); + const args = parts.slice(1); + return { cmd, args }; +} + +/** + * 进入交互式 review,最终把用户确认后的结果作为返回值(不写盘)。 + * + * 支持操作: + * a — 接受全部 + * r — 逐项 review 低置信仓库 + * m N M — 合并域 N 与 M(N 吸收 M) + * s N — 拆分域 N + * e N — 重命名域 N + * l N M — 锁定域 N 中第 M 个仓(locked=true) + * x N M — 把域 N 的第 M 个仓重新分配到 newDomain + * q — 退出 + * + * 非 TTY 环境下直接返回 draft 不变,finalize='draft'。 + * + * @param draft 待 review 的草稿 + * @param opts.onEvent 每次有效操作的事件回调 + */ +export async function reviewDomains( + draft: DomainsFile, + opts?: { onEvent?: (e: HistoryEvent) => void | Promise } +): Promise { + // 非 TTY 直接返回 + if (!process.stdin.isTTY) { + return { result: draft, finalize: 'draft' }; + } + + const onEvent = opts?.onEvent; + let current = deepClone(draft); + const threshold = current.confidence_threshold; + + /** 触发事件回调 */ + async function emit(event: Omit): Promise { + if (onEvent) { + await onEvent({ + ts: new Date().toISOString(), + actor: 'user', + ...event, + } as HistoryEvent); + } + } + + printHelp(); + + // 主循环 + while (true) { + printSummary(current.domains, threshold); + + let input: string; + try { + input = await askQuestion('review> '); + } catch { + // readline 关闭时退出 + return { result: current, finalize: 'draft' }; + } + + const { cmd, args } = parseCommand(input); + + if (cmd === 'h' || cmd === '?') { + printHelp(); + continue; + } + + if (cmd === 'a') { + // 接受全部 + await emit({ action: 'accept', details: { count: current.domains.length } }); + return { result: current, finalize: 'save' }; + } + + if (cmd === 'q') { + // 退出询问 + console.log('\n退出选项:'); + console.log(' 1 — 保存为正式版本'); + console.log(' 2 — 仅保留草稿'); + console.log(' 3 — 放弃所有更改'); + let choice: string; + try { + choice = await askQuestion('请选择 (1/2/3): '); + } catch { + return { result: current, finalize: 'draft' }; + } + if (choice.trim() === '1') { + return { result: current, finalize: 'save' }; + } else if (choice.trim() === '3') { + return { result: draft, finalize: 'abort' }; + } else { + return { result: current, finalize: 'draft' }; + } + } + + if (cmd === 'r') { + // 逐项 review 低置信仓库 + const lowConfRepos: Array<{ domainIdx: number; repoIdx: number }> = []; + current.domains.forEach((d, dIdx) => { + d.repos.forEach((r, rIdx) => { + if ((r.confidence ?? 1) < threshold) { + lowConfRepos.push({ domainIdx: dIdx, repoIdx: rIdx }); + } + }); + }); + + if (lowConfRepos.length === 0) { + console.log(chalk.green('没有低置信度的仓库需要 review。')); + continue; + } + + console.log(`\n共 ${lowConfRepos.length} 个低置信度仓库需要 review:`); + for (const { domainIdx, repoIdx } of lowConfRepos) { + const domain = current.domains[domainIdx]; + const repo = domain?.repos[repoIdx]; + if (!domain || !repo) continue; + console.log(chalk.yellow(`\n域: ${domain.name}[${domainIdx}] / 仓库[${repoIdx}]: ${repo.url}`)); + console.log(chalk.gray(` 信号: ${repo.signal ?? '无'}, 置信度: ${repo.confidence ?? '未知'}`)); + + let action: string; + try { + action = await askQuestion('操作 (k=保留/d=移到未分类/r=重新分配): '); + } catch { + break; + } + + if (action.trim() === 'd') { + // 移到未分类 + domain.repos.splice(repoIdx, 1); + let unclassified = current.domains.find((d) => d.name === '未分类'); + if (!unclassified) { + unclassified = { name: '未分类', description: '', repos: [] }; + current.domains.push(unclassified); + } + unclassified.repos.push({ ...repo }); + await emit({ + action: 'reassign', + details: { url: repo.url, from: domain.name, to: '未分类' }, + }); + } else if (action.trim().startsWith('r')) { + let newDomainName: string; + try { + newDomainName = await askQuestion('目标域名: '); + } catch { + break; + } + const target = current.domains.find((d) => d.name === newDomainName.trim()); + if (!target) { + console.log(chalk.red(`域「${newDomainName.trim()}」不存在,跳过。`)); + continue; + } + domain.repos.splice(repoIdx, 1); + target.repos.push({ ...repo }); + await emit({ + action: 'reassign', + details: { url: repo.url, from: domain.name, to: newDomainName.trim() }, + }); + } + // k 或其他 → 保留 + } + continue; + } + + if (cmd === 'm') { + // 合并:m N M — N 吸收 M + const nIdx = parseInt(args[0] ?? '', 10); + const mIdx = parseInt(args[1] ?? '', 10); + if (isNaN(nIdx) || isNaN(mIdx) || !current.domains[nIdx] || !current.domains[mIdx]) { + console.log(chalk.red('用法: m ,N 和 M 必须是有效的域索引。')); + continue; + } + const target = current.domains[nIdx]!; + const source = current.domains[mIdx]!; + target.repos.push(...source.repos); + current.domains.splice(mIdx, 1); + await emit({ action: 'merge', details: { into: target.name, merged: source.name } }); + console.log(chalk.green(`已将「${source.name}」合并到「${target.name}」。`)); + continue; + } + + if (cmd === 's') { + // 拆分:s N + const nIdx = parseInt(args[0] ?? '', 10); + if (isNaN(nIdx) || !current.domains[nIdx]) { + console.log(chalk.red('用法: s ,N 必须是有效的域索引。')); + continue; + } + const domain = current.domains[nIdx]!; + if (domain.repos.length < 2) { + console.log(chalk.red(`域「${domain.name}」只有 ${domain.repos.length} 个仓库,无法拆分。`)); + continue; + } + // 显示仓库列表 + domain.repos.forEach((r, idx) => { + console.log(` [${idx}] ${r.url}`); + }); + let indicesInput: string; + try { + indicesInput = await askQuestion('请输入要拆出的仓库索引(空格分隔): '); + } catch { + continue; + } + const indices = indicesInput.trim().split(/\s+/) + .map((s) => parseInt(s, 10)) + .filter((n) => !isNaN(n) && n >= 0 && n < domain.repos.length); + if (indices.length === 0) { + console.log(chalk.red('无有效索引,取消拆分。')); + continue; + } + let newDomainName: string; + try { + newDomainName = await askQuestion('新域名: '); + } catch { + continue; + } + const splitRepos = indices.map((i) => domain.repos[i]!); + // 从原域移除(倒序删除避免索引错位) + [...indices].sort((a, b) => b - a).forEach((i) => { + domain.repos.splice(i, 1); + }); + current.domains.push({ + name: newDomainName.trim(), + description: '', + repos: splitRepos, + }); + await emit({ + action: 'split', + details: { from: domain.name, newDomain: newDomainName.trim(), repoCount: splitRepos.length }, + }); + console.log(chalk.green(`已从「${domain.name}」拆出 ${splitRepos.length} 个仓库到「${newDomainName.trim()}」。`)); + continue; + } + + if (cmd === 'e') { + // 重命名:e N + const nIdx = parseInt(args[0] ?? '', 10); + if (isNaN(nIdx) || !current.domains[nIdx]) { + console.log(chalk.red('用法: e ,N 必须是有效的域索引。')); + continue; + } + const domain = current.domains[nIdx]!; + const oldName = domain.name; + let newName: string; + try { + newName = await askQuestion(`新域名(当前: ${oldName}): `); + } catch { + continue; + } + if (!newName.trim()) { + console.log(chalk.red('域名不能为空。')); + continue; + } + domain.name = newName.trim(); + await emit({ action: 'rename', details: { from: oldName, to: newName.trim() } }); + console.log(chalk.green(`已将域「${oldName}」重命名为「${newName.trim()}」。`)); + continue; + } + + if (cmd === 'l') { + // 锁定:l N M + const nIdx = parseInt(args[0] ?? '', 10); + const mIdx = parseInt(args[1] ?? '', 10); + if (isNaN(nIdx) || isNaN(mIdx) || !current.domains[nIdx] || !current.domains[nIdx]!.repos[mIdx]) { + console.log(chalk.red('用法: l ,N 和 M 必须是有效的域/仓库索引。')); + continue; + } + const repo = current.domains[nIdx]!.repos[mIdx]!; + repo.locked = true; + await emit({ + action: 'lock', + details: { url: repo.url, domain: current.domains[nIdx]!.name }, + }); + console.log(chalk.cyan(`已锁定: ${repo.url}`)); + continue; + } + + if (cmd === 'x') { + // 重新分配:x N M + const nIdx = parseInt(args[0] ?? '', 10); + const mIdx = parseInt(args[1] ?? '', 10); + const newDomainName = args.slice(2).join(' ').trim(); + if ( + isNaN(nIdx) || isNaN(mIdx) || + !current.domains[nIdx] || !current.domains[nIdx]!.repos[mIdx] || + !newDomainName + ) { + console.log(chalk.red('用法: x ')); + continue; + } + const sourceDomain = current.domains[nIdx]!; + const repo = sourceDomain.repos[mIdx]!; + const targetDomain = current.domains.find((d) => d.name === newDomainName); + if (!targetDomain) { + console.log(chalk.red(`域「${newDomainName}」不存在。`)); + continue; + } + sourceDomain.repos.splice(mIdx, 1); + targetDomain.repos.push({ ...repo }); + await emit({ + action: 'reassign', + details: { url: repo.url, from: sourceDomain.name, to: newDomainName }, + }); + console.log(chalk.green(`已将 ${repo.url} 从「${sourceDomain.name}」移到「${newDomainName}」。`)); + continue; + } + + if (cmd !== '') { + console.log(chalk.red(`未知指令「${cmd}」,输入 h 查看帮助。`)); + } + } +} diff --git a/src/domains/schema.ts b/src/domains/schema.ts new file mode 100644 index 0000000..09523d1 --- /dev/null +++ b/src/domains/schema.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +/** 域内单个仓库条目 schema。 */ +export const RepoEntrySchema = z.object({ + url: z.string().url(), + confidence: z.number().min(0).max(1).optional(), + signal: z.string().optional(), // AI 给出的归类依据,可选 + locked: z.boolean().optional().default(false), +}); + +/** 单个业务域条目 schema。 */ +export const DomainEntrySchema = z.object({ + name: z.string().min(1), + description: z.string().optional().default(''), + confidence: z.number().min(0).max(1).optional(), + repos: z.array(RepoEntrySchema).default([]), +}); + +/** domains.yaml 顶层文件 schema。 */ +export const DomainsFileSchema = z.object({ + version: z.literal(1).default(1), + generated_at: z.string().optional(), // ISO timestamp,draft 才有 + generator: z.string().optional(), // 例如 "import --bootstrap-domains" + confidence_threshold: z.number().min(0).max(1).default(0.6), + domains: z.array(DomainEntrySchema).default([]), +}); + +export type RepoEntry = z.infer; +export type DomainEntry = z.infer; +export type DomainsFile = z.infer; + +/** 历史日志条目 schema。 */ +export const HistoryEventSchema = z.object({ + ts: z.string(), // ISO timestamp + actor: z.enum(['ai', 'user']), + action: z.enum(['recommend', 'accept', 'reject', 'merge', 'split', 'rename', 'lock', 'reassign']), + details: z.record(z.unknown()), // 自由 payload +}); +export type HistoryEvent = z.infer; + +/** + * 仓库元信息(聚类输入),由 P5.1 提供。 + */ +export interface RepoMeta { + url: string; + name: string; // 仓库名(不含 org) + readme_excerpt?: string; // README 首段(最多 ~500 字) + description?: string; // package.json / setup.py 等 + keywords?: string[]; + primary_language?: string; +} diff --git a/src/domains/store.ts b/src/domains/store.ts new file mode 100644 index 0000000..c6b445e --- /dev/null +++ b/src/domains/store.ts @@ -0,0 +1,115 @@ +import path from 'node:path'; +import fs from 'fs-extra'; +import { parse as yamlParse, stringify as yamlStringify } from 'yaml'; +import { DomainsFileSchema, HistoryEventSchema } from './schema.js'; +import type { DomainsFile, HistoryEvent } from './schema.js'; + +const DOMAINS_PATH = '.teamai/domains.yaml'; +const DRAFT_PATH = '.teamai/domains.draft.yaml'; +const HISTORY_PATH = '.teamai/domains.history.jsonl'; +/** 反序列化大小上限:10 MB,防止超大文件导致 OOM。 */ +const MAX_CONFIG_FILE_BYTES = 10 * 1024 * 1024; + +/** + * 从 YAML 字符串解析并校验 DomainsFile,校验失败时抛出含字段信息的错误。 + */ +function parseAndValidate(content: string, filePath: string): DomainsFile { + const raw = yamlParse(content) as unknown; + const result = DomainsFileSchema.safeParse(raw); + if (!result.success) { + const issues = result.error.issues + .map((i) => `${i.path.join('.')}: ${i.message}`) + .join('; '); + throw new Error(`Invalid domains file at ${filePath}: ${issues}`); + } + return result.data; +} + +/** + * 读取正式生效的 domains.yaml;不存在时返回带空 domains 数组的默认值。 + * + * @param cwd 项目根目录 + */ +export async function loadDomains(cwd: string): Promise { + const filePath = path.join(cwd, DOMAINS_PATH); + const exists = await fs.pathExists(filePath); + if (!exists) { + return DomainsFileSchema.parse({}); + } + const stat = await fs.stat(filePath); + if (stat.size > MAX_CONFIG_FILE_BYTES) { + throw new Error(`${filePath} exceeds max allowed size 10MB`); + } + const content = await fs.readFile(filePath, 'utf8'); + return parseAndValidate(content, filePath); +} + +/** + * 读取草稿 domains.draft.yaml;不存在返回 null。 + * + * @param cwd 项目根目录 + */ +export async function loadDomainsDraft(cwd: string): Promise { + const filePath = path.join(cwd, DRAFT_PATH); + const exists = await fs.pathExists(filePath); + if (!exists) { + return null; + } + const stat = await fs.stat(filePath); + if (stat.size > MAX_CONFIG_FILE_BYTES) { + throw new Error(`${filePath} exceeds max allowed size 10MB`); + } + const content = await fs.readFile(filePath, 'utf8'); + return parseAndValidate(content, filePath); +} + +/** + * 把 DomainsFile 写到 .teamai/domains.yaml(正式)。 + * + * @param cwd 项目根目录 + * @param data 要写入的数据 + */ +export async function saveDomains(cwd: string, data: DomainsFile): Promise { + const filePath = path.join(cwd, DOMAINS_PATH); + await fs.ensureDir(path.dirname(filePath)); + await fs.writeFile(filePath, yamlStringify(data), 'utf8'); +} + +/** + * 把 DomainsFile 写到 .teamai/domains.draft.yaml(草稿)。 + * + * @param cwd 项目根目录 + * @param data 要写入的数据 + */ +export async function saveDomainsDraft(cwd: string, data: DomainsFile): Promise { + const filePath = path.join(cwd, DRAFT_PATH); + await fs.ensureDir(path.dirname(filePath)); + await fs.writeFile(filePath, yamlStringify(data), 'utf8'); +} + +/** + * 删除草稿文件。文件不存在不报错。 + * + * @param cwd 项目根目录 + */ +export async function clearDomainsDraft(cwd: string): Promise { + const filePath = path.join(cwd, DRAFT_PATH); + const exists = await fs.pathExists(filePath); + if (exists) { + await fs.remove(filePath); + } +} + +/** + * 追加一条历史事件到 domains.history.jsonl(每行一个 JSON 对象)。 + * + * @param cwd 项目根目录 + * @param event 要追加的历史事件 + */ +export async function appendHistory(cwd: string, event: HistoryEvent): Promise { + // 校验事件结构 + const validated = HistoryEventSchema.parse(event); + const filePath = path.join(cwd, HISTORY_PATH); + await fs.ensureDir(path.dirname(filePath)); + await fs.appendFile(filePath, JSON.stringify(validated) + '\n', 'utf8'); +} diff --git a/src/drift-cmd.ts b/src/drift-cmd.ts new file mode 100644 index 0000000..4424b07 --- /dev/null +++ b/src/drift-cmd.ts @@ -0,0 +1,317 @@ +// -*- coding: utf-8 -*- +/** + * drift 子命令实现:list / show / apply / lock 域漂移待处理项。 + * + * 通过 `teamai domains drift` 调用,支持批量 apply、单条 apply/lock 操作。 + */ + +import chalk from 'chalk'; + +import { + loadDomains, + saveDomains, + appendHistory, + type DomainsFile, +} from './domains/index.js'; +import { + loadPendingReview, + removePendingReview, + type PendingReviewItem, +} from './review-store.js'; +import { regenerateAggregate } from './aggregate.js'; +import { getTeamCodebasePaths } from './utils/team-codebase-paths.js'; +import { askConfirmation } from './utils/prompt.js'; +import { log } from './utils/logger.js'; +import type { GlobalOptions } from './types.js'; + +// ─── 类型 ──────────────────────────────────────────────── + +export interface DriftCmdOptions extends GlobalOptions { + /** 位置参数:repoUrl,从 commander argument 取 */ + repoUrlArg?: string; + apply?: boolean; + applyAll?: boolean; + threshold?: string; + lock?: boolean; + output?: string; + json?: boolean; + skipAggregate?: boolean; + /** 测试专用:非 TTY 下自动确认新建域 */ + assumeYesForNewDomain?: boolean; +} + +interface ApplyResult { + ok: boolean; + reason?: string; +} + +// ─── 渲染辅助 ──────────────────────────────────────────── + +function truncate(str: string, maxLen: number): string { + return str.length > maxLen ? str.slice(0, maxLen - 1) + '…' : str; +} + +function renderDriftList(items: PendingReviewItem[]): void { + const driftItems = items.filter((item) => item.kind === 'domain-drift'); + if (driftItems.length === 0) { + console.log(chalk.gray('[drift] 暂无待处理漂移项')); + return; + } + console.log(chalk.cyan(`[drift] 共 ${driftItems.length} 项`)); + const header = [ + ' ' + 'URL'.padEnd(40), + '旧域'.padEnd(12), + '新域(置信度)'.padEnd(18), + 'TS', + ].join(' '); + console.log(chalk.gray(header)); + for (const item of driftItems) { + const url = truncate(String(item.payload['url'] ?? ''), 40).padEnd(40); + const oldDomain = truncate(String(item.payload['oldDomain'] ?? ''), 12).padEnd(12); + const newDomain = String(item.payload['newRecommendedDomain'] ?? ''); + const newConf = Number(item.payload['newConfidence'] ?? 0).toFixed(2); + const newDomainCol = truncate(`${newDomain} (${newConf})`, 18).padEnd(18); + const ts = item.ts.slice(0, 19); + console.log(` ${url} ${chalk.yellow(oldDomain)} ${chalk.green(newDomainCol)} ${chalk.gray(ts)}`); + } +} + +function renderDriftJson(items: PendingReviewItem[]): void { + const driftItems = items.filter((item) => item.kind === 'domain-drift'); + console.log(JSON.stringify(driftItems, null, 2)); +} + +// ─── apply 单条 ─────────────────────────────────────────── + +async function applyOne( + cwd: string, + item: PendingReviewItem, + opts: DriftCmdOptions, +): Promise { + if (item.kind !== 'domain-drift') { + return { ok: false, reason: 'kind 不匹配' }; + } + + const url = String(item.payload['url'] ?? ''); + const oldDomain = String(item.payload['oldDomain'] ?? ''); + const newDomain = String(item.payload['newRecommendedDomain'] ?? ''); + const newConfidence = Number(item.payload['newConfidence'] ?? 0); + const signal = String(item.payload['signal'] ?? ''); + + if (!url || !oldDomain || !newDomain) { + return { ok: false, reason: 'payload 字段缺失' }; + } + + const domains = await loadDomains(cwd); + const oldEntry = domains.domains.find((d) => d.name === oldDomain); + if (!oldEntry) { + return { ok: false, reason: `旧域 ${oldDomain} 不存在` }; + } + const repoIdx = oldEntry.repos.findIndex((r) => r.url === url); + if (repoIdx === -1) { + return { ok: false, reason: `${url} 不在旧域 ${oldDomain}` }; + } + const repoEntry = oldEntry.repos[repoIdx]!; + + // 处理新域:不存在则提示新建 + let newEntry = domains.domains.find((d) => d.name === newDomain); + if (!newEntry) { + if (!process.stdin.isTTY && !opts.assumeYesForNewDomain) { + return { + ok: false, + reason: `新域 ${newDomain} 不存在;非 TTY 不能自动新建(用 -y 或交互模式)`, + }; + } + const confirmed = opts.assumeYesForNewDomain + ?? await askConfirmation(`新域「${newDomain}」不存在,是否新建?`, true); + if (!confirmed) { + return { ok: false, reason: '用户取消新建域' }; + } + newEntry = { name: newDomain, description: '', confidence: 1.0, repos: [] }; + domains.domains.push(newEntry); + } + + // 移动 entry + oldEntry.repos.splice(repoIdx, 1); + newEntry.repos.push({ + ...repoEntry, + confidence: newConfidence, + signal: signal || repoEntry.signal, + }); + + await saveDomains(cwd, domains); + await appendHistory(cwd, { + ts: new Date().toISOString(), + actor: 'user', + action: 'reassign', + details: { url, fromDomain: oldDomain, toDomain: newDomain, newConfidence }, + }); + + await removePendingReview(cwd, item.id); + + if (!opts.skipAggregate) { + try { + const paths = getTeamCodebasePaths(cwd, opts.output); + await regenerateAggregate({ paths, domains }); + } catch (err) { + log.warn(`[drift] aggregate 刷新失败:${err instanceof Error ? err.message : String(err)}`); + } + } + + return { ok: true }; +} + +// ─── lock 单条 ──────────────────────────────────────────── + +async function lockOne(cwd: string, url: string): Promise { + const domains = await loadDomains(cwd); + + let found = false; + for (const domainEntry of domains.domains) { + const repoIdx = domainEntry.repos.findIndex((r) => r.url === url); + if (repoIdx !== -1) { + domainEntry.repos[repoIdx] = { ...domainEntry.repos[repoIdx]!, locked: true }; + found = true; + break; + } + } + + if (!found) { + return { ok: false, reason: `${url} 不在任何域中` }; + } + + await saveDomains(cwd, domains); + await appendHistory(cwd, { + ts: new Date().toISOString(), + actor: 'user', + action: 'lock', + details: { url }, + }); + + // 移除所有该 url 的 drift 项 + const existing = await loadPendingReview(cwd); + for (const item of existing) { + if (item.kind === 'domain-drift' && String(item.payload['url'] ?? '') === url) { + await removePendingReview(cwd, item.id); + } + } + + return { ok: true }; +} + +// ─── 主入口 ─────────────────────────────────────────────── + +/** + * teamai domains drift [repoUrl] [--apply | --lock | --apply-all] 主入口。 + * + * 操作分发: + * - 无 repoUrlArg + 无 --apply-all → list 模式 + * - repoUrlArg + 无标志 → show 单条 + * - repoUrlArg + --apply → applyOne + * - repoUrlArg + --lock → lockOne + * - --apply-all [--threshold N] → 批量 apply + */ +export async function driftCmd(opts: DriftCmdOptions): Promise { + const cwd = process.cwd(); + const { repoUrlArg, apply, applyAll, threshold = '0.8', lock, json } = opts; + + // ── apply-all ── + if (applyAll) { + const thresholdNum = parseFloat(threshold); + const items = await loadPendingReview(cwd); + const driftItems = items + .filter((item) => item.kind === 'domain-drift') + .sort((a, b) => { + const ca = Number(a.payload['newConfidence'] ?? 0); + const cb = Number(b.payload['newConfidence'] ?? 0); + return cb - ca; + }); + + let okCount = 0; + let skippedCount = 0; + let failedCount = 0; + + for (const item of driftItems) { + const conf = Number(item.payload['newConfidence'] ?? 0); + if (conf <= thresholdNum) { + skippedCount++; + continue; + } + const result = await applyOne(cwd, item, opts); + if (result.ok) { + okCount++; + } else { + failedCount++; + log.warn(`[drift] apply 失败(${String(item.payload['url'] ?? '')}):${result.reason ?? '未知错误'}`); + } + } + + if (json) { + console.log(JSON.stringify({ ok: okCount, skipped: skippedCount, failed: failedCount })); + } else { + console.log( + chalk.cyan('[drift] apply-all 完成:') + + chalk.green(`${okCount} 成功`) + ' ' + + chalk.yellow(`${skippedCount} 跳过`) + ' ' + + chalk.red(`${failedCount} 失败`), + ); + } + return; + } + + // ── 单条操作 ── + if (repoUrlArg) { + const items = await loadPendingReview(cwd); + const driftItems = items.filter( + (item) => item.kind === 'domain-drift' && String(item.payload['url'] ?? '') === repoUrlArg, + ); + + if (apply) { + if (driftItems.length === 0) { + log.error(`[drift] 未找到 ${repoUrlArg} 的漂移项`); + process.exitCode = 1; + return; + } + const item = driftItems[0]!; + const result = await applyOne(cwd, item, opts); + if (result.ok) { + console.log(chalk.green(`[drift] apply 成功:${repoUrlArg}`)); + } else { + log.error(`[drift] apply 失败:${result.reason ?? '未知错误'}`); + process.exitCode = 1; + } + return; + } + + if (lock) { + const result = await lockOne(cwd, repoUrlArg); + if (result.ok) { + console.log(chalk.green(`[drift] lock 成功:${repoUrlArg}`)); + } else { + log.error(`[drift] lock 失败:${result.reason ?? '未知错误'}`); + process.exitCode = 1; + } + return; + } + + // show 单条 + if (driftItems.length === 0) { + console.log(chalk.gray(`[drift] 未找到 ${repoUrlArg} 的漂移项`)); + return; + } + if (json) { + console.log(JSON.stringify(driftItems, null, 2)); + } else { + renderDriftList(driftItems); + } + return; + } + + // ── list 模式 ── + const items = await loadPendingReview(cwd); + if (json) { + renderDriftJson(items); + } else { + renderDriftList(items); + } +} diff --git a/src/env-commands.ts b/src/env-commands.ts index ec26505..0d21648 100644 --- a/src/env-commands.ts +++ b/src/env-commands.ts @@ -9,10 +9,24 @@ import type { GlobalOptions } from './types.js'; const envHandler = new EnvHandler(); +/** + * Mask an env variable value for display. + * Shows first 2 chars + "****", or "****" for very short values. + * + * @param value Original value string. + * @returns Masked string. + */ +function maskValue(value: string): string { + if (value.length < 4) return '****'; + return `${value.slice(0, 2)}****`; +} + /** * List all team env variables from env.yaml. + * + * By default, values are masked. Pass `reveal: true` to show plaintext. */ -export async function envList(options: GlobalOptions): Promise { +export async function envList(options: GlobalOptions & { reveal?: boolean }): Promise { const projectConfig = await detectProjectConfig(); const localConfig = projectConfig ?? (await requireInit()).localConfig; const envYamlPath = path.join(localConfig.repo.localPath, 'env', 'env.yaml'); @@ -28,11 +42,16 @@ export async function envList(options: GlobalOptions): Promise { return; } + if (options.reveal) { + process.stderr.write('[warn] 敏感信息将明文输出,请确认环境无录屏\n'); + } + console.log(''); console.log(`Team env variables (${envConfig.variables.length}):`); console.log(''); for (const v of envConfig.variables) { - console.log(` ${v.key}=${v.value}`); + const displayValue = options.reveal ? v.value : maskValue(v.value); + console.log(` ${v.key}=${displayValue}`); if (v.description && options.verbose) { log.dim(` ${v.description}`); } diff --git a/src/hooks.ts b/src/hooks.ts index 2b0d327..ad0aaae 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -26,13 +26,23 @@ function getAutoRecallCommand(tool: string): string { return `bash -lc "teamai auto-recall --stdin 2>/dev/null" || true`; } +/** Generate the todowrite-hint command with tool identifier. */ +function getTodoWriteHintCommand(tool: string): string { + return `bash -lc "teamai todowrite-hint --stdin --tool ${tool} 2>/dev/null" || true`; +} + /** Generate the contribute-check command with tool identifier. */ function getContributeCheckCommand(tool: string): string { return `bash -lc "teamai contribute-check --stdin --tool ${tool} 2>/dev/null" || true`; } +/** Generate the mr-hint command with tool identifier. */ +function getMrHintCommand(tool: string): string { + return `bash -lc "teamai mr-hint --stdin --tool ${tool} 2>/dev/null" || true`; +} + /** Subcommands expected in each tool settings file (for `teamai doctor`). */ -export const TEAMAI_HOOK_SUBCOMMANDS = ['pull', 'update', 'track', 'track-slash', 'dashboard-report', 'contribute-check', 'auto-recall'] as const; +export const TEAMAI_HOOK_SUBCOMMANDS = ['pull', 'update', 'track', 'track-slash', 'dashboard-report', 'contribute-check', 'auto-recall', 'todowrite-hint', 'mr-hint'] as const; /** Claude PascalCase event → Cursor camelCase event (for tests / docs). */ export const CLAUDE_TO_CURSOR_EVENTS: Record = { @@ -153,6 +163,26 @@ function getClaudeHooks(tool: string): ClaudeHookDef[] { description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Auto-recall on ${matcher}`, }, })), + // ─── TodoWrite hint (Phase 1 reminder to call teamai-recall subagent) ──────── + { + eventType: 'PostToolUse', + descriptionKeyword: 'TodoWrite hint', + hook: { + matcher: 'TodoWrite', + hooks: [{ type: 'command', command: getTodoWriteHintCommand(tool) }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} TodoWrite hint to call teamai-recall subagent`, + }, + }, + // ─── MR hint (alert AI about recently merged but un-imported MRs) ──────── + { + eventType: 'SessionStart', + descriptionKeyword: 'MR hint', + hook: { + matcher: '*', + hooks: [{ type: 'command', command: getMrHintCommand(tool) }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} MR hint on session start`, + }, + }, // ─── Dashboard hooks (independent from tracking) ──────── { eventType: 'SessionStart', @@ -211,6 +241,7 @@ function buildCursorHooks(tool: string): Record { return { sessionStart: [ { command: TEAMAI_PULL_COMMAND, timeout: 30 }, + { command: getMrHintCommand(tool), timeout: 10 }, { command: getDashboardReportCommand(tool), timeout: 10 }, ], stop: [ @@ -226,6 +257,7 @@ function buildCursorHooks(tool: string): Record { timeout: 3, matcher, })), + { command: getTodoWriteHintCommand(tool), timeout: 3, matcher: 'TodoWrite' }, ], beforeSubmitPrompt: [ { command: getTrackSlashCommand(tool), timeout: 10 }, @@ -270,7 +302,7 @@ function isTeamaiHookCommand(command: string): boolean { /** Known teamai command substrings used to identify teamai-managed hooks. */ const TEAMAI_COMMAND_MARKERS = [ - 'teamai pull', 'teamai update', 'teamai track', 'teamai dashboard', 'teamai contribute-check', 'teamai auto-recall', + 'teamai pull', 'teamai update', 'teamai track', 'teamai dashboard', 'teamai contribute-check', 'teamai auto-recall', 'teamai todowrite-hint', 'teamai mr-hint', ]; /** diff --git a/src/import-iwiki.ts b/src/import-iwiki.ts new file mode 100644 index 0000000..4275100 --- /dev/null +++ b/src/import-iwiki.ts @@ -0,0 +1,197 @@ +/** + * iWiki 导入入口。 + * + * 负责从 iWiki 拉取页面并转换为候选列表, + * 分类、审查、推送均复用 import-local.ts 的现有函数。 + */ + +import { classifyWithAI, interactiveReview, pushAccepted } from './import-local.js'; +import { IWikiClient } from './utils/iwiki-client.js'; +import type { IWikiDocument, IWikiPage } from './utils/iwiki-client.js'; +import { log, spinner } from './utils/logger.js'; + +// ─── 内部辅助函数 ────────────────────────────────────────────── + +/** + * 解析用户输入,识别 Space ID 或页面 ID。 + * + * - 纯数字 → space id + * - 含 `/p/` 或 `/pages/` 的 URL → page id + * - 其他格式 → 抛出 Error + * + * @param input 用户输入的 Space ID 或页面 URL + * @returns 解析结果 `{ type, id }` + * @throws 无法识别格式时抛出 Error + */ +function parseIWikiInput(input: string): { type: 'space' | 'page'; id: string } { + const trimmed = input.trim(); + + // 纯数字视为 space id + if (/^\d+$/.test(trimmed)) { + return { type: 'space', id: trimmed }; + } + + // URL 中含 /p/ 或 /pages/ + const pageMatch = trimmed.match(/\/(?:p|pages)\/([^/?#]+)/); + if (pageMatch) { + return { type: 'page', id: pageMatch[1] }; + } + + throw new Error( + `无法识别 iWiki 输入格式:"${trimmed}"。` + + '请输入纯数字 Space ID 或含 /p/ 的页面 URL。', + ); +} + +/** + * 将 IWikiDocument 转换为 classifyWithAI 期望的候选格式。 + * + * path 使用虚拟路径 `iwiki://p/`,rawContent 取前 3000 字符。 + * + * @param doc iWiki 文档对象 + * @returns 候选格式对象 + */ +function docToCandidate(doc: IWikiDocument): { path: string; rawContent: string } { + return { + path: `iwiki://p/${doc.docid}`, + rawContent: doc.content.slice(0, 3000), + }; +} + +// ─── 并发下载辅助 ────────────────────────────────────────────── + +/** 每批并发下载的默认文档数量。 */ +const DOWNLOAD_BATCH_SIZE = 5; + +/** + * 按批次并发下载文档,每批最多 DOWNLOAD_BATCH_SIZE 个并发请求。 + * + * 使用 Promise.allSettled 保证单页失败不中断整体。 + * + * @param client IWikiClient 实例 + * @param pages 待下载的页面信息列表 + * @returns 成功下载的 IWikiDocument[] + */ +async function downloadDocuments( + client: IWikiClient, + pages: IWikiPage[], +): Promise { + const documents: IWikiDocument[] = []; + + for (let i = 0; i < pages.length; i += DOWNLOAD_BATCH_SIZE) { + const batch = pages.slice(i, i + DOWNLOAD_BATCH_SIZE); + const results = await Promise.allSettled( + batch.map((page) => client.getDocument(page.docid)), + ); + + for (const result of results) { + if (result.status === 'fulfilled') { + documents.push(result.value); + } else { + log.warn(`下载文档失败,已跳过: ${String(result.reason)}`); + } + } + } + + return documents; +} + +// ─── 导出函数 ────────────────────────────────────────────────── + +/** + * 从 iWiki 导入文档到团队仓库。 + * + * 步骤:获取页面列表 → 下载内容 → AI 分类 → 交互审查 → 推送。 + * + * @param opts 导入选项 + * @param opts.input Space ID 或页面 URL + * @param opts.token PAT Token,优先用此值,否则读 process.env['TAI_PAT_TOKEN'] + * @param opts.all true 时跳过交互,全部接受 + * @param opts.outputDir 指定输出目录,覆盖自动路由 + * @param opts.repoPath 团队仓库本地路径 + * @param opts.dryRun true 时仅预览,不写入文件 + * @param opts.maxPages 最大抓取页数,默认 200 + */ +export async function importFromIWiki(opts: { + input: string; + token?: string; + all?: boolean; + outputDir?: string; + repoPath?: string; + dryRun?: boolean; + maxPages?: number; +}): Promise { + // 1. 读取 token + const token = opts.token ?? process.env['TAI_PAT_TOKEN']; + if (!token) { + throw new Error( + '请设置 TAI_PAT_TOKEN 环境变量(获取地址:https://tai.it.woa.com/user/pat)', + ); + } + + // 2. 解析输入 + const { type, id } = parseIWikiInput(opts.input); + + // 3. 创建客户端 + const client = new IWikiClient(token); + + // 4. 获取页面列表 + let pages: IWikiPage[]; + if (type === 'page') { + // 单页模式:用占位符,后续直接下载该页 + pages = [{ docid: id, title: id }]; + } else { + const fetchSpinner = spinner(`获取 iWiki Space(${id})页面树...`); + try { + pages = await client.fetchAllPages(id, { maxPages: opts.maxPages ?? 200 }); + fetchSpinner.succeed(`获取页面树完成,共 ${pages.length} 页`); + } catch (err: unknown) { + fetchSpinner.fail(`获取页面树失败: ${String(err)}`); + throw err; + } + } + + if (pages.length === 0) { + log.warn('未找到任何页面,导入终止'); + return; + } + + // 5. 并发下载文档内容 + const downloadSpin = spinner(`下载 iWiki 文档内容(共 ${pages.length} 页)...`); + let documents: IWikiDocument[]; + try { + documents = await downloadDocuments(client, pages); + downloadSpin.succeed(`文档下载完成,成功 ${documents.length}/${pages.length} 页`); + } catch (err: unknown) { + downloadSpin.fail(`文档下载出错: ${String(err)}`); + throw err; + } + + if (documents.length === 0) { + log.warn('所有文档下载失败,导入终止'); + return; + } + + // 6. 转换为候选格式 + const candidates = documents.map(docToCandidate); + + // 7. AI 分类 + const classified = await classifyWithAI(candidates); + + if (classified.length === 0) { + log.warn('AI 分类后无有效条目,导入终止'); + return; + } + + // 8. 交互式审查 + const session = await interactiveReview(classified, { all: opts.all }); + + // 9. 推送 + const repoPath = opts.repoPath ?? `${process.env['HOME']}/.teamai/team-repo`; + await pushAccepted(session, repoPath, { + dryRun: opts.dryRun, + outputDir: opts.outputDir, + }); + + log.success('iWiki 导入完成'); +} diff --git a/src/import-local.ts b/src/import-local.ts new file mode 100644 index 0000000..6a99fe3 --- /dev/null +++ b/src/import-local.ts @@ -0,0 +1,554 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import readline from 'node:readline'; + +import { callClaudeParallel } from './utils/ai-client.js'; +import { listFilesRecursive, readFileSafe, writeFile, expandHome, ensureDir } from './utils/fs.js'; +import { log } from './utils/logger.js'; +import { assertSafePath, defaultAllowedRoots } from './utils/path-safety.js'; +import type { ClassifiedItem, ImportSession, ImportSessionItem } from './types.js'; + +// ─── 常量 ────────────────────────────────────────────────── + +/** 扫描时跳过超过此大小(字节)的文件。 */ +const MAX_FILE_SIZE_BYTES = 50 * 1024; + +/** AI 分类时截取的最大内容长度(字符)。 */ +const MAX_CONTENT_CHARS = 3000; + +/** import 会话文件默认路径。 */ +const DEFAULT_SESSION_PATH = `${process.env.HOME}/.teamai/import-session.json`; + +/** 并发调用 Claude 的最大数量。 */ +const AI_CONCURRENCY = 3; + +// ─── 内部辅助 ────────────────────────────────────────────── + +/** + * 将字符串转换为 kebab-case slug,去除特殊字符,最长 60 字符。 + * + * @param title 原始标题 + * @returns slug 字符串 + */ +function toSlug(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9一-鿿]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} + +/** + * 获取当前日期的 YYYY-MM-DD 字符串。 + * + * @returns 日期字符串 + */ +function todayStr(): string { + return new Date().toISOString().slice(0, 10); +} + +/** + * 生成目标文件名:`-.md`。 + * + * @param title 文档标题 + * @returns 文件名字符串 + */ +function buildFilename(title: string): string { + const slug = toSlug(title) || 'untitled'; + return `${todayStr()}-${slug}.md`; +} + +/** + * 构造 AI 分类提示词。 + * + * @param filePath 候选文件路径 + * @param rawContent 文件内容(前 3000 字符) + * @returns 提示词字符串 + */ +function buildClassifyPrompt(filePath: string, rawContent: string): string { + return ( + `你是团队知识库管理员。分析以下文件内容,返回严格 JSON(不要加 markdown 代码块):\n` + + `{"type":"rule|doc|learning","title":"<简短标题,<60字符>","summary":"<一句话摘要>",` + + `"tags":["tag1","tag2"],"confidence":0.8,"isPersonal":false}\n\n` + + `判断规则:\n` + + `- type="rule":编码规范、团队约定、最佳实践文档\n` + + `- type="doc":技术文档、设计文档、API 说明\n` + + `- type="learning":经验总结、踩坑记录、解决方案\n` + + `- isPersonal=true:个人偏好/环境配置(如本地路径、个人 token、个人习惯),不应进入团队库\n\n` + + `文件路径:${filePath}\n` + + `文件内容(前${MAX_CONTENT_CHARS}字):\n` + + rawContent + ); +} + +/** + * 解析 AI 返回的 JSON 为 ClassifiedItem,解析失败时返回保守默认值。 + * + * 保守策略:isPersonal=true、confidence=0,确保不会意外将无法判断的文件写入团队库。 + * + * @param sourcePath 源文件路径 + * @param rawContent 原始文件内容 + * @param output AI 输出文本 + * @returns ClassifiedItem + */ +function parseClassifyOutput( + sourcePath: string, + rawContent: string, + output: string, +): ClassifiedItem { + // 去掉可能残留的 markdown 代码块标记 + const cleaned = output.replace(/^```[a-z]*\n?/i, '').replace(/\n?```$/i, '').trim(); + try { + const parsed = JSON.parse(cleaned) as { + type?: string; + title?: string; + summary?: string; + tags?: unknown[]; + confidence?: number; + isPersonal?: boolean; + }; + const typeValue = parsed.type; + const knownType: 'rule' | 'doc' | 'learning' = + typeValue === 'rule' || typeValue === 'doc' || typeValue === 'learning' + ? typeValue + : 'learning'; + return { + sourcePath, + rawContent, + type: knownType, + title: typeof parsed.title === 'string' ? parsed.title : path.basename(sourcePath), + summary: typeof parsed.summary === 'string' ? parsed.summary : '', + tags: Array.isArray(parsed.tags) + ? (parsed.tags as string[]).filter((t) => typeof t === 'string') + : [], + confidence: typeof parsed.confidence === 'number' ? parsed.confidence : 0, + isPersonal: typeof parsed.isPersonal === 'boolean' ? parsed.isPersonal : false, + }; + } catch (parseErr: unknown) { + // 解析失败 → 保守策略:标记为个人(不导入团队库),confidence=0 + log.warn(`AI 分类结果解析失败,使用保守默认值(isPersonal=true):${String(parseErr)}`); + return { + sourcePath, + rawContent, + type: 'learning', + title: path.basename(sourcePath), + summary: '', + tags: [], + confidence: 0, + isPersonal: true, + }; + } +} + +/** + * 根据 ClassifiedItem 构建带 YAML frontmatter 的 Markdown 字符串。 + * + * 仅写入摘要作为正文,完整原始内容不重新格式化。 + * + * @param item 分类结果 + * @returns 完整 Markdown 内容 + */ +function buildMarkdown(item: ClassifiedItem): string { + const tagsYaml = + item.tags.length > 0 + ? `[${item.tags.map((t) => `"${t}"`).join(', ')}]` + : '[]'; + return [ + '---', + `title: "${item.title}"`, + `author: import`, + `date: ${todayStr()}`, + `tags: ${tagsYaml}`, + '---', + '', + item.summary, + '', + ].join('\n'); +} + +/** + * 从 Markdown frontmatter 内容中粗略检测 type 字段。 + * + * @param content Markdown 文本 + * @returns 'rule' | 'doc' | 'learning' + */ +function detectTypeFromContent(content: string): 'rule' | 'doc' | 'learning' { + if (/\btype:\s*rule\b/.test(content)) return 'rule'; + if (/\btype:\s*doc\b/.test(content)) return 'doc'; + return 'learning'; +} + +/** + * 将 ImportSession 持久化到指定路径。 + * + * @param session 会话对象 + * @param sessionPath 目标文件路径 + */ +async function persistSession(session: ImportSession, sessionPath: string): Promise { + try { + await writeFile(sessionPath, JSON.stringify(session, null, 2) + '\n'); + } catch (err: unknown) { + log.error(`会话持久化失败 [${sessionPath}]: ${String(err)}`); + } +} + +// ─── 公开导出函数 ────────────────────────────────────────── + +/** + * 扫描候选文件列表,返回路径与内容数组。 + * + * 支持两种模式: + * - dir 模式:扫描指定目录下的 .md/.txt 文件(跳过隐藏文件和 >50KB 文件) + * - fromClaude 模式:扫描 ~/.claude/rules/ 和 ~/.cursor/rules/ 下的 .md 文件 + * + * rawContent 只取前 3000 字符(用于 AI 分类,节省 token)。 + * + * @param opts 扫描选项 + * @param opts.dir 要扫描的目录路径(可含 ~ 展开) + * @param opts.fromClaude 为 true 时扫描 claude/cursor rules 目录 + * @returns 候选文件列表,每项包含 path 和 rawContent + */ +export async function scanCandidates(opts: { + dir?: string; + fromClaude?: boolean; +}): Promise> { + const results: Array<{ path: string; rawContent: string }> = []; + + if (opts.dir) { + const expandedDir = expandHome(opts.dir); + // 安全校验:拒绝用户目录之外的路径(防止路径遍历) + try { + assertSafePath(expandedDir, defaultAllowedRoots()); + } catch (err: unknown) { + throw new Error(`拒绝扫描目录:${String(err)}`); + } + const relPaths = await listFilesRecursive(expandedDir); + for (const relPath of relPaths) { + // 跳过路径中含隐藏段(以 . 开头)的文件 + if (relPath.split('/').some((seg) => seg.startsWith('.'))) continue; + const ext = path.extname(relPath).toLowerCase(); + if (ext !== '.md' && ext !== '.txt') continue; + const absPath = path.join(expandedDir, relPath); + try { + const stat = fs.statSync(absPath); + if (stat.size > MAX_FILE_SIZE_BYTES) continue; + } catch (statErr: unknown) { + log.warn(`无法读取文件信息,跳过: ${absPath}(${String(statErr)})`); + continue; + } + const raw = await readFileSafe(absPath); + if (raw === null) continue; + results.push({ path: absPath, rawContent: raw.slice(0, MAX_CONTENT_CHARS) }); + } + } + + if (opts.fromClaude) { + const rulesBaseDirs = [ + expandHome('~/.claude/rules'), + expandHome('~/.cursor/rules'), + ]; + for (const baseDir of rulesBaseDirs) { + if (!fs.existsSync(baseDir)) continue; + const relPaths = await listFilesRecursive(baseDir); + for (const relPath of relPaths) { + if (path.extname(relPath).toLowerCase() !== '.md') continue; + const absPath = path.join(baseDir, relPath); + try { + const stat = fs.statSync(absPath); + if (stat.size > MAX_FILE_SIZE_BYTES) continue; + } catch (statErr: unknown) { + log.warn(`无法读取文件信息,跳过: ${absPath}(${String(statErr)})`); + continue; + } + const raw = await readFileSafe(absPath); + if (raw === null) continue; + results.push({ path: absPath, rawContent: raw.slice(0, MAX_CONTENT_CHARS) }); + } + } + } + + return results; +} + +/** + * 用 AI 批量分类候选文件,过滤个人配置,并发 ≤ 3。 + * + * 某个条目 AI 调用失败时,该条目以 isPersonal=true、confidence=0 保守处理; + * 最终返回列表中已过滤掉 isPersonal=true 的条目。 + * + * @param candidates 候选文件列表 + * @returns 过滤个人配置后的分类结果 + */ +export async function classifyWithAI( + candidates: Array<{ path: string; rawContent: string }>, +): Promise { + if (candidates.length === 0) return []; + + const tasks = candidates.map((candidate) => ({ + prompt: buildClassifyPrompt(candidate.path, candidate.rawContent), + parse: (output: string): ClassifiedItem => + parseClassifyOutput(candidate.path, candidate.rawContent, output), + })); + + let classified: ClassifiedItem[]; + try { + classified = await callClaudeParallel(tasks, AI_CONCURRENCY); + } catch (err: unknown) { + // AggregateError:部分失败,已在 parse 阶段尝试降级处理;此处全量 fallback 保守处理 + log.error(`AI 分类部分失败,对所有条目使用保守策略: ${String(err)}`); + classified = candidates.map((c) => ({ + sourcePath: c.path, + rawContent: c.rawContent, + type: 'learning' as const, + title: path.basename(c.path), + summary: '', + tags: [], + confidence: 0, + isPersonal: true, + })); + } + + // 过滤个人配置条目 + return classified.filter((item) => !item.isPersonal); +} + +/** + * 交互式审查每个候选条目,支持 --resume 从已有会话继续。 + * + * 用户选项: + * - [A]ccept / Enter → 接受 + * - [S]kip → 跳过 + * - [E]dit → 提示用户输入新标题后接受(edited 状态) + * + * 每次选择后立即将会话状态持久化到 sessionPath,支持中断恢复。 + * + * @param items 已分类的候选条目列表 + * @param opts 交互选项 + * @param opts.all true 时跳过交互,全部接受 + * @param opts.sessionPath 会话状态文件路径,默认 ~/.teamai/import-session.json + * @param opts.resume true 时从已有会话继续(跳过非 pending 条目) + * @returns 完整的 ImportSession + */ +export async function interactiveReview( + items: ClassifiedItem[], + opts: { + all?: boolean; + sessionPath?: string; + resume?: boolean; + }, +): Promise { + const sessionPath = opts.sessionPath ?? DEFAULT_SESSION_PATH; + + // 尝试加载已有会话(resume 模式) + let session: ImportSession | null = null; + if (opts.resume) { + try { + const raw = fs.readFileSync(expandHome(sessionPath), 'utf-8'); + session = JSON.parse(raw) as ImportSession; + } catch (loadErr: unknown) { + // 文件不存在或解析失败 → 新建会话 + log.warn(`加载会话文件失败,将新建会话: ${String(loadErr)}`); + } + } + + if (session === null) { + // 新建会话:将所有候选项映射为 pending 条目 + const sessionItems: ImportSessionItem[] = items.map((item, idx) => ({ + id: `item-${idx}`, + sourcePath: item.sourcePath, + status: 'pending' as const, + learningDraft: { + title: item.title, + content: buildMarkdown(item), + }, + })); + session = { + id: Date.now().toString(), + createdAt: new Date().toISOString(), + mode: 'local', + items: sessionItems, + progress: 0, + }; + } else { + // resume 模式:补充新增条目(以 sourcePath 去重,避免重复) + const existingPaths = new Set(session.items.map((i) => i.sourcePath ?? '')); + for (let idx = 0; idx < items.length; idx++) { + const item = items[idx]; + if (!existingPaths.has(item.sourcePath)) { + session.items.push({ + id: `item-${session.items.length}`, + sourcePath: item.sourcePath, + status: 'pending' as const, + learningDraft: { + title: item.title, + content: buildMarkdown(item), + }, + }); + } + } + } + + // 构建 sourcePath → ClassifiedItem 的快速查找表 + const classifiedMap = new Map( + items.map((item) => [item.sourcePath, item]), + ); + + // 过滤出待处理条目 + const pendingItems = session.items.filter((item) => item.status === 'pending'); + const total = session.items.length; + + if (pendingItems.length === 0) { + log.info('所有条目已处理完毕,无需继续交互。'); + return session; + } + + // all 模式:全部自动接受,不读 stdin + if (opts.all) { + for (const item of pendingItems) { + item.status = 'accepted'; + } + session.progress = session.items.filter((i) => i.status !== 'pending').length; + await persistSession(session, sessionPath); + return session; + } + + // 交互模式:逐条审查 + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const question = (prompt: string): Promise => + new Promise((resolve) => rl.question(prompt, resolve)); + + let processedCount = session.items.filter((i) => i.status !== 'pending').length; + + for (const sessionItem of pendingItems) { + const currentIndex = session.items.indexOf(sessionItem) + 1; + const classified = classifiedMap.get(sessionItem.sourcePath ?? ''); + const title = sessionItem.learningDraft?.title + ?? classified?.title + ?? path.basename(sessionItem.sourcePath ?? ''); + const itemType = classified?.type ?? 'learning'; + const summary = classified?.summary ?? ''; + const tags = classified?.tags ?? []; + + process.stdout.write('\n'); + process.stdout.write(`[${currentIndex}/${total}] 📄 ${title} (${itemType})\n`); + process.stdout.write(` 路径: ${sessionItem.sourcePath ?? ''}\n`); + process.stdout.write(` 摘要: ${summary}\n`); + process.stdout.write(` Tags: ${tags.join(', ')}\n`); + + let answered = false; + while (!answered) { + // eslint-disable-next-line no-await-in-loop + const input = await question('[A]ccept [E]dit [S]kip > '); + const choice = input.trim().toLowerCase(); + + if (choice === 'a' || choice === '') { + sessionItem.status = 'accepted'; + answered = true; + } else if (choice === 's') { + sessionItem.status = 'skipped'; + answered = true; + } else if (choice === 'e') { + // eslint-disable-next-line no-await-in-loop + const newTitle = await question(' 新标题: '); + const trimmedTitle = newTitle.trim(); + if (trimmedTitle.length > 0 && sessionItem.learningDraft) { + sessionItem.learningDraft.title = trimmedTitle; + if (classified !== undefined) { + // 用新标题重建 content 的 frontmatter + sessionItem.learningDraft.content = buildMarkdown({ ...classified, title: trimmedTitle }); + } + } + sessionItem.status = 'edited'; + answered = true; + } else { + process.stdout.write(' 请输入 A(接受)、E(编辑)或 S(跳过)\n'); + } + } + + processedCount++; + session.progress = processedCount; + // 每次选择后立即持久化,支持中断恢复 + // eslint-disable-next-line no-await-in-loop + await persistSession(session, sessionPath); + } + + rl.close(); + return session; +} + +/** + * 将已接受的条目写入目标目录(团队 repo 或指定 outputDir)。 + * + * 文件名格式:`-.md` + * 文件内容:Markdown(含 YAML frontmatter) + * dryRun=true 时只打印路径,不实际写文件。 + * + * @param session import 会话(含所有条目及状态) + * @param repoPath 团队 repo 本地路径 + * @param opts 推送选项 + * @param opts.dryRun true 时仅打印不写文件 + * @param opts.outputDir 指定统一输出目录(优先于 repoPath 子目录) + * @returns pushed 和 skipped 数量统计 + */ +export async function pushAccepted( + session: ImportSession, + repoPath: string, + opts: { dryRun?: boolean; outputDir?: string }, +): Promise<{ pushed: number; skipped: number }> { + let pushed = 0; + let skipped = 0; + + const acceptedItems = session.items.filter( + (item) => (item.status === 'accepted' || item.status === 'edited') && item.learningDraft, + ); + + for (const item of acceptedItems) { + const draft = item.learningDraft!; + const filename = buildFilename(draft.title); + + let destDir: string; + if (opts.outputDir) { + destDir = expandHome(opts.outputDir); + // 安全校验:防止写出到用户目录范围之外 + try { + assertSafePath(destDir, defaultAllowedRoots()); + } catch (err: unknown) { + log.error(`拒绝写出到目录 [${destDir}]: ${String(err)}`); + skipped++; + continue; + } + } else { + // 根据 content frontmatter 判断 type,决定写入子目录 + const typeInContent = detectTypeFromContent(draft.content); + const subDir = + typeInContent === 'rule' ? 'rules' + : typeInContent === 'doc' ? 'docs' + : 'learnings'; + destDir = path.join(expandHome(repoPath), subDir); + } + + const destPath = path.join(destDir, filename); + + if (opts.dryRun) { + log.info(`[dry-run] 将写入: ${destPath}`); + pushed++; + continue; + } + + try { + await ensureDir(destDir); + await writeFile(destPath, draft.content); + log.info(`已写入: ${destPath}`); + pushed++; + } catch (err: unknown) { + log.error(`写入失败 [${destPath}]: ${String(err)}`); + skipped++; + } + } + + return { pushed, skipped }; +} diff --git a/src/import-mr.ts b/src/import-mr.ts new file mode 100644 index 0000000..5724a82 --- /dev/null +++ b/src/import-mr.ts @@ -0,0 +1,460 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import readline from 'node:readline/promises'; + +import matter from 'gray-matter'; + +import { fetchGitHubPR } from './providers/github/mr-fetch.js'; +import { fetchTGitMR } from './providers/tgit/mr-fetch.js'; +import type { MRData, LearningDraft, CodebaseSuggestion } from './types.js'; +import { callClaude, callClaudeParallel } from './utils/ai-client.js'; +import { applyCodebaseSuggestions } from './codebase.js'; +import { extractKeywords, findSupersededLearnings } from './utils/dedup.js'; +import { log, spinner } from './utils/logger.js'; + +/** 默认 learning 存放目录。 */ +const DEFAULT_LEARNINGS_DIR = path.join(process.env.HOME ?? '/tmp', '.teamai', 'learnings'); + +/** dedup 相似度阈值。 */ +const SUPERSEDE_THRESHOLD = 0.6; + +/** + * 根据 URL 自动判断 provider 并获取 MR 数据。 + * + * @param url MR / PR 的完整 URL + * @returns 标准化的 MRData 对象 + * @throws URL 不属于已知 provider 时抛出 Error + */ +async function fetchMR(url: string): Promise { + if (url.includes('github.com')) { + return fetchGitHubPR(url); + } + if (url.includes('git.woa.com')) { + return fetchTGitMR(url); + } + throw new Error(`Unsupported MR URL: ${url},仅支持 GitHub 和 TGit`); +} + +/** + * 构造 learning 提炼 prompt。 + * + * @param mr MR 数据对象 + * @returns 用于 callClaude 的完整提示词字符串 + */ +function extractMRLearningPrompt(mr: MRData): string { + const commitsFormatted = mr.commits + .map((c) => `- ${c.hash.slice(0, 8)}: ${c.message}`) + .join('\n'); + const diff3000 = mr.diff.slice(0, 3000); + const author = mr.author ?? 'unknown'; + const date = mr.mergedAt ? mr.mergedAt.slice(0, 10) : new Date().toISOString().slice(0, 10); + + return `你是团队知识库管理员。从以下 MR 信息提炼一条有价值的团队 learning。 +【必须】用中文撰写,输出完整 Markdown 文档(含 YAML frontmatter)。 + +frontmatter 字段(严格按此格式,不要加其他字段): +--- +title: "<简短标题,描述核心问题或发现,<60字符>" +author: ${author} +date: ${date} +tags: [tag1, tag2, tag3] +confidence: 0.85 +source_mr: "${mr.url}" +--- + +body 结构(以下各节必须包含): +## 背景 +在做什么?遇到了什么问题? + +## 解决方案 +怎么解决的?关键步骤是什么? + +## 经验总结 +- 经验 1 +- 经验 2 + +## 相关 Skills +- skill-name(如无则写"暂无") + +tags 从以下类别选 2-5 个: +技术栈: python, typescript, go, k8s, docker, sglang, cuda +问题类型: troubleshooting, performance, deployment, config, api +模式: workflow, pattern, tool-usage, best-practice +场景: debugging, testing, monitoring, security + +--- +MR 标题:${mr.title} +MR 描述: +${mr.description} + +提交信息: +${commitsFormatted} + +关键 diff(前 3000 字): +${diff3000}`; +} + +/** + * 构造 codebase.md 建议提炼 prompt。 + * + * 传入现有 codebase.md 内容时,AI 会参考其格式和粒度生成风格一致的增量条目; + * 未传入时使用示例格式引导。 + * + * @param mr MR 数据对象 + * @param existingCodebaseMd 现有 codebase.md 全文(可选) + * @returns 用于 callClaude 的完整提示词字符串 + */ +function extractCodebaseSuggestionPrompt(mr: MRData, existingCodebaseMd?: string): string { + const diff2000 = mr.diff.slice(0, 2000); + + // 构造现有文档上下文:有则注入全文,无则给一个示例格式 + const existingContext = existingCodebaseMd + ? `以下是现有的 codebase.md 全文,你必须参考其格式、粒度和分组逻辑: + +${existingCodebaseMd.slice(0, 4000)} +` + : `参考以下格式示例(按功能分组,每条含路径和功能说明): +## 主要模块 +- **src/utils/git.ts** — git 操作工具(simple-git 封装) +- **src/utils/fs.ts** — 文件系统工具(fs-extra 封装) +- **src/providers/** — Git provider 抽象层(GitHub / TGit) +- **src/resources/** — 六类资源处理器(skills/rules/docs/env/wiki/agents)`; + + return `分析以下 MR 变更,判断是否需要更新 codebase.md。 + +${existingContext} + +请返回严格 JSON(不要加 markdown 代码块): +{"needsUpdate":true,"suggestions":[{"section":"主要模块","action":"add","content":"多行 Markdown 条目,见格式要求"}]} +或 +{"needsUpdate":false,"suggestions":[]} + +action 取值: +- "add":在该 section 末尾追加新条目 +- "update":替换该 section 中某条已有内容(content 中包含原文和新文) +- "noop":无需变更 + +判断规则: +- 有新文件/模块 → add "主要模块" +- 有接口/调用链变更 → add/update "关键路径"(用 → 串联的流程描述) +- 有架构决策 → add "备注"(带 ✅ 标注) +- 纯内部实现(重构、bug fix、性能优化)→ needsUpdate=false + +【格式要求】严格参照现有 codebase.md 的风格和粒度: +1. 若现有条目是目录级(**src/utils/**),新增条目也用目录级 +2. 若现有条目是文件级(**src/utils/git.ts**),新增条目也用文件级 +3. 同一个 MR 新增的相关文件可合并为一条 suggestion 的多行 content,而非每文件一条 +4. content 字段使用 Markdown 列表格式(每行 "- **路径** — 说明") +5. 关键路径的 content 使用 "N. **触发点**:步骤1 → 步骤2 → 结果" 格式 + +MR 标题:${mr.title} +MR 描述:${mr.description} +关键 diff(前 2000 字):${diff2000}`; +} + +/** + * 交互式询问用户是否确认某项操作。 + * + * @param question 询问文本,末尾不需要加空格 + * @returns 用户输入 'n'/'N' 时返回 false,其余(包括直接回车)返回 true + */ +async function promptConfirm(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question(`${question} `); + return answer.trim().toLowerCase() !== 'n'; + } finally { + rl.close(); + } +} + +/** + * 交互式 codebase 建议审阅循环。 + * + * 展示当前建议摘要,询问用户: + * [y] 直接确认并 apply + * [n] 跳过(不 apply) + * [其他文字] 视为修改意见,调用 AI 修订建议后重新展示,循环直到用户输入 y 或 n + * + * @param suggestions 当前 codebase 建议列表 + * @param mr MR 数据(用于 AI 修订上下文) + * @returns 最终确认的建议列表(用户跳过时返回 null) + */ +async function reviewCodebaseSuggestions( + suggestions: CodebaseSuggestion[], + mr: MRData, +): Promise { + let current = suggestions; + + while (true) { + // 展示当前建议摘要 + log.info(''); + log.info('📋 当前 codebase.md 更新建议:'); + for (const s of current) { + log.info(` [${s.action}] ${s.section}: ${s.content.slice(0, 80).replace(/\n/g, ' ')}…`); + } + log.info(''); + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + let answer: string; + try { + answer = await rl.question('确认应用?[y/n] 或输入修改意见后按回车让 AI 调整:'); + } finally { + rl.close(); + } + + const trimmed = answer.trim().toLowerCase(); + if (trimmed === 'y' || trimmed === '') { + return current; + } + if (trimmed === 'n') { + return null; + } + + // 用户给出了修改意见,调用 AI 修订 + const reviseSpinner = spinner('AI 根据意见修订建议中...').start(); + try { + const revisePrompt = + `你是团队知识库维护者。请根据用户的修改意见,调整以下 codebase.md 更新建议。\n\n` + + `MR 标题:${mr.title}\n` + + `MR 描述:${mr.description.slice(0, 500)}\n\n` + + `当前建议(JSON):\n${JSON.stringify(current, null, 2)}\n\n` + + `用户意见:${answer.trim()}\n\n` + + `请返回修订后的建议,严格 JSON 格式(数组,结构与输入相同),不要加 markdown 代码块。`; + + const revised = await callClaude(revisePrompt); + const jsonMatch = revised.match(/\[[\s\S]*\]/); + const jsonStr = jsonMatch ? jsonMatch[0] : revised; + current = JSON.parse(jsonStr) as CodebaseSuggestion[]; + reviseSpinner.succeed('建议已修订'); + } catch (err: unknown) { + reviseSpinner.fail(`AI 修订失败:${(err as Error).message}`); + log.info('保持原建议,请重新选择。'); + } + } +} + +/** + * 从 MR URL 提炼 learning 草稿和 codebase.md 建议。 + * + * 对应 P0.5 + P4.4 功能:获取 MR 数据 → 并行 AI 提炼 → dedup → 交互确认 → 写文件。 + * + * @param opts.url MR / PR 完整 URL(必填) + * @param opts.learningsDir 用于 dedup 扫描的目录,默认 ~/.teamai/learnings + * @param opts.all 跳过交互确认,全部接受 + * @param opts.outputDir 输出模式:写到此目录(learning.md + codebase-suggestions.json) + * @param opts.repoPath 团队 repo 路径(outputDir 未设时写入 learnings/) + * @param opts.existingCodebaseMd 现有 codebase.md 全文,用于生成风格一致的增量建议(可选) + * @param opts.dryRun 试运行,不写磁盘 + * @returns 提炼结果,包含 learning 草稿和 codebase 建议 + */ +export async function importFromMR(opts: { + url: string; + learningsDir?: string; + all?: boolean; + outputDir?: string; + repoPath?: string; + existingCodebaseMd?: string; + dryRun?: boolean; +}): Promise<{ learning?: LearningDraft; codebaseSuggestions?: CodebaseSuggestion[] }> { + const learningsDir = opts.learningsDir ?? DEFAULT_LEARNINGS_DIR; + + // ── 步骤 1:获取 MR 数据 ──────────────────────────────── + const fetchSpinner = spinner('获取 MR 数据...'); + fetchSpinner.start(); + + let mr: MRData; + try { + mr = await fetchMR(opts.url); + fetchSpinner.succeed('MR 数据获取完成'); + } catch (err: unknown) { + fetchSpinner.fail('MR 数据获取失败'); + throw err; + } + + // ── 步骤 2:并行 AI 分析 ──────────────────────────────── + const aiSpinner = spinner('AI 分析中...'); + aiSpinner.start(); + + type CodebaseSuggestionResponse = { needsUpdate: boolean; suggestions: CodebaseSuggestion[] }; + + let learningContent: string; + let codebaseResponse: CodebaseSuggestionResponse; + + try { + const [rawLearning, rawCodebase] = await callClaudeParallel( + [ + { + prompt: extractMRLearningPrompt(mr), + parse: (output: string) => output, + }, + { + prompt: extractCodebaseSuggestionPrompt(mr, opts.existingCodebaseMd), + parse: (output: string) => { + try { + // AI 可能在 JSON 前附加说明文字,提取第一个 { ... } 块 + const jsonMatch = output.match(/\{[\s\S]*\}/); + const jsonStr = jsonMatch ? jsonMatch[0] : output; + return JSON.parse(jsonStr) as CodebaseSuggestionResponse; + } catch { + log.debug(`codebase suggestion JSON 解析失败,原始输出:${output.slice(0, 200)}`); + return { needsUpdate: false, suggestions: [] }; + } + }, + }, + ], + ); + learningContent = rawLearning as string; + codebaseResponse = rawCodebase as CodebaseSuggestionResponse; + aiSpinner.succeed('AI 分析完成'); + } catch (err: unknown) { + aiSpinner.fail('AI 分析失败'); + throw err; + } + + // ── 步骤 3:解析 learning 草稿 + dedup ───────────────── + const parsed = matter(learningContent); + const learningTitle = (parsed.data['title'] as string | undefined) ?? mr.title; + + const draftKeywords = extractKeywords(learningContent); + const supersededEntries = await findSupersededLearnings(draftKeywords, learningsDir); + const supersedes = supersededEntries + .filter((entry) => entry.overlap >= SUPERSEDE_THRESHOLD) + .map((entry) => entry.filename); + + const learning: LearningDraft = { + title: learningTitle, + content: learningContent, + supersedes: supersedes.length > 0 ? supersedes : undefined, + }; + + // ── 步骤 4:解析 codebase 建议 ───────────────────────── + const codebaseSuggestions: CodebaseSuggestion[] = codebaseResponse.needsUpdate + ? codebaseResponse.suggestions + : []; + + // ── 步骤 5:打印摘要 ──────────────────────────────────── + log.info(`✅ Learning 草稿已生成:${learningTitle}`); + + const tags = parsed.data['tags'] as string[] | undefined; + if (tags && tags.length > 0) { + log.info(` Tags: ${tags.join(', ')}`); + } + + if (supersedes.length > 0) { + log.warn(`⚠️ 发现 ${supersedes.length} 条重叠的 session learning,将标记为 superseded`); + } + + if (codebaseSuggestions.length > 0) { + const sections = [...new Set(codebaseSuggestions.map((s) => s.section))].join('、'); + log.info(`📝 Codebase.md 建议 ${codebaseSuggestions.length} 条(涉及:${sections})`); + } + + // ── 步骤 6:交互确认 ─────────────────────────────────── + let acceptLearning = true; + let finalSuggestions: CodebaseSuggestion[] | null = codebaseSuggestions.length > 0 ? codebaseSuggestions : null; + + if (!opts.all) { + acceptLearning = await promptConfirm('是否接受 learning?[Y/n]'); + if (codebaseSuggestions.length > 0) { + finalSuggestions = await reviewCodebaseSuggestions(codebaseSuggestions, mr); + } + } + + // ── 步骤 7:写文件 ───────────────────────────────────── + if (!opts.dryRun) { + if (acceptLearning) { + await writeLearning(learning, opts.outputDir, opts.repoPath); + } + + if (finalSuggestions && finalSuggestions.length > 0) { + // --output 模式:写 suggestions.json + apply 到 codebase-after.md + if (opts.outputDir) { + const suggestionsPath = path.join(opts.outputDir, 'codebase-suggestions.json'); + await fs.writeFile(suggestionsPath, JSON.stringify(finalSuggestions, null, 2), 'utf-8'); + log.info(`已写入 codebase 建议:${suggestionsPath}`); + + if (opts.existingCodebaseMd) { + const applySpinner = spinner('应用 codebase 建议中...').start(); + try { + const afterContent = await applyCodebaseSuggestions(opts.existingCodebaseMd, finalSuggestions); + const afterPath = path.join(opts.outputDir, 'codebase-after.md'); + await fs.writeFile(afterPath, afterContent, 'utf-8'); + applySpinner.succeed(`已写入更新后的 codebase.md:${afterPath}`); + } catch (err: unknown) { + applySpinner.fail(`codebase 应用失败:${(err as Error).message}`); + } + } + } + + // repoPath 模式:读取并更新 docs/codebase.md + if (opts.repoPath) { + const codebasePath = path.join(opts.repoPath, 'docs', 'codebase.md'); + let currentContent: string | undefined; + try { + currentContent = await fs.readFile(codebasePath, 'utf-8'); + } catch { + log.debug('repoPath 下未找到 docs/codebase.md,跳过 apply'); + } + + if (currentContent) { + const applySpinner = spinner('更新 codebase.md...').start(); + try { + const afterContent = await applyCodebaseSuggestions(currentContent, finalSuggestions); + await fs.writeFile(codebasePath, afterContent, 'utf-8'); + applySpinner.succeed(`已更新:${codebasePath}`); + } catch (err: unknown) { + applySpinner.fail(`codebase 更新失败:${(err as Error).message}`); + } + } + } + } + } + + return { + learning: acceptLearning ? learning : undefined, + codebaseSuggestions: finalSuggestions ?? undefined, + }; +} + +/** + * 将 learning 草稿写入磁盘。 + * + * outputDir 优先;否则尝试写到 repoPath/learnings/;两者均未设则打印警告跳过。 + * + * @param draft LearningDraft 对象 + * @param outputDir 输出目录(可选) + * @param repoPath 团队 repo 根路径(可选) + */ +async function writeLearning( + draft: LearningDraft, + outputDir?: string, + repoPath?: string, +): Promise { + if (outputDir) { + await fs.mkdir(outputDir, { recursive: true }); + const filePath = path.join(outputDir, 'learning.md'); + await fs.writeFile(filePath, draft.content, 'utf-8'); + log.info(`已写入 learning:${filePath}`); + return; + } + + if (repoPath) { + const learningsDir = path.join(repoPath, 'learnings'); + await fs.mkdir(learningsDir, { recursive: true }); + const datePrefix = new Date().toISOString().slice(0, 10); + // 将标题转为合法文件名:取前 40 字符,替换非法字符为连字符 + const safeTitle = draft.title + .slice(0, 40) + .replace(/[^a-zA-Z0-9一-鿿_-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + const filename = `${datePrefix}-${safeTitle}.md`; + const filePath = path.join(learningsDir, filename); + await fs.writeFile(filePath, draft.content, 'utf-8'); + log.info(`已写入 learning:${filePath}`); + return; + } + + log.warn('未指定 outputDir 或 repoPath,learning 草稿未写入磁盘'); +} diff --git a/src/import-org.ts b/src/import-org.ts new file mode 100644 index 0000000..f000db8 --- /dev/null +++ b/src/import-org.ts @@ -0,0 +1,353 @@ +// -*- coding: utf-8 -*- +/** + * 组织级一键初始化入口。 + * + * 对应 CLI:teamai import --from-org [--bootstrap] + * + * 流程: + * 1. 解析 org URL → provider + org 路径 + * 2. provider.listOrgRepos → OrgRepoInfo[] + * 3. 按 includePattern / excludePattern / excludeArchived 过滤 + * 4. 转 RepoMeta[] → clusterRepos → DomainsFile 草稿 + * 5. 同时生成 RepoListFile 草稿 + * 6. 写草稿到 .teamai/domains.draft.yaml + .teamai/repo-whitelist.draft.yaml + * 7. 若 bootstrap=true 进 reviewDomains → 写正式配置 + * 8. 若 skipImport=false,调 importFromRepoList 完成首次全量 + * 9. appendHistory 记录 bootstrap 元事件 + */ + +import path from 'node:path'; +import fs from 'fs-extra'; +import { stringify as yamlStringify } from 'yaml'; + +import { + clusterRepos, + saveDomainsDraft, + saveDomains, + reviewDomains, + appendHistory, +} from './domains/index.js'; +import type { DomainsFile, RepoMeta } from './domains/index.js'; +import type { RepoListFile, RepoListEntry } from './repo-list/schema.js'; +import { importFromRepoList } from './import-repo-list.js'; +import { getProviderFromUrl, getProvider } from './providers/registry.js'; +import type { OrgRepoInfo } from './providers/types.js'; +import { log } from './utils/logger.js'; + +// ─── 常量 ──────────────────────────────────────────────── + +const WHITELIST_DRAFT_PATH = '.teamai/repo-whitelist.draft.yaml'; +const WHITELIST_PATH = '.teamai/repo-whitelist.yaml'; + +// ─── 类型 ──────────────────────────────────────────────── + +/** importFromOrg 的选项。 */ +export interface ImportFromOrgOptions { + /** org URL 或 "github.com/org" / "git.woa.com/group" 或裸 "team-org" */ + org: string; + /** true=进入交互 review;false=只生成草稿 */ + bootstrap?: boolean; + /** 最多拉取的仓库数,默认 200 */ + maxRepos?: number; + /** 排除 archived 仓库,默认 true */ + excludeArchived?: boolean; + /** 仅纳入 fullName 匹配此正则的仓 */ + includePattern?: string; + /** 排除 fullName 匹配此正则的仓 */ + excludePattern?: string; + /** true=只产 yaml 草稿,跳过批量导入 */ + skipImport?: boolean; + dryRun?: boolean; + output?: string; + forceSsh?: boolean; +} + +// ─── 辅助函数 ──────────────────────────────────────────── + +/** + * 解析 org 输入,返回 provider 名和 org 路径。 + * + * 支持格式: + * - "https://github.com/team-org" → { providerName: 'github', orgPath: 'team-org' } + * - "github.com/team-org" → { providerName: 'github', orgPath: 'team-org' } + * - "git.woa.com/group/sub" → { providerName: 'tgit', orgPath: 'group/sub' } + * - "team-org"(裸名) → { providerName: default, orgPath: 'team-org' } + * + * @param org 用户输入 + */ +function parseOrgInput(org: string): { providerName: string; orgPath: string } { + const trimmed = org.trim(); + + // 完整 HTTPS URL + const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/(.+)/); + if (httpsMatch) { + const host = httpsMatch[1].toLowerCase(); + const orgPath = httpsMatch[2].replace(/\/$/, ''); + const providerName = host.includes('woa.com') ? 'tgit' : 'github'; + return { providerName, orgPath }; + } + + // "host/org" 格式(不含协议) + const hostOrgMatch = trimmed.match(/^([^/]+)\/(.+)/); + if (hostOrgMatch) { + const host = hostOrgMatch[1].toLowerCase(); + const orgPath = hostOrgMatch[2]; + if (host.includes('.')) { + // 有效 hostname + const providerName = host.includes('woa.com') ? 'tgit' : 'github'; + return { providerName, orgPath }; + } + // 裸 "owner/repo" 模式 → 视整体为 org 路径,用默认 provider + return { providerName: getProviderFromUrl('').name, orgPath: trimmed }; + } + + // 裸 org 名 + const providerName = getProvider().name; + return { providerName, orgPath: trimmed }; +} + +/** + * 过滤仓库列表。 + */ +function filterRepos( + repos: OrgRepoInfo[], + opts: { + excludeArchived: boolean; + includePattern?: string; + excludePattern?: string; + }, +): OrgRepoInfo[] { + let result = repos; + + if (opts.excludeArchived) { + result = result.filter((r) => !r.archived); + } + + if (opts.includePattern) { + const re = new RegExp(opts.includePattern); + result = result.filter((r) => re.test(r.fullName)); + } + + if (opts.excludePattern) { + const re = new RegExp(opts.excludePattern); + result = result.filter((r) => !re.test(r.fullName)); + } + + return result; +} + +/** + * 将 OrgRepoInfo 转换为 RepoMeta(聚类输入)。 + */ +function toRepoMeta(info: OrgRepoInfo): RepoMeta { + return { + url: info.url, + name: info.name, + description: info.description, + primary_language: info.primaryLanguage, + }; +} + +/** + * 根据 DomainsFile 草稿找到某 URL 所属域名。 + */ +function findDomainForUrl(url: string, domains: DomainsFile): string | undefined { + for (const domain of domains.domains) { + if (domain.repos.some((r) => r.url === url)) { + return domain.name; + } + } + return undefined; +} + +/** + * 构建白名单草稿文件内容(YAML 字符串,含顶部注释)。 + */ +function buildWhitelistYaml(repos: OrgRepoInfo[], domains: DomainsFile): string { + const entries: RepoListEntry[] = repos.map((r) => ({ + url: r.url, + domain: findDomainForUrl(r.url, domains), + auth: 'token' as const, + priority: 'normal' as const, + })); + + const file: RepoListFile = { + version: 1, + repos: entries, + }; + + const header = + '# 由 teamai import --from-org --bootstrap 生成;可手工编辑后再次 review\n'; + return header + yamlStringify(file); +} + +// ─── 主入口 ────────────────────────────────────────────── + +/** + * 组织级一键初始化。 + * + * 列出 org 下所有仓 → AI 聚类 → 生成白名单和域字典草稿 → 可选 review → 可选全量导入。 + * + * @param opts 导入选项 + */ +export async function importFromOrg(opts: ImportFromOrgOptions): Promise { + const cwd = process.cwd(); + const maxRepos = opts.maxRepos ?? 200; + const excludeArchived = opts.excludeArchived ?? true; + + // 1. 解析 org → provider + orgPath + const { providerName, orgPath } = parseOrgInput(opts.org); + const provider = getProvider(providerName); + + if (!provider.listOrgRepos) { + throw new Error( + `Provider "${providerName}" 不支持 listOrgRepos,无法使用 --from-org`, + ); + } + + // 记录开始事件 + const startTs = new Date().toISOString(); + await appendHistory(cwd, { + ts: startTs, + actor: 'ai', + action: 'recommend', + details: { event: 'bootstrap-start', org: opts.org, orgPath, provider: providerName }, + }); + + // 2. 拉取仓库列表 + log.info(`正在从 ${providerName}/${orgPath} 拉取仓库列表...`); + let rawRepos: OrgRepoInfo[]; + try { + rawRepos = await provider.listOrgRepos(orgPath, { maxRepos }); + } catch (err) { + throw new Error(`listOrgRepos 失败: ${String(err)}`); + } + + log.info(`获取到 ${rawRepos.length} 个仓库,开始过滤...`); + + // 3. 过滤 + const filteredRepos = filterRepos(rawRepos, { + excludeArchived, + includePattern: opts.includePattern, + excludePattern: opts.excludePattern, + }); + + if (filteredRepos.length === 0) { + log.warn('过滤后无可用仓库,终止'); + return; + } + + log.info(`过滤后剩余 ${filteredRepos.length} 个仓库,开始 AI 聚类...`); + + // 4. 转换 RepoMeta 并聚类 + const repoMetas: RepoMeta[] = filteredRepos.map(toRepoMeta); + let domainsDraft: DomainsFile; + try { + domainsDraft = await clusterRepos(repoMetas); + } catch (err) { + throw new Error(`AI 聚类失败: ${String(err)}`); + } + + // 5. 写草稿 + if (!opts.dryRun) { + await saveDomainsDraft(cwd, domainsDraft); + const whitelistDraftPath = path.join(cwd, WHITELIST_DRAFT_PATH); + await fs.ensureDir(path.dirname(whitelistDraftPath)); + await fs.writeFile( + whitelistDraftPath, + buildWhitelistYaml(filteredRepos, domainsDraft), + 'utf8', + ); + log.info(`草稿已写入:.teamai/domains.draft.yaml + .teamai/repo-whitelist.draft.yaml`); + } else { + log.info('[dry-run] 跳过草稿写入'); + } + + let finalAction: 'save' | 'draft' | 'abort' = 'draft'; + + // 6. 若 bootstrap=true,进 reviewDomains + if (opts.bootstrap) { + const { result, finalize } = await reviewDomains(domainsDraft); + finalAction = finalize; + + if (finalize === 'save') { + if (!opts.dryRun) { + await saveDomains(cwd, result); + // 写正式白名单 + const whitelistPath = path.join(cwd, WHITELIST_PATH); + await fs.ensureDir(path.dirname(whitelistPath)); + await fs.writeFile( + whitelistPath, + buildWhitelistYaml(filteredRepos, result), + 'utf8', + ); + // 删除草稿 + const draftPath = path.join(cwd, WHITELIST_DRAFT_PATH); + if (await fs.pathExists(draftPath)) { + await fs.remove(draftPath); + } + log.success('正式配置已写入:.teamai/domains.yaml + .teamai/repo-whitelist.yaml'); + } else { + log.info('[dry-run] 跳过正式配置写入'); + } + } else if (finalize === 'abort') { + // 删除两份草稿 + if (!opts.dryRun) { + const draftDomains = path.join(cwd, '.teamai/domains.draft.yaml'); + const draftWhitelist = path.join(cwd, WHITELIST_DRAFT_PATH); + const removeDraft = async (p: string): Promise => { + if (await fs.pathExists(p)) await fs.remove(p); + }; + await Promise.all([removeDraft(draftDomains), removeDraft(draftWhitelist)]); + log.info('已放弃,草稿已删除'); + } + } else { + log.info('已保留草稿,可稍后手动编辑后导入'); + } + } + + // 7. 若未 abort 且非 skipImport,调 importFromRepoList + if (!opts.skipImport && finalAction !== 'abort') { + const whitelistPath = opts.dryRun + ? path.join(cwd, WHITELIST_DRAFT_PATH) + : path.join(cwd, finalAction === 'save' ? WHITELIST_PATH : WHITELIST_DRAFT_PATH); + + if (await fs.pathExists(whitelistPath)) { + log.info(`开始批量导入(白名单:${whitelistPath})...`); + try { + const result = await importFromRepoList({ + listPath: whitelistPath, + concurrency: 3, + forceSsh: opts.forceSsh ?? false, + dryRun: opts.dryRun, + output: opts.output, + skipAggregate: false, + incremental: false, + }); + log.info( + `批量导入完成:成功 ${result.succeeded},失败 ${result.failed.length},跳过 ${result.skipped.length}`, + ); + } catch (err) { + log.warn(`批量导入出错(不中断流程):${String(err)}`); + } + } else { + log.debug('白名单文件不存在,跳过批量导入'); + } + } + + // 8. 记录完成事件 + await appendHistory(cwd, { + ts: new Date().toISOString(), + actor: 'ai', + action: 'recommend', + details: { + event: 'bootstrap-complete', + org: opts.org, + repo_count: filteredRepos.length, + domain_count: domainsDraft.domains.length, + final_action: finalAction, + }, + }); + + log.success(`组织级初始化完成(${filteredRepos.length} 仓库 / ${domainsDraft.domains.length} 个域)`); +} diff --git a/src/import-repo-list.ts b/src/import-repo-list.ts new file mode 100644 index 0000000..c45388c --- /dev/null +++ b/src/import-repo-list.ts @@ -0,0 +1,168 @@ +// -*- coding: utf-8 -*- +import { loadRepoList } from './repo-list/store.js'; +import { isOrgEntry, type RepoListEntry } from './repo-list/schema.js'; +import { importFromRepo } from './import-repo.js'; +import { loadDomains } from './domains/index.js'; +import { regenerateAggregate } from './aggregate.js'; +import { getTeamCodebasePaths } from './utils/team-codebase-paths.js'; +import { log } from './utils/logger.js'; + +/** importFromRepoList 入参。 */ +export interface ImportFromRepoListOptions { + /** 白名单 yaml 路径 */ + listPath: string; + /** 并发数,默认 3 */ + concurrency?: number; + /** 强制 SSH clone */ + forceSsh?: boolean; + /** Dry-run 模式 */ + dryRun?: boolean; + /** 自定义产物根(同 P5.1 的 output 语义) */ + output?: string; + /** 跳过 domain-*.md 与 index.md 重生(仅做单仓) */ + skipAggregate?: boolean; + /** 增量模式:缓存命中时仅 fetch+reset,未命中时 fallback 到全量 clone */ + incremental?: boolean; +} + +/** importFromRepoList 汇总结果。 */ +export interface ImportFromRepoListResult { + succeeded: number; + failed: Array<{ url: string; error: string }>; + skipped: Array<{ url: string; reason: string }>; + aggregateGenerated: boolean; +} + +/** + * 按优先级排序条目(high 优先,low 最后,normal 居中)。 + * + * @param entries RepoListEntry 数组 + * @returns 排序后的副本 + */ +function sortByPriority(entries: RepoListEntry[]): RepoListEntry[] { + const order: Record = { high: 0, normal: 1, low: 2 }; + return [...entries].sort((a, b) => { + const pa = order[a.priority ?? 'normal'] ?? 1; + const pb = order[b.priority ?? 'normal'] ?? 1; + return pa - pb; + }); +} + +/** + * 主入口:teamai import --from-repo-list + * + * 流程: + * 1. 加载白名单 + * 2. 展开 org entry(P5.2 暂不实现,遇到 org entry 直接 warn 跳过;留给 P5.4) + * 3. 用 P5.1 的 importFromRepo 单仓内核处理每个 entry,并发上限 = concurrency + * 4. 单仓失败不阻塞,最终汇总 succeeded/failed/skipped + * 5. 全部完成后调用 regenerateAggregate 重建 domain-*.md + index.md + * + * @param opts ImportFromRepoListOptions + * @returns 汇总结果 + */ +export async function importFromRepoList( + opts: ImportFromRepoListOptions, +): Promise { + const { + listPath, + concurrency = 3, + forceSsh = false, + dryRun = false, + output, + skipAggregate = false, + incremental = false, + } = opts; + + // 1. 加载白名单 + const repoListFile = await loadRepoList(listPath); + + const succeeded: number[] = []; + const failed: Array<{ url: string; error: string }> = []; + const skipped: Array<{ url: string; reason: string }> = []; + + // 2. 分拣 org entry(暂不支持)与单仓 entry + const singleEntries: ReturnType = []; + for (const item of repoListFile.repos) { + if (isOrgEntry(item)) { + log.warn(`org entry 暂不支持,已跳过:${item.org}(将在 P5.4 实现)`); + skipped.push({ url: item.org, reason: 'org entry 暂不支持(P5.4 实现)' }); + } else { + singleEntries.push(item); + } + } + + // 按优先级排序 + const orderedEntries = sortByPriority(singleEntries); + + // 3. 并发调度(简单 semaphore 循环) + const semaphore = { running: 0 }; + const queue = [...orderedEntries]; + + async function processEntry(entry: RepoListEntry): Promise { + const isPublic = entry.auth === 'public'; + const entryForceSsh = entry.auth === 'ssh' || forceSsh; + + try { + await importFromRepo({ + url: entry.url, + forceSsh: entryForceSsh, + forceAnonymous: isPublic, + explicitDomain: entry.domain, + dryRun, + output, + interactive: false, + incremental, + }); + succeeded.push(1); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn(`导入失败:${entry.url} — ${message}`); + failed.push({ url: entry.url, error: message }); + } + } + + // 并发控制循环 + const inFlight: Promise[] = []; + + for (const entry of queue) { + while (semaphore.running >= concurrency) { + // 等待任意一个完成 + await Promise.race(inFlight); + } + + semaphore.running++; + const task = processEntry(entry).finally(() => { + semaphore.running--; + const idx = inFlight.indexOf(task); + if (idx !== -1) inFlight.splice(idx, 1); + }); + inFlight.push(task); + } + + // 等待全部完成 + await Promise.all(inFlight); + + // 5. 重建聚合文件 + let aggregateGenerated = false; + if (!skipAggregate && !dryRun) { + try { + const cwd = process.cwd(); + const paths = getTeamCodebasePaths(cwd, output); + const domains = await loadDomains(cwd); + await regenerateAggregate({ paths, domains }); + aggregateGenerated = true; + log.info(`聚合文件已生成:${paths.index}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn(`聚合文件生成失败(不中断流程):${message}`); + } + } + + return { + succeeded: succeeded.length, + failed, + skipped, + aggregateGenerated, + }; +} diff --git a/src/import-repo.ts b/src/import-repo.ts new file mode 100644 index 0000000..c24976f --- /dev/null +++ b/src/import-repo.ts @@ -0,0 +1,729 @@ +import path from 'node:path'; +import fs from 'fs-extra'; +import chalk from 'chalk'; + +import { generateCodebaseMd } from './codebase.js'; +import { mergeWithAnchors } from './section-patcher.js'; +import { detectProvider } from './providers/registry.js'; +import { shallowClone, shallowFetch } from './clone.js'; +import { appendPendingReview, loadPendingReview, removePendingReview } from './review-store.js'; +import { + getRepoCacheDir, + getRepoSlug, + writeLastSync, + readLastSync, + ensureCacheRoot, +} from './utils/repo-cache.js'; +import { touchCacheEntry } from './utils/cache-index.js'; +import { + loadDomains, + saveDomains, + appendHistory, + recommendDomain, + type DomainsFile, + type RepoEntry, + type RepoMeta, +} from './domains/index.js'; +import { askQuestion } from './utils/prompt.js'; +import { log } from './utils/logger.js'; +import { assertSafePath } from './utils/path-safety.js'; + +// ─── Types ────────────────────────────────────────────── + +export interface ImportFromRepoOptions { + /** 仓库 URL(https/ssh 任一) */ + url: string; + /** Shallow clone 深度,默认 1 */ + depth?: number; + /** 强制 SSH clone */ + forceSsh?: boolean; + /** 强制匿名 HTTPS(即使 token 可用),用于白名单 auth='public' */ + forceAnonymous?: boolean; + /** --domain 显式指定时跳过 AI 推荐 */ + explicitDomain?: string; + /** Dry-run 模式:跳过写盘但执行 clone+扫描 */ + dryRun?: boolean; + /** 自定义产物根目录;默认 cwd/docs/team-codebase */ + output?: string; + /** + * 是否启用交互式确认。 + * 默认 true(TTY 下展示 AI 推荐并等待用户输入); + * 批量导入时传 false → 无 TTY 路径(置信度不足直接归未分类)。 + */ + interactive?: boolean; + /** 增量模式:缓存命中时仅 fetch+reset,未命中时 fallback 到全量 clone */ + incremental?: boolean; +} + +// ─── Helpers ──────────────────────────────────────────── + +/** + * 判断 url 是否已在 domains.yaml 某个域中。 + * 返回所在域名,不存在返回 null。 + */ +function findExistingDomain(domains: DomainsFile, url: string): string | null { + for (const domain of domains.domains) { + if (domain.repos.some((r) => r.url === url)) { + return domain.name; + } + } + return null; +} + +/** + * 统计目录(深度 ≤ maxDepth)内各语言文件数量,返回占比最高的语言标识符。 + */ +async function detectPrimaryLanguage( + repoPath: string, + maxDepth: number = 3, +): Promise { + const langExtMap: Record = { + '.ts': 'TypeScript', + '.tsx': 'TypeScript', + '.js': 'JavaScript', + '.jsx': 'JavaScript', + '.py': 'Python', + '.go': 'Go', + '.java': 'Java', + '.rs': 'Rust', + '.cpp': 'C++', + '.c': 'C', + '.rb': 'Ruby', + '.php': 'PHP', + }; + + const counts: Map = new Map(); + + async function walk(dir: string, depth: number): Promise { + if (depth > maxDepth) return; + let entries: fs.Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const name = entry.name; + // 跳过常见的无关目录 + if (entry.isDirectory()) { + if (['node_modules', '.git', 'dist', 'build', '__pycache__', '.venv'].includes(name)) { + continue; + } + await walk(path.join(dir, name), depth + 1); + } else if (entry.isFile()) { + const ext = path.extname(name).toLowerCase(); + const lang = langExtMap[ext]; + if (lang) { + counts.set(lang, (counts.get(lang) ?? 0) + 1); + } + } + } + } + + await walk(repoPath, 1); + + if (counts.size === 0) return undefined; + let topLang = ''; + let topCount = 0; + for (const [lang, count] of counts) { + if (count > topCount) { + topCount = count; + topLang = lang; + } + } + return topLang || undefined; +} + +// ─── Public API ───────────────────────────────────────── + +/** + * 从 clone 出的 repoPath 抽取 RepoMeta,用于 AI 推荐输入。 + * + * @param repoPath 本地仓库路径 + * @param url 仓库远端 URL + * @param name 仓库名(不含 org) + */ +export async function buildRepoMetaFromPath( + repoPath: string, + url: string, + name: string, +): Promise { + const meta: RepoMeta = { url, name }; + + // README 首段 + const readmeCandidates = ['README.md', 'readme.md', 'README.zh-CN.md', 'README.zh.md']; + for (const candidate of readmeCandidates) { + const filePath = path.join(repoPath, candidate); + if (await fs.pathExists(filePath)) { + try { + const content = await fs.readFile(filePath, 'utf8'); + // 去掉 Markdown 标题前缀,取首 ~500 字 + const stripped = content.replace(/^#+\s.*\n?/gm, '').trim(); + meta.readme_excerpt = stripped.slice(0, 500); + break; + } catch { + // 忽略读取错误 + } + } + } + + // package.json + const pkgPath = path.join(repoPath, 'package.json'); + if (await fs.pathExists(pkgPath)) { + try { + const pkgRaw = await fs.readFile(pkgPath, 'utf8'); + const pkg = JSON.parse(pkgRaw) as Record; + if (typeof pkg.description === 'string' && pkg.description) { + meta.description = pkg.description; + } + if (Array.isArray(pkg.keywords) && pkg.keywords.length > 0) { + meta.keywords = pkg.keywords as string[]; + } + } catch { + // 忽略解析错误 + } + } + + // setup.py description(Python 项目) + if (!meta.description) { + const setupPath = path.join(repoPath, 'setup.py'); + if (await fs.pathExists(setupPath)) { + try { + const setupContent = await fs.readFile(setupPath, 'utf8'); + const match = setupContent.match(/description\s*=\s*['"]([^'"]+)['"]/); + if (match) { + meta.description = match[1]; + } + } catch { + // 忽略 + } + } + } + + // 主要语言 + meta.primary_language = await detectPrimaryLanguage(repoPath); + + return meta; +} + +/** + * 单点确认 UX:展示 AI 推荐,等待用户输入 Y/n/o/u。 + * 非 TTY 模式直接归入「未分类」。 + * + * 返回最终确定的域名。 + */ +async function interactiveConfirmDomain( + repoName: string, + recommend: Awaited>, + domains: DomainsFile, +): Promise<{ domainName: string; accepted: boolean; rejectReason?: string }> { + if (!process.stdin.isTTY) { + log.warn(`非 TTY 模式,仓库 ${repoName} 直接归入「未分类」`); + return { domainName: '未分类', accepted: false }; + } + + const { domain, confidence, signal, alternatives } = recommend; + + console.log(''); + console.log(chalk.cyan(`[AI 推荐 domain: ${domain} (confidence ${confidence.toFixed(2)})]`)); + console.log(chalk.gray(`[依据: ${signal}]`)); + if (alternatives.length > 0) { + const altStr = alternatives.map((a) => `${a.domain} (${a.confidence.toFixed(2)})`).join(', '); + console.log(chalk.gray(`[备选: ${altStr}]`)); + } + console.log(''); + + const answer = await askQuestion( + `确认归入「${domain}」吗? [Y/n/o (其他域)/u (未分类)] `, + 'y', + ); + + const lower = answer.toLowerCase().trim(); + + if (lower === '' || lower === 'y') { + return { domainName: domain, accepted: true }; + } + + if (lower === 'u') { + return { domainName: '未分类', accepted: false }; + } + + if (lower === 'n') { + let rejectReason: string | undefined; + try { + rejectReason = await askQuestion('请简述拒绝原因(可留空):', ''); + } catch { + // 非 TTY fallback + } + return { domainName: '未分类', accepted: false, rejectReason: rejectReason || undefined }; + } + + if (lower === 'o') { + const existingDomains = domains.domains.map((d, idx) => ` ${idx + 1}. ${d.name}`); + console.log('已有域列表:'); + console.log(existingDomains.join('\n')); + const numStr = await askQuestion('请输入编号:', ''); + const num = parseInt(numStr, 10); + if (!isNaN(num) && num >= 1 && num <= domains.domains.length) { + return { domainName: domains.domains[num - 1].name, accepted: true }; + } + log.warn('无效编号,归入「未分类」'); + return { domainName: '未分类', accepted: false }; + } + + return { domainName: '未分类', accepted: false }; +} + +// ─── Domain Drift Detection ───────────────────────────── + +/** + * 检测仓库域归属漂移(仅在增量同步场景执行)。 + * + * 当推荐域与当前归属不同、且 confidence 偏差 > threshold 时,写入 history 并告警。 + * 任何错误只 debug 日志,不抛出,不阻塞主流程。 + * + * @internal + */ +export async function detectDomainDrift(args: { + cwd: string; + url: string; + newMeta: RepoMeta; + domains: DomainsFile; + threshold?: number; + oldSha: string | null; + newSha: string; +}): Promise { + const { cwd, url, newMeta, domains, threshold = 0.4, oldSha, newSha } = args; + + if (oldSha === null) { + // 非增量场景,不检测漂移 + return; + } + + try { + // 找到 url 当前归属域 + let currentDomain: string | null = null; + let currentConfidence = 0; + for (const domain of domains.domains) { + const repoEntry = domain.repos.find((r) => r.url === url); + if (repoEntry) { + currentDomain = domain.name; + currentConfidence = repoEntry.confidence ?? 0; + break; + } + } + + if (currentDomain === null) { + // 不在任何域,跳过 + return; + } + + const recommendResult = await recommendDomain(newMeta, domains); + + // 同域无需报告 + if (recommendResult.domain === currentDomain) { + return; + } + + const confidenceDiff = Math.abs(recommendResult.confidence - currentConfidence); + if (recommendResult.confidence <= 0.5 || confidenceDiff <= threshold) { + return; + } + + // 写 history + await appendHistory(cwd, { + ts: new Date().toISOString(), + actor: 'ai', + action: 'recommend', + details: { + kind: 'drift', + url, + oldDomain: currentDomain, + newRecommendedDomain: recommendResult.domain, + oldConfidence: currentConfidence, + newConfidence: recommendResult.confidence, + oldSha, + newSha, + signal: recommendResult.signal, + }, + }); + + log.warn( + `[drift] 仓库 ${url} 可能需要重新分类` + + `(推荐域 ${recommendResult.domain},confidence ${recommendResult.confidence.toFixed(2)}),` + + `已记入 history。请人工 review,自动归属未变。`, + ); + + // 写 pending-review(24h 去重:移除同 url 的旧 drift 项) + try { + const existing = await loadPendingReview(cwd); + const cutoffMs = Date.now() - 24 * 3600 * 1000; + for (const existingItem of existing) { + if (existingItem.kind !== 'domain-drift') continue; + const itemUrl = String(existingItem.payload['url'] ?? ''); + if (itemUrl !== url) continue; + const itemMs = Date.parse(existingItem.ts); + if (Number.isFinite(itemMs) && itemMs >= cutoffMs) { + await removePendingReview(cwd, existingItem.id); + } + } + await appendPendingReview(cwd, { + kind: 'domain-drift', + target: { file: '.teamai/domains.yaml' }, + payload: { + url, + oldDomain: currentDomain, + newRecommendedDomain: recommendResult.domain, + oldConfidence: currentConfidence, + newConfidence: recommendResult.confidence, + signal: recommendResult.signal, + oldSha, + newSha, + }, + source: 'drift-detector', + }); + } catch (err) { + log.debug(`[drift] 写入 pending-review 失败:${err instanceof Error ? err.message : String(err)}`); + } + } catch (err) { + log.debug(`[drift] 域漂移检测失败(不影响主流程):${String(err)}`); + } +} + +/** + * teamai import --from-repo 主入口。 + * + * 流程: + * 1. 解析 url → provider + RepoInfo(owner/repo) + * 2. shallow clone(或增量 fetch+reset)到 ~/.teamai/cache/repos/// + * 3. generateCodebaseMd({ repoPath: cacheDir }) + * 4. 写出到 /repos/.md(默认 outputRoot=cwd/docs/team-codebase) + * 5. 推荐业务域(或使用 --domain 显式指定) + * 6. 写入 .teamai/domains.yaml + appendHistory + * 7. 写 LAST_SYNC + * + * @throws 克隆失败、扫描失败、IO 失败时抛 Error + */ +export async function importFromRepo(opts: ImportFromRepoOptions): Promise { + const { + url, depth = 1, forceSsh = false, forceAnonymous = false, + explicitDomain, dryRun = false, output, interactive = true, + incremental = false, + } = opts; + + // 1. 解析 provider 和仓库信息 + const providerName = detectProvider(url); + if (!providerName) { + throw new Error(`Unsupported repo URL: ${url}`); + } + + // 从 url 提取 owner 和 repo 名 + // 支持 https://github.com/owner/repo[.git] 和 git@github.com:owner/repo[.git] + let owner: string; + let repoName: string; + const httpsMatch = url.match(/https?:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/.*)?$/); + const sshMatch = url.match(/git@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/); + if (httpsMatch) { + owner = httpsMatch[1]; + repoName = httpsMatch[2]; + } else if (sshMatch) { + owner = sshMatch[1]; + repoName = sshMatch[2]; + } else { + throw new Error(`Unsupported repo URL: ${url}`); + } + + log.info(`导入远端仓库: ${owner}/${repoName} (provider: ${providerName})`); + + // 2. shallow clone 或增量 fetch+reset + await ensureCacheRoot(); + const cacheDir = getRepoCacheDir(providerName, owner, repoName); + const slug = getRepoSlug(providerName, owner, repoName); + + const lastSync = await readLastSync(cacheDir); + const cacheExists = await fs.pathExists(path.join(cacheDir, '.git')); + const useIncremental = incremental && cacheExists && lastSync !== null; + + let cloneSha: string; + let cloneBranch: string; + let oldSha: string | null = null; + + if (useIncremental) { + oldSha = lastSync.sha; + log.info(`[incremental] 缓存命中 ${cacheDir},从 ${oldSha.slice(0, 8)} 增量同步`); + try { + const fetchResult = await shallowFetch(cacheDir); + cloneSha = fetchResult.sha; + cloneBranch = 'HEAD'; + log.info(`[incremental] Fetch 完成: SHA=${cloneSha.slice(0, 8)}`); + } catch (fetchErr) { + log.warn( + `[incremental] fetch 失败,fallback 到全量 clone:` + + `${fetchErr instanceof Error ? fetchErr.message : String(fetchErr)}`, + ); + try { + const cloneResult = await shallowClone(url, cacheDir, providerName, { + depth, forceSsh, forceAnonymous, + }); + cloneSha = cloneResult.sha; + cloneBranch = cloneResult.branch; + oldSha = null; // fallback 时视为全量,不做漂移检测 + } catch (err) { + throw new Error(`克隆失败 (${url}): ${err instanceof Error ? err.message : String(err)}`); + } + } + } else { + log.info(`Shallow clone 到缓存目录: ${cacheDir}`); + try { + const cloneResult = await shallowClone(url, cacheDir, providerName, { + depth, forceSsh, forceAnonymous, + }); + cloneSha = cloneResult.sha; + cloneBranch = cloneResult.branch; + } catch (err) { + // shallowClone 内部已清理目录 + throw new Error(`克隆失败 (${url}): ${err instanceof Error ? err.message : String(err)}`); + } + } + + log.info(`Clone/Fetch 完成: SHA=${cloneSha.slice(0, 8)}, branch=${cloneBranch}`); + + // 3. 扫描生成 codebase.md + log.info(`扫描仓库内容...`); + let codebaseMd: string; + try { + codebaseMd = await generateCodebaseMd({ repoPath: cacheDir }); + } catch (err) { + // 保留缓存便于排查 + throw new Error(`codebase 扫描失败: ${err instanceof Error ? err.message : String(err)}`); + } + + // 4. 确定产物输出路径 + const outputRoot = output ?? path.join(process.cwd(), 'docs', 'team-codebase'); + const repoMdPath = path.join(outputRoot, 'repos', `${slug}.md`); + // path-safety:确保写入路径在 reposDir 内,防止 slug 含路径分隔符导致目录穿越 + assertSafePath(repoMdPath, [path.join(outputRoot, 'repos')]); + + // 章节级 diff + 锚点合并 + const sourceTag = `${url}@${cloneSha.slice(0, 8)}`; + const syncedAt = new Date().toISOString(); + + let oldFile: string | null = null; + if (await fs.pathExists(repoMdPath)) { + try { + oldFile = await fs.readFile(repoMdPath, 'utf8'); + } catch { + oldFile = null; + } + } + + let merged: ReturnType; + let toWrite: string; + try { + merged = mergeWithAnchors(oldFile, codebaseMd, { source: sourceTag, syncedAt }); + toWrite = merged.mergedMd; + } catch (err) { + log.warn(`[section-merge] ${err instanceof Error ? err.message : err};fallback 到全量重写`); + // fallback 前备份旧文件,防止已有章节数据丢失 + if (oldFile !== null && !dryRun) { + const bakPath = `${repoMdPath}.bak`; + try { + await fs.writeFile(bakPath, oldFile, 'utf8'); + log.warn(`[section-merge] 旧文件已备份至:${bakPath}`); + } catch (bakErr) { + log.debug(`[section-merge] 备份失败:${bakErr instanceof Error ? bakErr.message : bakErr}`); + } + } + merged = mergeWithAnchors(null, codebaseMd, { source: sourceTag, syncedAt }); + toWrite = merged.mergedMd; + } + + if (dryRun) { + console.log(chalk.yellow(`[dry-run] 产物路径: ${repoMdPath}`)); + console.log(chalk.yellow('[dry-run] 产物预览(前 50 行):')); + const preview = toWrite.split('\n').slice(0, 50).join('\n'); + console.log(preview); + if (merged.changedSlugs.length > 0) { + console.log(chalk.yellow(`[dry-run] 变化章节: ${merged.changedSlugs.join(', ')}`)); + } + if (merged.addedSlugs.length > 0) { + console.log(chalk.yellow(`[dry-run] 新增章节: ${merged.addedSlugs.join(', ')}`)); + } + if (merged.removedSlugs.length > 0) { + console.log(chalk.yellow(`[dry-run] 移除章节: ${merged.removedSlugs.join(', ')}`)); + } + } else { + await fs.ensureDir(path.dirname(repoMdPath)); + const noChange = + merged.changedSlugs.length === 0 && + merged.addedSlugs.length === 0 && + merged.removedSlugs.length === 0 && + oldFile !== null && + oldFile === toWrite; + if (noChange) { + log.info(`[section-merge] 仓库 ${repoName} 无章节变化,跳过写入`); + } else { + await fs.writeFile(repoMdPath, toWrite, 'utf8'); + log.info(`产物已写入: ${repoMdPath}`); + if (merged.changedSlugs.length > 0) { + log.debug(`[section-merge] 变化章节: ${merged.changedSlugs.join(', ')}`); + } + if (merged.addedSlugs.length > 0) { + log.debug(`[section-merge] 新增章节: ${merged.addedSlugs.join(', ')}`); + } + if (merged.removedSlugs.length > 0) { + log.debug(`[section-merge] 移除章节: ${merged.removedSlugs.join(', ')}`); + } + } + } + + // 5. 业务域推荐 + const cwd = process.cwd(); + const existingDomains = await loadDomains(cwd); + + // 检查 url 是否已在其他域 + const existingDomainName = findExistingDomain(existingDomains, url); + + // 增量场景下进行域漂移检测(先于归属检查,允许对已有仓库检测) + if (existingDomainName && !dryRun) { + const newMeta = await buildRepoMetaFromPath(cacheDir, url, repoName); + await detectDomainDrift({ + cwd, + url, + newMeta, + domains: existingDomains, + oldSha, + newSha: cloneSha, + }); + // 已在域中:更新 LAST_SYNC 后直接返回 + await writeLastSync(cacheDir, cloneSha); + log.info(`LAST_SYNC 已更新: ${cloneSha.slice(0, 8)}`); + try { + await touchCacheEntry({ provider: providerName, owner, repo: repoName, lastSyncedSha: cloneSha }); + } catch (touchErr) { + log.debug(`[cache-index] touchCacheEntry 失败(不阻塞主流程): ${String(touchErr)}`); + } + log.info(chalk.green(`✓ 仓库 ${owner}/${repoName} 增量同步完成`)); + return; + } + + if (existingDomainName) { + log.warn(`仓库 ${url} 已在域「${existingDomainName}」中,跳过重复添加(请先手动清理后再导入)`); + return; + } + + let finalDomainName: string; + let confidence: number; + let signal: string; + let historyActor: 'ai' | 'user' = 'ai'; + let rejectReason: string | undefined; + + if (explicitDomain) { + // --domain 显式指定 + finalDomainName = explicitDomain; + confidence = 1.0; + signal = 'user explicitly specified'; + historyActor = 'user'; + log.info(`使用显式指定域: ${finalDomainName}`); + } else { + // AI 推荐 + const repoMeta = await buildRepoMetaFromPath(cacheDir, url, repoName); + + const threshold = existingDomains.confidence_threshold; + let recommendResult: Awaited>; + try { + recommendResult = await recommendDomain(repoMeta, existingDomains); + } catch (err) { + log.warn(`AI 推荐失败,归入「未分类」: ${err instanceof Error ? err.message : String(err)}`); + recommendResult = { domain: '未分类', confidence: 0, signal: 'AI 推荐失败', alternatives: [] }; + } + + if (recommendResult.confidence < threshold) { + log.info( + `AI 推荐置信度 ${recommendResult.confidence.toFixed(2)} 低于阈值 ${threshold},` + + `仓库 ${repoName} 直接归入「未分类」`, + ); + finalDomainName = '未分类'; + confidence = recommendResult.confidence; + signal = recommendResult.signal; + } else if (!interactive) { + // 批量模式(interactive=false):不走交互确认,直接接受 AI 推荐 + const conf = recommendResult.confidence.toFixed(2); + log.info( + `[批量] 仓库 ${repoName} 归入域「${recommendResult.domain}」(confidence=${conf})`, + ); + finalDomainName = recommendResult.domain; + confidence = recommendResult.confidence; + signal = recommendResult.signal; + } else { + const confirmResult = await interactiveConfirmDomain(repoName, recommendResult, existingDomains); + finalDomainName = confirmResult.domainName; + confidence = confirmResult.accepted ? recommendResult.confidence : 0; + signal = recommendResult.signal; + rejectReason = confirmResult.rejectReason; + } + } + + // 6. 写入 domains.yaml + if (!dryRun) { + // 找到或新建目标域 + const updatedDomains = { ...existingDomains, domains: [...existingDomains.domains] }; + let targetDomainIdx = updatedDomains.domains.findIndex((d) => d.name === finalDomainName); + + if (targetDomainIdx === -1) { + // 新建域 + log.info(`域「${finalDomainName}」不存在,自动新建`); + updatedDomains.domains.push({ + name: finalDomainName, + description: '', + confidence: explicitDomain ? 1.0 : undefined, + repos: [], + }); + targetDomainIdx = updatedDomains.domains.length - 1; + } + + const newEntry: RepoEntry = { + url, + confidence, + signal, + locked: false, + }; + + // 拷贝目标域并追加 repo + updatedDomains.domains = updatedDomains.domains.map((domain, idx) => { + if (idx !== targetDomainIdx) return domain; + return { ...domain, repos: [...domain.repos, newEntry] }; + }); + + await saveDomains(cwd, updatedDomains); + log.info(`已将仓库 ${repoName} 归入域「${finalDomainName}」`); + + // appendHistory + await appendHistory(cwd, { + ts: new Date().toISOString(), + actor: historyActor, + action: rejectReason ? 'reject' : 'accept', + details: { + url, + domain: finalDomainName, + confidence, + signal, + ...(rejectReason ? { reject_reason: rejectReason } : {}), + }, + }); + + // 7. 写 LAST_SYNC + await writeLastSync(cacheDir, cloneSha); + log.info(`LAST_SYNC 已更新: ${cloneSha.slice(0, 8)}`); + try { + await touchCacheEntry({ provider: providerName, owner, repo: repoName, lastSyncedSha: cloneSha }); + } catch (touchErr) { + log.debug(`[cache-index] touchCacheEntry 失败(不阻塞主流程): ${String(touchErr)}`); + } + } else { + console.log(chalk.yellow(`[dry-run] 域推荐结果: 归入「${finalDomainName}」(confidence=${confidence.toFixed(2)})`)); + console.log(chalk.yellow('[dry-run] 跳过写盘(domains.yaml / LAST_SYNC)')); + } + + log.info(chalk.green(`✓ 仓库 ${owner}/${repoName} 导入完成`)); +} diff --git a/src/import.ts b/src/import.ts new file mode 100644 index 0000000..1c3ad77 --- /dev/null +++ b/src/import.ts @@ -0,0 +1,261 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; + +import { autoDetectInit } from './config.js'; +import { generateCodebaseMd, generateCodebaseIndex, lintCodebaseMd } from './codebase.js'; +import { scanCandidates, classifyWithAI, interactiveReview, pushAccepted } from './import-local.js'; +import { importFromIWiki } from './import-iwiki.js'; +import { importFromMR } from './import-mr.js'; +import { importFromRepo } from './import-repo.js'; +import { importFromRepoList } from './import-repo-list.js'; +import { importFromOrg } from './import-org.js'; +import { importFromIWikiDual } from './iwiki-dual.js'; +import { GlobalOptions } from './types.js'; +import { log } from './utils/logger.js'; + +/** + * import 命令的扩展选项,合并全局选项与子命令专属选项。 + */ +interface ImportOptions extends GlobalOptions { + /** 本地目录路径,用于扫描可导入文件 */ + dir?: string; + /** 是否扫描 Claude/Cursor rule 目录 */ + fromClaude?: boolean; + /** 是否从当前 git 工作区生成 codebase.md */ + workspace?: boolean; + /** 从已合并 MR/PR URL 提取知识 */ + fromMr?: string; + /** iWiki Space ID 或页面 URL,用于批量导入 iWiki 文档 */ + fromIwiki?: string; + /** 批量模式下最多扫描的 MR 数量(字符串,需 parseInt) */ + limit?: string; + /** 是否恢复中断的导入会话 */ + resume?: boolean; + /** 是否导入全部候选(跳过交互确认) */ + all?: boolean; + /** 将草稿写入指定目录而非推送至团队仓库 */ + output?: string; + /** 显式指定现有 codebase.md 路径(优先于从团队仓库自动读取) */ + existingCodebase?: string; + /** 拉取远端仓库并生成单仓 codebase 摘要 */ + fromRepo?: string; + /** --from-repo 的 shallow clone 深度(字符串,需 parseInt),默认 1 */ + depth?: string; + /** 强制 SSH clone(即使 HTTPS token 可用) */ + ssh?: boolean; + /** 跳过 AI 推荐,直接将仓库归入指定域 */ + domain?: string; + /** 批量从 yaml 白名单导入多个仓库 */ + fromRepoList?: string; + /** --from-repo-list 的并发数(字符串,需 parseInt),默认 3 */ + concurrency?: string; + /** 跳过 domain-*.md / index.md 重生(仅做单仓) */ + skipAggregate?: boolean; + /** 增量模式:缓存命中时仅 fetch+reset,未命中时 fallback 到全量 clone */ + incremental?: boolean; + /** --from-org:org URL 或 group 路径 */ + fromOrg?: string; + /** --bootstrap:在 --from-org 后进入交互 review */ + bootstrap?: boolean; + /** --max-repos:--from-org 拉取仓库上限(字符串,需 parseInt) */ + maxRepos?: string; + /** --exclude-archived:排除 archived 仓库 */ + excludeArchived?: boolean; + /** --include-pattern:仅纳入匹配此正则的仓库 */ + includePattern?: string; + /** --exclude-pattern:排除匹配此正则的仓库 */ + excludePattern?: string; + /** --skip-import:只写草稿,跳过批量导入 */ + skipImport?: boolean; + /** --iwiki-dual:iWiki 双路模式,同时产出 codebase sections */ + iwikiDual?: boolean; + /** --require-review:codebase sections 落到 pending-review.jsonl */ + requireReview?: boolean; +} + +/** + * import 命令主入口,根据选项组合 local、workspace、MR 三条导入流程。 + * + * @param opts - 合并了全局选项与子命令选项的参数对象 + */ +export async function importCmd(opts: ImportOptions): Promise { + try { + if (opts.fromOrg) { + // 分支:--from-org ,组织级一键初始化 + await importFromOrg({ + org: opts.fromOrg, + bootstrap: opts.bootstrap ?? false, + maxRepos: opts.maxRepos ? parseInt(opts.maxRepos, 10) : 200, + excludeArchived: opts.excludeArchived ?? true, + includePattern: opts.includePattern, + excludePattern: opts.excludePattern, + skipImport: opts.skipImport ?? false, + dryRun: opts.dryRun, + output: opts.output, + forceSsh: opts.ssh ?? false, + }); + return; + } else if (opts.fromRepo) { + // 分支:--from-repo ,拉取远端仓库并生成单仓 codebase 摘要 + await importFromRepo({ + url: opts.fromRepo, + depth: opts.depth ? parseInt(opts.depth, 10) : 1, + forceSsh: opts.ssh ?? false, + explicitDomain: opts.domain, + dryRun: opts.dryRun, + output: opts.output, + incremental: opts.incremental ?? false, + }); + return; + } else if (opts.fromRepoList) { + // 分支:--from-repo-list ,批量导入 + const result = await importFromRepoList({ + listPath: opts.fromRepoList, + concurrency: opts.concurrency ? parseInt(opts.concurrency, 10) : 3, + forceSsh: opts.ssh ?? false, + dryRun: opts.dryRun, + output: opts.output, + skipAggregate: opts.skipAggregate ?? false, + incremental: opts.incremental ?? false, + }); + log.info(`完成:成功 ${result.succeeded},失败 ${result.failed.length},跳过 ${result.skipped.length}`); + if (result.failed.length > 0) process.exitCode = 1; + return; + } else if (opts.fromIwiki) { + // 分支 0:--from-iwiki,从 iWiki Space 或单页批量导入 + const { localConfig } = await autoDetectInit(); + await importFromIWiki({ + input: opts.fromIwiki, + all: opts.all, + outputDir: opts.output, + repoPath: opts.dryRun ? undefined : localConfig.repo.localPath, + dryRun: opts.dryRun, + }); + // 若启用双路模式,追加调用 importFromIWikiDual + if (opts.iwikiDual) { + try { + const dualResult = await importFromIWikiDual({ + input: opts.fromIwiki, + output: opts.output, + dryRun: opts.dryRun, + requireReview: opts.requireReview ?? false, + }); + log.info( + `iWiki 双路完成:更新章节 [${dualResult.sectionsUpdated.join(', ')}]` + + (dualResult.pendingReview ? '(待 review)' : ''), + ); + } catch (dualErr) { + log.warn(`iWiki 双路模式出错(不影响 learning 路径):${String(dualErr)}`); + } + } + } else if (opts.fromMr) { + // 分支 1:--from-mr ,从已合并 MR 提取学习内容 + const { localConfig } = await autoDetectInit(); + + // 尝试读取现有 codebase.md,用于生成风格一致的增量建议 + // 优先使用 --existing-codebase 显式指定的路径,其次从团队仓库读取 + let existingCodebaseMd: string | undefined; + if (opts.existingCodebase) { + try { + existingCodebaseMd = await fs.readFile(opts.existingCodebase, 'utf-8'); + log.debug(`已加载指定 codebase.md(${existingCodebaseMd.length} 字符):${opts.existingCodebase}`); + } catch { + log.warn(`无法读取 --existing-codebase 指定的文件:${opts.existingCodebase}`); + } + } else { + const codebasePath = path.join(localConfig.repo.localPath, 'docs', 'codebase.md'); + try { + existingCodebaseMd = await fs.readFile(codebasePath, 'utf-8'); + log.debug(`已加载现有 codebase.md(${existingCodebaseMd.length} 字符)`); + } catch { + log.debug('未找到现有 codebase.md,将使用默认格式示例'); + } + } + + await importFromMR({ + url: opts.fromMr, + learningsDir: path.join(localConfig.repo.localPath, 'learnings'), + all: opts.all, + outputDir: opts.output, + repoPath: opts.dryRun ? undefined : localConfig.repo.localPath, + existingCodebaseMd, + dryRun: opts.dryRun, + }); + } else if (opts.workspace) { + // 分支 2:--workspace,从当前 git 工作区生成 codebase.md + const repoPath = process.cwd(); + + // 尝试使用默认 learnings 目录(不增加 CLI flag) + const defaultLearningsDir = path.join(repoPath, 'learnings'); + const learningsDir = fsSync.existsSync(defaultLearningsDir) ? defaultLearningsDir : undefined; + + const codebaseMd = await generateCodebaseMd({ repoPath, learningsDir }); + + // 决定 codebase.md 的写出路径 + let codebaseOutputPath: string | undefined; + if (opts.output) { + await fs.writeFile(opts.output, codebaseMd, 'utf-8'); + log.info(`已写入:${opts.output}`); + codebaseOutputPath = opts.output; + } else { + log.info(codebaseMd); + // stdout 模式:把索引写到 cwd/codebase-index.md + codebaseOutputPath = path.join(repoPath, 'codebase.md'); + } + + // 生成并写出索引 + try { + const indexMd = await generateCodebaseIndex(codebaseMd); + const indexDir = opts.output ? path.dirname(codebaseOutputPath) : repoPath; + const indexPath = path.join(indexDir, 'codebase-index.md'); + await fs.writeFile(indexPath, indexMd, 'utf-8'); + log.info(`索引已写入:${indexPath}`); + } catch (indexErr) { + log.debug(`生成索引失败(不中断流程):${String(indexErr)}`); + } + + // 执行 lint 检查(只打印不写文件,不因失败中断) + try { + const lintReport = await lintCodebaseMd(codebaseMd); + const highIssues = lintReport.issues.filter((i) => i.severity === 'high'); + log.info(`[lint] ${lintReport.summary}(共 ${lintReport.issues.length} 个问题)`); + if (highIssues.length > 0) { + const displayCount = Math.min(highIssues.length, 5); + log.info(`[lint] 高严重度问题(${highIssues.length} 条):`); + for (let idx = 0; idx < displayCount; idx++) { + const issue = highIssues[idx]!; + log.info(` ⚠️ [${issue.category}] ${issue.location}: ${issue.description}`); + } + if (highIssues.length > 5) { + log.info(` … 还有 ${highIssues.length - 5} 条 high 级 lint 问题,请查阅完整报告`); + } + } + } catch (lintErr) { + log.debug(`lint 检查失败(不中断流程):${String(lintErr)}`); + } + } else if (opts.dir || opts.fromClaude) { + // 分支 3:--dir 或 --from-claude,扫描本地文件并交互式导入 + const candidates = await scanCandidates({ dir: opts.dir, fromClaude: opts.fromClaude }); + if (candidates.length === 0) { + log.info('未发现可导入的文件'); + return; + } + const classified = await classifyWithAI(candidates); + const session = await interactiveReview(classified, { all: opts.all, resume: opts.resume }); + const { localConfig } = await autoDetectInit(); + await pushAccepted(session, localConfig.repo.localPath, { + dryRun: opts.dryRun, + outputDir: opts.output, + }); + log.success('导入完成'); + } else { + // 默认:未指定来源,提示用户 + log.info('请指定导入来源:--dir 、--from-claude、--workspace、--from-mr 或 --from-iwiki '); + process.exit(0); + } + } catch (err: unknown) { + log.error((err as Error).message); + process.exit(1); + } +} diff --git a/src/index.ts b/src/index.ts index fff2e0d..82d19b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { createRequire } from 'node:module'; import { Command } from 'commander'; -import { setVerbose, setSilent } from './utils/logger.js'; +import { setVerbose, setSilent, log } from './utils/logger.js'; import type { GlobalOptions } from './types.js'; const require = createRequire(import.meta.url); @@ -365,20 +365,22 @@ program const envCmd = program .command('env') .description('Manage team environment variables') - .action(async () => { + .option('--reveal', 'Show env variable values in plaintext (default: masked)') + .action(async (cmdOpts) => { // Default action: list env vars (backward compatible) const globalOpts = program.opts() as GlobalOptions; const { envList } = await import('./env-commands.js'); - await envList(globalOpts); + await envList({ ...globalOpts, ...cmdOpts }); }); envCmd .command('list') .description('List team environment variables') - .action(async () => { + .option('--reveal', 'Show env variable values in plaintext (default: masked)') + .action(async (cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; const { envList } = await import('./env-commands.js'); - await envList(globalOpts); + await envList({ ...globalOpts, ...cmdOpts }); }); envCmd @@ -554,4 +556,131 @@ program } }); +program + .command('todowrite-hint') + .description('Remind the agent to invoke teamai-recall when TodoWrite is used (PostToolUse hook)') + .option('--stdin', 'Read hook data from STDIN') + .option('--tool ', 'Source AI tool (claude / codebuddy / cursor)') + .action(async (cmdOpts) => { + if (cmdOpts.stdin) { + const { todoWriteHint } = await import('./todowrite-hint.js'); + await todoWriteHint(); + } + }); + +program + .command('import') + .description('Import knowledge from local files, Claude/Cursor rules, git workspace, MRs, or iWiki') + .option('--dir ', 'Scan local directory for importable Markdown files') + .option('--from-claude', 'Scan Claude/Cursor rule directories (~/.claude/rules, ~/.cursor/rules)') + .option('--workspace', 'Generate codebase.md from current git workspace') + .option('--from-mr ', 'Extract learning and codebase suggestions from a merged MR/PR URL') + .option('--from-iwiki ', 'Import documents from iWiki Space ID or page URL (requires TAI_PAT_TOKEN)') + .option('--limit ', 'Max number of recent merged MRs to scan (used with --from-mr batch mode)', '10') + .option('--resume', 'Resume an interrupted import session') + .option('--all', 'Accept all suggestions without interactive confirmation') + .option('--output ', 'Write drafts to this directory instead of pushing to team repo') + .option('--existing-codebase ', 'Path to existing codebase.md (used with --from-mr; overrides auto-detection from team repo)') + .option('--from-repo ', 'Clone a remote repo and generate per-repo codebase summary') + .option('--depth ', 'Shallow clone depth for --from-repo (default 1)', '1') + .option('--ssh', 'Force SSH clone even if HTTPS token is available') + .option('--domain ', 'Skip AI recommendation and assign repo to this domain explicitly') + .option('--from-repo-list ', 'Batch import repos from a YAML whitelist') + .option('--concurrency ', 'Concurrent repos for --from-repo-list (default 3)', '3') + .option('--skip-aggregate', 'Skip domain-*.md / index.md regeneration') + .option('--incremental', 'Use cached clone with fetch+reset (with --from-repo or --from-repo-list)') + .option('--from-org ', 'List repos under an org and bootstrap whitelist + domains') + .option('--bootstrap', 'Run interactive review after --from-org') + .option('--max-repos ', 'Cap on repos pulled from --from-org (default 200)', '200') + .option('--exclude-archived', 'Exclude archived repos from --from-org (default true)') + .option('--include-pattern ', 'Regex to include repos by full name (used with --from-org)') + .option('--exclude-pattern ', 'Regex to exclude repos by full name (used with --from-org)') + .option('--skip-import', 'Only write drafts; skip the actual --from-repo-list run') + .option('--iwiki-dual', 'Enable dual-output mode for --from-iwiki (write codebase sections in addition to learning)') + .option('--require-review', 'Defer codebase section writes to .teamai/pending-review.jsonl for human review') + .action(async (cmdOpts) => { + const globalOpts = program.opts() as GlobalOptions; + const { importCmd } = await import('./import.js'); + await importCmd({ ...globalOpts, ...cmdOpts }); + }); + +program + .command('mr-hint') + .description('Hint AI about recently merged but un-imported MRs (SessionStart hook)') + .option('--stdin', 'Read hook data from STDIN') + .option('--tool ', 'Source AI tool (claude / codebuddy / cursor)') + .action(async (cmdOpts) => { + if (cmdOpts.stdin) { + const { mrHint } = await import('./mr-hint.js'); + await mrHint(); + } + }); + +program + .command('codebase') + .description('Inspect and maintain team-codebase outputs') + .option('--lint', 'Run global consistency lint over docs/team-codebase') + .option('--fix', 'Apply low-risk mechanical fixes (only with --lint)') + .option('--severity ', 'Minimum severity to report: high|medium|low|info', 'info') + .option('--stale-days ', 'Threshold for sync-stale check', '60') + .option('--pending-review-threshold ', 'Threshold for pending-review backlog', '10') + .option('--json', 'Output report as JSON (suitable for CI)') + .option('--output ', 'Custom team-codebase root (mirrors --from-repo)') + .action(async (cmdOpts) => { + const globalOpts = program.opts() as GlobalOptions; + const { codebaseCmd } = await import('./codebase-cmd.js'); + await codebaseCmd({ ...globalOpts, ...cmdOpts }); + }); + +program + .command('cache') + .description('Inspect and clean ~/.teamai/cache/repos') + .option('--status', 'Print cache status (default action)') + .option('--gc', 'Run garbage collection') + .option('--max-bytes ', 'Override capacity cap for --gc') + .option('--stale-days ', 'Threshold for stale-eviction (default 30)', '30') + .option('--dry-run', 'Report actions without removing files') + .option('--json', 'Machine-readable output') + .action(async (cmdOpts) => { + const globalOpts = program.opts() as GlobalOptions; + const { cacheCmd } = await import('./cache-cmd.js'); + await cacheCmd({ ...globalOpts, ...cmdOpts }); + }); + +program + .command('review [id]') + .description('Inspect and process .teamai/pending-review.jsonl items') + .option('--apply', 'Apply the change for the given id (only for codebase-section)') + .option('--reject', 'Reject the given id without applying') + .option('--reason ', 'Reason for reject') + .option('--all-apply', 'Apply all items at or below --max-risk') + .option('--max-risk ', 'Risk ceiling for --all-apply: high|medium|low (default medium)', 'medium') + .option('--json', 'Machine-readable output') + .action(async (idArg, cmdOpts) => { + const globalOpts = program.opts() as GlobalOptions; + const { reviewCmd } = await import('./review-cmd.js'); + await reviewCmd({ ...globalOpts, ...cmdOpts, idArg }); + }); + +program + .command('domains [repoUrl]') + .description('Inspect / accept / reject domain-drift signals (subcommand: drift)') + .option('--apply', 'Apply drift for the given repoUrl') + .option('--apply-all', 'Apply all drift items above confidence threshold') + .option('--threshold ', 'Confidence threshold for --apply-all (default 0.8)', '0.8') + .option('--lock', 'Lock the repo against future drift signals') + .option('--output ', 'Custom team-codebase root (mirrors --from-repo)') + .option('--skip-aggregate', 'Skip regenerateAggregate after apply') + .option('--json', 'Machine-readable output') + .action(async (subcommand, repoUrlArg, cmdOpts) => { + if (subcommand !== 'drift') { + log.error(`Unknown subcommand: ${subcommand}(仅支持 drift)`); + process.exitCode = 2; + return; + } + const globalOpts = program.opts() as GlobalOptions; + const { driftCmd } = await import('./drift-cmd.js'); + await driftCmd({ ...globalOpts, ...cmdOpts, repoUrlArg }); + }); + program.parse(); diff --git a/src/iwiki-dual.ts b/src/iwiki-dual.ts new file mode 100644 index 0000000..b9b880f --- /dev/null +++ b/src/iwiki-dual.ts @@ -0,0 +1,361 @@ +// -*- coding: utf-8 -*- +/** + * iWiki 双路模式:在产出 learning 之外,同时产出 codebase suggestions。 + * + * 将内容写入 docs/team-codebase/external-knowledge.md 的章节锚点。 + * 不替换既有 importFromIWiki,是独立的补充入口。 + */ + +import path from 'node:path'; +import fs from 'fs-extra'; + +import { IWikiClient } from './utils/iwiki-client.js'; +import type { IWikiDocument, IWikiPage } from './utils/iwiki-client.js'; +import { appendPendingReview } from './review-store.js'; +import { getTeamCodebasePaths } from './utils/team-codebase-paths.js'; +import { callClaude } from './utils/ai-client.js'; +import { log } from './utils/logger.js'; + +// ─── 常量 ──────────────────────────────────────────────── + +/** 每页截取的最大字符数。 */ +const MAX_CONTENT_PER_PAGE = 5000; + +/** 并发下载页面数。 */ +const DOWNLOAD_BATCH_SIZE = 5; + +/** 默认拉取最大页数。 */ +const DEFAULT_MAX_PAGES = 200; + +/** 各章节的中文标题。 */ +const SECTION_TITLES: Record = { + 'business-api': '业务接口', + 'external-knowledge': '外部知识源', + 'glossary': '术语表', +}; + +// ─── 类型 ──────────────────────────────────────────────── + +/** 支持的章节类型。 */ +export type SectionKey = 'business-api' | 'external-knowledge' | 'glossary'; + +/** importFromIWikiDual 的选项。 */ +export interface IWikiDualOptions { + /** Space ID / 页面 URL */ + input: string; + /** PAT;或 TAI_PAT_TOKEN */ + token?: string; + /** 要更新的章节列表,默认全部三章节 */ + sections?: SectionKey[]; + /** 自定义产物根(同 P5.x output) */ + output?: string; + dryRun?: boolean; + maxPages?: number; + /** 默认 false;true 时不直接写盘,进 .teamai/pending-review.jsonl */ + requireReview?: boolean; +} + +/** AI 抽取的三章节内容。 */ +interface AiSectionOutput { + 'business-api': string; + 'external-knowledge': string; + 'glossary': string; +} + +// ─── 辅助函数 ──────────────────────────────────────────── + +/** + * 解析用户输入,识别 Space ID 或页面 ID(与 import-iwiki 保持一致)。 + */ +function parseIWikiInput(input: string): { type: 'space' | 'page'; id: string } { + const trimmed = input.trim(); + if (/^\d+$/.test(trimmed)) { + return { type: 'space', id: trimmed }; + } + const pageMatch = trimmed.match(/\/(?:p|pages)\/([^/?#]+)/); + if (pageMatch) { + return { type: 'page', id: pageMatch[1] }; + } + throw new Error( + `无法识别 iWiki 输入格式:"${trimmed}"。请输入纯数字 Space ID 或含 /p/ 的页面 URL。`, + ); +} + +/** + * 批量并发下载文档内容(每批 DOWNLOAD_BATCH_SIZE 个)。 + */ +async function downloadDocuments( + client: IWikiClient, + pages: IWikiPage[], +): Promise { + const documents: IWikiDocument[] = []; + for (let i = 0; i < pages.length; i += DOWNLOAD_BATCH_SIZE) { + const batch = pages.slice(i, i + DOWNLOAD_BATCH_SIZE); + const results = await Promise.allSettled( + batch.map((page) => client.getDocument(page.docid)), + ); + for (const result of results) { + if (result.status === 'fulfilled') { + documents.push(result.value); + } else { + log.warn(`下载文档失败,已跳过: ${String(result.reason)}`); + } + } + } + return documents; +} + +/** + * 构建 AI 抽取 prompt。 + */ +function buildExtractionPrompt(docs: IWikiDocument[]): string { + const content = docs + .map((d) => `=== ${d.docid} ===\n${d.content.slice(0, MAX_CONTENT_PER_PAGE)}`) + .join('\n\n'); + + return `你是团队知识整理专家,请从以下 iWiki 文档中抽取三类知识,以 JSON 格式输出。 + +## 文档内容 + +${content} + +## 输出要求 + +请严格输出以下 JSON 格式,三个字段都是可直接嵌入 Markdown 的内容: + +{ + "business-api": "<关于内部业务 API/接口规范的 Markdown 摘要,无相关内容则为空字符串>", + "external-knowledge": "<关于外部系统/第三方知识源的 Markdown 摘要,无相关内容则为空字符串>", + "glossary": "<项目术语表 Markdown(| 术语 | 说明 | 格式),无相关内容则为空字符串>" +} + +不要输出 JSON 以外的任何内容。`; +} + +/** + * 从 AI 输出文本中提取 JSON。 + */ +function extractJson(text: string): string { + const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenceMatch) { + return fenceMatch[1].trim(); + } + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start !== -1 && end !== -1 && end > start) { + return text.slice(start, end + 1); + } + return text.trim(); +} + +/** + * 在文件内容中替换某章节的 managed 锚点区间。 + * + * 锚点格式: + * ## <章节标题> + * + * + * + * + * @param content 文件当前内容 + * @param sectionKey 章节标识符 + * @param newBody 新内容(Markdown) + * @param source 数据来源标识(iwiki://) + * @param ts 同步时间戳(ISO) + */ +function replaceManagedSection( + content: string, + sectionKey: string, + newBody: string, + source: string, + ts: string, +): string { + const openTag = + ``; + const closeTag = ``; + + const openRegex = new RegExp( + ``, + 'g', + ); + const closeRegex = new RegExp(``, 'g'); + + const openIdx = content.search(openRegex); + const closeIdx = content.search(closeRegex); + + if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) { + // 替换 open tag 到 close tag 之间(含两个 tag) + const before = content.slice(0, openIdx); + const after = content.slice(closeIdx + ``.length); + return `${before}${openTag}\n${newBody}\n${closeTag}${after}`; + } + + // 找到对应章节标题并追加 + const title = SECTION_TITLES[sectionKey] ?? sectionKey; + const headingRegex = new RegExp(`(##\\s+${title}\\s*\\n)`, 'm'); + const headingMatch = content.match(headingRegex); + if (headingMatch?.index !== undefined) { + const insertPos = headingMatch.index + headingMatch[0].length; + const before = content.slice(0, insertPos); + const after = content.slice(insertPos); + const block = `${openTag}\n${newBody}\n${closeTag}\n`; + return `${before}${block}${after}`; + } + + // 找不到标题则在末尾追加整个章节 + const block = + `\n## ${title}\n${openTag}\n${newBody}\n${closeTag}\n`; + return content + block; +} + +/** + * 生成初始骨架文件(三个空章节)。 + */ +function buildSkeletonContent(): string { + return `# 外部知识源 + +本文档由 \`teamai import --from-iwiki --iwiki-dual\` 自动维护。 + +## 业务接口 + + + + + +## 外部知识源 + + + + + +## 术语表 + + + + +`; +} + +// ─── 主入口 ────────────────────────────────────────────── + +/** + * 从 iWiki 拉取文档,AI 抽取业务接口/外部知识源/术语表三类内容, + * 写入 docs/team-codebase/external-knowledge.md 的对应章节锚点。 + * + * @param opts 双路导入选项 + * @returns 更新的章节列表 + 是否进入 pending-review + */ +export async function importFromIWikiDual(opts: IWikiDualOptions): Promise<{ + sectionsUpdated: string[]; + pendingReview: boolean; +}> { + const cwd = process.cwd(); + const sections: SectionKey[] = opts.sections ?? ['business-api', 'external-knowledge', 'glossary']; + + // 1. 读取 token + const token = opts.token ?? process.env['TAI_PAT_TOKEN']; + if (!token) { + throw new Error( + '请设置 TAI_PAT_TOKEN 环境变量(获取地址:https://tai.it.woa.com/user/pat)', + ); + } + + // 2. 解析输入 + const { type, id } = parseIWikiInput(opts.input); + const source = `iwiki://${id}`; + + // 3. 创建客户端 + const client = new IWikiClient(token); + + // 4. 获取页面列表 + let pages: IWikiPage[]; + if (type === 'page') { + pages = [{ docid: id, title: id }]; + } else { + pages = await client.fetchAllPages(id, { maxPages: opts.maxPages ?? DEFAULT_MAX_PAGES }); + } + + if (pages.length === 0) { + log.warn('iWiki 双路:未找到任何页面'); + return { sectionsUpdated: [], pendingReview: false }; + } + + // 5. 下载文档内容 + const documents = await downloadDocuments(client, pages); + + if (documents.length === 0) { + log.warn('iWiki 双路:所有文档下载失败'); + return { sectionsUpdated: [], pendingReview: false }; + } + + // 6. AI 抽取 + const prompt = buildExtractionPrompt(documents); + const rawOutput = await callClaude(prompt); + const jsonStr = extractJson(rawOutput); + + let aiOutput: Partial = {}; + try { + aiOutput = JSON.parse(jsonStr) as Partial; + } catch (err) { + log.warn(`iWiki 双路:AI 输出非 JSON,跳过全部章节。错误:${String(err)}`); + return { sectionsUpdated: [], pendingReview: false }; + } + + // 7. 确定 external-knowledge.md 路径 + const paths = getTeamCodebasePaths(cwd, opts.output); + const filePath = path.join(paths.root, 'external-knowledge.md'); + + // 8. 若启用 requireReview,写到 pending-review.jsonl + if (opts.requireReview) { + if (!opts.dryRun) { + const relativeFilePath = path.relative(cwd, filePath); + for (const sectionKey of sections) { + const body = aiOutput[sectionKey] ?? ''; + if (!body) continue; + await appendPendingReview(cwd, { + kind: 'codebase-section', + target: { file: relativeFilePath, section: sectionKey }, + payload: { content: body }, + source, + }); + } + } + return { sectionsUpdated: sections, pendingReview: true }; + } + + // 9. 写入 external-knowledge.md + const updatedSections: string[] = []; + const ts = new Date().toISOString(); + + if (!opts.dryRun) { + await fs.ensureDir(paths.root); + + // 首次创建时写骨架 + const exists = await fs.pathExists(filePath); + let content = exists ? await fs.readFile(filePath, 'utf8') : buildSkeletonContent(); + + for (const sectionKey of sections) { + const body = aiOutput[sectionKey] ?? ''; + if (!body) { + log.warn(`iWiki 双路:章节 "${sectionKey}" 内容为空,跳过`); + continue; + } + content = replaceManagedSection(content, sectionKey, body, source, ts); + updatedSections.push(sectionKey); + } + + if (updatedSections.length > 0) { + await fs.writeFile(filePath, content, 'utf8'); + } + } else { + for (const sectionKey of sections) { + if (aiOutput[sectionKey]) { + updatedSections.push(sectionKey); + } + } + log.info(`[dry-run] 将更新章节:${updatedSections.join(', ')}`); + } + + return { sectionsUpdated: updatedSections, pendingReview: false }; +} diff --git a/src/mr-hint.ts b/src/mr-hint.ts new file mode 100644 index 0000000..6ad019a --- /dev/null +++ b/src/mr-hint.ts @@ -0,0 +1,487 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { gfGetOAuthToken } from './providers/tgit/gf-cli.js'; +import { log } from './utils/logger.js'; + +// ─── MR Hint data flow ────────────────────────────────── +// +// SessionStart hook +// │ +// ▼ +// teamai mr-hint --stdin +// │ +// ├─ Read STDIN { session_id } +// ├─ getGitRemote(CWD) → remote URL +// ├─ parseRemoteToRepo(url) → { provider, owner, repo } +// ├─ listMergedMRs(provider, owner, repo, since) +// │ ├─ TGit: REST API /api/v3/projects/:id/merge_requests +// │ └─ GitHub: gh pr list --state merged --json +// ├─ filter by hint cache (avoid re-hinting same MR) +// └─ Has new MRs? → STDOUT JSON { hookSpecificOutput.additionalContext } +// + +/** Days to look back for merged MRs. */ +const LOOKBACK_DAYS = 7; + +/** Max MRs to list per session. */ +const MAX_MRS = 10; + +/** Cache TTL: 30 days. After this, cache is cleared. */ +const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +// ─── Types ─────────────────────────────────────────────── + +/** Minimal MR summary used for hint. */ +export interface MRSummary { + /** Provider-specific MR/PR identifier (iid for TGit, number for GitHub). */ + id: string; + /** MR title. */ + title: string; + /** MR web URL. */ + url: string; + /** ISO 8601 merged timestamp. */ + mergedAt: string; +} + +/** Persisted cache for a repo. */ +interface HintCache { + /** MR IDs that have already been hinted. */ + hintedMrIds: string[]; + /** ISO 8601 timestamp of last update. */ + updatedAt: string; +} + +// ─── Cache helpers ─────────────────────────────────────── + +/** + * Derive a filesystem-safe slug from a repo path. + * + * @param owner Repository owner / group (may contain '/') + * @param repo Repository name + * @returns Slug safe for use in filenames + */ +function repoSlug(owner: string, repo: string): string { + return `${owner}/${repo}`.replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +/** + * Build cache file path: ~/.teamai/sessions/mr-hint-.json + */ +function getCachePath(owner: string, repo: string): string { + return path.join( + process.env.HOME ?? '', + '.teamai', + 'sessions', + `mr-hint-${repoSlug(owner, repo)}.json`, + ); +} + +/** + * Load hint cache from disk. Returns empty cache when missing or expired. + */ +function loadCache(owner: string, repo: string): HintCache { + try { + const raw = fs.readFileSync(getCachePath(owner, repo), 'utf-8'); + const parsed = JSON.parse(raw) as HintCache; + const age = Date.now() - new Date(parsed.updatedAt).getTime(); + if (age > CACHE_TTL_MS) { + return { hintedMrIds: [], updatedAt: new Date().toISOString() }; + } + return parsed; + } catch { + // cache missing or malformed — start fresh + return { hintedMrIds: [], updatedAt: new Date().toISOString() }; + } +} + +/** + * Save hint cache to disk (best-effort, never throws). + */ +function saveCache(owner: string, repo: string, cache: HintCache): void { + try { + const cachePath = getCachePath(owner, repo); + const dir = path.dirname(cachePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf-8'); + } catch { + // best-effort + } +} + +// ─── Git remote detection ──────────────────────────────── + +/** + * Get the `origin` remote URL for the given working directory. + * + * Returns null if not in a git repo or remote not configured. + * + * @param cwd Working directory to inspect + * @returns Remote URL string, or null + */ +export function getGitRemote(cwd: string): string | null { + try { + const result = spawnSync('git', ['remote', 'get-url', 'origin'], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (result.status !== 0) return null; + return result.stdout.trim() || null; + } catch { + return null; + } +} + +// ─── Remote URL parsing ────────────────────────────────── + +/** Parsed result from a git remote URL. */ +export interface RemoteRepo { + /** Git provider. */ + provider: 'tgit' | 'github'; + /** Owner or group path (may contain '/'). */ + owner: string; + /** Repository name (last path segment). */ + repo: string; +} + +/** + * Parse a git remote URL into provider + owner/repo info. + * + * Supports HTTPS and SSH formats for git.woa.com (TGit) and github.com. + * + * @param remoteUrl Full git remote URL + * @returns Parsed RemoteRepo, or null if unrecognized + */ +export function parseRemoteToRepo(remoteUrl: string): RemoteRepo | null { + const url = remoteUrl.trim(); + + // TGit HTTPS: https://git.woa.com/group/sub/repo.git + const tgitHttps = url.match(/^https?:\/\/[^@]*git\.woa\.com\/(.+)\/([^/]+?)(?:\.git)?\/?$/); + if (tgitHttps) { + return { provider: 'tgit', owner: tgitHttps[1], repo: tgitHttps[2] }; + } + + // TGit SSH: git@git.woa.com:group/sub/repo.git + const tgitSsh = url.match(/^git@git\.woa\.com:(.+)\/([^/]+?)(?:\.git)?\/?$/); + if (tgitSsh) { + return { provider: 'tgit', owner: tgitSsh[1], repo: tgitSsh[2] }; + } + + // GitHub HTTPS: https://github.com/owner/repo.git + const ghHttps = url.match(/^https?:\/\/[^@]*github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + if (ghHttps) { + return { provider: 'github', owner: ghHttps[1], repo: ghHttps[2] }; + } + + // GitHub SSH: git@github.com:owner/repo.git + const ghSsh = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + if (ghSsh) { + return { provider: 'github', owner: ghSsh[1], repo: ghSsh[2] }; + } + + return null; +} + +// ─── MR listing ───────────────────────────────────────── + +/** TGit API MR object (subset of fields). */ +interface TGitMR { + iid: number; + title: string; + web_url: string; + merged_at: string | null; +} + +/** + * List recently merged MRs from TGit REST API. + * + * Calls: GET /api/v3/projects//merge_requests?state=merged&... + * Uses the OAuth token from gf credential store. + * + * @param owner Owner or group path + * @param repo Repository name + * @param since Include only MRs merged after this date + * @returns Array of MRSummary, empty on any error + */ +async function listTGitMergedMRs( + owner: string, + repo: string, + since: Date, +): Promise { + const token = gfGetOAuthToken(); + if (!token) { + log.debug('mr-hint: no TGit token, skipping TGit MR check'); + return []; + } + + const projectId = encodeURIComponent(`${owner}/${repo}`); + const apiUrl = + `https://git.woa.com/api/v3/projects/${projectId}/merge_requests` + + `?state=merged&order_by=updated_at&sort=desc&per_page=${MAX_MRS}`; + + try { + const resp = await fetch(apiUrl, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(8000), + }); + if (!resp.ok) { + log.debug(`mr-hint: TGit API returned ${resp.status}`); + return []; + } + const items = (await resp.json()) as TGitMR[]; + const sinceMs = since.getTime(); + return items + .filter((mr) => mr.merged_at && new Date(mr.merged_at).getTime() >= sinceMs) + .map((mr) => ({ + id: String(mr.iid), + title: mr.title, + url: mr.web_url, + mergedAt: mr.merged_at!, + })); + } catch (err) { + log.debug(`mr-hint: TGit API error: ${(err as Error).message}`); + return []; + } +} + +/** GitHub PR object from gh CLI JSON output. */ +interface GhPR { + number: number; + title: string; + url: string; + mergedAt: string; +} + +/** GitHub REST API pull request object (subset of fields used). */ +interface GitHubRestPR { + number: number; + title: string; + html_url: string; + merged_at: string | null; + pull_request?: { merged_at: string | null }; +} + +/** + * Fetch merged PRs from the GitHub REST API. + * + * Used as fallback when `gh` CLI is unavailable. Supports public repos + * without a token; uses GITHUB_TOKEN env var when present to raise rate limits. + * + * @param owner Repository owner + * @param repo Repository name + * @param since Include only PRs merged after this date + * @returns Array of MRSummary, empty on any error + */ +async function listGitHubMergedMRsViaREST( + owner: string, + repo: string, + since: Date, +): Promise { + const token = process.env['GITHUB_TOKEN']; + const headers: Record = { 'User-Agent': 'teamai-cli', Accept: 'application/vnd.github+json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const url = `https://api.github.com/repos/${owner}/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=${MAX_MRS}`; + try { + const resp = await fetch(url, { headers, signal: AbortSignal.timeout(8000) }); + if (!resp.ok) { + log.debug(`mr-hint: GitHub REST API returned ${resp.status}`); + return []; + } + const items = (await resp.json()) as GitHubRestPR[]; + const sinceMs = since.getTime(); + return items + .filter((pr) => pr.merged_at && new Date(pr.merged_at).getTime() >= sinceMs) + .map((pr) => ({ + id: String(pr.number), + title: pr.title, + url: pr.html_url, + mergedAt: pr.merged_at!, + })); + } catch (err) { + log.debug(`mr-hint: GitHub REST API error: ${(err as Error).message}`); + return []; + } +} + +/** + * List recently merged PRs from GitHub. + * + * Primary path: `gh pr list` CLI. + * Fallback: GitHub REST API (supports public repos without token). + * + * @param owner Repository owner + * @param repo Repository name + * @param since Include only PRs merged after this date + * @returns Array of MRSummary, empty when all paths fail + */ +async function listGitHubMergedMRs( + owner: string, + repo: string, + since: Date, +): Promise { + // ── Primary: gh CLI ────────────────────────────────────── + try { + const result = spawnSync( + 'gh', + [ + 'pr', 'list', + '--repo', `${owner}/${repo}`, + '--state', 'merged', + '--limit', String(MAX_MRS), + '--json', 'number,title,url,mergedAt', + ], + { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 8000 }, + ); + if (result.status === 0 && result.stdout.trim()) { + const items = JSON.parse(result.stdout) as GhPR[]; + const sinceMs = since.getTime(); + return items + .filter((pr) => pr.mergedAt && new Date(pr.mergedAt).getTime() >= sinceMs) + .map((pr) => ({ + id: String(pr.number), + title: pr.title, + url: pr.url, + mergedAt: pr.mergedAt, + })); + } + log.debug(`mr-hint: gh pr list unavailable (status=${result.status}), falling back to REST API`); + } catch (err) { + log.debug(`mr-hint: gh CLI error: ${(err as Error).message}, falling back to REST API`); + } + + // ── Fallback: GitHub REST API ──────────────────────────── + return listGitHubMergedMRsViaREST(owner, repo, since); +} + +// ─── Hint message builder ──────────────────────────────── + +/** + * Build the additionalContext hint message for AI. + * + * @param mrs New merged MRs that have not been imported yet + * @returns Bilingual hint string + */ +export function buildHintMessage(mrs: MRSummary[]): string { + const lines: string[] = [ + `[teamai:mr-hint] 发现 ${mrs.length} 个近期已合入但尚未提炼的 MR。`, + '', + '请在任务完成后告知用户,可运行以下命令将 MR 提炼为 learning 并更新 codebase.md:', + '', + ]; + + for (const mr of mrs) { + const date = mr.mergedAt.slice(0, 10); + lines.push(`- ${mr.title}(merged: ${date})`); + lines.push(` teamai import --from-mr ${mr.url}`); + } + + lines.push(''); + lines.push('[teamai:mr-hint] Found merged MR(s) not yet imported into team knowledge base.'); + lines.push('Please remind the user to run `teamai import --from-mr ` when convenient.'); + + return lines.join('\n'); +} + +// ─── Main entry point ──────────────────────────────────── + +/** + * Entry point for `teamai mr-hint --stdin`. + * + * Called by the SessionStart hook. Reads STDIN for session metadata, + * detects the git repo remote, queries recently merged MRs, and outputs + * an additionalContext hint when unimported MRs are found. + * + * Behavior: + * - Silently exits when TEAMAI_MR_HINT_DISABLED=1. + * - Silently exits when CWD is not a git repo or remote is unrecognized. + * - Silently exits when API/CLI calls fail (best-effort, non-blocking). + * - Per-repo cache prevents re-hinting the same MR across sessions. + */ +export async function mrHint(): Promise { + if (process.env.TEAMAI_MR_HINT_DISABLED === '1') return; + + // Read STDIN (may be absent in non-hook invocations) + let sessionId = process.env.CLAUDE_SESSION_ID ?? ''; + if (!process.stdin.isTTY) { + try { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + const raw = Buffer.concat(chunks).toString('utf-8').trim(); + if (raw) { + const data = JSON.parse(raw) as Record; + if (typeof data.session_id === 'string') sessionId = data.session_id; + } + } catch { + // non-critical, continue + } + } + + // Suppress unused-variable lint: sessionId is reserved for future dedup use + void sessionId; + + // Detect git remote + const rawCwd = process.env.TEAMAI_MR_HINT_CWD ?? process.cwd(); + const cwd = path.resolve(rawCwd); + try { + if (!fs.statSync(cwd).isDirectory()) { + // 不是目录,静默跳过(避免误报) + return; + } + } catch { + // 路径不存在,静默跳过 + return; + } + const remoteUrl = getGitRemote(cwd); + if (!remoteUrl) { + log.debug('mr-hint: no git remote, skipping'); + return; + } + + const repoInfo = parseRemoteToRepo(remoteUrl); + if (!repoInfo) { + log.debug(`mr-hint: unrecognized remote URL: ${remoteUrl}`); + return; + } + + const { provider, owner, repo } = repoInfo; + + // Load cache to filter already-hinted MRs + const cache = loadCache(owner, repo); + const alreadyHinted = new Set(cache.hintedMrIds); + + // Query merged MRs from past LOOKBACK_DAYS days + const since = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000); + let allMrs: MRSummary[] = []; + + if (provider === 'tgit') { + allMrs = await listTGitMergedMRs(owner, repo, since); + } else { + allMrs = await listGitHubMergedMRs(owner, repo, since); + } + + // Filter out already-hinted MRs + const newMrs = allMrs.filter((mr) => !alreadyHinted.has(mr.id)); + if (newMrs.length === 0) { + log.debug('mr-hint: no new merged MRs to hint'); + return; + } + + // Update cache with all MR IDs seen this round + const updatedIds = [...alreadyHinted, ...newMrs.map((mr) => mr.id)]; + saveCache(owner, repo, { hintedMrIds: updatedIds, updatedAt: new Date().toISOString() }); + + // Output additionalContext hint + const hintText = buildHintMessage(newMrs); + const hookOutput = JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: hintText, + }, + }); + process.stdout.write(hookOutput + '\n'); +} diff --git a/src/providers/github/gh-org.ts b/src/providers/github/gh-org.ts new file mode 100644 index 0000000..a4c181e --- /dev/null +++ b/src/providers/github/gh-org.ts @@ -0,0 +1,202 @@ +import { log } from '../../utils/logger.js'; +import type { OrgRepoInfo } from '../types.js'; +import { ghExec, isGhInstalled, getGitHubToken } from './gh-cli.js'; + +// ─── GitHub API types ──────────────────────────────────── + +interface GhRepoApiItem { + clone_url: string; + full_name: string; + name: string; + description: string | null; + language: string | null; + archived: boolean; + stargazers_count: number; + pushed_at: string | null; +} + +// ─── 分页辅助 ───────────────────────────────────────────── + +/** + * 通过 gh api 调用指定分页 URL 并返回解析后的数组。 + * + * @param endpoint 相对 API 路径(不含 base URL 前缀) + * @returns 解析后的 JSON 数组,失败抛出 Error + */ +function ghApiPage(endpoint: string): GhRepoApiItem[] { + const result = ghExec([ + 'api', + '-H', 'Accept: application/vnd.github+json', + endpoint, + ]); + if (result.status !== 0) { + throw new Error(`gh api failed (${result.status}): ${result.stderr || result.stdout}`); + } + return JSON.parse(result.stdout) as GhRepoApiItem[]; +} + +/** + * 通过 GITHUB_TOKEN 直接调用 GitHub REST API 分页。 + * + * @param url 完整 API URL + * @param token GitHub personal access token + */ +// 响应体最大 50 MB,防止恶意服务器返回超大响应导致 OOM +const MAX_RESPONSE_BYTES = 50 * 1024 * 1024; + +async function fetchApiPage(url: string, token: string): Promise { + // redirect: 'manual' 防止跟随重定向到内网地址(SSRF) + const resp = await fetch(url, { + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + redirect: 'manual', + }); + if (resp.status >= 300 && resp.status < 400) { + throw new Error(`Unexpected redirect from GitHub API: ${resp.status}`); + } + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + throw new Error(`GitHub API error ${resp.status}: ${body}`); + } + + // 流式读取响应体,限制最大 50 MB 防止 OOM + const reader = resp.body?.getReader(); + let received = 0; + const chunks: Uint8Array[] = []; + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + received += value.length; + if (received > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error(`GitHub API response exceeds ${MAX_RESPONSE_BYTES} bytes`); + } + chunks.push(value); + } + } + const body = Buffer.concat(chunks).toString('utf-8'); + return JSON.parse(body) as GhRepoApiItem[]; +} + +// ─── 转换函数 ───────────────────────────────────────────── + +/** + * 将 GitHub API 返回的仓库对象映射为 OrgRepoInfo。 + */ +function mapToOrgRepoInfo(item: GhRepoApiItem): OrgRepoInfo { + return { + url: item.clone_url, + fullName: item.full_name, + name: item.name, + description: item.description ?? undefined, + primaryLanguage: item.language ?? undefined, + archived: item.archived, + stars: item.stargazers_count, + pushedAt: item.pushed_at ?? undefined, + }; +} + +// ─── 主入口 ─────────────────────────────────────────────── + +/** + * 列出 GitHub org(或用户)下的所有仓库。 + * + * 优先使用 gh CLI(`gh api /orgs//repos`),若无 gh CLI 则通过 + * GITHUB_TOKEN 直接调用 REST API。org 不存在时 fallback 到 + * `/users//repos`(用于用户账号)。 + * + * 默认过滤掉 archived 仓库;分页拉取直至 maxRepos 上限。 + * + * @param org org 或用户名(裸名,不含 URL 前缀) + * @param opts.maxRepos 最多返回的仓库数,默认 200 + * @returns OrgRepoInfo 列表 + * @throws API 调用失败且无法 fallback 时抛出 Error + */ +export async function ghListOrgRepos( + org: string, + opts?: { maxRepos?: number }, +): Promise { + const maxRepos = opts?.maxRepos ?? 200; + const perPage = 100; + const results: OrgRepoInfo[] = []; + + if (isGhInstalled()) { + // 使用 gh CLI 分页拉取 + const tryEndpointPrefix = async (prefix: string): Promise => { + let page = 1; + while (results.length < maxRepos) { + // 不加 type=public,依赖调用者认证(gh CLI)可见范围;GitHub API 默认 type=all + const endpoint = `${prefix}?per_page=${perPage}&page=${page}`; + let items: GhRepoApiItem[]; + try { + items = ghApiPage(endpoint); + } catch (err) { + if (page === 1) { + // 第一页失败,说明此 endpoint 不通 + log.debug(`gh api ${prefix} failed: ${String(err)}`); + return false; + } + throw err; + } + // 第一页空视为 endpoint 不通(触发 fallback),而非"仓库为零" + if (items.length === 0 && page === 1) return false; + if (items.length === 0) break; + results.push(...items.map(mapToOrgRepoInfo)); + if (items.length < perPage) break; + page++; + } + return true; + }; + + const orgSuccess = await tryEndpointPrefix(`/orgs/${encodeURIComponent(org)}/repos`); + if (!orgSuccess) { + // fallback: user repos + await tryEndpointPrefix(`/users/${encodeURIComponent(org)}/repos`); + } + } else { + // 使用 GITHUB_TOKEN 直接调用 REST API + const token = getGitHubToken(); + if (!token) { + throw new Error( + 'GitHub authentication unavailable: gh CLI not found and GITHUB_TOKEN not set.', + ); + } + + const BASE = 'https://api.github.com'; + const tryUrl = async (urlPrefix: string): Promise => { + let page = 1; + while (results.length < maxRepos) { + // 不加 type=public,依赖 GITHUB_TOKEN 可见范围;GitHub API 默认 type=all + const url = `${urlPrefix}?per_page=${perPage}&page=${page}`; + let items: GhRepoApiItem[]; + try { + items = await fetchApiPage(url, token); + } catch (err) { + if (page === 1) { + log.debug(`fetch ${urlPrefix} failed: ${String(err)}`); + return false; + } + throw err; + } + // 第一页空视为 endpoint 不通(触发 fallback),而非"仓库为零" + if (items.length === 0 && page === 1) return false; + if (items.length === 0) break; + results.push(...items.map(mapToOrgRepoInfo)); + if (items.length < perPage) break; + page++; + } + return true; + }; + + const orgSuccess = await tryUrl(`${BASE}/orgs/${encodeURIComponent(org)}/repos`); + if (!orgSuccess) { + await tryUrl(`${BASE}/users/${encodeURIComponent(org)}/repos`); + } + } + + return results.slice(0, maxRepos); +} diff --git a/src/providers/github/index.ts b/src/providers/github/index.ts index e54b1c9..3f623f7 100644 --- a/src/providers/github/index.ts +++ b/src/providers/github/index.ts @@ -1,4 +1,4 @@ -import type { GitProvider, PrCreateOptions, RepoInfo } from '../types.js'; +import type { GitProvider, PrCreateOptions, RepoInfo, OrgRepoInfo } from '../types.js'; import { RepoNotFoundError } from '../types.js'; import { ensureGhAvailable, @@ -10,6 +10,7 @@ import { ghPrCreate, RepoNotFoundError as GhRepoNotFoundError, } from './gh-cli.js'; +import { ghListOrgRepos } from './gh-org.js'; import { parseGitHubRepoInput } from './repo-url.js'; export class GitHubProvider implements GitProvider { @@ -65,6 +66,10 @@ export class GitHubProvider implements GitProvider { getDefaultEmailDomain(): string | null { return null; } + + async listOrgRepos(org: string, opts?: { maxRepos?: number }): Promise { + return ghListOrgRepos(org, opts); + } } export { diff --git a/src/providers/github/mr-fetch.ts b/src/providers/github/mr-fetch.ts new file mode 100644 index 0000000..2facbd2 --- /dev/null +++ b/src/providers/github/mr-fetch.ts @@ -0,0 +1,199 @@ +import { execSync } from 'node:child_process'; +import https from 'node:https'; + +import { type MRData } from '../../types.js'; +import { log } from '../../utils/logger.js'; + +/** GitHub PR URL 解析结果 */ +interface ParsedGitHubPR { + owner: string; + repo: string; + number: string; +} + +/** + * 从 GitHub PR URL 解析出 owner / repo / PR number。 + * + * 支持格式:https://github.com///pull/ + * 解析失败时抛出 Error。 + */ +function parseGitHubPRUrl(url: string): ParsedGitHubPR { + const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); + if (!match) { + throw new Error(`Invalid GitHub PR URL: ${url}`); + } + return { owner: match[1], repo: match[2], number: match[3] }; +} + +/** gh pr view 返回的提交结构 */ +interface GhCommit { + oid: string; + messageHeadline: string; +} + +/** gh pr view 返回的 JSON 结构(仅使用的字段) */ +interface GhPRView { + title: string; + body: string; + author: { login: string }; + mergedAt: string | null; + commits: GhCommit[]; +} + +/** GitHub REST API PR 响应(仅使用的字段) */ +interface GitHubApiPR { + title: string; + body: string | null; + merged_at: string | null; + user: { login: string }; +} + +/** GitHub REST API Commit 响应(仅使用的字段) */ +interface GitHubApiCommit { + sha: string; + commit: { message: string }; +} + +/** + * 通过 Node.js 内置 https 模块调用 GitHub REST API。 + * + * 支持公开仓库无需 token;如有 GITHUB_TOKEN 环境变量则自动携带以提高限流上限。 + */ +async function githubApiGet(path: string): Promise { + return new Promise((resolve, reject) => { + const token = process.env['GITHUB_TOKEN']; + const headers: Record = { + 'User-Agent': 'teamai-cli', + 'Accept': 'application/vnd.github+json', + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const req = https.request( + { hostname: 'api.github.com', path, headers }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); + } catch (e) { + reject(e); + } + }); + }, + ); + req.on('error', reject); + req.setTimeout(30000, () => { req.destroy(); reject(new Error('GitHub API timeout')); }); + req.end(); + }); +} + +/** + * 通过 GitHub REST API 获取 PR 数据(gh CLI 不可用时的回退路径)。 + */ +async function fetchGitHubPRViaApi(owner: string, repo: string, number: string): Promise { + const url = `https://github.com/${owner}/${repo}/pull/${number}`; + log.debug(`fetchGitHubPR fallback: REST API ${owner}/${repo}#${number}`); + + // ── 1. PR 元信息 ────────────────────────────────────────── + const pr = await githubApiGet(`/repos/${owner}/${repo}/pulls/${number}`) as GitHubApiPR; + + // ── 2. 提交列表 ────────────────────────────────────────── + const commitsRaw = await githubApiGet( + `/repos/${owner}/${repo}/pulls/${number}/commits?per_page=50`, + ) as GitHubApiCommit[]; + const commits = commitsRaw.map((c) => ({ + hash: c.sha, + message: c.commit.message.split('\n')[0], + })); + + // ── 3. diff(Accept: application/vnd.github.v3.diff) ──── + const diff = await new Promise((resolve, reject) => { + const token = process.env['GITHUB_TOKEN']; + const headers: Record = { + 'User-Agent': 'teamai-cli', + 'Accept': 'application/vnd.github.v3.diff', + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const req = https.request( + { hostname: 'api.github.com', path: `/repos/${owner}/${repo}/pulls/${number}`, headers }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8').slice(0, 50000))); + }, + ); + req.on('error', reject); + req.setTimeout(30000, () => { req.destroy(); reject(new Error('GitHub API diff timeout')); }); + req.end(); + }); + + return { + title: pr.title, + description: pr.body ?? '', + author: pr.user?.login, + mergedAt: pr.merged_at ?? undefined, + commits, + diff, + url, + }; +} + +/** + * 通过 gh CLI 获取 GitHub PR 的完整数据。 + * + * gh CLI 不可用时自动回退到 GitHub REST API(支持公开仓库,无需 token)。 + * + * @param url - GitHub PR 完整 web URL,例如 https://github.com/owner/repo/pull/123 + * @returns 包含标题、描述、提交列表、diff 的 MRData 对象 + * @throws Error 当 URL 格式不合法或两种方式均失败时 + */ +export async function fetchGitHubPR(url: string): Promise { + const { owner, repo, number } = parseGitHubPRUrl(url); + const repoArg = `${owner}/${repo}`; + + log.debug(`fetchGitHubPR: ${repoArg}#${number}`); + + // ── 优先尝试 gh CLI ────────────────────────────────────── + try { + const viewOutput = execSync( + `gh pr view ${number} --repo ${repoArg} --json title,body,author,mergedAt,commits`, + { maxBuffer: 10 * 1024 * 1024, encoding: 'utf8' }, + ); + const prView = JSON.parse(viewOutput) as GhPRView; + + const rawDiff = execSync( + `gh pr diff ${number} --repo ${repoArg}`, + { maxBuffer: 50 * 1024 * 1024, encoding: 'utf8' }, + ); + + return { + title: prView.title, + description: prView.body ?? '', + author: prView.author?.login, + mergedAt: prView.mergedAt ?? undefined, + commits: (prView.commits ?? []).map((c) => ({ + hash: c.oid, + message: c.messageHeadline, + })), + diff: rawDiff.slice(0, 50000), + url, + }; + } catch { + // gh CLI 不可用或失败,回退到 REST API + log.debug('gh CLI unavailable, falling back to GitHub REST API'); + } + + // ── 回退:GitHub REST API ──────────────────────────────── + return fetchGitHubPRViaApi(owner, repo, number); +} + + +/** GitHub PR URL 解析结果 */ +interface ParsedGitHubPR { + owner: string; + repo: string; + number: string; +} + diff --git a/src/providers/tgit/gf-org.ts b/src/providers/tgit/gf-org.ts new file mode 100644 index 0000000..68b0b7f --- /dev/null +++ b/src/providers/tgit/gf-org.ts @@ -0,0 +1,125 @@ +import type { OrgRepoInfo } from '../types.js'; +import { gfGetOAuthToken } from './gf-cli.js'; +import { log } from '../../utils/logger.js'; + +const TGIT_API_BASE = 'https://git.woa.com/api/v3'; +const DEFAULT_PER_PAGE = 100; +const DEFAULT_MAX_REPOS = 200; +// 响应体最大 50 MB,防止恶意服务器返回超大响应导致 OOM +const MAX_RESPONSE_BYTES = 50 * 1024 * 1024; + +interface TgitProjectApiItem { + id: number; + name: string; + path_with_namespace: string; + description?: string | null; + http_url_to_repo: string; + default_branch?: string | null; + archived?: boolean; + last_activity_at?: string; + star_count?: number; +} + +/** + * 将工蜂 API 返回的 project 条目映射为 OrgRepoInfo。 + * + * primaryLanguage 在列 projects 接口不直接返回,P6.0 留空。 + */ +function mapItem(item: TgitProjectApiItem): OrgRepoInfo { + return { + url: item.http_url_to_repo, + fullName: item.path_with_namespace, + name: item.name, + description: item.description ?? undefined, + primaryLanguage: undefined, + archived: item.archived ?? false, + stars: item.star_count, + pushedAt: item.last_activity_at, + }; +} + +/** + * 列出工蜂 group / 子 group 下的所有 projects(轻量元信息)。 + * + * 实现:复用 gfGetOAuthToken 取 token,调用工蜂 GitLab 风格 API: + * GET /api/v3/groups//projects?per_page=100&page=N + * + * 分页直到响应数组长度 < per_page 或累计达到 maxRepos。 + * + * @param group 组路径(如 "team-org" / "team/sub-group") + * @param opts.maxRepos 上限,默认 200 + * @throws Error + * - 缺 token:`Error('TGit token unavailable: ...')` + * - group 不存在 / 无权限:`Error('TGit group not found or no access')` + * - 其他 HTTP 错误:`Error('TGit API HTTP : ')` + */ +export async function gfListOrgRepos( + group: string, + opts?: { maxRepos?: number }, +): Promise { + const token = gfGetOAuthToken(); + if (!token) { + throw new Error( + 'TGit token unavailable: configure ~/.netrc for git.woa.com or set TAI_PAT_TOKEN', + ); + } + + const maxRepos = opts?.maxRepos ?? DEFAULT_MAX_REPOS; + const perPage = DEFAULT_PER_PAGE; + const encodedGroup = encodeURIComponent(group); + + const headers = { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json', + }; + + const collected: OrgRepoInfo[] = []; + let page = 1; + + while (collected.length < maxRepos) { + const url = `${TGIT_API_BASE}/groups/${encodedGroup}/projects?per_page=${perPage}&page=${page}`; + // redirect: 'manual' 防止跟随重定向到内网地址(SSRF) + const resp = await fetch(url, { headers, redirect: 'manual' }); + + if (resp.status >= 300 && resp.status < 400) { + throw new Error(`Unexpected redirect from TGit API: ${resp.status}`); + } + if (resp.status === 404) { + throw new Error(`TGit group ${group} not found or no access`); + } + if (!resp.ok) { + throw new Error(`TGit API HTTP ${resp.status}: ${await resp.text().catch(() => '')}`); + } + + // 流式读取响应体,限制最大 50 MB 防止 OOM + const reader = resp.body?.getReader(); + let received = 0; + const chunks: Uint8Array[] = []; + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + received += value.length; + if (received > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error(`TGit API response exceeds ${MAX_RESPONSE_BYTES} bytes`); + } + chunks.push(value); + } + } + const bodyText = Buffer.concat(chunks).toString('utf-8'); + const items = JSON.parse(bodyText) as TgitProjectApiItem[]; + if (!Array.isArray(items) || items.length === 0) break; + + for (const item of items) { + collected.push(mapItem(item)); + if (collected.length >= maxRepos) break; + } + + if (items.length < perPage) break; + page++; + } + + log.debug(`gfListOrgRepos: ${group} 共 ${collected.length} 项`); + return collected; +} diff --git a/src/providers/tgit/index.ts b/src/providers/tgit/index.ts index d03e83d..03b8f14 100644 --- a/src/providers/tgit/index.ts +++ b/src/providers/tgit/index.ts @@ -1,4 +1,4 @@ -import type { GitProvider, PrCreateOptions, RepoInfo } from '../types.js'; +import type { GitProvider, PrCreateOptions, RepoInfo, OrgRepoInfo } from '../types.js'; import { RepoNotFoundError } from '../types.js'; import { ensureGfInstalled, @@ -11,8 +11,8 @@ import { gfGetOAuthToken, RepoNotFoundError as GfRepoNotFoundError, } from './gf-cli.js'; +import { gfListOrgRepos } from './gf-org.js'; import { parseTGitRepoInput } from './repo-url.js'; -import { log } from '../../utils/logger.js'; export class TGitProvider implements GitProvider { readonly name = 'tgit'; @@ -67,6 +67,10 @@ export class TGitProvider implements GitProvider { getDefaultEmailDomain(): string | null { return 'tencent.com'; } + + async listOrgRepos(org: string, opts?: { maxRepos?: number }): Promise { + return gfListOrgRepos(org, opts); + } } // Re-export commonly used items for backward compatibility diff --git a/src/providers/tgit/mr-fetch.ts b/src/providers/tgit/mr-fetch.ts new file mode 100644 index 0000000..313045b --- /dev/null +++ b/src/providers/tgit/mr-fetch.ts @@ -0,0 +1,147 @@ +import { execFileSync } from 'node:child_process'; +import { type MRData } from '../../types.js'; +import { log } from '../../utils/logger.js'; +import { gfExec, gfGetOAuthToken } from './gf-cli.js'; + +/** TGit MR URL 解析结果 */ +interface ParsedTGitMR { + group: string; + project: string; + mrIid: string; +} + +/** + * 从 TGit MR URL 解析出 group / project / MR IID。 + * + * 支持格式:https://git.woa.com///merge_requests/ + * group 可以是多级路径(如 group/subgroup)。 + * 解析失败时抛出 Error。 + */ +function parseTGitMRUrl(url: string): ParsedTGitMR { + // 匹配 git.woa.com 后的路径,最后两段为 merge_requests/ + const match = url.match(/git\.woa\.com\/(.+)\/([^/]+)\/merge_requests\/(\d+)/); + if (!match) { + throw new Error(`Invalid TGit MR URL: ${url}`); + } + return { group: match[1], project: match[2], mrIid: match[3] }; +} + +/** gf mr desc 返回的 JSON 结构(仅使用的字段) */ +interface GfMRDesc { + title: string; + description: string; + author: { username: string }; + merged_at: string | null; +} + +/** + * 通过 TGit REST API 获取 MR 数据(gf CLI 不可用时的 fallback)。 + * + * 使用 ~/.netrc 中存储的 OAuth token 调用 git.woa.com API。 + * + * @param group - 项目所属 group(可含子 group,如 group/subgroup) + * @param project - 项目名称 + * @param mrIid - MR 内部编号(字符串数字) + * @returns 包含标题、描述、提交列表、diff 的 MRData 对象 + * @throws Error 当 token 不可用或 API 调用失败时 + */ +async function fetchTGitMRViaApi(group: string, project: string, mrIid: string): Promise { + const token = gfGetOAuthToken(); + if (!token) { + throw new Error('TGit REST API fallback 不可用:无法从 ~/.netrc 获取 OAuth token,请先运行 `gf auth login`'); + } + + const encodedPath = encodeURIComponent(`${group}/${project}`); + const baseUrl = `https://git.woa.com/api/v3/projects/${encodedPath}`; + const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; + + // 获取 MR 元信息 + const mrResp = await fetch(`${baseUrl}/merge_requests/${mrIid}`, { headers }); + if (!mrResp.ok) { + throw new Error(`TGit API 返回错误 ${mrResp.status}:${await mrResp.text()}`); + } + const mr = await mrResp.json() as { title: string; description: string; author: { username: string }; merged_at: string | null }; + + // 获取 MR diff(截断至 50KB) + const diffResp = await fetch(`${baseUrl}/merge_requests/${mrIid}/changes`, { headers }); + let diff = ''; + if (diffResp.ok) { + const diffData = await diffResp.json() as { changes: Array<{ diff: string }> }; + diff = (diffData.changes ?? []).map((c) => c.diff).join('\n').slice(0, 50000); + } + + return { + title: mr.title, + description: mr.description ?? '', + author: mr.author?.username, + mergedAt: mr.merged_at ?? undefined, + commits: [], + diff, + url: `https://git.woa.com/${group}/${project}/merge_requests/${mrIid}`, + }; +} + +/** + * 通过 gf CLI 获取 TGit MR 的完整数据,gf CLI 不可用时自动 fallback 到 REST API。 + * + * 依次执行: + * 1. `gf mr desc --repo / --json` 获取元信息 + * 2. `gf mr diff --repo /` 获取 diff(截断至 50KB) + * 若 gf CLI 失败,则尝试通过 TGit REST API 获取数据。 + * + * @param url - TGit MR 完整 web URL,例如 https://git.woa.com/group/repo/merge_requests/456 + * @returns 包含标题、描述、提交列表、diff 的 MRData 对象 + * @throws Error 当 URL 格式不合法或 gf CLI 与 REST API 均调用失败时 + */ +export async function fetchTGitMR(url: string): Promise { + const { group, project, mrIid } = parseTGitMRUrl(url); + const repoArg = `${group}/${project}`; + + log.debug(`fetchTGitMR: ${repoArg}!${mrIid}`); + + // ── 1. 获取元信息(优先 gf CLI,不可用时 fallback 到 REST API)───────────────── + let mrDesc: GfMRDesc; + try { + const result = gfExec(['mr', 'desc', mrIid, '-R', repoArg, '--json']); + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout); + } + mrDesc = JSON.parse(result.stdout) as GfMRDesc; + } catch (gfErr) { + log.debug(`gf CLI 不可用(${(gfErr as Error).message}),尝试 REST API fallback`); + try { + return await fetchTGitMRViaApi(group, project, mrIid); + } catch (apiErr) { + throw new Error( + `Failed to fetch TGit MR via gf CLI (${(gfErr as Error).message}) ` + + `and REST API fallback (${(apiErr as Error).message})`, + ); + } + } + + // ── 2. 获取 diff ───────────────────────────────────────── + let diff: string; + try { + const rawDiff = execFileSync('gf', ['mr', 'diff', String(mrIid), '-R', repoArg], { + maxBuffer: 50 * 1024 * 1024, + encoding: 'utf8', + }); + // 截断至约 50KB(50000 字符) + diff = rawDiff.slice(0, 50000); + } catch (err) { + // diff 获取失败不阻断流程,记录警告并置空 + log.debug(`gf mr diff 失败,diff 将为空:${(err as Error).message}`); + diff = ''; + } + + // ── 3. 组装结果(gf mr desc 不含 commits 字段,设为空数组) ── + return { + title: mrDesc.title, + description: mrDesc.description ?? '', + author: mrDesc.author?.username, + mergedAt: mrDesc.merged_at ?? undefined, + commits: [], + diff, + url, + }; +} diff --git a/src/providers/types.ts b/src/providers/types.ts index db1acdf..bf99c63 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -39,6 +39,26 @@ export interface PrCreateOptions { cwd?: string; } +/** + * 轻量级仓库元信息,用于 listOrgRepos 返回。 + */ +export interface OrgRepoInfo { + /** HTTPS clone URL */ + url: string; + /** owner/repo(含可能的多级 group) */ + fullName: string; + /** 仅 repo 名 */ + name: string; + /** 来自 GitHub topic / TGit description */ + description?: string; + primaryLanguage?: string; + /** 已 archive 的仓库(默认排除) */ + archived?: boolean; + stars?: number; + /** ISO 时间 */ + pushedAt?: string; +} + export interface GitProvider { /** Provider identifier: 'github' | 'tgit' */ readonly name: string; @@ -90,6 +110,27 @@ export interface GitProvider { */ createPullRequest(opts: PrCreateOptions): Promise; + /** + * 获取指定 MR/PR 的完整数据(标题、描述、提交列表、diff)。 + * + * 此方法为可选实现,不支持的 provider 可不实现(接口中用 ? 标记)。 + * url 为 MR/PR 的完整 web URL,例如: + * GitHub: https://github.com/owner/repo/pull/123 + * TGit: https://git.woa.com/group/repo/merge_requests/456 + */ + fetchMergeRequest?(url: string): Promise; + + /** + * 列出 org / group / namespace 下的所有仓库(轻量元信息)。 + * + * 实现可分页拉取,但本调用应返回完整列表(或 maxRepos 上限)。 + * + * @param org 组织或 group 路径(如 "team-org" / "team-group/sub") + * @param opts.maxRepos 上限保护,默认 200 + * @throws Error 当未实现或 API 调用失败 + */ + listOrgRepos?(org: string, opts?: { maxRepos?: number }): Promise; + // ─── Utilities ──────────────────────────────────────── /** diff --git a/src/pull.ts b/src/pull.ts index 498b646..ae18c90 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -17,6 +17,8 @@ import { TEAMAI_CULTURE_END, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, + TEAMAI_RECALL_RULES_START, + TEAMAI_RECALL_RULES_END, CultureFrontmatterSchema, resolveBaseDir, isWikiEnabled, @@ -286,8 +288,8 @@ async function pullForScope( // Step 2: Sync each resource type const wikiEnabled = isWikiEnabled(); const resourceTypes: ResourceType[] = wikiEnabled - ? ['skills', 'rules', 'docs', 'env', 'wiki'] - : ['skills', 'rules', 'docs', 'env']; + ? ['skills', 'rules', 'docs', 'env', 'wiki', 'agents'] + : ['skills', 'rules', 'docs', 'env', 'agents']; let totalSynced = 0; let desiredSkillNames: Set | null = null; let knownRepoSkillNames: Set | null = null; @@ -421,10 +423,11 @@ async function pullForScope( const tombstoneTypes: { type: ResourceType; ext?: string; - toolPathField: 'rules' | 'skills'; + toolPathField: 'rules' | 'skills' | 'agents'; }[] = [ { type: 'rules', ext: '.md', toolPathField: 'rules' }, { type: 'skills', toolPathField: 'skills' }, + { type: 'agents', ext: '.md', toolPathField: 'agents' }, ]; const baseDir = resolveBaseDir(localConfig); @@ -513,30 +516,53 @@ async function pullForScope( await saveStateForScope(state, localConfig.scope, localConfig.projectRoot); } - // Step 3.5: Sync learnings and rebuild search index (user scope only) + // Step 3.5: Sync learnings and rebuild the multi-category search index + // (Phase 1: covers learnings + docs + rules + skills). user scope only. if (!options.dryRun && localConfig.scope === 'user') { try { const learningsRepoDir = path.join(localConfig.repo.localPath, 'learnings'); + const docsRepoDir = path.join(localConfig.repo.localPath, 'docs'); + const rulesRepoDir = path.join(localConfig.repo.localPath, 'rules'); + const skillsRepoDir = path.join(localConfig.repo.localPath, 'skills'); + const votesDir = path.join(localConfig.repo.localPath, 'votes'); + + // Always sync learnings to ~/.teamai/learnings/ when present (legacy behavior) + let learningsCount = 0; if (await pathExists(learningsRepoDir)) { await fse.copy(learningsRepoDir, LEARNINGS_LOCAL_DIR, { overwrite: true, filter: (src: string) => !path.basename(src).startsWith('.'), }); const allFiles = await listFiles(learningsRepoDir); - const mdFiles = allFiles.filter((f) => f.endsWith('.md')); - if (mdFiles.length > 0) { - const votesDir = path.join(localConfig.repo.localPath, 'votes'); - const votesExist = await pathExists(votesDir); - const { buildIndex } = await import('./utils/search-index.js'); - const elapsed = await buildIndex( - LEARNINGS_LOCAL_DIR, - votesExist ? votesDir : undefined, - ); - log.success(`Synced ${mdFiles.length} learnings (index: ${elapsed}ms)`); + learningsCount = allFiles.filter((f) => f.endsWith('.md')).length; + } + + // Build the index when ANY of the four categories has content. Missing + // categories are silently skipped by the collectors. + const hasAnySource = + await pathExists(LEARNINGS_LOCAL_DIR) || + await pathExists(docsRepoDir) || + await pathExists(rulesRepoDir) || + await pathExists(skillsRepoDir); + + if (hasAnySource) { + const votesExist = await pathExists(votesDir); + const { buildIndex } = await import('./utils/search-index.js'); + const elapsed = await buildIndex({ + learningsDir: await pathExists(LEARNINGS_LOCAL_DIR) ? LEARNINGS_LOCAL_DIR : undefined, + docsDir: await pathExists(docsRepoDir) ? docsRepoDir : undefined, + rulesDir: await pathExists(rulesRepoDir) ? rulesRepoDir : undefined, + skillsDir: await pathExists(skillsRepoDir) ? skillsRepoDir : undefined, + votesDir: votesExist ? votesDir : undefined, + }); + if (learningsCount > 0) { + log.success(`Synced ${learningsCount} learnings (index: ${elapsed}ms)`); + } else { + log.debug(`[${scopeLabel}] Built multi-category search index in ${elapsed}ms`); } } } catch (e) { - log.debug(`Learnings sync skipped: ${(e as Error).message}`); + log.debug(`Learnings/index sync skipped: ${(e as Error).message}`); } } @@ -599,6 +625,43 @@ async function pullForScope( } } + // Step 3.8: Inject teamai-recall subagent rules block (Phase 1) + // + // Only injected for Tier-1 tools that have BOTH `agents` and `claudemd` + // configured. Tools without subagent support (cursor / codex / openclaw / + // workbuddy) are skipped — for them the recall flow runs purely via hooks + // (auto-recall, TodoWrite hint) and the manual `teamai recall` command. + if (!options.dryRun) { + try { + const baseDir = resolveBaseDir(localConfig); + const recallBlock = compileRecallRulesBlock(); + let injected = 0; + for (const [tool, toolPath] of Object.entries(freshConfig.toolPaths)) { + if (!toolPath.claudemd || !toolPath.agents) continue; + if (!await ResourceHandler.isToolInstalled(toolPath.agents, baseDir)) continue; + + const claudeMdPath = path.join(baseDir, toolPath.claudemd); + try { + await injectClaudeMdSection( + claudeMdPath, + TEAMAI_RECALL_RULES_START, + TEAMAI_RECALL_RULES_END, + recallBlock, + ); + injected++; + log.debug(`Injected recall rules into ${tool} CLAUDE.md`); + } catch (e) { + log.warn(`Failed to inject recall rules into ${tool} CLAUDE.md: ${(e as Error).message}`); + } + } + if (injected > 0) { + log.debug(`[${scopeLabel}] Injected recall rules into ${injected} tool(s) CLAUDE.md`); + } + } catch (e) { + log.debug(`[${scopeLabel}] Recall rules injection skipped: ${(e as Error).message}`); + } + } + // Step 4: Deploy CLI built-in skills if (!options.dryRun) { try { @@ -625,6 +688,19 @@ async function pullForScope( } } + // Step 4.6: Deploy CLI built-in agents (e.g. teamai-recall subagent) + if (!options.dryRun) { + try { + const { deployBuiltinAgents } = await import('./builtin-agents.js'); + const deployed = await deployBuiltinAgents(freshConfig, localConfig); + if (deployed > 0) { + log.debug(`[${scopeLabel}] Deployed built-in agents to ${deployed} location(s)`); + } + } catch (e) { + log.debug(`[${scopeLabel}] Built-in agents deployment skipped: ${(e as Error).message}`); + } + } + // Step 5: Auto-report usage data (user scope only) if (!options.dryRun && localConfig.scope === 'user') { try { @@ -756,6 +832,46 @@ export function compileClaudemd(contents: string[]): string | null { ].join('\n'); } +/** + * Build the CLAUDE.md block that instructs the main conversation to: + * 1. Invoke the `teamai-recall` subagent before starting any task that + * involves code changes / troubleshooting / design. + * 2. Declare which doc_ids were actually consulted at task completion. + * + * Only injected for Tier-1 tools (those with both `agents` and `claudemd` + * paths configured) — see pull.ts Step 3.8. + */ +export function compileRecallRulesBlock(): string { + const lines = [ + TEAMAI_RECALL_RULES_START, + '', + '', + '## Team Knowledge Recall (teamai)', + '', + '**Before** starting any task that involves code changes, debugging,', + 'or design decisions, you **MUST** first invoke the `teamai-recall`', + 'subagent via the Agent tool with a concise natural-language', + 'description of the task. The subagent will return a compact summary', + 'of relevant team knowledge (skills, learnings, docs, rules) without', + 'polluting this conversation with raw content.', + '', + '**After** completing the task, in your final reply you **MUST**', + 'declare which knowledge entries were actually referenced, using an', + 'HTML comment of the form:', + '', + '```', + '', + '```', + '', + 'If the recall returned no relevant hits, declare an empty list', + '(``). Do not skip the', + 'declaration — downstream tooling parses it to credit knowledge use.', + '', + TEAMAI_RECALL_RULES_END, + ]; + return lines.join('\n'); +} + /** * Collect claudemd .md files filtered by the user's active knowledge namespaces. * diff --git a/src/push.ts b/src/push.ts index 6058b1c..1de7c8f 100644 --- a/src/push.ts +++ b/src/push.ts @@ -8,6 +8,7 @@ import { getHandler } from './resources/index.js'; import { scanTeamRepoNamespaces } from './resources/skills.js'; import type { GlobalOptions, ResourceItem, ResourceType } from './types.js'; import { isWikiEnabled } from './types.js'; +import { assertSafePath, assertSafeResourceName, defaultAllowedRoots } from './utils/path-safety.js'; import { loadRolesManifest, resolveRoleResourceNamespaces } from './roles.js'; import { askQuestion, askSelection } from './utils/prompt.js'; import { pathExists } from './utils/fs.js'; @@ -139,8 +140,8 @@ export async function push(options: GlobalOptions & { all?: boolean; role?: stri // Scan for pushable resources first, then resolve namespace for new skills only. // Modified skills already carry their namespace from scanLocalForPush. const pushableTypes: ResourceType[] = isWikiEnabled() - ? ['skills', 'rules', 'env', 'wiki'] - : ['skills', 'rules', 'env']; + ? ['skills', 'rules', 'env', 'wiki', 'agents'] + : ['skills', 'rules', 'env', 'agents']; const allItems: ResourceItem[] = []; for (const type of pushableTypes) { @@ -153,6 +154,21 @@ export async function push(options: GlobalOptions & { all?: boolean; role?: stri // ── Handle --skill parameter: filter to a single specific skill ────── if (options.skill) { + // 校验 skill 名称安全性:从输入路径中提取 basename 作为资源名, + // 防御路径遍历、URL 编码绕过、非法字符等攻击 + const skillBasename = path.basename( + options.skill.startsWith('~') + ? options.skill.slice(1).replace(/^[/\\]+/, '') + : options.skill, + ); + try { + assertSafeResourceName(skillBasename); + } catch (e) { + console.error(`[push] --skill 参数不合法: ${(e as Error).message}`); + process.exitCode = 2; + return; + } + // Normalize the input path (expand ~, resolve to absolute) const os = await import('node:os'); const skillPath = options.skill.startsWith('~') diff --git a/src/recall.ts b/src/recall.ts index 1abc0b6..69b5ab9 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import YAML from 'yaml'; import { requireInit, detectProjectConfig } from './config.js'; -import { loadIndex, buildIndex, search } from './utils/search-index.js'; +import { loadIndex, buildIndex, search, isLegacyIndex } from './utils/search-index.js'; import type { SearchResult } from './utils/search-index.js'; import { readFileSafe, writeFile, ensureDir, pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; @@ -43,7 +43,8 @@ interface ScopedSearchResult extends SearchResult { * Format search results for CLI / AI consumption. * * Output uses delimiters so AI treats content as reference, not instruction. - * Each entry includes a scope label (user/project) when source is known. + * Each entry includes a scope label (user/project) when source is known and + * a type tag (skills/learnings/docs/rules) introduced in Phase 1. */ export function formatResults(results: ScopedSearchResult[]): string { const lines: string[] = []; @@ -54,14 +55,23 @@ export function formatResults(results: ScopedSearchResult[]): string { const { entry, score, scope, learningsBase } = results[i]; const voteStr = entry.votes > 0 ? ` ★${entry.votes}` : ''; const scopeStr = scope ? ` [${scope}]` : ''; - lines.push(`[${i + 1}/${results.length}] ${entry.title}${voteStr}${scopeStr}`); + // Phase 1: prepend a [type] tag so callers can quickly tell which knowledge + // bucket each hit came from. Falls back to no tag for legacy entries that + // pre-date the schema bump (these are auto-rebuilt on the next pull). + const typeTag = entry.type ? `[${entry.type}] ` : ''; + lines.push(`[${i + 1}/${results.length}] ${typeTag}${entry.title}${voteStr}${scopeStr}`); lines.push(`Author: ${entry.author || 'unknown'} | Date: ${entry.date || 'unknown'} | Score: ${score.toFixed(1)}`); if (entry.tags.length > 0) { lines.push(`Tags: ${entry.tags.join(', ')}`); } - const filePath = learningsBase - ? `${learningsBase}/${entry.filename}` - : `~/.teamai/learnings/${entry.filename}`; + // Prefer the absolute path captured at index build time when available + // (Phase 1 entries from docs/rules/skills carry it); otherwise fall back + // to the legacy ~/.teamai/learnings/ rendering. + const filePath = entry.path + ? entry.path + : learningsBase + ? `${learningsBase}/${entry.filename}` + : `~/.teamai/learnings/${entry.filename}`; lines.push(`File: ${filePath}`); lines.push(''); } @@ -165,15 +175,26 @@ async function loadOrBuildScopeIndex( } let index = await loadIndex(indexPath); - if (!index && effectiveLearningsDir) { + + // Auto-rebuild legacy / missing indexes (Phase 1 schema bump): the old + // index only covered learnings, the new one covers four categories. Same + // condition triggers rebuild when the file is missing entirely. + const needsRebuild = !index || isLegacyIndex(index); + if (needsRebuild && (effectiveLearningsDir || await pathExists(path.join(localConfig.repo.localPath, 'docs')) || await pathExists(path.join(localConfig.repo.localPath, 'rules')) || await pathExists(path.join(localConfig.repo.localPath, 'skills')))) { const votesDir = path.join(localConfig.repo.localPath, 'votes'); const votesExist = await pathExists(votesDir); + const docsDir = path.join(localConfig.repo.localPath, 'docs'); + const rulesDir = path.join(localConfig.repo.localPath, 'rules'); + const skillsDir = path.join(localConfig.repo.localPath, 'skills'); try { - await buildIndex( - effectiveLearningsDir, - votesExist ? votesDir : undefined, + await buildIndex({ + learningsDir: effectiveLearningsDir ?? undefined, + docsDir: await pathExists(docsDir) ? docsDir : undefined, + rulesDir: await pathExists(rulesDir) ? rulesDir : undefined, + skillsDir: await pathExists(skillsDir) ? skillsDir : undefined, + votesDir: votesExist ? votesDir : undefined, indexPath, - ); + }); index = await loadIndex(indexPath); } catch (e) { log.debug(`Index build failed for ${scopeLabel}: ${(e as Error).message}`); diff --git a/src/remove.ts b/src/remove.ts index 0a15097..5b94d71 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -6,7 +6,7 @@ import { getHandler } from './resources/index.js'; import type { GlobalOptions, ResourceType } from './types.js'; import { askConfirmation } from './utils/prompt.js'; -const REMOVABLE_TYPES: ResourceType[] = ['skills', 'rules', 'wiki']; +const REMOVABLE_TYPES: ResourceType[] = ['skills', 'rules', 'wiki', 'agents']; export async function remove( type: string, diff --git a/src/repo-list/schema.ts b/src/repo-list/schema.ts new file mode 100644 index 0000000..0bb3aba --- /dev/null +++ b/src/repo-list/schema.ts @@ -0,0 +1,44 @@ +// -*- coding: utf-8 -*- +import { z } from 'zod'; + +/** 单仓白名单条目 schema。 */ +export const RepoListEntrySchema = z.object({ + url: z.string().url(), + domain: z.string().optional(), + iwiki_space: z.string().optional(), + auth: z.enum(['token', 'ssh', 'public']).optional(), + priority: z.enum(['low', 'normal', 'high']).optional().default('normal'), +}); + +/** org/group 批量导入条目 schema(P5.4 实现;P5.2 遇到时 warn + 跳过)。 */ +export const RepoListOrgEntrySchema = z.object({ + org: z.string().url(), + include_pattern: z.string().optional(), + exclude_pattern: z.string().optional(), + default_domain: z.string().optional(), + auth: z.enum(['token', 'ssh', 'public']).optional(), +}); + +/** 白名单条目:单仓或 org 批量。 */ +export const RepoListItemSchema = z.union([RepoListOrgEntrySchema, RepoListEntrySchema]); + +/** 白名单 yaml 顶层文件 schema。 */ +export const RepoListFileSchema = z.object({ + version: z.literal(1).default(1), + repos: z.array(RepoListItemSchema).default([]), +}); + +export type RepoListEntry = z.infer; +export type RepoListOrgEntry = z.infer; +export type RepoListItem = z.infer; +export type RepoListFile = z.infer; + +/** + * 判断条目是否为 org 批量条目。 + * + * @param item 白名单条目 + * @returns 是 org 条目时为 true + */ +export function isOrgEntry(item: RepoListItem): item is RepoListOrgEntry { + return 'org' in item; +} diff --git a/src/repo-list/store.ts b/src/repo-list/store.ts new file mode 100644 index 0000000..26b96bb --- /dev/null +++ b/src/repo-list/store.ts @@ -0,0 +1,34 @@ +// -*- coding: utf-8 -*- +import fs from 'fs-extra'; +import { parse as parseYaml } from 'yaml'; + +import { RepoListFileSchema, type RepoListFile } from './schema.js'; + +/** 反序列化大小上限:10 MB,防止超大文件导致 OOM。 */ +const MAX_CONFIG_FILE_BYTES = 10 * 1024 * 1024; + +/** + * 加载并校验 repo-list yaml 文件。 + * + * @param filePath yaml 文件路径 + * @returns 校验通过的 RepoListFile 对象 + * @throws 文件不存在时抛 Error('Repo list not found: ') + * @throws 文件超过 10MB 时抛 Error(' exceeds max allowed size 10MB') + * @throws yaml 解析或 schema 校验失败时抛对应错误 + */ +export async function loadRepoList(filePath: string): Promise { + const exists = await fs.pathExists(filePath); + if (!exists) { + throw new Error(`Repo list not found: ${filePath}`); + } + + const stat = await fs.stat(filePath); + if (stat.size > MAX_CONFIG_FILE_BYTES) { + throw new Error(`${filePath} exceeds max allowed size 10MB`); + } + + const raw = await fs.readFile(filePath, 'utf8'); + const parsed: unknown = parseYaml(raw); + const result = RepoListFileSchema.parse(parsed); + return result; +} diff --git a/src/resources/agent-format.ts b/src/resources/agent-format.ts new file mode 100644 index 0000000..174bed9 --- /dev/null +++ b/src/resources/agent-format.ts @@ -0,0 +1,497 @@ +import path from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import matter from 'gray-matter'; +import { stringify as stringifyToml, parse as parseToml } from 'smol-toml'; + +// ─── Tool name type ────────────────────────────────────────────────────────── + +export type ToolName = 'claude' | 'claude-internal' | 'codebuddy' | 'codex' | 'codex-internal' | 'cursor'; + +export const ALL_SUPPORTED_TOOLS: ToolName[] = [ + 'claude', + 'claude-internal', + 'codebuddy', + 'codex', + 'codex-internal', + 'cursor', +]; + +// ─── Intermediate format ───────────────────────────────────────────────────── + +/** + * Intermediate YAML representation of a subagent definition. + * This is the canonical format stored in the team repo (agents/.yaml). + * Each tool renderer translates this into its native format. + */ +export interface AgentSpec { + /** Agent name, must match the YAML filename stem. */ + name: string; + /** Single-line description shown in tool UI. */ + description: string; + /** Main prompt / instructions body (multi-line). */ + instructions: string; + /** Optional model override. */ + model?: string; + /** Optional tool whitelist (claude / codebuddy / cursor use this). */ + tools?: string[]; + /** + * Per-tool private fields that are not part of the common schema. + * Passed through verbatim when rendering for the matching tool, + * and collected when reversing from a tool's native format. + */ + tool_extras?: { + claude?: Record; + 'claude-internal'?: Record; + codebuddy?: Record; + codex?: Record; + 'codex-internal'?: Record; + cursor?: Record; + }; + /** + * Which tools this agent should be deployed to. + * When undefined, the agent is deployed to ALL installed supported tools. + */ + targets?: ToolName[]; +} + +// ─── Parse intermediate YAML ───────────────────────────────────────────────── + +/** + * Result type for parseAgentYaml — avoids throwing on bad input. + */ +export type ParseResult = + | { ok: true; spec: AgentSpec } + | { ok: false; reason: string }; + +/** + * Parse a team-repo YAML file into an AgentSpec. + * + * Returns a ParseResult instead of throwing, so a single malformed file + * does not abort the entire pull operation. + * + * @param content - Raw YAML string content. + * @param filename - Filename used for error messages. + * @returns ParseResult — ok=true with spec on success, ok=false with reason on failure. + */ +export function parseAgentYaml(content: string, filename: string): ParseResult { + let raw: unknown; + try { + raw = parseYaml(content); + } catch (err) { + return { ok: false, reason: `${filename} parse error: ${(err as Error).message}` }; + } + + if (typeof raw !== 'object' || raw === null) { + return { ok: false, reason: `${filename} must be a YAML object` }; + } + + const obj = raw as Record; + + for (const field of ['name', 'description', 'instructions'] as const) { + if (!obj[field] || typeof obj[field] !== 'string' || (obj[field] as string).trim() === '') { + return { ok: false, reason: `${filename} missing required field ${field}` }; + } + } + + return { + ok: true, + spec: { + name: obj['name'] as string, + description: obj['description'] as string, + instructions: obj['instructions'] as string, + ...(obj['model'] !== undefined ? { model: obj['model'] as string } : {}), + ...(obj['tools'] !== undefined ? { tools: obj['tools'] as string[] } : {}), + ...(obj['tool_extras'] !== undefined ? { tool_extras: obj['tool_extras'] as AgentSpec['tool_extras'] } : {}), + ...(obj['targets'] !== undefined ? { targets: obj['targets'] as ToolName[] } : {}), + }, + }; +} + +// ─── Serialize intermediate YAML ───────────────────────────────────────────── + +/** + * Serialize an AgentSpec back to canonical team-repo YAML format. + * + * @param spec - The AgentSpec to serialize. + * @returns YAML string. + */ +export function serializeAgentYaml(spec: AgentSpec): string { + return stringifyYaml(spec, { lineWidth: 120 }); +} + +// ─── Render: AgentSpec → tool-native format ─────────────────────────────────── + +/** Result of rendering an AgentSpec for a specific tool. */ +export interface RenderResult { + ext: '.md' | '.toml'; + content: string; +} + +/** + * Render an AgentSpec for Claude / Claude Code. + * Output: YAML frontmatter (.md) with optional model/tools and tool_extras.claude fields. + */ +export function renderForClaude(spec: AgentSpec): RenderResult { + return { ext: '.md', content: renderMarkdownAgent(spec, spec.tool_extras?.['claude']) }; +} + +/** + * Render an AgentSpec for Claude Internal. + * Same format as Claude — YAML frontmatter + body. + */ +export function renderForClaudeInternal(spec: AgentSpec): RenderResult { + return { ext: '.md', content: renderMarkdownAgent(spec, spec.tool_extras?.['claude-internal']) }; +} + +/** + * Render an AgentSpec for CodeBuddy. + * Same format as Claude, but merges tool_extras.codebuddy into frontmatter. + */ +export function renderForCodebuddy(spec: AgentSpec): RenderResult { + return { ext: '.md', content: renderMarkdownAgent(spec, spec.tool_extras?.['codebuddy']) }; +} + +/** + * Render an AgentSpec for Codex. + * Output: TOML with developer_instructions and flattened tool_extras.codex fields. + */ +export function renderForCodex(spec: AgentSpec): RenderResult { + return { ext: '.toml', content: renderTomlAgent(spec, spec.tool_extras?.['codex']) }; +} + +/** + * Render an AgentSpec for Codex Internal. + * Same format as Codex — TOML with developer_instructions. + */ +export function renderForCodexInternal(spec: AgentSpec): RenderResult { + return { ext: '.toml', content: renderTomlAgent(spec, spec.tool_extras?.['codex-internal']) }; +} + +/** + * Render an AgentSpec for Cursor. + * Output: YAML frontmatter (.md) using agent_id instead of name. + */ +export function renderForCursor(spec: AgentSpec): RenderResult { + const frontmatterData: Record = { + agent_id: spec.name, + description: spec.description, + }; + if (spec.tools !== undefined && spec.tools.length > 0) { + frontmatterData['tools'] = spec.tools; + } + // Flatten tool_extras.cursor into frontmatter + const extras = spec.tool_extras?.['cursor']; + if (extras) { + for (const [key, value] of Object.entries(extras)) { + frontmatterData[key] = value; + } + } + const content = matter.stringify(spec.instructions, frontmatterData); + return { ext: '.md', content }; +} + +// ─── Internal render helpers ───────────────────────────────────────────────── + +/** + * Build a gray-matter .md file: YAML frontmatter (name/description/model?/tools?/extras) + body. + */ +function renderMarkdownAgent(spec: AgentSpec, extras?: Record): string { + const frontmatterData: Record = { + name: spec.name, + description: spec.description, + }; + if (spec.model !== undefined) { + frontmatterData['model'] = spec.model; + } + if (spec.tools !== undefined && spec.tools.length > 0) { + frontmatterData['tools'] = spec.tools; + } + // Flatten tool-private extras into frontmatter + if (extras) { + for (const [key, value] of Object.entries(extras)) { + frontmatterData[key] = value; + } + } + return matter.stringify(spec.instructions, frontmatterData); +} + +/** + * Build a smol-toml TOML file: name/description/developer_instructions/model?/extras. + * Note: `tools` is intentionally omitted from TOML output — Codex uses mcp_servers instead. + */ +function renderTomlAgent(spec: AgentSpec, extras?: Record): string { + const tomlData: Record = { + name: spec.name, + description: spec.description, + developer_instructions: spec.instructions, + }; + if (spec.model !== undefined) { + tomlData['model'] = spec.model; + } + // Flatten tool-private extras into top-level TOML fields + if (extras) { + for (const [key, value] of Object.entries(extras)) { + tomlData[key] = value; + } + } + return stringifyToml(tomlData); +} + +// ─── Reverse: tool-native format → AgentSpec ──────────────────────────────── + +/** Result of reversing a tool-native agent file. */ +export type ReverseResult = + | { ok: true; spec: AgentSpec } + | { ok: false; reason: string }; + +/** Common fields that belong in the AgentSpec root (not tool_extras). */ +const COMMON_CLAUDE_FIELDS = new Set(['name', 'description', 'model', 'tools']); +const COMMON_CURSOR_FIELDS = new Set(['agent_id', 'description', 'model', 'tools']); +const COMMON_CODEX_FIELDS = new Set(['name', 'description', 'developer_instructions', 'model']); + +/** + * Reverse a Claude-format .md file into an AgentSpec. + * claude-internal reuses this same function. + * + * @param filePath - Absolute path, used to derive the agent name. + * @param content - File content string. + */ +export function reverseFromClaude(filePath: string, content: string): ReverseResult { + let parsed: matter.GrayMatterFile; + try { + parsed = matter(content); + } catch (err) { + return { ok: false, reason: `parse error: ${(err as Error).message}` }; + } + + const fm = parsed.data as Record; + const body = parsed.content.trim(); + + const name = (fm['name'] as string | undefined) ?? path.basename(filePath, '.md'); + if (!name) return { ok: false, reason: 'missing field name' }; + if (!fm['description']) return { ok: false, reason: 'missing field description' }; + if (!body) return { ok: false, reason: 'missing field instructions (empty body)' }; + + // Collect non-common frontmatter fields as tool_extras + const extras: Record = {}; + for (const [key, value] of Object.entries(fm)) { + if (!COMMON_CLAUDE_FIELDS.has(key)) { + extras[key] = value; + } + } + + const spec: AgentSpec = { + name, + description: fm['description'] as string, + instructions: body, + }; + if (fm['model'] !== undefined) spec.model = fm['model'] as string; + if (fm['tools'] !== undefined) spec.tools = fm['tools'] as string[]; + if (Object.keys(extras).length > 0) spec.tool_extras = { claude: extras }; + + return { ok: true, spec }; +} + +/** + * Reverse a CodeBuddy-format .md file into an AgentSpec. + * Format is identical to Claude, but tool_extras key is 'codebuddy'. + */ +export function reverseFromCodebuddy(filePath: string, content: string): ReverseResult { + const result = reverseFromClaude(filePath, content); + if (!result.ok) return result; + + const spec = result.spec; + // Move extras from 'claude' to 'codebuddy' + if (spec.tool_extras?.['claude']) { + spec.tool_extras = { codebuddy: spec.tool_extras['claude'] }; + } + return { ok: true, spec }; +} + +/** + * Reverse a Codex-format .toml file into an AgentSpec. + * codex-internal reuses this same function. + * + * @param filePath - Absolute path, used to derive the agent name. + * @param content - File content string. + */ +export function reverseFromCodex(filePath: string, content: string): ReverseResult { + let parsed: Record; + try { + parsed = parseToml(content) as Record; + } catch (err) { + return { ok: false, reason: `parse error: ${(err as Error).message}` }; + } + + const name = (parsed['name'] as string | undefined) ?? path.basename(filePath, '.toml'); + if (!name) return { ok: false, reason: 'missing field name' }; + if (!parsed['description']) return { ok: false, reason: 'missing field description' }; + if (!parsed['developer_instructions']) return { ok: false, reason: 'missing field developer_instructions' }; + + // Collect non-common fields as tool_extras + const extras: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (!COMMON_CODEX_FIELDS.has(key)) { + extras[key] = value; + } + } + + const spec: AgentSpec = { + name, + description: parsed['description'] as string, + instructions: parsed['developer_instructions'] as string, + }; + if (parsed['model'] !== undefined) spec.model = parsed['model'] as string; + if (Object.keys(extras).length > 0) spec.tool_extras = { codex: extras }; + + return { ok: true, spec }; +} + +/** + * Reverse a Cursor-format .md file into an AgentSpec. + * Uses agent_id instead of name in the frontmatter. + */ +export function reverseFromCursor(filePath: string, content: string): ReverseResult { + let parsed: matter.GrayMatterFile; + try { + parsed = matter(content); + } catch (err) { + return { ok: false, reason: `parse error: ${(err as Error).message}` }; + } + + const fm = parsed.data as Record; + const body = parsed.content.trim(); + + const name = (fm['agent_id'] as string | undefined) ?? path.basename(filePath, '.md'); + if (!name) return { ok: false, reason: 'missing field agent_id' }; + if (!fm['description']) return { ok: false, reason: 'missing field description' }; + if (!body) return { ok: false, reason: 'missing field instructions (empty body)' }; + + // Collect non-common frontmatter fields as tool_extras + const extras: Record = {}; + for (const [key, value] of Object.entries(fm)) { + if (!COMMON_CURSOR_FIELDS.has(key)) { + extras[key] = value; + } + } + + const spec: AgentSpec = { + name, + description: fm['description'] as string, + instructions: body, + }; + if (fm['model'] !== undefined) spec.model = fm['model'] as string; + if (fm['tools'] !== undefined) spec.tools = fm['tools'] as string[]; + if (Object.keys(extras).length > 0) spec.tool_extras = { cursor: extras }; + + return { ok: true, spec }; +} + +// ─── Merge multi-tool reverse results ─────────────────────────────────────── + +/** Conflict details when merging results from multiple tools. */ +export interface MergeConflict { + field: string; + values: Record; +} + +/** Result of merging multiple tool AgentSpecs into one canonical AgentSpec. */ +export type MergeResult = + | { ok: true; spec: AgentSpec } + | { ok: false; conflicts: MergeConflict[] }; + +/** Common fields subject to conflict detection during merge. */ +const MERGE_COMMON_FIELDS: Array = [ + 'name', + 'description', + 'instructions', + 'model', + 'tools', +]; + +/** + * Merge AgentSpec results from multiple tools into a single canonical AgentSpec. + * + * Common fields (name, description, instructions, model, tools) are compared + * across tools — any discrepancy is reported as a conflict. + * Tool-private fields (tool_extras) are merged by union, as they are independent. + * + * @param perTool - Map of tool name → AgentSpec (only successful reverses included). + * @returns Merged spec if all common fields agree, or conflict details otherwise. + */ +export function mergeReverseResults( + perTool: Partial>, +): MergeResult { + const entries = Object.entries(perTool) as Array<[ToolName, AgentSpec]>; + if (entries.length === 0) { + return { ok: false, conflicts: [{ field: 'all', values: {} }] }; + } + if (entries.length === 1) { + return { ok: true, spec: entries[0][1] }; + } + + const conflicts: MergeConflict[] = []; + + // Check each common field for discrepancies + for (const field of MERGE_COMMON_FIELDS) { + const valuesByTool: Record = {}; + for (const [tool, spec] of entries) { + const value = spec[field]; + if (value !== undefined) { + valuesByTool[tool] = value; + } + } + if (Object.keys(valuesByTool).length === 0) continue; + + // Normalize: convert to JSON for deep comparison + const uniqueValues = new Set(Object.values(valuesByTool).map((v) => JSON.stringify(v))); + if (uniqueValues.size > 1) { + conflicts.push({ field, values: valuesByTool }); + } + } + + if (conflicts.length > 0) { + return { ok: false, conflicts }; + } + + // All common fields agree — pick values from first spec, merge tool_extras + const baseSpec = { ...entries[0][1] }; + const mergedExtras: AgentSpec['tool_extras'] = {}; + + for (const [, spec] of entries) { + if (spec.tool_extras) { + for (const [toolKey, extras] of Object.entries(spec.tool_extras) as Array<[ToolName, Record]>) { + if (!mergedExtras[toolKey]) { + mergedExtras[toolKey] = {}; + } + Object.assign(mergedExtras[toolKey]!, extras); + } + } + } + + if (Object.keys(mergedExtras).length > 0) { + baseSpec.tool_extras = mergedExtras; + } + + return { ok: true, spec: baseSpec }; +} + +// ─── Dispatch helpers ───────────────────────────────────────────────────────── + +/** + * Render an AgentSpec for the specified tool. + * + * @param spec - The agent specification. + * @param tool - Target tool name. + * @returns Rendered file extension and content. + */ +export function renderForTool(spec: AgentSpec, tool: ToolName): RenderResult { + switch (tool) { + case 'claude': return renderForClaude(spec); + case 'claude-internal': return renderForClaudeInternal(spec); + case 'codebuddy': return renderForCodebuddy(spec); + case 'codex': return renderForCodex(spec); + case 'codex-internal': return renderForCodexInternal(spec); + case 'cursor': return renderForCursor(spec); + } +} diff --git a/src/resources/agents.ts b/src/resources/agents.ts new file mode 100644 index 0000000..9631722 --- /dev/null +++ b/src/resources/agents.ts @@ -0,0 +1,422 @@ +import path from 'node:path'; +import { ResourceHandler } from './base.js'; +import type { ResourceItem, ResourceItemStatus, TeamaiConfig, LocalConfig } from '../types.js'; +import { listFiles, pathExists, copyFile, ensureDir, remove, fileContentEqual, getFileMtime, writeFile, readFileSafe } from '../utils/fs.js'; +import { log } from '../utils/logger.js'; +import { resolveBaseDir } from '../types.js'; +import { BUILTIN_AGENT_NAMES } from '../builtin-agents.js'; +import { + parseAgentYaml, + serializeAgentYaml, + renderForTool, + reverseFromClaude, + reverseFromCodebuddy, + reverseFromCodex, + reverseFromCursor, + mergeReverseResults, + ALL_SUPPORTED_TOOLS, +} from './agent-format.js'; +import type { AgentSpec, ToolName, ReverseResult, ParseResult } from './agent-format.js'; + +/** + * Extended ResourceItem for agents — carries merged spec or skip reason + * from multi-tool reverse parse (new YAML format push path). + */ +export interface AgentResourceItem extends ResourceItem { + /** Merged spec produced by scanLocalForPush (new .yaml format only). */ + mergedSpec?: AgentSpec; + /** Human-readable reason to skip this item during pushItem (merge failed). */ + skipReason?: string; + /** True when item came from a legacy .md team-repo file (older format). */ + legacy?: boolean; +} + +/** + * AgentsHandler — manage AI subagent definitions distributed via the team repo. + * + * Layout: + * New format: team-repo/agents/.yaml → rendered per-tool on pull + * Legacy format: team-repo/agents/.md → copied as-is (claude/claude-internal/codebuddy only) + * + * Tools without an `agents` path in toolPaths are silently skipped. + */ +export class AgentsHandler extends ResourceHandler { + readonly type = 'agents' as const; + + /** + * Scan local AI tool agents/ directories for files that are new or modified + * compared to the team repo. Groups by agent name stem across all tools. + * + * New format (.yaml in team repo): attempts multi-tool reverse + merge. + * Built-in CLI agents are excluded from push. + */ + async scanLocalForPush(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const teamAgentsDir = path.join(localConfig.repo.localPath, 'agents'); + const tombstones = await this.readTombstones(localConfig); + const baseDir = resolveBaseDir(localConfig); + + // Collect all local agent files grouped by stem + const grouped = new Map>(); // stem → (tool → filePath) + + for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + if (!toolPath.agents) continue; + const agentsDir = path.join(baseDir, toolPath.agents); + if (!await pathExists(agentsDir)) continue; + + const files = await listFiles(agentsDir); + for (const file of files) { + const stem = getAgentStem(file); + if (stem === null) continue; + if (tombstones.has(stem)) continue; + if (BUILTIN_AGENT_NAMES.has(stem)) continue; + + const filePath = path.join(agentsDir, file); + let toolGroup = grouped.get(stem); + if (!toolGroup) { + toolGroup = new Map(); + grouped.set(stem, toolGroup); + } + // Use latest mtime if same tool appears via multiple tool paths (shouldn't happen normally) + if (!toolGroup.has(tool)) { + toolGroup.set(tool, filePath); + } + } + } + + const items: AgentResourceItem[] = []; + + for (const [stem, toolFiles] of grouped) { + const teamYamlPath = path.join(teamAgentsDir, `${stem}.yaml`); + const teamMdPath = path.join(teamAgentsDir, `${stem}.md`); + + // Determine if this agent is already in the team repo + const hasTeamYaml = await pathExists(teamYamlPath); + const hasTeamMd = await pathExists(teamMdPath); + + // Check if any local file differs from team copy + let hasChange = false; + if (!hasTeamYaml && !hasTeamMd) { + hasChange = true; // brand new + } else { + for (const [, filePath] of toolFiles) { + const teamRef = hasTeamYaml ? teamYamlPath : teamMdPath; + const equal = await fileContentEqual(filePath, teamRef).catch((err) => { + console.warn( + `[agents] 比较文件内容失败 ${filePath} vs ${teamRef}: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + }); + if (!equal) { + hasChange = true; + break; + } + } + } + + if (!hasChange) continue; + + const status: ResourceItemStatus = (hasTeamYaml || hasTeamMd) ? 'modified' : 'new'; + + // Determine representative source path (prefer highest mtime) + let bestPath = ''; + let bestMtime = 0; + for (const [, filePath] of toolFiles) { + const mtime = await getFileMtime(filePath); + if (mtime > bestMtime) { + bestMtime = mtime; + bestPath = filePath; + } + } + + // Attempt reverse + merge for new YAML format push + const perToolSpecs: Partial> = {}; + let skipReason: string | undefined; + + for (const [tool, filePath] of toolFiles) { + if (!isKnownTool(tool)) continue; + const content = await readFileSafe(filePath); + if (!content) continue; + + const result = reverseByTool(tool, filePath, content); + if (result.ok) { + perToolSpecs[tool as ToolName] = result.spec; + } else { + log.debug(`Reverse failed for ${stem} from ${tool}: ${result.reason}`); + } + } + + if (Object.keys(perToolSpecs).length === 0) { + skipReason = `could not reverse-parse any tool's agent file for ${stem}`; + } else { + const mergeResult = mergeReverseResults(perToolSpecs); + if (!mergeResult.ok) { + const conflictSummary = mergeResult.conflicts + .map((c) => `${c.field}: ${JSON.stringify(c.values)}`) + .join('; '); + skipReason = `conflicting values across tools — ${conflictSummary}`; + } else { + items.push({ + name: stem, + type: 'agents', + sourcePath: bestPath, + relativePath: `agents/${stem}.yaml`, + status, + mergedSpec: mergeResult.spec, + }); + continue; + } + } + + // Fall back to pushing the raw md file (legacy behavior) + items.push({ + name: stem, + type: 'agents', + sourcePath: bestPath, + relativePath: `agents/${stem}.md`, + status, + skipReason, + }); + } + + return items; + } + + /** + * Scan team repo `agents/` for files to pull. + * Recognizes both *.yaml (new) and *.md (legacy). + * Hidden files (tombstones) are filtered out by listFiles. + */ + async scanTeamForPull(_teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const agentsDir = path.join(localConfig.repo.localPath, 'agents'); + if (!await pathExists(agentsDir)) return []; + + const files = await listFiles(agentsDir); + const items: AgentResourceItem[] = []; + + for (const file of files) { + if (file.endsWith('.yaml')) { + const stem = file.replace(/\.yaml$/, ''); + items.push({ + name: stem, + type: 'agents', + sourcePath: path.join(agentsDir, file), + relativePath: `agents/${file}`, + legacy: false, + }); + } else if (file.endsWith('.md')) { + const stem = file.replace(/\.md$/, ''); + items.push({ + name: stem, + type: 'agents', + sourcePath: path.join(agentsDir, file), + relativePath: `agents/${file}`, + legacy: true, + }); + } + } + + return items; + } + + /** + * Push an agent to the team repo. + * New format: writes mergedSpec as .yaml. + * Skip: logs warning and returns without writing. + * Legacy fallback: copies the raw .md file. + */ + async pushItem(item: ResourceItem, _teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const agentItem = item as AgentResourceItem; + + if (agentItem.skipReason) { + log.warn(`[agents] 跳过 ${item.name}: ${agentItem.skipReason}`); + log.warn(' 建议修改后重新 push 该 subagent'); + return; + } + + if (agentItem.mergedSpec) { + const dest = path.join(localConfig.repo.localPath, 'agents', `${item.name}.yaml`); + await ensureDir(path.dirname(dest)); + const yamlContent = serializeAgentYaml(agentItem.mergedSpec); + await writeFile(dest, yamlContent); + log.debug(`Wrote agent ${item.name} → team repo (YAML format)`); + return; + } + + // Legacy: copy raw .md + const dest = path.join(localConfig.repo.localPath, 'agents', `${item.name}.md`); + if (item.sourcePath !== dest) { + await ensureDir(path.dirname(dest)); + await copyFile(item.sourcePath, dest); + } + log.debug(`Copied agent ${item.name} → team repo (legacy MD format)`); + } + + /** + * Pull an agent to every installed tool's agents/ directory. + * + * New format (.yaml): parses spec, respects spec.targets, renders per-tool native format. + * Legacy format (.md): copies .md as-is to claude/claude-internal/codebuddy only. + */ + async pullItem(item: ResourceItem, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const agentItem = item as AgentResourceItem; + const baseDir = resolveBaseDir(localConfig); + + // Determine format: explicit flag takes precedence; fall back to extension detection + const isLegacy = agentItem.legacy === true || (!agentItem.legacy && !item.sourcePath.endsWith('.yaml')); + + if (isLegacy) { + // Legacy: copy .md to tools that support agents + await this.pullLegacyMd(item, teamConfig, baseDir); + return; + } + + // New YAML format: parse + render per-tool + const content = await readFileSafe(item.sourcePath); + if (!content) { + log.warn(`agents: cannot read ${item.sourcePath}`); + return; + } + + let spec: AgentSpec; + const parseResult: ParseResult = parseAgentYaml(content, item.name + '.yaml'); + if (!parseResult.ok) { + console.warn(`[agents] 解析失败 ${item.name}.yaml: ${parseResult.reason}, 已跳过`); + return; + } + spec = parseResult.spec; + + const targets = spec.targets ?? ALL_SUPPORTED_TOOLS; + + for (const tool of targets) { + const toolPath = teamConfig.toolPaths[tool]; + if (!toolPath?.agents) { + log.debug(`Skipping agent sync for ${tool}: no agents path configured`); + continue; + } + if (!await ResourceHandler.isToolInstalled(toolPath.agents, baseDir)) { + log.debug(`Skipping agent sync for ${tool}: tool not installed`); + continue; + } + + const destDir = path.join(baseDir, toolPath.agents); + try { + await ensureDir(destDir); + const { ext, content: rendered } = renderForTool(spec, tool); + const dest = path.join(destDir, `${item.name}${ext}`); + await writeFile(dest, rendered); + log.debug(`Rendered agent ${item.name} → ${tool} (${ext})`); + } catch (e) { + log.warn(`Failed to sync agent ${item.name} to ${tool}: ${(e as Error).message}`); + } + } + } + + /** + * Remove an agent from the team repo and all tool agents/ directories. + * Tries both .yaml and .md extensions in the team repo. + * Records a tombstone to prevent re-push. + */ + async removeItem(name: string, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const removed: string[] = []; + const baseDir = resolveBaseDir(localConfig); + + const teamAgentsDir = path.join(localConfig.repo.localPath, 'agents'); + + for (const ext of ['.yaml', '.md'] as const) { + const teamFile = path.join(teamAgentsDir, `${name}${ext}`); + if (await pathExists(teamFile)) { + await remove(teamFile); + removed.push(teamFile); + } + } + + await this.addTombstone(name, localConfig); + + for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + if (!toolPath.agents) continue; + // Try removing both .md and .toml variants + for (const ext of ['.md', '.toml'] as const) { + const filePath = path.join(baseDir, toolPath.agents, `${name}${ext}`); + if (await pathExists(filePath)) { + await remove(filePath); + removed.push(filePath); + log.debug(`Removed agent ${name} from ${tool}`); + } + } + } + + return removed; + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + /** + * Legacy pull: copies .md as-is to claude/claude-internal/codebuddy. + */ + private async pullLegacyMd( + item: ResourceItem, + teamConfig: TeamaiConfig, + baseDir: string, + ): Promise { + const legacyTools = new Set(['claude', 'claude-internal', 'codebuddy']); + + for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + if (!legacyTools.has(tool)) continue; + if (!toolPath.agents) { + log.debug(`Skipping legacy agent sync for ${tool}: no agents path configured`); + continue; + } + if (!await ResourceHandler.isToolInstalled(toolPath.agents, baseDir)) { + log.debug(`Skipping legacy agent sync for ${tool}: tool not installed`); + continue; + } + + const destDir = path.join(baseDir, toolPath.agents); + try { + await ensureDir(destDir); + const dest = path.join(destDir, `${item.name}.md`); + await copyFile(item.sourcePath, dest); + log.debug(`Synced legacy agent ${item.name} → ${tool}`); + } catch (e) { + log.warn(`Failed to sync legacy agent ${item.name} to ${tool}: ${(e as Error).message}`); + } + } + } +} + +// ─── Module-level helpers ────────────────────────────────────────────────── + +/** + * Extract agent name stem from a filename. + * Accepts .md and .toml extensions only; returns null for other files. + */ +function getAgentStem(filename: string): string | null { + if (filename.endsWith('.md')) return filename.slice(0, -3); + if (filename.endsWith('.toml')) return filename.slice(0, -5); + return null; +} + +/** + * Check if a tool name is one of the 6 known agent-capable tools. + */ +function isKnownTool(tool: string): tool is ToolName { + return (ALL_SUPPORTED_TOOLS as string[]).includes(tool); +} + +/** + * Dispatch reverse parsing to the correct function for each tool. + */ +function reverseByTool(tool: ToolName, filePath: string, content: string): ReverseResult { + switch (tool) { + case 'claude': + case 'claude-internal': + return reverseFromClaude(filePath, content); + case 'codebuddy': + return reverseFromCodebuddy(filePath, content); + case 'codex': + case 'codex-internal': + return reverseFromCodex(filePath, content); + case 'cursor': + return reverseFromCursor(filePath, content); + } +} diff --git a/src/resources/index.ts b/src/resources/index.ts index 70bec11..663b002 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -4,6 +4,7 @@ import { RulesHandler } from './rules.js'; import { DocsHandler } from './docs.js'; import { EnvHandler } from './env.js'; import { WikiHandler } from './wiki.js'; +import { AgentsHandler } from './agents.js'; import type { ResourceType } from '../types.js'; const handlers: Record = { @@ -12,6 +13,7 @@ const handlers: Record = { docs: new DocsHandler(), env: new EnvHandler(), wiki: new WikiHandler(), + agents: new AgentsHandler(), }; export function getHandler(type: ResourceType): ResourceHandler { @@ -22,4 +24,4 @@ export function getAllHandlers(): ResourceHandler[] { return Object.values(handlers); } -export { SkillsHandler, RulesHandler, DocsHandler, EnvHandler, WikiHandler }; +export { SkillsHandler, RulesHandler, DocsHandler, EnvHandler, WikiHandler, AgentsHandler }; diff --git a/src/review-cmd.ts b/src/review-cmd.ts new file mode 100644 index 0000000..85e3186 --- /dev/null +++ b/src/review-cmd.ts @@ -0,0 +1,290 @@ +// -*- coding: utf-8 -*- +/** + * review 子命令实现:list / show / apply / reject 待处理项。 + * + * 支持 --apply / --reject / --all-apply 等操作,apply 时调用 patchManagedSection 落盘。 + */ + +import path from 'node:path'; + +import chalk from 'chalk'; +import fs from 'fs-extra'; + +import { appendHistory } from './domains/index.js'; +import { patchManagedSection } from './section-patcher.js'; +import type { GlobalOptions } from './types.js'; +import { log } from './utils/logger.js'; +import { + loadPendingReview, + removePendingReview, + savePendingReview, + type PendingReviewItem, + type Risk, +} from './review-store.js'; + +// ─── 类型 ──────────────────────────────────────────────── + +export interface ReviewCmdOptions extends GlobalOptions { + /** 位置参数:单条 ID(从 commander argument 取) */ + idArg?: string; + apply?: boolean; + reject?: boolean; + reason?: string; + allApply?: boolean; + /** --all-apply 时按风险过滤;默认 medium */ + maxRisk?: Risk; + json?: boolean; +} + +// ─── 风险排序 ──────────────────────────────────────────── + +const RISK_ORDER: Record = { high: 0, medium: 1, low: 2 }; + +function riskAtMost(itemRisk: Risk, ceiling: Risk): boolean { + return RISK_ORDER[itemRisk] >= RISK_ORDER[ceiling]; +} + +// ─── 渲染辅助 ──────────────────────────────────────────── + +function riskColor(risk: Risk): string { + if (risk === 'high') return chalk.red(risk); + if (risk === 'medium') return chalk.yellow(risk); + return chalk.green(risk); +} + +function truncate(str: string, maxLen: number): string { + return str.length > maxLen ? str.slice(0, maxLen - 1) + '…' : str; +} + +function renderList(items: PendingReviewItem[]): void { + const counts = { high: 0, medium: 0, low: 0 }; + for (const item of items) counts[item.risk]++; + + console.log( + chalk.bold(`[review] 共 ${items.length} 项`) + + `(high: ${counts.high}, medium: ${counts.medium}, low: ${counts.low})`, + ); + + if (items.length === 0) return; + + const header = [ + 'ID'.padEnd(14), + 'RISK'.padEnd(10), + 'KIND'.padEnd(22), + 'TARGET'.padEnd(42), + 'SOURCE', + ].join(' '); + console.log(chalk.dim(header)); + + for (const item of items) { + const target = item.target.section + ? `${truncate(item.target.file, 20)}:${truncate(item.target.section, 18)}` + : truncate(item.target.file, 40); + const row = [ + item.id.padEnd(14), + riskColor(item.risk).padEnd(10 + (riskColor(item.risk).length - item.risk.length)), + item.kind.padEnd(22), + truncate(target, 40).padEnd(42), + truncate(item.source, 30), + ].join(' '); + console.log(row); + } +} + +function renderShow(item: PendingReviewItem): void { + console.log(chalk.bold('─── Pending Review Item ─────────────────')); + console.log(` ${chalk.cyan('ID')}: ${item.id}`); + console.log(` ${chalk.cyan('ts')}: ${item.ts}`); + console.log(` ${chalk.cyan('kind')}: ${item.kind}`); + console.log(` ${chalk.cyan('risk')}: ${riskColor(item.risk)}`); + console.log(` ${chalk.cyan('source')}: ${item.source}`); + console.log(` ${chalk.cyan('target')}:`); + console.log(` file: ${item.target.file}`); + if (item.target.section) { + console.log(` section: ${item.target.section}`); + } + + const content = item.payload['content']; + if (typeof content === 'string' && content) { + console.log(` ${chalk.cyan('content')}:`); + const lines = content.split('\n').slice(0, 20); + for (const line of lines) { + console.log(` ${line}`); + } + if (content.split('\n').length > 20) { + console.log(chalk.dim(' ... (truncated)')); + } + } else { + console.log(` ${chalk.cyan('payload')}: ${JSON.stringify(item.payload)}`); + } + console.log(chalk.bold('──────────────────────────────────────────')); +} + +// ─── Apply 核心逻辑 ────────────────────────────────────── + +async function applyOne( + cwd: string, + item: PendingReviewItem, +): Promise<{ ok: boolean; reason?: string }> { + if (item.kind !== 'codebase-section') { + return { ok: false, reason: `kind ${item.kind} 不支持自动应用,请人工处理` }; + } + + const { file, section } = item.target; + if (!section) { + return { ok: false, reason: 'target.section 缺失' }; + } + + const filePath = path.isAbsolute(file) ? file : path.join(cwd, file); + if (!await fs.pathExists(filePath)) { + return { ok: false, reason: `目标文件不存在:${filePath}` }; + } + + const oldMd = await fs.readFile(filePath, 'utf8'); + const body = String(item.payload['content'] ?? ''); + if (!body) { + return { ok: false, reason: 'payload.content 为空' }; + } + + try { + const newMd = patchManagedSection(oldMd, section, body, { + source: item.source, + syncedAt: new Date().toISOString(), + }); + await fs.writeFile(filePath, newMd, 'utf8'); + return { ok: true }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : String(err) }; + } +} + +// ─── 主入口 ────────────────────────────────────────────── + +/** + * review 子命令主函数,分发 list / show / apply / reject / all-apply 模式。 + */ +export async function reviewCmd(opts: ReviewCmdOptions): Promise { + const cwd = process.cwd(); + const { idArg, apply, reject, allApply, maxRisk = 'medium', json: jsonMode } = opts; + + // ── all-apply 模式 ──────────────────────────────────── + if (allApply) { + const items = await loadPendingReview(cwd); + const candidates = items.filter( + (item) => item.kind === 'codebase-section' && riskAtMost(item.risk, maxRisk), + ); + const skipped = items.filter( + (item) => item.kind !== 'codebase-section' || !riskAtMost(item.risk, maxRisk), + ); + + const results: Array<{ id: string; ok: boolean; reason?: string }> = []; + for (const item of candidates) { + const result = await applyOne(cwd, item); + if (result.ok) { + await removePendingReview(cwd, item.id); + await appendHistory(cwd, { + ts: new Date().toISOString(), + actor: 'user', + action: 'accept', + details: { id: item.id, target: item.target }, + }); + } + results.push({ id: item.id, ok: result.ok, reason: result.reason }); + } + + if (jsonMode) { + console.log(JSON.stringify({ results, skipped: skipped.map((s) => s.id) })); + return; + } + + const succeeded = results.filter((r) => r.ok); + const failed = results.filter((r) => !r.ok); + const summary = `[review] --all-apply 完成:成功 ${succeeded.length},失败 ${failed.length},跳过 ${skipped.length}`; + console.log(chalk.bold(summary)); + for (const fail of failed) { + console.log(chalk.red(` ✗ ${fail.id}: ${fail.reason}`)); + } + for (const skip of skipped) { + console.log(chalk.dim(` ○ 跳过 ${skip.id}(kind=${skip.kind}, risk=${skip.risk})`)); + } + return; + } + + // ── 无 idArg → list 模式 ────────────────────────────── + if (!idArg) { + const items = await loadPendingReview(cwd); + const sorted = [...items].sort((a, b) => RISK_ORDER[a.risk] - RISK_ORDER[b.risk]); + + if (jsonMode) { + console.log(JSON.stringify(sorted)); + return; + } + + renderList(sorted); + return; + } + + // ── 有 idArg 先查出条目 ─────────────────────────────── + const items = await loadPendingReview(cwd); + const item = items.find((i) => i.id === idArg); + + if (!item) { + log.warn(`[review] 未找到 id="${idArg}"`); + if (jsonMode) { + console.log(JSON.stringify({ ok: false, reason: `未找到 id="${idArg}"` })); + } + return; + } + + // ── reject 模式 ─────────────────────────────────────── + if (reject) { + await removePendingReview(cwd, idArg); + await appendHistory(cwd, { + ts: new Date().toISOString(), + actor: 'user', + action: 'reject', + details: { id: idArg, reason: opts.reason ?? '' }, + }); + + if (jsonMode) { + console.log(JSON.stringify({ ok: true, action: 'reject', id: idArg })); + return; + } + console.log(chalk.yellow(`[review] 已拒绝:${idArg}`)); + return; + } + + // ── apply 模式 ──────────────────────────────────────── + if (apply) { + const result = await applyOne(cwd, item); + + if (result.ok) { + await removePendingReview(cwd, idArg); + await appendHistory(cwd, { + ts: new Date().toISOString(), + actor: 'user', + action: 'accept', + details: { id: idArg, target: item.target }, + }); + } + + if (jsonMode) { + console.log(JSON.stringify({ ok: result.ok, reason: result.reason, id: idArg })); + return; + } + + if (result.ok) { + console.log(chalk.green(`[review] 已应用:${idArg} → ${item.target.file}`)); + } else { + console.log(chalk.red(`[review] 应用失败:${idArg} — ${result.reason}`)); + } + return; + } + + // ── show 模式(默认,无 --apply / --reject)─────────── + if (jsonMode) { + console.log(JSON.stringify(item)); + return; + } + renderShow(item); +} diff --git a/src/review-store.ts b/src/review-store.ts new file mode 100644 index 0000000..4acb359 --- /dev/null +++ b/src/review-store.ts @@ -0,0 +1,240 @@ +// -*- coding: utf-8 -*- +/** + * Pending-review 存储层:读写 .teamai/pending-review.jsonl。 + * + * 负责新 schema 的增删改查,以及将旧条目(iwiki-dual.ts 写出格式)归一化为新 schema。 + */ + +import crypto from 'node:crypto'; +import path from 'node:path'; + +import fs from 'fs-extra'; + +import { log } from './utils/logger.js'; + +// ─── 类型 ──────────────────────────────────────────────── + +export type Risk = 'high' | 'medium' | 'low'; + +export interface PendingReviewItem { + id: string; + ts: string; + kind: 'codebase-section' | 'domain-drift' | 'multi-source-conflict'; + target: { file: string; section?: string }; + payload: Record; + source: string; + risk: Risk; +} + +// ─── 常量 ──────────────────────────────────────────────── + +export const PENDING_REVIEW_PATH = '.teamai/pending-review.jsonl'; + +/** 高风险章节名称集合。 */ +const HIGH_RISK_SECTIONS = new Set([ + '架构决策与权衡', '架构', 'architecture', + '目录结构与模块职责', '模块依赖', 'modules', 'dependencies', + 'external-knowledge', '外部知识源', +]); +/** 反序列化大小上限:10 MB,防止超大文件导致 OOM。 */ +const MAX_CONFIG_FILE_BYTES = 10 * 1024 * 1024; + +// ─── 工具函数 ──────────────────────────────────────────── + +/** + * 获取 pending-review.jsonl 的绝对路径。 + */ +export function getPendingReviewPath(cwd: string): string { + return path.join(cwd, PENDING_REVIEW_PATH); +} + +/** + * 计算条目 ID:sha1(file|section|ts) 取前 12 位十六进制。 + */ +export function computeReviewId( + file: string, + section: string | undefined, + ts: string, +): string { + return crypto + .createHash('sha1') + .update(`${file}|${section ?? ''}|${ts}`) + .digest('hex') + .slice(0, 12); +} + +/** + * 推断风险等级。 + * + * 高风险章节或含 external-knowledge 路径 → high;其余 → medium。 + */ +export function inferRisk(target: { file: string; section?: string }): Risk { + if (target.section && HIGH_RISK_SECTIONS.has(target.section)) return 'high'; + if (target.file.includes('external-knowledge')) return 'high'; + return 'medium'; +} + +// ─── 旧 schema 归一化 ──────────────────────────────────── + +interface LegacyRecord { + ts?: string; + type?: string; + file?: string; + section?: string; + source?: string; + content?: string; + [key: string]: unknown; +} + +/** + * 将旧格式条目归一化为新 schema。 + * 若条目已是新 schema(含 kind 字段),直接返回。 + */ +function normalizeItem(raw: Record): PendingReviewItem | null { + const legacy = raw as LegacyRecord; + + // 新 schema 判断:含 kind 字段 + if (typeof raw['kind'] === 'string') { + const item = raw as Partial; + const file = item.target?.file ?? ''; + const section = item.target?.section; + const ts = item.ts ?? new Date().toISOString(); + return { + id: item.id ?? computeReviewId(file, section, ts), + ts, + kind: item.kind ?? 'codebase-section', + target: { file, section }, + payload: item.payload ?? {}, + source: item.source ?? '', + risk: item.risk ?? inferRisk({ file, section }), + }; + } + + // 旧 schema:type / file / section / content + if (legacy.type === 'codebase-section' || legacy.file !== undefined) { + const file = legacy.file ?? ''; + const section = legacy.section; + const ts = legacy.ts ?? new Date().toISOString(); + return { + id: computeReviewId(file, section, ts), + ts, + kind: 'codebase-section', + target: { file, section }, + payload: legacy.content !== undefined ? { content: legacy.content } : {}, + source: legacy.source ?? '', + risk: inferRisk({ file, section }), + }; + } + + return null; +} + +// ─── 核心 API ──────────────────────────────────────────── + +/** + * 读取 jsonl 全部条目,归一化旧 schema 到新 schema。 + * + * 文件不存在 → 返回空数组(不抛错)。 + * 行解析失败 → 跳过该行并 log.debug。 + */ +export async function loadPendingReview(cwd: string): Promise { + const filePath = getPendingReviewPath(cwd); + if (!await fs.pathExists(filePath)) { + return []; + } + + const stat = await fs.stat(filePath); + if (stat.size > MAX_CONFIG_FILE_BYTES) { + throw new Error(`${filePath} exceeds max allowed size 10MB`); + } + + const text = await fs.readFile(filePath, 'utf8'); + const items: PendingReviewItem[] = []; + + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let parsed: Record; + try { + parsed = JSON.parse(trimmed) as Record; + } catch (err) { + log.debug(`[review-store] 跳过损坏行: ${trimmed.slice(0, 80)} — ${String(err)}`); + continue; + } + + const normalized = normalizeItem(parsed); + if (normalized) { + items.push(normalized); + } else { + log.debug(`[review-store] 跳过无法识别的条目: ${trimmed.slice(0, 80)}`); + } + } + + return items; +} + +/** + * 覆盖式写入整个 jsonl(每行一个 JSON)。原子性:先写 .tmp 再 rename。 + */ +export async function savePendingReview(cwd: string, items: PendingReviewItem[]): Promise { + const filePath = getPendingReviewPath(cwd); + const tmpPath = `${filePath}.tmp`; + + await fs.ensureDir(path.dirname(filePath)); + const content = items.map((item) => JSON.stringify(item)).join('\n') + (items.length > 0 ? '\n' : ''); + await fs.writeFile(tmpPath, content, 'utf8'); + await fs.rename(tmpPath, filePath); +} + +/** + * 追加单个条目到 jsonl 末尾(不读全量;高效)。 + * + * 输入若缺 id 自动计算;缺 ts 自动填 now;缺 risk 自动推断。 + * + * @returns 实际落盘的 PendingReviewItem + */ +export async function appendPendingReview( + cwd: string, + partial: Omit & { + id?: string; + ts?: string; + risk?: Risk; + }, +): Promise { + const ts = partial.ts ?? new Date().toISOString(); + const { file, section } = partial.target; + const id = partial.id ?? computeReviewId(file, section, ts); + const risk = partial.risk ?? inferRisk(partial.target); + + const item: PendingReviewItem = { + id, + ts, + kind: partial.kind, + target: partial.target, + payload: partial.payload, + source: partial.source, + risk, + }; + + const filePath = getPendingReviewPath(cwd); + await fs.ensureDir(path.dirname(filePath)); + await fs.appendFile(filePath, JSON.stringify(item) + '\n', 'utf8'); + + return item; +} + +/** + * 按 id 移除条目。返回是否真的移除。 + */ +export async function removePendingReview(cwd: string, id: string): Promise { + const items = await loadPendingReview(cwd); + const filtered = items.filter((item) => item.id !== id); + + if (filtered.length === items.length) { + return false; + } + + await savePendingReview(cwd, filtered); + return true; +} diff --git a/src/section-patcher.ts b/src/section-patcher.ts new file mode 100644 index 0000000..f3b3b1f --- /dev/null +++ b/src/section-patcher.ts @@ -0,0 +1,446 @@ +import crypto from 'node:crypto'; + +// ─── Types ────────────────────────────────────────────── + +export interface ManagedSection { + /** 切片 slug(来自标题,唯一) */ + slug: string; + /** 章节标题(不含 ## 前缀) */ + title: string; + /** body 内容(不含开闭锚点、不含 ## 标题行;保留前后空行) */ + body: string; + /** body 的 sha1(hex 前 16 位) */ + bodyHash: string; + /** 写入时的 source 字段 */ + source?: string; + /** 写入时的 syncedAt 字段 */ + syncedAt?: string; +} + +// ─── Internal helpers ─────────────────────────────────── + +/** + * 从标题文本生成 slug:去空格(用 -)、替换路径分隔符,保留中文。 + * 重复 slug 在调用处处理(加 -2 / -3 后缀)。 + */ +function slugify(title: string): string { + return title.trim().replace(/\s+/g, '-').replace(/[\/\\:]/g, '_'); +} + +/** + * 解析并去除 frontmatter(首部 `---...---` 块)。 + * 返回 { frontmatter: 原始 frontmatter 文本含首尾 ---,rest: 剩余内容 }。 + * 若不存在,frontmatter 为空字符串。 + */ +function extractFrontmatter(md: string): { frontmatter: string; rest: string } { + if (!md.startsWith('---')) { + return { frontmatter: '', rest: md }; + } + const endIdx = md.indexOf('\n---', 3); + if (endIdx === -1) { + return { frontmatter: '', rest: md }; + } + const fmEnd = endIdx + 4; // past '\n---' + // 可能后面还有 \n + const afterFm = md[fmEnd] === '\n' ? fmEnd + 1 : fmEnd; + return { + frontmatter: md.slice(0, afterFm), + rest: md.slice(afterFm), + }; +} + +// ─── Public API ───────────────────────────────────────── + +/** + * body 的 sha1 hex 前 16 位。 + * + * 归一化:去掉前后空行,行末 trailing whitespace 统一。 + */ +export function hashBody(body: string): string { + const normalized = body + .split('\n') + .map((line) => line.trimEnd()) + .join('\n') + .trim(); + return crypto.createHash('sha1').update(normalized, 'utf8').digest('hex').slice(0, 16); +} + +/** + * 把整篇 markdown 按 `^## ` 二级标题切分为章节。 + * + * 行为: + * - frontmatter 区段(首部 `---...---`)保留作为返回的 `prelude` 字段 + * - 第一个 `## ` 之前但 frontmatter 之后的内容也归入 prelude + * - 每个 `## 标题` 至下一个 `## ` 之间为一个 section(不含开闭锚点) + * - 标题行被剥离,仅 title 字段保留 + * - slug 重复时第二个加 -2 后缀,第三个 -3,依此类推 + * + * @returns { prelude, sections } 顺序保留 + */ +export function splitToSections(md: string): { prelude: string; sections: ManagedSection[] } { + const { frontmatter, rest } = extractFrontmatter(md); + + const lines = rest.split('\n'); + const sections: ManagedSection[] = []; + const slugCounts: Map = new Map(); + + // 找出所有 ## 标题的行号 + const headerIndices: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (/^## /.test(lines[i])) { + headerIndices.push(i); + } + } + + if (headerIndices.length === 0) { + return { prelude: frontmatter + rest, sections: [] }; + } + + // prelude = frontmatter + 第一个 ## 前的内容 + const preludeRest = lines.slice(0, headerIndices[0]).join('\n'); + const prelude = frontmatter + preludeRest; + + for (let hi = 0; hi < headerIndices.length; hi++) { + const headerLineIdx = headerIndices[hi]; + const title = lines[headerLineIdx].replace(/^## /, '').trim(); + + const bodyStartIdx = headerLineIdx + 1; + const bodyEndIdx = hi + 1 < headerIndices.length ? headerIndices[hi + 1] : lines.length; + const bodyLines = lines.slice(bodyStartIdx, bodyEndIdx); + // 去掉末尾的空行(章节间间距由 joinSections 控制) + while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === '') { + bodyLines.pop(); + } + const body = bodyLines.join('\n'); + + const baseSlug = slugify(title); + const count = slugCounts.get(baseSlug) ?? 0; + slugCounts.set(baseSlug, count + 1); + const slug = count === 0 ? baseSlug : `${baseSlug}-${count + 1}`; + + sections.push({ + slug, + title, + body, + bodyHash: hashBody(body), + }); + } + + return { prelude, sections }; +} + +/** + * 把 sections 重新组装为完整 markdown,每个 section 加上开闭 HTML 锚点。 + * + * 输出形如: + * + * + * ## + * <body> + * <!-- /managed-by: <slug> --> + * + * <!-- ... 下一个 section ... --> + */ +export function joinSections(prelude: string, sections: ManagedSection[]): string { + if (sections.length === 0) { + return prelude; + } + + // 规范化 prelude:去掉尾部所有换行,统一加一个 \n,再加一个 \n 作为与首章节的间隔 + const preludeNorm = prelude.replace(/\n+$/, '') + '\n'; + + const sectionStrs = sections.map((section) => { + const metaParts = ['managed-by: import --from-repo', `section: ${section.slug}`]; + if (section.source) { + metaParts.push(`source: ${section.source}`); + } + if (section.syncedAt) { + metaParts.push(`syncedAt: ${section.syncedAt}`); + } + const openAnchor = `<!-- ${metaParts.join(', ')} -->`; + const closeAnchor = `<!-- /managed-by: ${section.slug} -->`; + return `${openAnchor}\n## ${section.title}\n${section.body}\n${closeAnchor}`; + }); + + return preludeNorm + '\n' + sectionStrs.join('\n\n') + '\n'; +} + +/** + * 从一份**已有锚点**的 markdown 中读取所有 ManagedSection(带 source / syncedAt)。 + * + * 行为: + * - 严格匹配开锚 `<!-- managed-by:[^>]+section:\s*([^,>\s]+)[^>]*-->` + * - 严格匹配闭锚 `<!-- /managed-by:\s*([^>\s]+)\s*-->` + * - 未配对的开锚 → 整个文档抛 Error('unclosed anchor: <slug>') + * - 不存在任何锚点 → 返回 { prelude: 整篇, sections: [] } + */ +export function parseSections(md: string): { prelude: string; sections: ManagedSection[] } { + // 同时接受 --from-repo 和 --from-iwiki 来源(保持写入侧锚点不变,解析侧放宽)。 + // [^>\n]{0,256}? 限制字符种类(不含换行)与长度上限(256),防止恶意输入触发 ReDoS 回溯。 + const openRe = /<!--\s*managed-by:\s*import\s+--from-(?:repo|iwiki),\s*section:\s*([^,>\s]+)([^>\n]{0,256}?)-->/g; + const closeRe = /<!--\s*\/managed-by:\s*([^>\n\s]{0,256}?)\s*-->/g; + + // 收集所有开锚 + const opens: Array<{ slug: string; extra: string; index: number; end: number }> = []; + let m: RegExpExecArray | null; + while ((m = openRe.exec(md)) !== null) { + opens.push({ slug: m[1], extra: m[2], index: m.index, end: m.index + m[0].length }); + } + + if (opens.length === 0) { + return { prelude: md, sections: [] }; + } + + // 收集所有闭锚 + const closes: Array<{ slug: string; index: number; end: number }> = []; + while ((m = closeRe.exec(md)) !== null) { + closes.push({ slug: m[1], index: m.index, end: m.index + m[0].length }); + } + + // 按 slug 配对(顺序匹配) + const sections: ManagedSection[] = []; + const closeUsed = new Set<number>(); + + for (const open of opens) { + const closeIdx = closes.findIndex((c, i) => c.slug === open.slug && !closeUsed.has(i) && c.index > open.end); + if (closeIdx === -1) { + throw new Error(`unclosed anchor: ${open.slug}`); + } + closeUsed.add(closeIdx); + const close = closes[closeIdx]; + + // 提取 body(开锚 end 到闭锚 start 之间) + let inner = md.slice(open.end, close.index); + // 首行可能是 \n## title\n... + const innerLines = inner.split('\n'); + // 跳过可能的空行后取标题 + let titleLine = ''; + let bodyStartLine = 0; + for (let i = 0; i < innerLines.length; i++) { + if (innerLines[i].trim() === '') { + continue; + } + if (/^## /.test(innerLines[i])) { + titleLine = innerLines[i].replace(/^## /, '').trim(); + bodyStartLine = i + 1; + } + break; + } + const bodyLines = innerLines.slice(bodyStartLine); + // 去末尾空行 + while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === '') { + bodyLines.pop(); + } + const body = bodyLines.join('\n'); + + // 解析 extra 里的 source / syncedAt + let source: string | undefined; + let syncedAt: string | undefined; + const srcMatch = open.extra.match(/source:\s*([^,>]+)/); + if (srcMatch) { + source = srcMatch[1].trim(); + } + const syncMatch = open.extra.match(/syncedAt:\s*([^,>]+)/); + if (syncMatch) { + syncedAt = syncMatch[1].trim(); + } + + sections.push({ + slug: open.slug, + title: titleLine, + body, + bodyHash: hashBody(body), + source, + syncedAt, + }); + } + + // prelude = 第一个开锚之前的内容 + const firstOpenIdx = opens[0].index; + const prelude = md.slice(0, firstOpenIdx); + + return { prelude, sections }; +} + +/** + * 单章节原地替换:在 md 中找到 slug 对应的开闭锚点对, + * 用 newBody / newSource / newSyncedAt 替换 body 与元数据,标题不变。 + * + * 找不到 slug 时抛 Error('section not found: <slug>')。 + */ +export function patchManagedSection( + md: string, + slug: string, + newBody: string, + meta: { source?: string; syncedAt?: string }, +): string { + // 同时接受 --from-repo 和 --from-iwiki 来源(写入侧锚点保持原样,解析侧放宽匹配)。 + // [^>\n]{0,256}? 限制字符种类与长度上限,防止 ReDoS 回溯。 + const fromVariants = '--from-(?:repo|iwiki)'; + const openRe = new RegExp( + `<!--\\s*managed-by:\\s*import\\s+${fromVariants},\\s*section:\\s*${escapeRegex(slug)}([^>\\n]{0,256}?)-->`, + ); + const closeRe = new RegExp(`<!--\\s*/managed-by:\\s*${escapeRegex(slug)}[^>\\n]{0,256}?\\s*-->`); + + const openMatch = openRe.exec(md); + if (!openMatch) { + throw new Error(`section not found: ${slug}`); + } + + const openStart = openMatch.index; + const openEnd = openStart + openMatch[0].length; + + const afterOpen = md.slice(openEnd); + const closeMatch = closeRe.exec(afterOpen); + if (!closeMatch) { + throw new Error(`section not found: ${slug}`); + } + + const closeStart = openEnd + closeMatch.index; + const closeEnd = closeStart + closeMatch[0].length; + + // 从旧开锚中提取标题 + const oldInner = md.slice(openEnd, closeStart); + let title = ''; + for (const line of oldInner.split('\n')) { + if (/^## /.test(line)) { + title = line.replace(/^## /, '').trim(); + break; + } + } + + // 构建新开锚 + const metaParts = ['managed-by: import --from-repo', `section: ${slug}`]; + if (meta.source) metaParts.push(`source: ${meta.source}`); + if (meta.syncedAt) metaParts.push(`syncedAt: ${meta.syncedAt}`); + const newOpen = `<!-- ${metaParts.join(', ')} -->`; + const newClose = `<!-- /managed-by: ${slug} -->`; + const newInner = `\n## ${title}\n${newBody}\n`; + + return md.slice(0, openStart) + newOpen + newInner + newClose + md.slice(closeEnd); +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * 三路合并: + * - oldFile:当前盘上文件(可能含旧锚点,也可能旧版本无锚点) + * - freshMd:generateCodebaseMd 刚产出的整篇(无锚点) + * - meta:本轮的 source / syncedAt + * + * 返回: + * - mergedMd:合并后的整篇(带锚点) + * - changedSlugs:本轮 body hash 改变的 slug 列表 + * - keptSlugs:body 完全相同、保留旧 syncedAt 的 slug 列表 + * - addedSlugs:fresh 中有、old 中没有的 slug + * - removedSlugs:old 中有、fresh 中没有的 slug + */ +export function mergeWithAnchors( + oldFile: string | null, + freshMd: string, + meta: { source: string; syncedAt: string }, +): { + mergedMd: string; + changedSlugs: string[]; + keptSlugs: string[]; + addedSlugs: string[]; + removedSlugs: string[]; +} { + const { prelude: freshPrelude, sections: freshSections } = splitToSections(freshMd); + + // 首次写入 + if (oldFile === null) { + const allSections = freshSections.map((s) => ({ + ...s, + source: meta.source, + syncedAt: meta.syncedAt, + })); + return { + mergedMd: joinSections(freshPrelude, allSections), + changedSlugs: [], + keptSlugs: [], + addedSlugs: allSections.map((s) => s.slug), + removedSlugs: [], + }; + } + + // 解析旧文件 + let oldPrelude: string; + let oldSections: ManagedSection[]; + try { + const parsed = parseSections(oldFile); + oldPrelude = parsed.prelude; + oldSections = parsed.sections; + } catch (err) { + // 解析失败(如未闭合锚点):重新抛出,由调用方(import-repo)决定是否备份后 fallback + throw err; + } + + // 无旧锚点:视为首次写入 + if (oldSections.length === 0) { + const allSections = freshSections.map((s) => ({ + ...s, + source: meta.source, + syncedAt: meta.syncedAt, + })); + return { + mergedMd: joinSections(freshPrelude, allSections), + changedSlugs: [], + keptSlugs: [], + addedSlugs: allSections.map((s) => s.slug), + removedSlugs: [], + }; + } + + const oldBySlug = new Map(oldSections.map((s) => [s.slug, s])); + const freshBySlug = new Map(freshSections.map((s) => [s.slug, s])); + + const changedSlugs: string[] = []; + const keptSlugs: string[] = []; + const addedSlugs: string[] = []; + const removedSlugs: string[] = []; + + // 按 fresh 顺序构建合并后 sections + const mergedSections: ManagedSection[] = []; + for (const freshSection of freshSections) { + const old = oldBySlug.get(freshSection.slug); + if (old) { + if (old.bodyHash === freshSection.bodyHash) { + // 保留旧 syncedAt + source + keptSlugs.push(freshSection.slug); + mergedSections.push({ ...freshSection, source: old.source, syncedAt: old.syncedAt }); + } else { + // 内容变了 + changedSlugs.push(freshSection.slug); + mergedSections.push({ ...freshSection, source: meta.source, syncedAt: meta.syncedAt }); + } + } else { + // 新章节 + addedSlugs.push(freshSection.slug); + mergedSections.push({ ...freshSection, source: meta.source, syncedAt: meta.syncedAt }); + } + } + + // 统计被删除的章节 + for (const oldSection of oldSections) { + if (!freshBySlug.has(oldSection.slug)) { + removedSlugs.push(oldSection.slug); + } + } + + // 若全部 kept(无 added/removed/changed),保留旧 frontmatter 避免 lastUpdated 变化 + let finalPrelude = freshPrelude; + if (changedSlugs.length === 0 && addedSlugs.length === 0 && removedSlugs.length === 0) { + finalPrelude = oldPrelude; + } + + return { + mergedMd: joinSections(finalPrelude, mergedSections), + changedSlugs, + keptSlugs, + addedSlugs, + removedSlugs, + }; +} diff --git a/src/status.ts b/src/status.ts index 3097325..837cb05 100644 --- a/src/status.ts +++ b/src/status.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import YAML from 'yaml'; import { autoDetectInit, loadStateForScope } from './config.js'; import { getRepoStatus } from './utils/git.js'; +import { assertSafeResourceName } from './utils/path-safety.js'; import { log } from './utils/logger.js'; import { getAllHandlers } from './resources/index.js'; import { listDirs, listFiles, pathExists, readFileSafe } from './utils/fs.js'; @@ -142,6 +143,17 @@ export async function list(type: string | undefined, options: ListOptions): Prom return; } + // Validate --agent to prevent path traversal attacks + if (options.agent != null) { + try { + assertSafeResourceName(options.agent); + } catch (err) { + log.error(`Invalid --agent: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 2; + return; + } + } + // --agent / --source local restrict the output to local skill scanning, // which is only meaningful for the "skills" resource type. const isSkillsScope = !type || type === 'skills'; diff --git a/src/todowrite-hint.ts b/src/todowrite-hint.ts new file mode 100644 index 0000000..d837ced --- /dev/null +++ b/src/todowrite-hint.ts @@ -0,0 +1,164 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { log } from './utils/logger.js'; + +// ─── TodoWrite hint data flow ─────────────────────────── +// +// PostToolUse hook (matcher: 'TodoWrite') +// │ +// ▼ +// teamai todowrite-hint --stdin --tool <name> +// │ +// ├─ Honor TEAMAI_RECALL_DISABLED=1 → exit silently +// ├─ Read STDIN { tool_name, session_id } +// ├─ Check ~/.teamai/sessions/<sid>-todowrite-hint.json +// │ → already hinted in this session? → exit +// │ +// └─ STDOUT JSON { hookSpecificOutput.additionalContext } +// "Reminder: invoke teamai-recall before starting tasks…" +// + +/** TTL for the dedup cache file: 24 hours. Older sessions are treated as fresh. */ +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +interface TodoWriteHintCache { + hinted: boolean; + updatedAt: string; +} + +interface HookInput { + toolName: string; + sessionId: string; +} + +/** + * Resolve the dedup cache path for a session. Co-located with auto-recall + * cache files under ~/.teamai/sessions/. + */ +export function getTodoWriteHintCachePath(sessionId: string): string { + return path.join( + process.env.HOME ?? '', + '.teamai', + 'sessions', + `${sessionId}-todowrite-hint.json`, + ); +} + +function readCache(sessionId: string): TodoWriteHintCache | null { + try { + const cachePath = getTodoWriteHintCachePath(sessionId); + if (!fs.existsSync(cachePath)) return null; + const raw = fs.readFileSync(cachePath, 'utf-8'); + const parsed = JSON.parse(raw) as TodoWriteHintCache; + const age = Date.now() - new Date(parsed.updatedAt).getTime(); + if (age > CACHE_TTL_MS) return null; + return parsed; + } catch { + return null; + } +} + +function writeCache(sessionId: string, cache: TodoWriteHintCache): void { + try { + const cachePath = getTodoWriteHintCachePath(sessionId); + const dir = path.dirname(cachePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf-8'); + } catch { + // best-effort; do not throw + } +} + +/** + * Returns true if a hint should be skipped for this session (already hinted + * or rate limited). Otherwise marks the session as hinted and returns false. + */ +export function shouldSkipTodoWriteHint(sessionId: string): boolean { + const cache = readCache(sessionId); + if (cache?.hinted) return true; + + writeCache(sessionId, { hinted: true, updatedAt: new Date().toISOString() }); + return false; +} + +/** + * Read PostToolUse STDIN JSON and return the minimal fields we care about. + * Returns null when STDIN is a TTY or JSON cannot be parsed. + */ +export async function readStdin(): Promise<HookInput | null> { + if (process.stdin.isTTY) return null; + + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + const raw = Buffer.concat(chunks).toString('utf-8'); + if (!raw.trim()) return null; + + try { + const data = JSON.parse(raw) as Record<string, unknown>; + const toolName = typeof data.tool_name === 'string' ? data.tool_name : ''; + const sessionId = + (typeof data.session_id === 'string' && data.session_id) || + process.env.CLAUDE_SESSION_ID || + `pid-${process.ppid ?? process.pid}`; + return { toolName, sessionId }; + } catch { + return null; + } +} + +/** + * Build the bilingual reminder text emitted via additionalContext. + * + * Kept as a small pure function so unit tests can assert on its content + * without exercising STDIN handling. + */ +export function buildHintMessage(): string { + return [ + '[teamai:todowrite-hint] 任务已规划。', + '', + '请确认本次任务开始前已通过 Agent tool 调用 teamai-recall subagent 完成知识库检索;', + '如未检索,请立即调用 teamai-recall(一次即可),完成后再继续后续 Todo。', + '', + 'Task plan detected — confirm you have already invoked the `teamai-recall`', + 'subagent for relevant team knowledge before executing the todo list.', + 'If not, invoke it once now.', + ].join('\n'); +} + +/** + * Entry point for `teamai todowrite-hint --stdin --tool <name>`. + * + * Behavior: + * - Honors TEAMAI_RECALL_DISABLED=1 (silent exit). + * - Returns immediately when STDIN is missing or tool is not TodoWrite. + * - Per-session deduplication: at most one hint per session per 24h. + * - On match, writes a hookSpecificOutput JSON line to STDOUT. + */ +export async function todoWriteHint(): Promise<void> { + if (process.env.TEAMAI_RECALL_DISABLED === '1') return; + + const input = await readStdin(); + if (!input) { + log.debug('todowrite-hint: no STDIN data'); + return; + } + + // Some hosts wire the hook with matcher='*' instead of 'TodoWrite' — in that + // case we self-filter to keep the hint focused. + if (input.toolName !== 'TodoWrite') return; + + if (shouldSkipTodoWriteHint(input.sessionId)) { + log.debug(`todowrite-hint: already hinted in session ${input.sessionId}`); + return; + } + + const hookOutput = JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: buildHintMessage(), + }, + }); + process.stdout.write(hookOutput + '\n'); +} diff --git a/src/types.ts b/src/types.ts index 8ce0572..7b604e7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,9 @@ export const ToolPathsSchema = z.object({ settings: z.string().optional(), claudemd: z.string().optional(), wiki: z.string().optional(), + /** Per-tool agents directory (Phase 1: teamai-recall subagent target). + * Optional — tools without subagent support omit this and agents sync skips them. */ + agents: z.string().optional(), }); // ─── Scope ────────────────────────────────────────────── @@ -92,12 +95,12 @@ export const TeamaiConfigSchema = z.object({ * opinion (preserves legacy behavior). */ autoUpdate: z.boolean().optional(), toolPaths: z.record(z.string(), ToolPathsSchema).default({ - claude: { skills: '.claude/skills', rules: '.claude/rules', settings: '.claude/settings.json', claudemd: '.claude/CLAUDE.md', wiki: '.claude/wiki' }, - codex: { skills: '.codex/skills', rules: '.codex/rules' }, - 'codex-internal': { skills: '.codex-internal/skills', rules: '.codex-internal/rules' }, - 'claude-internal': { skills: '.claude-internal/skills', rules: '.claude-internal/rules', settings: '.claude-internal/settings.json', claudemd: '.claude-internal/CLAUDE.md', wiki: '.claude-internal/wiki' }, - cursor: { skills: '.cursor/skills', rules: '.cursor/rules', settings: '.cursor/hooks.json' }, - codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', settings: '.codebuddy/settings.json', claudemd: '.codebuddy/CODEBUDDY.md' }, + claude: { skills: '.claude/skills', rules: '.claude/rules', settings: '.claude/settings.json', claudemd: '.claude/CLAUDE.md', wiki: '.claude/wiki', agents: '.claude/agents' }, + codex: { skills: '.codex/skills', rules: '.codex/rules', agents: '.codex/agents' }, + 'codex-internal': { skills: '.codex-internal/skills', rules: '.codex-internal/rules', agents: '.codex-internal/agents' }, + 'claude-internal': { skills: '.claude-internal/skills', rules: '.claude-internal/rules', settings: '.claude-internal/settings.json', claudemd: '.claude-internal/CLAUDE.md', wiki: '.claude-internal/wiki', agents: '.claude-internal/agents' }, + cursor: { skills: '.cursor/skills', rules: '.cursor/rules', settings: '.cursor/hooks.json', agents: '.cursor/agents' }, + codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', settings: '.codebuddy/settings.json', claudemd: '.codebuddy/CODEBUDDY.md', agents: '.codebuddy/agents' }, openclaw: { skills: '.openclaw/skills', rules: '.openclaw/rules' }, workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules' }, }), @@ -176,7 +179,7 @@ export interface TagsConfig { // ─── Resource types ───────────────────────────────────── -export type ResourceType = 'skills' | 'rules' | 'docs' | 'env' | 'wiki'; +export type ResourceType = 'skills' | 'rules' | 'docs' | 'env' | 'wiki' | 'agents'; export type ResourceItemStatus = 'new' | 'modified'; @@ -219,7 +222,7 @@ export const TEAMAI_STATE_PATH = `${TEAMAI_HOME}/state.json`; export const TEAMAI_TOKEN_PATH = `${TEAMAI_HOME}/token`; export const TEAMAI_UPDATE_LOCK_PATH = `${TEAMAI_HOME}/.update-lock`; -export const RESOURCE_TYPES: ResourceType[] = ['skills', 'rules', 'docs', 'env', 'wiki']; +export const RESOURCE_TYPES: ResourceType[] = ['skills', 'rules', 'docs', 'env', 'wiki', 'agents']; export const TEAMAI_RULES_START = '<!-- [teamai:rules:start] -->'; export const TEAMAI_RULES_END = '<!-- [teamai:rules:end] -->'; @@ -235,6 +238,10 @@ export const TEAMAI_CULTURE_END = '<!-- [teamai:culture:end] -->'; export const TEAMAI_CLAUDEMD_START = '<!-- [teamai:claudemd:start] -->'; export const TEAMAI_CLAUDEMD_END = '<!-- [teamai:claudemd:end] -->'; +// Phase 1: marker section for the recall-subagent rules block injected by `teamai pull`. +export const TEAMAI_RECALL_RULES_START = '<!-- [teamai:recall-rules:start] -->'; +export const TEAMAI_RECALL_RULES_END = '<!-- [teamai:recall-rules:end] -->'; + // ─── Usage tracking ──────────────────────────────────── /** Regex for valid skill names: alphanumeric, hyphens, underscores, colons, dots. Max 200 chars. */ @@ -434,6 +441,20 @@ export interface LearningDocMeta { tags?: string[]; } +/** Knowledge category for search index entries (Phase 1 expansion). */ +export type KnowledgeType = 'learnings' | 'docs' | 'rules' | 'skills'; + +/** + * Content domain of a knowledge entry (Phase 1.4). + * Used to weight search results: technical > neutral > ops > support. + * + * - technical: code bugs, API design, architecture decisions, debugging + * - ops: deployment SOPs, cluster operations, monitoring, CI/CD + * - support: user FAQs, product guides, onboarding materials + * - neutral: unclassifiable — no matching tags/path/type signal + */ +export type KnowledgeDomain = 'technical' | 'ops' | 'support' | 'neutral'; + /** One entry in the local search index (search-index.json). */ export interface SearchIndexEntry { /** Original filename (e.g. "api-timeout-修复-2026-03-20-abc123.md") */ @@ -450,16 +471,33 @@ export interface SearchIndexEntry { tokens: string[]; /** Vote count (aggregated at index build time) */ votes: number; + /** Source category: which knowledge bucket this entry came from. */ + type: KnowledgeType; + /** Content domain inferred from frontmatter / tags / path (Phase 1.4). */ + domain?: KnowledgeDomain; + /** Absolute path to the source file (Phase 4.3 hot/cold path support). */ + path?: string; + /** Optional hotness score reserved for Phase 4.3 hot/cold splitting. */ + hotness?: number; } +/** Schema version of the on-disk search-index.json (bump on breaking change). */ +export const SEARCH_INDEX_VERSION = 4; + /** Shape of the search-index.json file. */ export interface SearchIndex { + /** Schema version. Phase 1 introduces v2 (multi-category index). */ + version?: number; /** ISO timestamp of when the index was built */ builtAt: string; /** Elapsed ms to build the index */ elapsedMs: number; /** Index entries, one per learning document */ entries: SearchIndexEntry[]; + /** Document-frequency map: token → number of entries containing that token. + * Used for IDF weighting in search(). Optional for backward compatibility + * with indexes built before this field was introduced. */ + df?: Record<string, number>; } /** Per-user vote file (votes/<user>.yaml). */ @@ -545,3 +583,133 @@ export function isWikiEnabled(): boolean { if (process.env.TEAMAI_WIKI_ENABLED === '0' || process.env.TEAMAI_WIKI_ENABLED === 'false') return false; return true; } + +// ============================================================ +// Phase 0 + P4.4:Import 相关类型定义 +// ============================================================ + +/** + * Git MR/PR 的完整数据结构,由 provider.fetchMergeRequest() 返回。 + */ +export interface MRData { + /** MR 标题 */ + title: string; + /** MR 描述正文(Markdown) */ + description: string; + /** 关联的提交列表 */ + commits: Array<{ hash: string; message: string }>; + /** git diff 全文,截断至 50KB */ + diff: string; + /** 合并时间(ISO 8601),可选 */ + mergedAt?: string; + /** MR 作者用户名,可选 */ + author?: string; + /** MR 原始 URL */ + url: string; +} + +/** + * AI 对单个候选文件的分类结果。 + */ +export interface ClassifiedItem { + /** 源文件路径 */ + sourcePath: string; + /** 原始文件内容(前 3000 字) */ + rawContent: string; + /** 知识类型判断 */ + type: 'rule' | 'doc' | 'learning'; + /** AI 建议标题 */ + title: string; + /** AI 生成的摘要 */ + summary: string; + /** AI 建议的 tags */ + tags: string[]; + /** 分类置信度 0-1 */ + confidence: number; + /** 是否为个人偏好/环境特定配置(true 则过滤,不导入团队库) */ + isPersonal: boolean; +} + +/** + * 待推送的 learning 草稿(含完整 Markdown + frontmatter)。 + */ +export interface LearningDraft { + /** 文档标题 */ + title: string; + /** 完整 Markdown 内容(含 YAML frontmatter) */ + content: string; + /** 被本 draft 取代的 session learning 文件名列表 */ + supersedes?: string[]; +} + +/** + * codebase.md 的单条变更建议(由 MR 提炼产生)。 + */ +export interface CodebaseSuggestion { + /** 要更新的 codebase.md 段落名称 */ + section: string; + /** 操作类型 */ + action: 'add' | 'update' | 'noop'; + /** 建议写入的 Markdown 内容 */ + content: string; +} + +/** + * codebase.md lint 检查的单条问题。 + */ +export interface LintIssue { + /** 问题严重程度 */ + severity: 'high' | 'medium' | 'low'; + /** 问题类型 */ + category: 'contradiction' | 'outdated' | 'orphan' | 'missing'; + /** 问题位置(章节名或行号区间) */ + location: string; + /** 问题描述 */ + description: string; + /** 修复建议 */ + suggestion: string; +} + +/** + * lintCodebaseMd 的返回结构,包含所有发现的问题与总体摘要。 + */ +export interface LintReport { + /** 所有 lint 问题列表 */ + issues: LintIssue[]; + /** 一句话总结 */ + summary: string; +} + +/** + * 单条 import 会话条目,记录每个候选项的处理状态。 + */ +export interface ImportSessionItem { + /** 条目唯一 ID */ + id: string; + /** 来源文件路径(本地文件导入时) */ + sourcePath?: string; + /** MR URL(MR 导入时) */ + mrUrl?: string; + /** 处理状态 */ + status: 'pending' | 'accepted' | 'skipped' | 'edited'; + /** AI 生成的 learning 草稿 */ + learningDraft?: LearningDraft; + /** AI 生成的 codebase 变更建议 */ + codebaseSuggestions?: CodebaseSuggestion[]; +} + +/** + * import 会话的完整状态,持久化到 ~/.teamai/import-session.json 支持 --resume。 + */ +export interface ImportSession { + /** 会话唯一 ID */ + id: string; + /** 创建时间(ISO 8601) */ + createdAt: string; + /** 导入模式 */ + mode: 'local' | 'mr' | 'workspace'; + /** 所有候选条目 */ + items: ImportSessionItem[]; + /** 已处理条目数(用于 --resume 进度恢复) */ + progress: number; +} diff --git a/src/utils/ai-client.ts b/src/utils/ai-client.ts new file mode 100644 index 0000000..acd9edc --- /dev/null +++ b/src/utils/ai-client.ts @@ -0,0 +1,276 @@ +import { spawn, execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +/** 白名单:允许探测的 CLI 名称,防止意外执行任意命令。 */ +const ALLOWED_CLI_CANDIDATES = [ + 'claude', 'claude-internal', 'codex', 'codex-internal', 'codebuddy', 'workbuddy', 'openclaw', +] as const; + +/** CLI 探测超时(毫秒),防止 execFileSync 挂死。 */ +const CLI_DETECT_TIMEOUT_MS = 5_000; + +/** 默认 AI 调用超时时间(毫秒)。 */ +const DEFAULT_TIMEOUT_MS = 120_000; + +/** 默认并发数量上限。 */ +const DEFAULT_CONCURRENCY = 3; + +/** CLI 探测结果,包含命令名和绝对路径。 */ +interface CliInfo { + cmd: string; + absPath: string; +} + +/** + * 按优先级探测可用的 AI CLI,返回命令名与绝对路径。 + * + * 各 CLI 非交互调用语法不同: + * - claude / claude-internal / codebuddy / workbuddy / openclaw:`<cli> -p <prompt>` + * - codex / codex-internal:`<cli> exec <prompt>` + * + * 依次通过以下方式获取绝对路径,确保覆盖各类 shell 环境: + * 1. `bash -lc command -v <cmd>` —— login shell,覆盖 ~/.nvm/ 等路径 + * 2. `zsh -lc command -v <cmd>` —— macOS 默认 shell fallback + * 3. `which <cmd>` —— 最终 fallback,使用 process.env.PATH 直接查找 + * + * 探测顺序:`claude` → `claude-internal` → `codex` → `codex-internal` → `codebuddy` → `workbuddy` → `openclaw`。 + * 结果缓存,进程生命周期内只探测一次。 + * + * @returns 含 cmd 与 absPath 的 CliInfo 对象 + * @throws 所有候选均不可用时抛出 Error + */ +function detectClaudeCli(): CliInfo { + const candidates = ALLOWED_CLI_CANDIDATES; + + for (const cmd of candidates) { + // 策略 1:bash login shell(shell: false 是 execFileSync 默认行为,此处显式标注) + try { + const p = execFileSync('bash', ['-lc', `command -v ${cmd}`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + shell: false, + timeout: CLI_DETECT_TIMEOUT_MS, + }).trim(); + if (p && existsSync(p)) return { cmd, absPath: p }; + } catch { + // 继续尝试下一策略 + } + + // 策略 2:zsh login shell(macOS 默认 shell / bash 不可用时) + try { + const p = execFileSync('zsh', ['-lc', `command -v ${cmd}`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + shell: false, + timeout: CLI_DETECT_TIMEOUT_MS, + }).trim(); + if (p && existsSync(p)) return { cmd, absPath: p }; + } catch { + // 继续尝试下一策略 + } + + // 策略 3:which 命令(使用 process.env.PATH,覆盖 fish / CI 容器等环境) + try { + const p = execFileSync('which', [cmd], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + shell: false, + timeout: CLI_DETECT_TIMEOUT_MS, + }).trim(); + if (p && existsSync(p)) return { cmd, absPath: p }; + } catch { + // 此候选不可用,尝试下一个 + } + } + + throw new Error( + 'AI CLI 不可用:请安装以下任意一个 CLI 工具:' + + 'claude / claude-internal / codex / codex-internal / codebuddy / workbuddy / openclaw' + ); +} + +/** + * 根据 CLI 类型构建非交互参数数组。 + * + * 各 CLI 非交互调用语法: + * - codex / codex-internal:`exec <prompt>` + * - 其他(claude 系、codebuddy 等):`-p <prompt>` + * + * @param cmd CLI 命令名 + * @param prompt 传递给 CLI 的提示词 + * @returns 参数数组 + */ +function buildCliArgs(cmd: string, prompt: string): string[] { + if (cmd === 'codex' || cmd === 'codex-internal') { + return ['exec', prompt]; + } + return ['-p', prompt]; +} + +/** 缓存探测到的 CLI 信息,避免重复 execFileSync。 */ +let _cliInfo: CliInfo | undefined; + +/** + * 通过子进程直接调用 AI CLI(claude/codex 等),返回 stdout 文本。 + * + * 按 CLI 类型自动选择 -p 或 exec 子命令,直接 spawn 绝对路径,不走 bash -lc,彻底消除 shell 拼接。 + * CLI 探测优先级:`claude` → `claude-internal` → `codex` → `codex-internal` → `codebuddy` → `workbuddy` → `openclaw`, + * 结果缓存,进程内只探测一次。 + * + * @param prompt 传递给 CLI 的提示词 + * @param opts 可选参数:timeout 超时毫秒数,默认 120000 + * @returns CLI 输出的 stdout(已 trim) + * @throws 超时时抛出 `Error('AI call timed out after Xs')` + * @throws 退出码非 0 时抛出 `Error('AI call failed: <stderr>')` + * @throws 所有候选 CLI 均不可用时抛出 Error + */ +export async function callClaude( + prompt: string, + opts?: { timeout?: number } +): Promise<string> { + const timeoutMs = opts?.timeout ?? DEFAULT_TIMEOUT_MS; + + return new Promise<string>((resolve, reject) => { + const chunks: Buffer[] = []; + const errChunks: Buffer[] = []; + + if (_cliInfo === undefined) { + _cliInfo = detectClaudeCli(); + } + const child = spawn(_cliInfo.absPath, buildCliArgs(_cliInfo.cmd, prompt), { stdio: ['ignore', 'pipe', 'pipe'] }); + + child.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk)); + + // 超时控制 + const timer = setTimeout(() => { + child.kill(); + const seconds = Math.round(timeoutMs / 1000); + reject(new Error(`AI call timed out after ${seconds}s`)); + }, timeoutMs); + + child.on('error', (err: Error) => { + clearTimeout(timer); + reject(err); + }); + + child.on('close', (code: number | null) => { + clearTimeout(timer); + if (code !== 0) { + const stderr = Buffer.concat(errChunks).toString('utf-8').trim(); + reject(new Error(`AI call failed: ${stderr}`)); + return; + } + const stdout = Buffer.concat(chunks).toString('utf-8').trim(); + resolve(stdout); + }); + }); +} + +/** + * 并发调用 Claude CLI 处理多个任务,保持输入顺序返回结果。 + * + * 使用信号量控制并发上限,不引入外部依赖。 + * 采用 Promise.allSettled 语义:某个 task 失败不中断其他 task; + * 若存在任何失败,最终抛出 AggregateError。 + * + * @param tasks 任务列表,每项包含 prompt 和解析函数 parse + * @param concurrency 最大并发数,默认 3 + * @returns 按输入顺序排列的解析结果数组 + * @throws 若有任意 task 失败,抛出 AggregateError + */ +export async function callClaudeParallel<T>( + tasks: Array<{ prompt: string; parse: (output: string) => T }>, + concurrency: number = DEFAULT_CONCURRENCY +): Promise<T[]> { + const results = await runWithConcurrency(tasks, concurrency); + + const errors: unknown[] = []; + const values: T[] = []; + + for (const result of results) { + if (result.status === 'fulfilled') { + values.push(result.value); + } else { + errors.push(result.reason); + // 占位,保持数组长度与输入一致(后续不使用此位置) + values.push(undefined as unknown as T); + } + } + + if (errors.length > 0) { + throw new AggregateError(errors, `${errors.length} AI task(s) failed`); + } + + return values; +} + +/** + * 使用信号量并发控制运行任务列表,返回 PromiseSettledResult 数组。 + * + * @param tasks 任务列表 + * @param concurrency 最大并发数 + * @returns 按输入顺序的 PromiseSettledResult 数组 + */ +async function runWithConcurrency<T>( + tasks: Array<{ prompt: string; parse: (output: string) => T }>, + concurrency: number +): Promise<PromiseSettledResult<T>[]> { + const results: PromiseSettledResult<T>[] = new Array(tasks.length); + let running = 0; + let index = 0; + + // 等待队列:每个元素是一个 resolve 回调,用于唤醒等待中的 slot + const waitQueue: Array<() => void> = []; + + /** + * 获取一个并发 slot:若当前 running < concurrency 则立即获得; + * 否则将自身挂入等待队列,直到有 slot 释放。 + */ + async function acquireSlot(): Promise<void> { + if (running < concurrency) { + running++; + return; + } + await new Promise<void>((resolve) => waitQueue.push(resolve)); + running++; + } + + /** + * 释放一个并发 slot,并唤醒队列中第一个等待者。 + */ + function releaseSlot(): void { + running--; + const next = waitQueue.shift(); + if (next !== undefined) { + next(); + } + } + + /** + * 执行单个任务,将结果写入 results[taskIndex]。 + */ + async function runTask(taskIndex: number): Promise<void> { + await acquireSlot(); + try { + const task = tasks[taskIndex]; + const output = await callClaude(task.prompt); + const parsed = task.parse(output); + results[taskIndex] = { status: 'fulfilled', value: parsed }; + } catch (err: unknown) { + results[taskIndex] = { status: 'rejected', reason: err }; + } finally { + releaseSlot(); + } + } + + // 启动所有任务(acquireSlot 内部会阻塞超出并发限制的任务) + const promises: Promise<void>[] = []; + while (index < tasks.length) { + promises.push(runTask(index)); + index++; + } + + await Promise.all(promises); + return results; +} diff --git a/src/utils/cache-index.ts b/src/utils/cache-index.ts new file mode 100644 index 0000000..002da88 --- /dev/null +++ b/src/utils/cache-index.ts @@ -0,0 +1,419 @@ +import path from 'node:path'; +import os from 'node:os'; + +import fs from 'fs-extra'; + +import { log } from './logger.js'; + +// ─── Constants ─────────────────────────────────────────── + +const INDEX_FILENAME = '.cache-index.json'; +const DEFAULT_MAX_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB +const DEFAULT_TARGET_RATIO = 0.8; +const DEFAULT_STALE_DAYS = 30; +/** 反序列化大小上限:10 MB,防止超大文件导致 OOM。 */ +const MAX_CONFIG_FILE_BYTES = 10 * 1024 * 1024; + +// ─── Types ─────────────────────────────────────────────── + +/** + * 单个缓存仓条目的元信息。 + */ +export interface CacheIndexEntry { + /** 唯一键:<provider>/<owner>/<repo>,对应实际目录路径相对 cache root */ + key: string; + /** 全量字节数(递归 stat 累加;不区分 .git 与工作区) */ + size_bytes: number; + /** 最近一次访问(clone/fetch/scan)的 ISO 时间 */ + last_used: string; + /** 最近一次同步时拿到的 commit SHA */ + last_synced_sha?: string; +} + +/** + * 缓存索引文件的完整结构。 + */ +export interface CacheIndex { + version: 1; + updated_at: string; + entries: CacheIndexEntry[]; +} + +export interface GcOptions { + /** 默认 DEFAULT_MAX_BYTES(可被 TEAMAI_CACHE_MAX_BYTES 覆盖) */ + maxBytes?: number; + /** 默认 0.8 */ + targetRatio?: number; + /** 默认 30 */ + staleDays?: number; + dryRun?: boolean; +} + +export interface GcResult { + before: { totalBytes: number; entryCount: number }; + after: { totalBytes: number; entryCount: number }; + removed: Array<{ key: string; size_bytes: number; reason: 'over-cap' | 'stale' }>; + skipped: Array<{ key: string; reason: string }>; +} + +// ─── Helpers ──────────────────────────────────────────── + +/** + * 读取 cache root(与 repo-cache.ts 行为完全一致:env TEAMAI_CACHE_DIR 优先,否则 ~/.teamai/cache/repos)。 + */ +export function getCacheRoot(): string { + return process.env.TEAMAI_CACHE_DIR ?? path.join(os.homedir(), '.teamai', 'cache', 'repos'); +} + +/** + * 构建缓存条目 key:<provider>/<owner>/<repo> + * + * @param provider git provider 标识 + * @param owner 仓库属主(可含多级 group) + * @param repo 仓库名 + */ +function buildKey(provider: string, owner: string, repo: string): string { + return `${provider}/${owner}/${repo}`; +} + +/** + * 根据 key 计算缓存目录绝对路径。 + * + * @param key buildKey 生成的键 + */ +function keyToAbsPath(key: string): string { + return path.join(getCacheRoot(), key); +} + +// ─── Index I/O ─────────────────────────────────────────── + +/** + * 读取索引文件;不存在或损坏返回空索引(不抛错)。 + */ +export async function loadCacheIndex(): Promise<CacheIndex> { + const indexPath = path.join(getCacheRoot(), INDEX_FILENAME); + try { + const stat = await fs.stat(indexPath); + if (stat.size > MAX_CONFIG_FILE_BYTES) { + throw new Error(`${indexPath} exceeds max allowed size 10MB`); + } + const raw = await fs.readFile(indexPath, 'utf8'); + const parsed = JSON.parse(raw) as CacheIndex; + if (parsed.version !== 1 || !Array.isArray(parsed.entries)) { + log.debug('[cache-index] 索引格式不符,返回空索引'); + return emptyIndex(); + } + return parsed; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + log.debug(`[cache-index] 读取索引失败,返回空索引: ${String(err)}`); + } + return emptyIndex(); + } +} + +/** + * 写入索引文件(覆盖式;调用方负责保证不并发)。 + */ +export async function saveCacheIndex(idx: CacheIndex): Promise<void> { + const root = getCacheRoot(); + await fs.ensureDir(root); + const indexPath = path.join(root, INDEX_FILENAME); + const updated: CacheIndex = { ...idx, updated_at: new Date().toISOString() }; + await fs.writeFile(indexPath, JSON.stringify(updated, null, 2), 'utf8'); +} + +function emptyIndex(): CacheIndex { + return { version: 1, updated_at: new Date().toISOString(), entries: [] }; +} + +// ─── Dir Size ──────────────────────────────────────────── + +/** + * 递归累加目录字节数。 + * + * 读取异常的子项跳过(log.debug)。软链接不跟随。 + * + * @param absPath 目录绝对路径 + */ +export async function statDirSize(absPath: string): Promise<number> { + let total = 0; + let stat: fs.Stats; + try { + stat = await fs.lstat(absPath); + } catch (err) { + log.debug(`[cache-index] statDirSize lstat 失败,跳过 ${absPath}: ${String(err)}`); + return 0; + } + + if (stat.isSymbolicLink()) { + return 0; + } + + if (stat.isFile()) { + return stat.size; + } + + if (!stat.isDirectory()) { + return 0; + } + + let entries: fs.Dirent[]; + try { + entries = await fs.readdir(absPath, { withFileTypes: true }); + } catch (err) { + log.debug(`[cache-index] statDirSize readdir 失败,跳过 ${absPath}: ${String(err)}`); + return 0; + } + + for (const entry of entries) { + const childPath = path.join(absPath, entry.name); + if (entry.isSymbolicLink()) { + continue; + } + if (entry.isDirectory()) { + total += await statDirSize(childPath); + } else if (entry.isFile()) { + try { + const childStat = await fs.lstat(childPath); + total += childStat.size; + } catch (err) { + log.debug(`[cache-index] statDirSize 子文件 stat 失败,跳过: ${String(err)}`); + } + } + } + + return total; +} + +// ─── Touch ─────────────────────────────────────────────── + +/** + * 把单个 entry 的元信息刷新到索引: + * - size_bytes 用 statDirSize(absPath) 重算 + * - last_used = now + * - last_synced_sha = lastSyncedSha(若提供) + * - 已存在则更新;不存在则新增 + * + * 不会触发 GC;GC 由单独入口控制。 + * + * @param args.provider git provider 标识 + * @param args.owner 仓库属主 + * @param args.repo 仓库名 + * @param args.lastSyncedSha 本次同步的 commit SHA(可选) + */ +export async function touchCacheEntry(args: { + provider: string; + owner: string; + repo: string; + lastSyncedSha?: string; +}): Promise<void> { + const { provider, owner, repo, lastSyncedSha } = args; + const key = buildKey(provider, owner, repo); + const absPath = keyToAbsPath(key); + + const sizeBytes = await statDirSize(absPath); + + const idx = await loadCacheIndex(); + const existingIdx = idx.entries.findIndex((e) => e.key === key); + + const newEntry: CacheIndexEntry = { + key, + size_bytes: sizeBytes, + last_used: new Date().toISOString(), + ...(lastSyncedSha !== undefined ? { last_synced_sha: lastSyncedSha } : {}), + }; + + // 保留已有 last_synced_sha(若本次未提供) + if (existingIdx >= 0) { + const existing = idx.entries[existingIdx]; + if (lastSyncedSha === undefined && existing.last_synced_sha !== undefined) { + newEntry.last_synced_sha = existing.last_synced_sha; + } + idx.entries[existingIdx] = newEntry; + } else { + idx.entries.push(newEntry); + } + + await saveCacheIndex(idx); +} + +// ─── GC ────────────────────────────────────────────────── + +/** + * 执行 GC: + * 1. 标记所有 last_used > staleDays 的 entry 为 'stale',无条件淘汰 + * 2. 若剩余总量仍 > maxBytes,按 last_used 升序(最旧优先)淘汰,直到 ≤ maxBytes * targetRatio + * 3. 删除磁盘目录 + 从索引移除 entry + * 4. dryRun=true 仅汇报不动盘 + * + * 淘汰物理路径用 fs.remove(不区分 .git)。 + * + * @param opts GC 参数 + * @returns GcResult 含前后对比 + 被删 / 被跳过列表 + */ +export async function gcCache(opts?: GcOptions): Promise<GcResult> { + const { + targetRatio = DEFAULT_TARGET_RATIO, + dryRun = false, + } = opts ?? {}; + + // 解析 maxBytes:opts 优先,其次 env,最后默认 + let maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES; + const envVal = process.env.TEAMAI_CACHE_MAX_BYTES; + if (opts?.maxBytes === undefined && envVal !== undefined) { + const parsed = parseInt(envVal, 10); + if (!isNaN(parsed) && parsed > 0) { + maxBytes = parsed; + } + } + + const staleDays = opts?.staleDays ?? DEFAULT_STALE_DAYS; + + const idx = await loadCacheIndex(); + + const beforeTotal = idx.entries.reduce((s, e) => s + e.size_bytes, 0); + const beforeCount = idx.entries.length; + + const removed: GcResult['removed'] = []; + const skipped: GcResult['skipped'] = []; + + // 阶段 1:淘汰 stale 条目 + const staleThresholdMs = staleDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + + const remaining: CacheIndexEntry[] = []; + + for (const entry of idx.entries) { + const lastUsedMs = new Date(entry.last_used).getTime(); + const isStale = now - lastUsedMs > staleThresholdMs; + + if (isStale) { + const absPath = keyToAbsPath(entry.key); + if (!dryRun) { + try { + await fs.remove(absPath); + removed.push({ key: entry.key, size_bytes: entry.size_bytes, reason: 'stale' }); + } catch (err) { + log.debug(`[gc] 删除失败,跳过 ${entry.key}: ${String(err)}`); + skipped.push({ key: entry.key, reason: `删除失败: ${String(err)}` }); + remaining.push(entry); + } + } else { + removed.push({ key: entry.key, size_bytes: entry.size_bytes, reason: 'stale' }); + } + } else { + remaining.push(entry); + } + } + + // 阶段 2:容量上限淘汰(按 last_used 升序) + const targetBytes = maxBytes * targetRatio; + let currentTotal = remaining.reduce((s, e) => s + e.size_bytes, 0); + + if (currentTotal > maxBytes) { + // 最旧优先 + remaining.sort((a, b) => new Date(a.last_used).getTime() - new Date(b.last_used).getTime()); + + const toKeep: CacheIndexEntry[] = []; + + for (const entry of remaining) { + if (currentTotal <= targetBytes) { + toKeep.push(entry); + continue; + } + const absPath = keyToAbsPath(entry.key); + if (!dryRun) { + try { + await fs.remove(absPath); + removed.push({ key: entry.key, size_bytes: entry.size_bytes, reason: 'over-cap' }); + currentTotal -= entry.size_bytes; + } catch (err) { + log.debug(`[gc] 删除失败,跳过 ${entry.key}: ${String(err)}`); + skipped.push({ key: entry.key, reason: `删除失败: ${String(err)}` }); + toKeep.push(entry); + } + } else { + removed.push({ key: entry.key, size_bytes: entry.size_bytes, reason: 'over-cap' }); + currentTotal -= entry.size_bytes; + } + } + + // 更新 remaining 为保留部分 + remaining.length = 0; + remaining.push(...toKeep); + } + + // 更新索引 + const removedKeys = new Set(removed.map((r) => r.key)); + const finalEntries = dryRun + ? idx.entries.filter((e) => !removedKeys.has(e.key)) + : remaining; + + const updatedIdx: CacheIndex = { + ...idx, + entries: finalEntries, + updated_at: new Date().toISOString(), + }; + + if (!dryRun) { + await saveCacheIndex(updatedIdx); + } + + const afterTotal = updatedIdx.entries.reduce((s, e) => s + e.size_bytes, 0); + + return { + before: { totalBytes: beforeTotal, entryCount: beforeCount }, + after: { totalBytes: afterTotal, entryCount: updatedIdx.entries.length }, + removed, + skipped, + }; +} + +// ─── Status ────────────────────────────────────────────── + +/** + * 返回当前 cache 状态摘要(status 子命令用)。 + * + * 注意:会同步索引中已不存在于磁盘的 entry(自动剪除)。 + * + * @returns 根目录、总字节数、条目数、条目列表 + */ +export async function getCacheStatus(): Promise<{ + root: string; + totalBytes: number; + entryCount: number; + entries: CacheIndexEntry[]; +}> { + const root = getCacheRoot(); + const idx = await loadCacheIndex(); + + // 自愈:移除磁盘已不存在的条目 + const validEntries: CacheIndexEntry[] = []; + let dirty = false; + + for (const entry of idx.entries) { + const absPath = keyToAbsPath(entry.key); + const exists = await fs.pathExists(absPath); + if (exists) { + validEntries.push(entry); + } else { + log.debug(`[cache-status] 磁盘已不存在,自动剪除条目: ${entry.key}`); + dirty = true; + } + } + + if (dirty) { + const cleanedIdx: CacheIndex = { ...idx, entries: validEntries }; + await saveCacheIndex(cleanedIdx); + } + + const totalBytes = validEntries.reduce((s, e) => s + e.size_bytes, 0); + + return { + root, + totalBytes, + entryCount: validEntries.length, + entries: validEntries, + }; +} diff --git a/src/utils/dedup.ts b/src/utils/dedup.ts new file mode 100644 index 0000000..e6aacef --- /dev/null +++ b/src/utils/dedup.ts @@ -0,0 +1,141 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import matter from 'gray-matter'; + +import { log } from './logger.js'; + +/** 英文停用词集合 */ +const EN_STOPWORDS = new Set([ + 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', + 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', + 'should', 'may', 'might', 'shall', 'can', 'need', 'dare', 'ought', 'used', + 'in', 'on', 'at', 'to', 'for', 'of', 'and', 'or', 'but', 'not', 'with', + 'from', 'by', 'as', 'this', 'that', 'it', 'he', 'she', 'we', 'they', +]); + +/** CJK 停用词集合 */ +const CJK_STOPWORDS = new Set(['的', '了', '是', '在', '有', '和', '与', '或', '不', '也', '都', '就', '被', '由', '从', '到', '对', '于']); + +/** + * 从文本中提取关键词。 + * + * 提取英文单词(lowercase,去停用词)和 CJK 单字(去停用词), + * 只保留长度 ≥ 2 的词。 + */ +export function extractKeywords(text: string): Set<string> { + const keywords = new Set<string>(); + + // 提取英文单词 + const enWords = text.match(/[a-zA-Z]+/g) ?? []; + for (const word of enWords) { + const lower = word.toLowerCase(); + if (lower.length >= 2 && !EN_STOPWORDS.has(lower)) { + keywords.add(lower); + } + } + + // 提取 CJK 字符 + const cjkChars = text.match(/[一-鿿]/g) ?? []; + for (const char of cjkChars) { + if (!CJK_STOPWORDS.has(char)) { + keywords.add(char); + } + } + + return keywords; +} + +/** + * 计算两个关键词集合的 Jaccard 相似度。 + * + * 返回值范围 [0, 1],任一集合为空时返回 0。 + */ +export function overlapRatio(a: Set<string>, b: Set<string>): number { + if (a.size === 0 || b.size === 0) { + return 0; + } + + let intersectionSize = 0; + for (const word of a) { + if (b.has(word)) { + intersectionSize++; + } + } + + const unionSize = a.size + b.size - intersectionSize; + return intersectionSize / unionSize; +} + +/** 文件名日期前缀正则,格式 YYYY-MM-DD */ +const DATE_PREFIX_RE = /^(\d{4}-\d{2}-\d{2})/; + +/** + * 从文件名或 mtime 解析文档日期。 + * + * 优先解析文件名前缀(YYYY-MM-DD),失败时回退到 mtime。 + */ +async function resolveDocDate(filePath: string, filename: string): Promise<Date> { + const match = DATE_PREFIX_RE.exec(filename); + if (match) { + const parsed = new Date(match[1]); + if (!isNaN(parsed.getTime())) { + return parsed; + } + } + + const stat = await fs.stat(filePath); + return stat.mtime; +} + +/** + * 查找与草稿关键词高度重叠的已有 learning 文件。 + * + * 扫描 learningsDir 下 withinDays 天内的 .md 文件, + * 返回 Jaccard 相似度 ≥ 0.6 的条目,按 overlap 降序排列。 + */ +export async function findSupersededLearnings( + draftKeywords: Set<string>, + learningsDir: string, + withinDays: number = 14, +): Promise<Array<{ filename: string; overlap: number }>> { + let entries: string[]; + + try { + entries = await fs.readdir(learningsDir); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + return []; + } + throw err; + } + + const mdFiles = entries.filter((name) => name.endsWith('.md')); + const cutoffDate = new Date(Date.now() - withinDays * 24 * 60 * 60 * 1000); + const results: Array<{ filename: string; overlap: number }> = []; + + for (const filename of mdFiles) { + const filePath = path.join(learningsDir, filename); + + try { + const docDate = await resolveDocDate(filePath, filename); + if (docDate < cutoffDate) { + continue; + } + + const raw = await fs.readFile(filePath, 'utf8'); + const { content: body } = matter(raw); + const fileKeywords = extractKeywords(body); + const ratio = overlapRatio(draftKeywords, fileKeywords); + + if (ratio >= 0.6) { + results.push({ filename, overlap: ratio }); + } + } catch (err: unknown) { + log.debug(`dedup: skip ${filename} — ${(err as Error).message}`); + } + } + + return results.sort((x, y) => y.overlap - x.overlap); +} diff --git a/src/utils/iwiki-client.ts b/src/utils/iwiki-client.ts new file mode 100644 index 0000000..813989d --- /dev/null +++ b/src/utils/iwiki-client.ts @@ -0,0 +1,364 @@ +/** + * iWiki MCP HTTP 客户端。 + * + * 封装 JSON-RPC 2.0 调用和页面树遍历逻辑, + * 仅依赖 Node.js 内置 `https` 模块,零外部依赖。 + */ + +import https from 'node:https'; + +import { log } from './logger.js'; + +// ─── 常量 ────────────────────────────────────────────────── + +/** iWiki MCP Server 端点 URL。 */ +const MCP_URL = 'https://prod.mcp.it.woa.com/app_iwiki_mcp/mcp3'; + +/** HTTP 请求超时时间(毫秒)。 */ +const REQUEST_TIMEOUT_MS = 30_000; + +/** fetchAllPages 默认最大页数。 */ +const DEFAULT_MAX_PAGES = 200; + +/** fetchAllPages 默认并发数。 */ +const DEFAULT_CONCURRENCY = 5; + +// ─── 导出类型 ────────────────────────────────────────────── + +/** + * iWiki 页面基本信息(来自页面树接口)。 + */ +export interface IWikiPage { + /** 文档 ID(数字或字符串,统一转为 string) */ + docid: string; + /** 文档标题 */ + title: string; + /** 父文档 ID */ + parentid?: string; + /** 是否有子文档 */ + has_children?: boolean; +} + +/** + * iWiki 文档完整内容(含 Markdown 正文)。 + */ +export interface IWikiDocument { + /** 文档 ID */ + docid: string; + /** 文档标题 */ + title: string; + /** Markdown 格式正文 */ + content: string; + /** 原始 URL */ + url: string; +} + +// ─── 内部类型 ────────────────────────────────────────────── + +/** JSON-RPC 2.0 请求体。 */ +interface JsonRpcRequest { + jsonrpc: '2.0'; + id: number; + method: string; + params?: Record<string, unknown>; +} + +/** JSON-RPC 2.0 响应体。 */ +interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number; + result?: unknown; + error?: { code: number; message: string }; +} + +// ─── 客户端类 ────────────────────────────────────────────── + +/** + * iWiki MCP HTTP 客户端。 + * + * 通过 JSON-RPC 2.0 协议与 iWiki MCP Server 通信, + * 支持页面树遍历和文档内容下载。 + */ +export class IWikiClient { + private readonly token: string; + private requestId: number; + + /** + * 创建 IWikiClient 实例。 + * + * @param token TAI_PAT_TOKEN,用于 Bearer 认证 + */ + constructor(token: string) { + this.token = token; + this.requestId = 0; + } + + /** + * 发送单次 HTTPS POST 请求,返回响应 body 字符串。 + * + * @param payload 序列化后的请求体字符串 + * @returns 响应 body 字符串 + * @throws 超时或请求失败时抛出 Error + */ + private async _postRaw(payload: string): Promise<string> { + return new Promise<string>((resolve, reject) => { + const url = new URL(MCP_URL); + const options: https.RequestOptions = { + hostname: url.hostname, + path: url.pathname + url.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.token}`, + 'Accept': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + }; + + const chunks: Buffer[] = []; + + const req = https.request(options, (res) => { + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + res.on('error', (err: Error) => reject(err)); + }); + + // 超时控制 + const timer = setTimeout(() => { + req.destroy(); + reject(new Error(`iWiki MCP request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`)); + }, REQUEST_TIMEOUT_MS); + + req.on('error', (err: Error) => { + clearTimeout(timer); + reject(err); + }); + + req.on('close', () => clearTimeout(timer)); + + req.write(payload); + req.end(); + }); + } + + /** + * 调用 iWiki MCP 工具,返回工具执行结果。 + * + * 遵循 JSON-RPC 2.0 协议,解析 MCP 标准响应格式 + * `result.content[0].text` 或直接 `result`。 + * + * @param toolName MCP 工具名称 + * @param args 工具参数 + * @returns 工具返回值(已解析为 unknown) + * @throws API 返回 error 字段时抛出 Error + */ + private async _callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> { + const rpcRequest: JsonRpcRequest = { + jsonrpc: '2.0', + id: ++this.requestId, + method: 'tools/call', + params: { + name: toolName, + arguments: args, + }, + }; + + const payload = JSON.stringify(rpcRequest); + const rawBody = await this._postRaw(payload); + + let response: JsonRpcResponse; + try { + response = JSON.parse(rawBody) as JsonRpcResponse; + } catch (parseErr: unknown) { + throw new Error(`iWiki MCP 响应解析失败: ${String(parseErr)},原始响应: ${rawBody.slice(0, 200)}`); + } + + if (response.error) { + throw new Error(`iWiki API error: ${response.error.message}`); + } + + // MCP 标准响应格式:result.content[0].text 包含实际内容 + const result = response.result; + if ( + result !== null && + typeof result === 'object' && + 'content' in result && + Array.isArray((result as Record<string, unknown>).content) + ) { + const content = (result as { content: Array<{ text?: string }> }).content; + if (content.length > 0 && typeof content[0].text === 'string') { + // text 可能是 JSON 字符串,尝试再次解析 + try { + return JSON.parse(content[0].text); + } catch { + return content[0].text; + } + } + } + + return result; + } + + /** + * 获取指定父节点下的页面树(一级子页面列表)。 + * + * @param parentid 父节点文档 ID + * @returns 子页面列表,失败时返回空数组 + */ + async getSpacePageTree(parentid: string): Promise<IWikiPage[]> { + try { + const result = await this._callTool('getSpacePageTree', { parentid }); + + if (!Array.isArray(result)) { + return []; + } + + return result.map((item: Record<string, unknown>) => ({ + docid: String(item['docid'] ?? item['id'] ?? ''), + title: typeof item['title'] === 'string' ? item['title'] : String(item['docid'] ?? ''), + parentid: item['parentid'] !== undefined ? String(item['parentid']) : undefined, + has_children: + typeof item['has_children'] === 'boolean' + ? item['has_children'] + : Boolean(item['has_children']), + })); + } catch (err: unknown) { + log.warn(`获取页面树失败 [parentid=${parentid}]: ${String(err)}`); + return []; + } + } + + /** + * 下载单个文档的完整内容(Markdown 正文 + 元数据)。 + * + * 并行调用 getDocument 和 metadata 两个工具。 + * + * @param docid 文档 ID + * @returns IWikiDocument(含 Markdown 正文) + * @throws 任一子调用失败时抛出 Error + */ + async getDocument(docid: string): Promise<IWikiDocument> { + const [contentResult, metaResult] = await Promise.all([ + this._callTool('getDocument', { docid }), + this._callTool('metadata', { docid }), + ]); + + // getDocument 返回 Markdown 字符串或含 content 字段的对象 + let content = ''; + if (typeof contentResult === 'string') { + content = contentResult; + } else if ( + contentResult !== null && + typeof contentResult === 'object' && + 'content' in contentResult + ) { + content = String((contentResult as Record<string, unknown>)['content'] ?? ''); + } + + // metadata 返回含 title、id 等字段的对象 + let title = ''; + if ( + metaResult !== null && + typeof metaResult === 'object' && + 'title' in metaResult + ) { + title = String((metaResult as Record<string, unknown>)['title'] ?? ''); + } + + return { + docid, + title: title || docid, + content, + url: `https://iwiki.woa.com/p/${docid}`, + }; + } + + /** + * 递归(BFS)遍历整个 Space,返回所有页面信息。 + * + * 并发控制:同时最多 concurrency 个 getSpacePageTree 请求。 + * 超出 maxPages 时停止并输出 warn 日志。 + * + * @param rootId Space 根节点 ID + * @param opts 可选配置:concurrency(默认 5)、maxPages(默认 200) + * @returns 所有发现的 IWikiPage[] + */ + async fetchAllPages( + rootId: string, + opts?: { concurrency?: number; maxPages?: number }, + ): Promise<IWikiPage[]> { + const concurrency = opts?.concurrency ?? DEFAULT_CONCURRENCY; + const maxPages = opts?.maxPages ?? DEFAULT_MAX_PAGES; + + const allPages: IWikiPage[] = []; + // BFS 队列:待获取子树的 parentid 列表 + const queue: string[] = [rootId]; + let running = 0; + let stopped = false; + + // 使用 Promise 包装的并发 BFS + await new Promise<void>((resolve, reject) => { + const tryDrain = (): void => { + if (stopped || (queue.length === 0 && running === 0)) { + resolve(); + return; + } + + // 填满并发槽 + while (queue.length > 0 && running < concurrency && !stopped) { + const parentid = queue.shift()!; + running++; + + this.getSpacePageTree(parentid) + .then((pages) => { + running--; + + for (const page of pages) { + if (allPages.length >= maxPages) { + if (!stopped) { + stopped = true; + log.warn( + `已达到最大页数限制(${maxPages}),停止继续遍历。已收集: ${allPages.length} 页`, + ); + } + break; + } + allPages.push(page); + // 有子文档则加入 BFS 队列 + if (page.has_children) { + queue.push(page.docid); + } + } + + tryDrain(); + }) + .catch((err: unknown) => { + running--; + log.warn(`BFS 遍历节点失败 [parentid=${parentid}]: ${String(err)}`); + // 单节点失败不中断整体,继续处理其他节点 + tryDrain(); + }); + } + + // 队列为空且无运行中任务则完成 + if (queue.length === 0 && running === 0) { + resolve(); + } + }; + + tryDrain(); + + // 防止初始队列为空时直接结束 + if (queue.length === 0) { + reject(new Error('fetchAllPages: rootId 队列为空')); + } + }).catch((err: unknown) => { + // 仅 rootId 为空时抛出,其他错误已在 tryDrain 内处理 + if (allPages.length === 0) { + throw err; + } + }); + + return allPages; + } +} diff --git a/src/utils/path-safety.ts b/src/utils/path-safety.ts new file mode 100644 index 0000000..87c7d2a --- /dev/null +++ b/src/utils/path-safety.ts @@ -0,0 +1,117 @@ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; + +/** + * Assert that a resolved target path is within one of the allowed root directories. + * + * Resolves symlinks on both sides before comparing, preventing symlink-escape attacks. + * Throws a descriptive error if the target is outside all allowed roots. + * + * @param target The path to validate (will be resolved to absolute). + * @param allowedRoots The set of allowed root directories (will be resolved too). + * @throws Error with a descriptive message if the target is outside all roots. + */ +export function assertSafePath(target: string, allowedRoots: string[]): void { + const resolvedTarget = resolveReal(target); + + for (const root of allowedRoots) { + const resolvedRoot = resolveReal(root); + if (resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + path.sep)) { + return; + } + } + + throw new Error( + `Path traversal detected: "${target}" is outside allowed directories: ${allowedRoots.join(', ')}`, + ); +} + +/** + * Resolve a path to its real absolute form. + * + * Uses fs.realpathSync when the path exists (follows symlinks). + * Falls back to path.resolve for non-existent paths (parent must exist check is + * left to the caller — we still resolve as far as possible). + * + * @param p Input path (may be relative, may contain ~). + * @returns Resolved absolute path string. + */ +function resolveReal(p: string): string { + const expanded = p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p; + const abs = path.resolve(expanded); + try { + return fs.realpathSync(abs); + } catch { + // Path does not exist yet — return the resolved absolute path without following symlinks. + // The parent-directory check is sufficient to prevent path traversal for new files. + return abs; + } +} + +/** + * Return the default allowed roots for user-facing path inputs: + * the current working directory and the user's home directory. + * + * @returns Array of two resolved paths: [cwd, homedir]. + */ +export function defaultAllowedRoots(): string[] { + return [process.cwd(), os.homedir()]; +} + +/** + * Validate a CLI user-supplied resource name (skill / agent / rule, etc.) for safety. + * + * Rules enforced: + * - Length must be 1–64 characters + * - Only [A-Za-z0-9._-] characters are allowed + * - Single dot ('.') and double dot ('..') are rejected + * - Must not contain path separators ('/' or '\') after URL-decoding + * - Must not be an absolute path after URL-decoding + * - Must not contain null bytes + * - Percent-encoded variants of the above are also rejected + * + * @param name The resource name string to validate. + * @throws Error with a descriptive message if the name is invalid. + */ +export function assertSafeResourceName(name: string): void { + // Reject null bytes before any other check + if (name.includes('\0')) { + throw new Error('Invalid resource name: contains null byte'); + } + + // Attempt URL-decode to catch %2e%2e, %2f, etc. + let decoded: string; + try { + decoded = decodeURIComponent(name); + } catch { + throw new Error('Invalid resource name: contains invalid percent-encoding'); + } + + // Reject null bytes in decoded form too + if (decoded.includes('\0')) { + throw new Error('Invalid resource name: contains null byte'); + } + + // Reject path separators (both slash styles) in decoded form + if (decoded.includes('/') || decoded.includes('\\')) { + throw new Error('Invalid resource name: contains path separator'); + } + + // Reject absolute paths in decoded form + if (path.isAbsolute(decoded)) { + throw new Error('Invalid resource name: must not be an absolute path'); + } + + // Reject dot-only segments + if (decoded === '.' || decoded === '..') { + throw new Error('Invalid resource name: "." and ".." are not allowed'); + } + + // Allowlist: only [A-Za-z0-9._-], length 1–64 + if (!/^[A-Za-z0-9._-]{1,64}$/.test(name)) { + throw new Error( + 'Invalid resource name: must be 1–64 characters and contain only [A-Za-z0-9._-]', + ); + } +} diff --git a/src/utils/repo-cache.ts b/src/utils/repo-cache.ts new file mode 100644 index 0000000..05fc6e3 --- /dev/null +++ b/src/utils/repo-cache.ts @@ -0,0 +1,84 @@ +import path from 'node:path'; +import os from 'node:os'; + +import fs from 'fs-extra'; + +// ─── Constants ────────────────────────────────────────── + +const LAST_SYNC_FILE = 'LAST_SYNC'; + +// ─── Helpers ──────────────────────────────────────────── + +/** + * 返回缓存根目录(可通过 TEAMAI_CACHE_DIR 环境变量覆盖)。 + */ +function getCacheRoot(): string { + return process.env.TEAMAI_CACHE_DIR ?? path.join(os.homedir(), '.teamai', 'cache', 'repos'); +} + +// ─── Public API ───────────────────────────────────────── + +/** + * 计算单仓的本地缓存目录:~/.teamai/cache/repos/<provider>/<owner>/<repo> + * + * @param provider 'github' | 'tgit' + * @param owner 仓库属主(含可能的多级 group,如 'team/sub') + * @param repo 仓库名 + */ +export function getRepoCacheDir(provider: string, owner: string, repo: string): string { + return path.join(getCacheRoot(), provider, owner, repo); +} + +/** + * 计算单仓 slug(用于产物文件命名):<provider>__<owner-with-slashes-replaced>__<repo> + * + * @param provider 'github' | 'tgit' + * @param owner 仓库属主(含可能的多级 group) + * @param repo 仓库名 + */ +export function getRepoSlug(provider: string, owner: string, repo: string): string { + const safeOwner = owner.replace(/\//g, '-'); + return `${provider}__${safeOwner}__${repo}`; +} + +/** + * 写入 LAST_SYNC 文件,记录 commit SHA + ISO 时间。 + * + * @param cacheDir 本地缓存目录路径 + * @param sha HEAD commit SHA + */ +export async function writeLastSync(cacheDir: string, sha: string): Promise<void> { + const isoTs = new Date().toISOString(); + const content = `${sha}\n${isoTs}\n`; + await fs.writeFile(path.join(cacheDir, LAST_SYNC_FILE), content, 'utf8'); +} + +/** + * 读取 LAST_SYNC 文件;不存在时返回 null。 + * + * @param cacheDir 本地缓存目录路径 + */ +export async function readLastSync( + cacheDir: string, +): Promise<{ sha: string; ts: string } | null> { + const filePath = path.join(cacheDir, LAST_SYNC_FILE); + const exists = await fs.pathExists(filePath); + if (!exists) { + return null; + } + const content = await fs.readFile(filePath, 'utf8'); + const lines = content.split('\n').filter((l) => l.trim()); + if (lines.length < 2) { + return null; + } + return { sha: lines[0].trim(), ts: lines[1].trim() }; +} + +/** + * 确保缓存父目录存在,返回缓存根路径。 + */ +export async function ensureCacheRoot(): Promise<string> { + const root = getCacheRoot(); + await fs.ensureDir(root); + return root; +} diff --git a/src/utils/search-index.ts b/src/utils/search-index.ts index 01e0e35..46e35f7 100644 --- a/src/utils/search-index.ts +++ b/src/utils/search-index.ts @@ -1,12 +1,15 @@ import path from 'node:path'; import matter from 'gray-matter'; -import { readFileSafe, readJson, writeJson, listFiles } from './fs.js'; +import { readFileSafe, readJson, writeJson, listFiles, listFilesRecursive, listDirs, pathExists } from './fs.js'; import { log } from './logger.js'; -import type { - LearningDocMeta, - SearchIndex, - SearchIndexEntry, - UserVotes, +import { + SEARCH_INDEX_VERSION, + type KnowledgeDomain, + type LearningDocMeta, + type SearchIndex, + type SearchIndexEntry, + type UserVotes, + type KnowledgeType, } from '../types.js'; /** Resolve search index path dynamically (respects HOME changes in tests). */ @@ -42,6 +45,160 @@ const CJK_RANGE = /[\u4e00-\u9fff]/; const MAX_BODY_CHARS = 2000; const MAX_DOC_BYTES = 50 * 1024; // 50KB +// \u2500\u2500\u2500 P1.4 Domain inference \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 +// +// Tags that signal each domain category. Built from real-world learnings tags. +// Ties resolved by: technical > ops > support. + +const TECHNICAL_TAGS = new Set([ + 'api', 'sdk', 'typescript', 'python', 'golang', 'rust', 'javascript', + 'bug', 'debug', 'error', 'exception', 'fix', 'patch', 'refactor', + 'architecture', 'framework', 'database', 'db', 'cache', 'redis', + 'async', 'concurrent', 'thread', 'performance', 'latency', 'timeout', + 'http', 'grpc', 'proto', 'json', 'schema', 'migration', 'index', + 'test', 'unittest', 'e2e', 'mock', 'lint', 'typecheck', + 'docker', 'build', 'package', 'dependency', 'import', 'module', +]); + +const OPS_TAGS = new Set([ + 'k8s', 'kubernetes', 'deploy', 'deployment', 'cluster', 'node', 'pod', + 'sop', 'upgrade', 'rollout', 'rollback', 'restart', 'scale', + 'monitor', 'alert', 'metrics', 'grafana', 'prometheus', 'log', + 'pipeline', 'ci', 'cd', 'cicd', 'release', 'publish', + 'nginx', 'lb', 'ingress', 'service', 'network', 'firewall', + 'backup', 'restore', 'disaster', 'incident', 'oncall', + 'gpu', 'resource', 'quota', 'tke', 'tcr', 'cos', +]); + +const SUPPORT_TAGS = new Set([ + 'faq', 'support', 'user', 'customer', 'guide', 'tutorial', + 'onboard', 'onboarding', 'help', 'howto', 'usage', 'example', + 'feedback', 'issue', 'complaint', 'request', 'ticket', +]); + +// Directory path sub-strings that signal a domain. +// Checked in priority order: technical > ops > support. +const TECHNICAL_PATH_PATTERNS = ['docs/architecture/', 'docs/design/', 'docs/api/', 'docs/adr/']; +const OPS_PATH_PATTERNS = ['learnings/ops/', 'docs/ops/', 'docs/deploy/', 'docs/sre/']; +const SUPPORT_PATH_PATTERNS = ['docs/support/', 'docs/faq/', 'docs/guide/', 'learnings/support/']; + +// Query-aware domain weights. +// +// Rows = inferred domain of the *query*; columns = domain of the *entry*. +// When the query looks like an ops question (contains k8s/deploy/... tokens), +// ops entries are no longer penalised. When the query is neutral/unknown, a +// mild penalty is kept so technical entries still rank slightly higher. +const DOMAIN_WEIGHT: Record<KnowledgeDomain, Record<KnowledgeDomain, number>> = { + // entry domain \u2192 + // query domain \u2193 technical neutral ops support + technical: { technical: 1.0, neutral: 0.85, ops: 0.5, support: 0.3 }, + ops: { technical: 0.7, neutral: 0.85, ops: 1.0, support: 0.3 }, + neutral: { technical: 1.0, neutral: 0.85, ops: 0.75, support: 0.3 }, + support: { technical: 0.8, neutral: 0.85, ops: 0.5, support: 1.0 }, +}; + +/** + * Infer the domain of a query from its tokens. + * Uses the same tag sets used for document domain inference so the two sides + * of the matching are symmetric. + */ +function inferQueryDomain(queryTokens: string[]): KnowledgeDomain { + let techScore = 0; + let opsScore = 0; + for (const t of queryTokens) { + if (TECHNICAL_TAGS.has(t)) techScore++; + if (OPS_TAGS.has(t)) opsScore++; + } + if (opsScore > techScore) return 'ops'; + if (techScore > opsScore) return 'technical'; + if (techScore > 0) return 'technical'; // tie \u2192 technical + return 'neutral'; +} + +// Type bonuses: skills/rules already represent curated, high-confidence knowledge. +const TYPE_BONUS: Record<KnowledgeType, number> = { + skills: 1.1, + rules: 1.1, + learnings: 1.0, + docs: 1.0, +}; + +/** + * codebase 索引文件名(高权重代理,取代全量 codebase.md)。 + * 同目录下若存在同名全量文档,将被自动跳过收录。 + */ +const CODEBASE_INDEX_FILENAME = 'codebase-index.md'; + +/** + * codebase 全量文档文件名,有索引文件存在时跳过收录。 + */ +const CODEBASE_FULL_FILENAME = 'codebase.md'; + +/** + * codebase-index.md 相对于普通 docs 类型的额外权重倍数。 + */ +const CODEBASE_INDEX_WEIGHT_BOOST = 1.5; + +/** + * Infer the content domain of a knowledge entry from four signals (priority order): + * 1. Explicit `domain:` frontmatter field + * 2. Tag keyword matching (TECHNICAL_TAGS / OPS_TAGS / SUPPORT_TAGS) + * 3. Directory path patterns (e.g. docs/architecture/ \u2192 technical) + * 4. Knowledge type fallback (skills/rules \u2192 technical; everything else \u2192 neutral) + * + * In case of a score tie between domains, technical beats ops beats support. + */ +export function inferDomain( + frontmatterDomain: string | undefined, + tags: string[], + filePath: string, + type: KnowledgeType, +): KnowledgeDomain { + // 1. Explicit frontmatter override + if ( + frontmatterDomain === 'technical' || + frontmatterDomain === 'ops' || + frontmatterDomain === 'support' || + frontmatterDomain === 'neutral' + ) { + return frontmatterDomain; + } + + // 2. Tags keyword matching + const normalizedTags = tags.map((t) => t.toLowerCase()); + let techScore = 0; + let opsScore = 0; + let supportScore = 0; + for (const tag of normalizedTags) { + if (TECHNICAL_TAGS.has(tag)) techScore++; + if (OPS_TAGS.has(tag)) opsScore++; + if (SUPPORT_TAGS.has(tag)) supportScore++; + } + const maxScore = Math.max(techScore, opsScore, supportScore); + if (maxScore > 0) { + // Tie-breaking: technical > ops > support + if (techScore === maxScore) return 'technical'; + if (opsScore === maxScore) return 'ops'; + return 'support'; + } + + // 3. Directory path matching + const normalizedPath = filePath.replace(/\\/g, '/').toLowerCase(); + for (const pattern of TECHNICAL_PATH_PATTERNS) { + if (normalizedPath.includes(pattern)) return 'technical'; + } + for (const pattern of OPS_PATH_PATTERNS) { + if (normalizedPath.includes(pattern)) return 'ops'; + } + for (const pattern of SUPPORT_PATH_PATTERNS) { + if (normalizedPath.includes(pattern)) return 'support'; + } + + // 4. Type fallback + if (type === 'skills' || type === 'rules') return 'technical'; + return 'neutral'; +} + /** * Hybrid tokenizer: Intl.Segmenter for word boundaries + CJK bigrams. * @@ -172,6 +329,168 @@ async function aggregateVotes(votesDir: string): Promise<Map<string, number>> { return counts; } +/** + * Read a markdown file, truncate oversized content, and convert it to a + * SearchIndexEntry of the given category. Used by all four collectors. + * Returns null when the file is empty/unreadable. + */ +async function entryFromMdFile( + absPath: string, + filenameForId: string, + type: KnowledgeType, + voteCounts: Map<string, number>, +): Promise<SearchIndexEntry | null> { + // 若当前文件是全量 codebase.md,且同目录存在 codebase-index.md,则跳过以避免重复命中。 + const basename = path.basename(absPath); + if (basename === CODEBASE_FULL_FILENAME) { + const dir = path.dirname(absPath); + const indexPath = path.join(dir, CODEBASE_INDEX_FILENAME); + if (await pathExists(indexPath)) { + log.debug(`Skipping ${absPath}: codebase-index.md exists in same directory`); + return null; + } + } + + let content = await readFileSafe(absPath); + if (!content) return null; + + if (Buffer.byteLength(content, 'utf-8') > MAX_DOC_BYTES) { + content = content.slice(0, MAX_DOC_BYTES); + log.debug(`Truncated oversized ${type} doc: ${filenameForId}`); + } + + const parsed = parseLearningDoc(content, filenameForId); + if (!parsed) return null; + + const { meta, bodyExcerpt } = parsed; + const title = meta.title ?? titleFromFilename(filenameForId); + const tags = meta.tags ?? []; + + // Infer domain for P1.4 search weighting. + // parseLearningDoc only populates the LearningDocMeta fields; read the raw + // `domain` frontmatter field directly from the raw gray-matter parse. + const rawFrontmatterDomain = (() => { + try { + return (matter(content).data['domain'] as string | undefined); + } catch { + return undefined; + } + })(); + const domain = inferDomain(rawFrontmatterDomain, tags, absPath, type); + + const titleTokens = tokenize(title); + const tagTokens = tags.flatMap((tag) => tokenize(tag)); + const bodyTokens = tokenize(bodyExcerpt); + + const tokens = [ + ...titleTokens.map((t) => `title:${t}`), + ...titleTokens, + ...tagTokens.map((t) => `tag:${t}`), + ...tagTokens, + ...bodyTokens, + // Type-prefixed token enables future filtered searches (e.g. type:skills). + `type:${type}`, + ]; + + const docId = filenameForId.replace(/\.md$/i, ''); + + return { + filename: filenameForId, + title, + author: meta.author ?? '', + date: meta.date ?? '', + tags, + tokens: [...new Set(tokens)], + votes: voteCounts.get(docId) ?? 0, + type, + domain, + path: absPath, + }; +} + +/** Collect entries from a flat *.md directory (used for `learnings`). */ +async function collectFlatMdEntries( + dir: string, + type: KnowledgeType, + voteCounts: Map<string, number>, +): Promise<SearchIndexEntry[]> { + if (!await pathExists(dir)) return []; + const files = await listFiles(dir); + const out: SearchIndexEntry[] = []; + for (const filename of files) { + if (!filename.endsWith('.md')) continue; + const e = await entryFromMdFile(path.join(dir, filename), filename, type, voteCounts); + if (e) out.push(e); + } + return out; +} + +/** + * Collect entries from a recursive *.md directory (used for `docs` and + * `rules`, which may have subdirectories like `rules/common/`). + */ +async function collectRecursiveMdEntries( + dir: string, + type: KnowledgeType, + voteCounts: Map<string, number>, +): Promise<SearchIndexEntry[]> { + if (!await pathExists(dir)) return []; + const files = await listFilesRecursive(dir); + const out: SearchIndexEntry[] = []; + for (const rel of files) { + if (!rel.endsWith('.md')) continue; + // Use the relative path as the filename so the entry id is unique + // across subdirectories, e.g. `common/coding-style.md`. + const e = await entryFromMdFile(path.join(dir, rel), rel, type, voteCounts); + if (e) out.push(e); + } + return out; +} + +/** + * Collect entries from a skills directory whose layout is + * skills/<name>/SKILL.md (flat) + * skills/<namespace>/<name>/SKILL.md (namespaced) + * + * Each entry's `filename` is `<skill-name>.md` (so doc_id = skill name). + */ +async function collectSkillEntries( + dir: string, + voteCounts: Map<string, number>, +): Promise<SearchIndexEntry[]> { + if (!await pathExists(dir)) return []; + const out: SearchIndexEntry[] = []; + + async function walk(current: string): Promise<void> { + const subdirs = await listDirs(current); + for (const sub of subdirs) { + if (sub.startsWith('.')) continue; + const subPath = path.join(current, sub); + const skillMd = path.join(subPath, 'SKILL.md'); + if (await pathExists(skillMd)) { + const e = await entryFromMdFile(skillMd, `${sub}.md`, 'skills', voteCounts); + if (e) out.push(e); + } else { + // Treat as a namespace directory and recurse one level. + await walk(subPath); + } + } + } + + await walk(dir); + return out; +} + +/** Options for the multi-category build. */ +export interface BuildIndexOptions { + learningsDir?: string; + docsDir?: string; + rulesDir?: string; + skillsDir?: string; + votesDir?: string; + indexPath?: string; +} + /** * Build the search index from local learning documents. * @@ -180,75 +499,58 @@ async function aggregateVotes(votesDir: string): Promise<Map<string, number>> { * @returns elapsed ms */ export async function buildIndex( - learningsDir: string, + optionsOrLearningsDir: BuildIndexOptions | string, votesDir?: string, indexPath?: string, ): Promise<number> { const start = Date.now(); - const files = await listFiles(learningsDir); - const mdFiles = files.filter((f) => f.endsWith('.md')); - // Aggregate votes if votesDir provided - const voteCounts = votesDir - ? await aggregateVotes(votesDir) + // Backward compatibility: original signature was + // buildIndex(learningsDir: string, votesDir?: string, indexPath?: string) + // The Phase 1 multi-category form takes a single options object instead. + const opts: BuildIndexOptions = typeof optionsOrLearningsDir === 'string' + ? { learningsDir: optionsOrLearningsDir, votesDir, indexPath } + : optionsOrLearningsDir; + + // Aggregate votes once and reuse across all collectors. + const voteCounts = opts.votesDir + ? await aggregateVotes(opts.votesDir) : new Map<string, number>(); const entries: SearchIndexEntry[] = []; - for (const filename of mdFiles) { - const filePath = path.join(learningsDir, filename); - let content = await readFileSafe(filePath); - if (!content) continue; + if (opts.learningsDir) { + entries.push(...await collectFlatMdEntries(opts.learningsDir, 'learnings', voteCounts)); + } + if (opts.docsDir) { + entries.push(...await collectRecursiveMdEntries(opts.docsDir, 'docs', voteCounts)); + } + if (opts.rulesDir) { + entries.push(...await collectRecursiveMdEntries(opts.rulesDir, 'rules', voteCounts)); + } + if (opts.skillsDir) { + entries.push(...await collectSkillEntries(opts.skillsDir, voteCounts)); + } - // Truncate oversized documents - if (Buffer.byteLength(content, 'utf-8') > MAX_DOC_BYTES) { - content = content.slice(0, MAX_DOC_BYTES); - log.debug(`Truncated oversized learning doc: ${filename}`); + // Build document-frequency map for IDF weighting. + // Count how many *entries* contain each token (not raw term frequency). + const df: Record<string, number> = {}; + for (const entry of entries) { + for (const token of new Set(entry.tokens)) { + df[token] = (df[token] ?? 0) + 1; } - - const parsed = parseLearningDoc(content, filename); - if (!parsed) continue; - - const { meta, bodyExcerpt } = parsed; - const title = meta.title ?? titleFromFilename(filename); - const tags = meta.tags ?? []; - - // Build tokens from title + tags + body excerpt - const titleTokens = tokenize(title); - const tagTokens = tags.flatMap((tag) => tokenize(tag)); - const bodyTokens = tokenize(bodyExcerpt); - - // Prefix title and tag tokens for boosted matching - const tokens = [ - ...titleTokens.map((t) => `title:${t}`), - ...titleTokens, // Also include raw for body-level matching - ...tagTokens.map((t) => `tag:${t}`), - ...tagTokens, - ...bodyTokens, - ]; - - // Derive doc ID from filename (without .md) for vote lookup - const docId = filename.replace(/\.md$/i, ''); - - entries.push({ - filename, - title, - author: meta.author ?? '', - date: meta.date ?? '', - tags, - tokens: [...new Set(tokens)], - votes: voteCounts.get(docId) ?? 0, - }); } const elapsed = Date.now() - start; const index: SearchIndex = { + version: SEARCH_INDEX_VERSION, builtAt: new Date().toISOString(), elapsedMs: elapsed, entries, + df, }; - await writeJson(indexPath ?? getSearchIndexPath(), index); + await writeJson(opts.indexPath ?? getSearchIndexPath(), index); if (elapsed > 2000) { log.warn(`Search index build took ${elapsed}ms — consider incremental updates for large knowledge bases`); @@ -257,6 +559,18 @@ export async function buildIndex( return elapsed; } +/** + * Returns true when the on-disk index pre-dates the current schema version. + * Covers both pre-Phase-1 (no version/type) and pre-Phase-1.4 (no domain) indexes. + * The caller should rebuild such an index using the multi-category collectors. + */ +export function isLegacyIndex(index: SearchIndex | null): boolean { + if (!index) return false; + if (typeof index.version !== 'number' || index.version < SEARCH_INDEX_VERSION) return true; + // Any entry missing type or domain → legacy; domain was added in v3. + return index.entries.some((e) => !e.type || e.domain === undefined) || !index.df; +} + /** * Load the search index from disk. Returns null if missing or corrupt. */ @@ -277,11 +591,13 @@ export interface SearchResult { /** * Search the index with a query string. * - * Scoring: + * Scoring (P1.4 domain-weighted): * - Title token match: 3 points * - Tag token match: 2 points * - Body token match: 1 point * - Vote bonus: +0.5 per vote (caps at 5 points) + * - Domain multiplier: technical ×1.0, neutral ×0.85, ops ×0.5, support ×0.3 + * - Type bonus: skills/rules ×1.1 (curated high-confidence knowledge) * * @returns Results sorted by score descending, limited to top N. */ @@ -295,6 +611,26 @@ export function search( const queryTokens = tokenize(query); if (queryTokens.length === 0) return []; + // Infer query domain for adaptive weighting (改动 A). + const queryDomain = inferQueryDomain(queryTokens); + const domainWeightRow = DOMAIN_WEIGHT[queryDomain]; + + // IDF helpers (改动 B). + // N = total number of indexed entries; df = per-token document frequency. + // Falls back gracefully when df is absent (legacy index built before v4). + const N = index.entries.length; + const df = index.df ?? {}; + + /** + * IDF score for a token: log((N + 1) / (docFreq + 1)). + * Returns 1.0 when df map is unavailable (no-op for legacy indexes). + */ + const idf = (token: string): number => { + if (!index.df) return 1.0; + const docFreq = df[token] ?? 0; + return Math.log((N + 1) / (docFreq + 1)) + 1; // +1 smoothing keeps score ≥ 1 + }; + const results: SearchResult[] = []; for (const entry of index.entries) { @@ -303,28 +639,43 @@ export function search( const entryTokens = new Set(entry.tokens); for (const qt of queryTokens) { - if (entryTokens.has(`title:${qt}`)) { - score += 3; + const titleToken = `title:${qt}`; + const tagToken = `tag:${qt}`; + + if (entryTokens.has(titleToken)) { + score += 3 * idf(titleToken); hasTitleOrTagMatch = true; } - if (entryTokens.has(`tag:${qt}`)) { - score += 2; + if (entryTokens.has(tagToken)) { + score += 2 * idf(tagToken); hasTitleOrTagMatch = true; } if (entryTokens.has(qt)) { - score += 1; + score += 1 * idf(qt); } } - // Require at least one title or tag match to filter out body-only noise + // Require at least one title or tag match to filter out body-only noise. if (score > 0 && hasTitleOrTagMatch) { - // Vote bonus: +0.5 per vote, max 5 points + // Vote bonus: +0.5 per vote, max 5 points (unchanged). score += Math.min(entry.votes * 0.5, 5); + + // Query-aware domain weight (改动 A) × type bonus (unchanged). + // Missing domain degrades gracefully to 'neutral'. + const domainMultiplier = domainWeightRow[entry.domain ?? 'neutral']; + const typeMultiplier = TYPE_BONUS[entry.type]; + score *= domainMultiplier * typeMultiplier; + + // codebase-index.md 额外权重 boost,确保章节摘要优先于普通 docs 返回。 + if (path.basename(entry.path ?? '') === CODEBASE_INDEX_FILENAME) { + score *= CODEBASE_INDEX_WEIGHT_BOOST; + } + results.push({ entry, score }); } } - // Sort by score descending, then by date descending for ties + // Sort by score descending, then by date descending for ties. results.sort((a, b) => { if (b.score !== a.score) return b.score - a.score; return (b.entry.date || '').localeCompare(a.entry.date || ''); diff --git a/src/utils/source-conflict.ts b/src/utils/source-conflict.ts new file mode 100644 index 0000000..9db8e76 --- /dev/null +++ b/src/utils/source-conflict.ts @@ -0,0 +1,111 @@ +// -*- coding: utf-8 -*- +/** + * 多源冲突检测工具。 + * + * 在更新 codebase 章节前记录"本轮被哪些源更新", + * 同一文件 + 同一章节在同一日内被多源更新时标记 conflict。 + * + * 状态文件:.teamai/source-marks.jsonl + */ + +import path from 'node:path'; +import fs from 'fs-extra'; + +// ─── 类型 ──────────────────────────────────────────────── + +/** 数据来源标记。 */ +export interface SourceMark { + source: 'iwiki' | 'mr' | 'repo' | 'manual'; + /** iwiki page id / MR url / repo url */ + sourceId: string; + /** ISO 时间 */ + syncedAt: string; +} + +/** source-marks.jsonl 中一条记录。 */ +interface SourceMarkRecord { + file: string; + section: string; + mark: SourceMark; +} + +// ─── 常量 ──────────────────────────────────────────────── + +const SOURCE_MARKS_FILE = '.teamai/source-marks.jsonl'; + +/** 冲突检测窗口(24 小时,毫秒)。 */ +const CONFLICT_WINDOW_MS = 24 * 60 * 60 * 1000; + +// ─── 辅助函数 ──────────────────────────────────────────── + +/** + * 读取 source-marks.jsonl,过滤损坏行。 + */ +async function readMarks(cwd: string): Promise<SourceMarkRecord[]> { + const filePath = path.join(cwd, SOURCE_MARKS_FILE); + const exists = await fs.pathExists(filePath); + if (!exists) { + return []; + } + + const content = await fs.readFile(filePath, 'utf8'); + const records: SourceMarkRecord[] = []; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + records.push(JSON.parse(trimmed) as SourceMarkRecord); + } catch { + // 损坏行跳过 + } + } + + return records; +} + +// ─── 主入口 ────────────────────────────────────────────── + +/** + * 记录本轮 codebase 章节更新来源,检测是否与近 24 小时内其他来源冲突。 + * + * @param cwd 工作目录 + * @param file 被更新的文件绝对路径 + * @param section 被更新的章节标识符 + * @param mark 本轮来源信息 + * @returns { conflict, previousSources } + */ +export async function recordSourceUpdate( + cwd: string, + file: string, + section: string, + mark: SourceMark, +): Promise<{ conflict: boolean; previousSources: SourceMark[] }> { + const now = new Date(mark.syncedAt).getTime(); + const windowStart = now - CONFLICT_WINDOW_MS; + + const allRecords = await readMarks(cwd); + + // 找近 24 小时内同 file + section 的记录 + const recentRecords = allRecords.filter((r) => { + if (r.file !== file || r.section !== section) return false; + const ts = new Date(r.mark.syncedAt).getTime(); + return ts >= windowStart && ts <= now; + }); + + // 不同 source 且不同 sourceId → 冲突 + const conflictRecords = recentRecords.filter( + (r) => r.mark.source !== mark.source || r.mark.sourceId !== mark.sourceId, + ); + + const conflict = conflictRecords.length > 0; + const previousSources = conflictRecords.map((r) => r.mark); + + // 追加本次记录 + const newRecord: SourceMarkRecord = { file, section, mark }; + const filePath = path.join(cwd, SOURCE_MARKS_FILE); + await fs.ensureDir(path.dirname(filePath)); + await fs.appendFile(filePath, JSON.stringify(newRecord) + '\n', 'utf8'); + + return { conflict, previousSources }; +} diff --git a/src/utils/team-codebase-paths.ts b/src/utils/team-codebase-paths.ts new file mode 100644 index 0000000..4c3a563 --- /dev/null +++ b/src/utils/team-codebase-paths.ts @@ -0,0 +1,51 @@ +// -*- coding: utf-8 -*- +import path from 'node:path'; + +/** 相对于 docs/ 的团队 codebase 子目录名。 */ +export const TEAM_CODEBASE_DIR = 'team-codebase'; + +/** 团队 codebase 各层路径集合。 */ +export interface TeamCodebasePaths { + /** <cwd>/docs/team-codebase */ + root: string; + /** <root>/index.md */ + index: string; + /** <root>/domains */ + domainsDir: string; + /** <root>/repos */ + reposDir: string; +} + +/** + * 由 cwd 派生出团队 codebase 全部路径。 + * + * @param cwd 工作目录(通常 process.cwd()) + * @param output 自定义产物根(绝对路径);指定时直接使用,不再向下拼 docs/ + * @returns TeamCodebasePaths 对象 + */ +export function getTeamCodebasePaths(cwd: string, output?: string): TeamCodebasePaths { + const root = output ?? path.join(cwd, 'docs', TEAM_CODEBASE_DIR); + return { + root, + index: path.join(root, 'index.md'), + domainsDir: path.join(root, 'domains'), + reposDir: path.join(root, 'repos'), + }; +} + +/** + * 将域名转换为文件名安全形式(safe slug)。 + * + * 规则:把 /、\、: 替换为 _;trim 空白;空名退化为 'unnamed'。 + * 中文及其他 Unicode 字符保留(写盘时 utf-8)。 + * + * @param name 原始域名 + * @returns 文件名安全的 slug 字符串 + */ +export function safeDomainSlug(name: string): string { + const trimmed = name.trim(); + if (!trimmed) { + return 'unnamed'; + } + return trimmed.replace(/[/\\:]/g, '_'); +} diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 2f1f69e..824488e 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ include: [ 'src/__tests__/e2e/**/*.test.ts', 'src/__tests__/*-e2e.test.ts', + 'validation/*.test.ts', ], testTimeout: 60_000, hookTimeout: 30_000,