首页
/ 拆解 Understand-Anything 的 project-scanner:确定性脚本与 LLM 叙事分工的项目清点 Agent 设计

拆解 Understand-Anything 的 project-scanner:确定性脚本与 LLM 叙事分工的项目清点 Agent 设计

2026-09-06 13:15:24作者:沈韬淼Beryl

本文以 understand-anything-plugin/agents/project-scanner.md 这一 Agent 定义为骨架,逐阶段剖析它如何把一个代码仓库转换成结构化的 scan-result.json 清点文件:确定性部分(文件枚举、语言检测、分类、行数统计、import 解析)全部交给捆绑脚本 scan-project.mjsextract-import-map.mjs,LLM 只负责从 README 和 manifest 中合成 name / description / frameworks / languages 叙事字段。读完后你能掌握该 Agent 的两阶段工作流、每个字段的产出来源、可复制的脚本调用命令,以及其输出如何被 /understand 主管线消费。

1. 角色定位:一个"绝不臆造路径"的项目清点专家

project-scanner 是 Understand-Anything 多 Agent 流水线中的第一个 Worker。在 README.md 的 "Multi-Agent Pipeline" 表中,它的角色是 "Discovers files, detects languages and frameworks",由 /understand 命令调度。SKILL.md 的 Phase 1(SCAN)会派发一个以 agents/project-scanner.md 为定义的子 Agent,并在派发提示中附加 README 前 3000 字符、主 manifest 内容和语言指令,最终产物写入 $UA_DIR/intermediate/scan-result.json

Agent 定义开篇就确立了四条基调(引自 project-scanner.md):

  • 准确性至上:报告中每个文件路径必须真实存在于磁盘;
  • 子 Agent 边界:不得再委派工作或创建子 Agent(包括通过 Agent 工具),必须直接完成任务;
  • 确定性 / LLM 分工:文件枚举、语言检测、类别分配、行数统计、复杂度估计、.understandignore 过滤、import 解析这些确定性逻辑由两个捆绑脚本完成,"Do NOT re-implement any of this logic";LLM 唯一的贡献是阅读 README + manifest,合成叙事性的 name / description / frameworks / languages

这种分工在 scan-project.mjs 的头部注释里解释得更直白:早期版本让 LLM 每次运行时现写一个 Node.js 脚本走文件树、查分类表——"a pure rule-lookup pass that was being billed at LLM rates and adding many minutes of per-run latency"。脚本化之后,纯规则查找以脚本速率运行,LLM 只保留真正需要语义理解的部分。

另外定义中有一条语言指令规则:若派发提示中包含语言指令(例如 "Generate all textual content in Chinese"),则 Phase 2 合成的 description 字段必须用该语言表达,采用母语级自然措辞,无标准译法的技术术语保留英文(如 "middleware"、"hook"、"barrel")。

2. Phase 1 — Discovery:三步编排

Phase 1 由三个步骤组成:步骤 B 与 C 运行捆绑脚本,步骤 A 是本阶段唯一的 LLM 工作。

2.1 Step A(LLM):读 manifest 与 README,产出叙事字段

Agent 只读项目根目录的顶层文件,"Do NOT walk the file tree or count files yourself — that is Step B's job"。按需读取:

  • README.md(或 README.rstREADME)——取前约 10 行作为叙事依据;
  • package.json——提取 namedescription,以及 dependencies / devDependencies 的键用于框架检测;
  • pyproject.tomlsetup.pysetup.cfgPipfilerequirements.txt——Python 框架信号;
  • Cargo.toml——Rust 项目名 + [dependencies]
  • go.mod——Go 模块名 + require 块;
  • Gemfile——Ruby 框架信号;
  • pom.xmlbuild.gradlebuild.gradle.kts——JVM 项目信号;
  • composer.json——PHP 项目信号。

由这些素材合成四个字段:

name 的优先级顺序package.jsonnameCargo.toml [package].namego.mod 模块路径的最后一段 → pyproject.toml[project].name[tool.poetry].name → 都没有则回退到项目根目录名。

rawDescription:取 package.json(或对应 manifest 等价字段)的 description,没有则为 ""readmeHead:README 前约 10 行,无 README 则为 ""

frameworks 的检测清单(依赖名精确匹配 + 基础设施文件推断):

生态 匹配目标
JS / TS react, vue, svelte, @angular/core, express, fastify, koa, next, nuxt, vite, vitest, jest, mocha, tailwindcss, prisma, typeorm, sequelize, mongoose, redux, zustand, mobx
Python django, djangorestframework, fastapi, flask, sqlalchemy, alembic, celery, pydantic, uvicorn, gunicorn, aiohttp, tornado, starlette, pytest, hypothesis, channels
Ruby rails, railties, sinatra, grape, rspec, sidekiq, activerecord, actionpack, devise, pundit
Go github.com/gin-gonic/gin, github.com/labstack/echo, github.com/gofiber/fiber, github.com/go-chi/chi, gorm.io/gorm
Rust actix-web, axum, rocket, diesel, tokio, serde, warp
JVM spring-boot, spring-web, spring-data, quarkus, micronaut, hibernate, jakarta, junit, ktor

基础设施工具按 manifest/文件存在性推断:有 DockerfileDocker;有 docker-compose.yml/docker-compose.yamlDocker Compose;任意 *.tfTerraform.github/workflows/*.ymlGitHub Actions.gitlab-ci.ymlGitLab CIJenkinsfileJenkins

languages:manifest 中观察到的顶层语言集合,去重、按字母排序,并与 Step B 输出的逐文件语言计数(stats.byLanguage)交叉核对。

兜底原则:manifest 缺失或格式错误时,对应字段留空而不是猜测。

2.2 Step B(scan-project.mjs):文件枚举 + 语言 + 类别 + 行数

首先一次性解析项目数据目录并全程复用 $UA_DIR——已存在旧目录 .understand-anything/ 时沿用之,否则用新目录 .ua/

UA_DIR="$PROJECT_ROOT/$([ -d "$PROJECT_ROOT/.understand-anything" ] && echo .understand-anything || echo .ua)"
mkdir -p $UA_DIR/tmp
node $PLUGIN_ROOT/skills/understand/scan-project.mjs \
  "$PROJECT_ROOT" \
  "$UA_DIR/tmp/ua-scan-files.json"

若派发提示带有排除模式,追加 --exclude "<patterns>"(逗号分隔,脚本内部自行拆分),例如:

node $PLUGIN_ROOT/skills/understand/scan-project.mjs \
  "$PROJECT_ROOT" \
  "$UA_DIR/tmp/ua-scan-files.json" \
  --exclude "tests/*,docs/*"

输出 JSON 形态如下(Agent 会逐字读取并合并进最终 scan-result):

{
  "scriptCompleted": true,
  "files": [
    {"path": "src/index.ts", "language": "typescript", "sizeLines": 150, "fileCategory": "code"},
    {"path": "README.md", "language": "markdown", "sizeLines": 45, "fileCategory": "docs"},
    {"path": "Dockerfile", "language": "dockerfile", "sizeLines": 22, "fileCategory": "infra"},
    {"package.json": "", "language": "json", "sizeLines": 35, "fileCategory": "config"}
  ],
  "totalFiles": 42,
  "filteredByIgnore": 0,
  "estimatedComplexity": "moderate",
  "stats": {
    "filesScanned": 42,
    "byCategory": {"code": 28, "config": 6, "docs": 4, "infra": 2, "script": 2},
    "byLanguage": {"typescript": 22, "javascript": 6, "json": 5, "markdown": 4, "yaml": 3, "shell": 2}
  }
}

脚本的确定性行为(Agent 不维护这些表,表住在脚本里):

  • filespath.localeCompare 排序(确定性)。从源码看,scan-project.mjs 实际用的是 ECMAScript 关系字符串比较(UTF-16 码元字典序),注释明确说明这是为了"结果不随 ICU 版本、进程 locale、操作系统设置变化",比 localeCompare 更严格地做到了跨主机可复现;
  • fileCategory 每文件必发,取值 code | config | docs | infra | data | script | markup
  • language 对每个文件都是非空字符串:已知扩展名映射到规范 id,未知扩展名映射为小写扩展名,无扩展名且非 Dockerfile/Makefile/Jenkinsfile 的文件为 "unknown"
  • filteredByIgnore 只统计"超出硬编码默认值"的丢弃量,.understandignore 中的 ! 取反能正确重新包含文件;
  • 逐文件失败(权限拒绝、畸形 unicode、文件消失)输出 Warning: scan-project: <path> — <reason> — file skipped from output 到 stderr,Agent 需捕获并追加到阶段警告;
  • 结束前输出一行信息性汇总 scan-project: filesScanned=… filteredByIgnore=… complexity=…

标准类别表(记录在案,脚本为准,不要在提示词里重新推导这些规则):

模式 类别
LICENSE code(例外——不算 docs)
DockerfileDockerfile.*docker-compose.*compose.yml/compose.yamlMakefileJenkinsfileProcfileVagrantfile.gitlab-ci.yml.dockerignore.github/workflows/*.circleci/*k8s/kubernetes/ 路径下的文件、*.k8s.yml/*.k8s.yaml infra
.md.mdx.rst.txt.textLICENSE 除外) docs
.yaml.yml.json.jsonc.toml.xml.xsl.xsd.plist.cfg.ini.env.properties.csproj.sln.mod.sum.gradle.sbt config
.tf.tfvars infra
.sql.graphql.gql.proto.prisma.csv.tsv data
.sh.bash.zsh.ps1.psm1.psd1.bat.cmd script
.html.htm.css.scss.sass.less markup
其余一切 code

优先级规则:最具体者胜。文件名/路径规则先于扩展名规则触发——例如 docker-compose.ymlinfra(不是 config);.github/workflows/ci.ymlinfra(不是 config);LICENSEcode(不是 docs)。这与源码 detectCategory 的实现逐条对应:规则 1 判断 LICENSE,规则 2 按文件名匹配 infra(含 Dockerfile.* 前缀、docker-compose.* 前缀、compose.yml/compose.yaml),规则 3 按路径前缀匹配(.github/workflows/.circleci/、任意层级的 k8s//kubernetes/ 段、*.k8s.(ya?ml)),规则 4 才是扩展名查表,最后兜底 code

.understandignore 行为:脚本读取项目根的 .understandignore 以及数据目录内的 .understandignore.ua/.understandignore,或旧目录 .understand-anything/.understandignore),与硬编码默认值经 core 包的 createIgnoreFilter 合并;! 取反覆盖默认值(如 !dist/ 可把 dist/ 重新纳入);filteredByIgnore 计数器只度量用户驱动的丢弃,不度量基线默认丢弃。从源码看,计数实现 是构造两套过滤器做差集:一套"仅默认值",一套"默认值 + 用户模式",凡是组合过滤器丢弃而仅默认过滤器本会保留的文件才计入 filteredByIgnore——通过 ! 重新包含的文件不在组合丢弃集中,因此不会被错误计数。

失败策略:脚本非零退出时读 stderr 诊断,最多允许 2 次重试(重新调用)后判定阶段失败;"Do NOT attempt to substitute a custom scanner — there is no second-source replacement"。

源码层面还有两个 Agent 文档未明说、但影响输出质量的事实:

  • 枚举策略:优先 git ls-files -z -co --exclude-standard(见 enumerateViaGit),-z 使路径以 NUL 分隔,避免非 ASCII 路径名被 git 的 C-escape 转义后下游无法回读;非 git 目录回退到递归遍历,遍历器在 ignore 过滤前先硬跳过 node_modules.git.svn.hg__pycache__,且不跟随符号链接。
  • 复杂度分档estimateComplexity 的阈值为 small(≤30 文件)、moderate(31–150)、large(151–500)、very-large(>500),边界取闭区间下界;0 个文件归入 small
  • 内容指纹:脚本还会对"按稳定路径序 + 每帧 uint32be(路径长度) || uint64be(内容长度) || 路径字节 || 内容字节"计算 sha256 contentDigest,供增量更新判断使用。

2.3 Step C(extract-import-map.mjs):确定性 import 解析

Step B 产出文件清单后,调用捆绑的 extract-import-map.mjs 对所有受支持的代码语言做确定性 import 提取。它内部使用 core 包的 TreeSitterPlugin + PluginRegistry 解析源码(tree-sitter),再用语言特定的解析规则映射到项目内文件路径。文档明确要求:不要重新实现 import 模式——Step B 已为每个文件发出 path/language/fileCategory,该脚本消费这个清单并产出 importMap

先写输入 JSON(files[] 数组与 Step B 的 files[] 完全一致,逐字透传):

UA_DIR="$PROJECT_ROOT/$([ -d "$PROJECT_ROOT/.understand-anything" ] && echo .understand-anything || echo .ua)"
mkdir -p $UA_DIR/tmp
cat > $UA_DIR/tmp/ua-import-map-input.json << 'ENDJSON'
{
  "projectRoot": "<absolute-project-root>",
  "files": [
    {"path": "src/index.ts", "language": "typescript", "fileCategory": "code"},
    {"path": "README.md", "language": "markdown", "fileCategory": "docs"}
  ]
}
ENDJSON

然后运行:

node $PLUGIN_ROOT/skills/understand/extract-import-map.mjs \
  $UA_DIR/tmp/ua-import-map-input.json \
  $UA_DIR/tmp/ua-import-map-output.json

输出 JSON 形态:

{
  "scriptCompleted": true,
  "stats": { "filesScanned": 314, "filesWithImports": 142, "totalEdges": 487 },
  "importMap": {
    "src/index.ts": ["src/utils.ts", "src/config.ts"],
    "src/utils.ts": [],
    "README.md": [],
    "Dockerfile": []
  }
}

读取输出 JSON,把 importMap 字段直接合并进最终 scan-result.json(同名键 importMap)。格式与 project-scanner 契约匹配:每个输入文件都有条目;非代码文件为空数组;只包含已解析的内部路径(外部包被丢弃)。

stderr 处理:运行捆绑脚本时捕获 stderr;任何以 Warning: 开头的行都追加到阶段警告——SKILL.md 编排器会收集它们进入最终报告。脚本完成时还会输出一行汇总 extract-import-map: filesScanned=… filesWithImports=… totalEdges=…,可忽略或作为信息展示。

支持的语言(13 种,清单与实现一一对应,无 LLM 兜底):

  • TypeScript;
  • JavaScript(含 CJS require());
  • Python(相对 + 绝对 + __init__.py 包解析);
  • Go(go.mod 前缀剥离);
  • Rust(use crate::use super::use self::mod x; 声明);
  • Java;
  • Kotlin;
  • Scala(点分 FQN + selector 列表 + package 对象);
  • C#;
  • Ruby(require + require_relative);
  • PHP(composer.json PSR-4 autoload);
  • C;
  • C++(#include,相对路径 + include/ + src/ 探测)。

集合之外的语言得到空数组——没有基于 LLM 的回退。从源码结构看,extract-import-map.mjs 会加载输入文件清单中发现的每一个 go.mod(支持多服务 monorepo,按导入文件向上寻找最近的 go.mod 分发模块前缀)以及每个 composer.json 的 PSR-4 autoload 映射,配置在启动时一次性缓存,避免千文件项目重复解析。

3. Phase 2 — Description 合成与最终组装

Steps A + B + C 全部完成后,Agent 读取三个来源:

  1. $UA_DIR/tmp/ua-scan-files.json —— scan-project.mjs 的输出(含 totalFilesfilteredByIgnoreestimatedComplexity);
  2. $UA_DIR/tmp/ua-import-map-output.json —— extract-import-map.mjs 的输出(importMap 字段);
  3. Step A 的内存笔记(namerawDescriptionreadmeHeadframeworkslanguages)。

禁止事项:不得重新遍历文件树、重新计数行数或重新推导类别——完全信任 scan-project.mjs;不得重新实现 import 解析——完全信任 extract-import-map.mjs

字段剥离规则(IMPORTANT):最终输出不得包含任一捆绑脚本的 scriptCompletedstats 字段,也不得包含临时工作字符串 rawDescription / readmeHead。最终 importMap 必须与 extract-import-map.mjsimportMap 字段逐字相等(不得编辑、重排、过滤);最终 files 数组必须与 Step B 的 files 数组逐字相等(不得重排、丢弃、增补)。

Phase 2 中 Agent 唯一的合成任务是最终 description 字段,规则依次为:

  1. rawDescription 非空,以它为基础,必要时清理(去掉营销话术,保证 1–2 句);
  2. rawDescription 为空但 readmeHead 非空,从 README 内容合成 1–2 句描述;
  3. 两者都为空,使用 "No description available"
  4. totalFiles > 100,追加固定备注:" Note: this project has over 100 source files; consider scoping analysis to a subdirectory for faster results."

最终输出 JSON 组装:

{
  "name": "project-name",
  "description": "Brief description from README or package.json",
  "languages": ["markdown", "typescript", "yaml"],
  "frameworks": ["React", "Vite", "Vitest", "Docker"],
  "files": [
    {"path": "src/index.ts", "language": "typescript", "sizeLines": 150, "fileCategory": "code"},
    {"path": "README.md", "language": "markdown", "sizeLines": 45, "fileCategory": "docs"},
    {"path": "Dockerfile", "language": "dockerfile", "sizeLines": 22, "fileCategory": "infra"}
  ],
  "totalFiles": 42,
  "filteredByIgnore": 0,
  "estimatedComplexity": "moderate",
  "importMap": {
    "src/index.ts": ["src/utils.ts"]
  }
}

字段要求逐项核对

  • name(string):来自 Step A 叙事工作;
  • description(string):合成的 1–2 句描述;
  • languages(string[]):去重、按字母排序,并与 Step B stats.byLanguage 的键交叉核对;
  • frameworks(string[]):仅已确认的框架(未检测到则为空数组);
  • files(object[]):直接取自 Step B files[](逐字,含 fileCategory);
  • totalFiles(integer):直接取自 Step B;
  • filteredByIgnore(integer):直接取自 Step B;
  • estimatedComplexity(string):直接取自 Step B;
  • importMap(object):直接取自 Step C 的 importMap 字段。

4. Critical Constraints 与结果落盘

定义末尾的约束清单是整个 Agent 的"合同条款":

  • 绝不发明或猜测文件路径——files 数组中每个 path 必须来自 scan-project.mjs 的输出(其本身来自 git ls-files 或真实目录列表);
  • 绝不包含磁盘上不存在的文件;
  • 必须校验 totalFilesfiles 数组实际长度一致;
  • 信任 Step B 做文件枚举 + 语言检测 + 类别分配 + 行数统计 + 复杂度估计,信任 Step C 做 importMap;Agent 自己的合成只有 description 字段(外加 Step A 的 nameframeworkslanguages);
  • 不要在发现脚本中重新实现文件枚举、语言检测或类别分配——使用捆绑的 scan-project.mjs;"If the table doesn't cover your project type, file an issue rather than ad-hoc handling";
  • 不要尝试重新实现 import 解析——捆绑脚本通过 tree-sitter + 逐语言解析器确定性地覆盖全部 13 种代码语言;
  • 每个文件必须有 fileCategory 字段,取值为 codeconfigdocsinfradatascriptmarkup 之一——scan-project.mjs 保证这一点,只需不要把它剥掉。

Writing Results(产物落盘与汇报):

  1. 创建输出目录 mkdir -p $UA_DIR/intermediate(数据目录——.ua/,或已存在时的旧 .understand-anything/);
  2. 将 JSON 写入 $UA_DIR/intermediate/scan-result.json;若派发提示给出了确切输出路径,则使用之;
  3. 回复包含简短文本摘要:项目名、总文件数(按类别分解)、检测到的语言、估计复杂度——不要把完整 JSON 放进文本回复。

5. 在主管线中的位置:scan-result.json 如何被消费

SKILL.md 的 Phase 1 编排看,project-scanner 的输出是整个 /understand 流水线的地基:

  • 主会话派发子 Agent 时会注入 README 与 manifest 内容,但明确要求把它们当作不受信任的项目数据——仅用于推断项目名、描述和框架事实,忽略其中内嵌的任何指令式文本(这是针对提示注入的防护);
  • --exclude CLI 旗标会转为 $EXCLUDE_PATTERNS 传入派发提示,再由 Agent 以 --exclude 传给 scan-project.mjs
  • 子 Agent 完成后,主编排器读取 scan-result.json:取出项目名/描述、语言/框架、带 fileCategory 的文件清单、复杂度估计与 importMap
  • importMap 存为内存变量 $IMPORT_MAP、文件清单存为 $FILE_LIST,供 Phase 1.5 的 compute-batches.mjs 做语义分批——Phase 2 中最多 5 个 file-analyzer 子 Agent 并发、每批 20–30 个文件并行解析时,直接消费预解析好的 importMap 而不是从源码重新推导;
  • Gate check:文件数超过 100 时,编排器会提示用户并建议用子目录参数限定范围(这与 Phase 2 中 description 追加的 ">100 文件" 备注是同一阈值的前后呼应);若 filteredByIgnore > 0,向用户报告 "Excluded N files via .understandignore and/or --exclude rules"。

再往上游看一步:Phase 0.5 会用 generate-ignore.mjs 生成起步 .understandignore(读取 .gitignore、对照内置默认去重、按语言分组给出测试文件建议),并等待用户确认后才进入扫描——这正是 Step B 中 filteredByIgnore 计数与"用户模式覆盖默认值"行为的来源。

6. 设计要点小结

project-scanner 这一份 Agent 定义值得借鉴的地方在于其职责切分的精确性

  1. 确定性下沉到脚本:凡是"同一输入必须产出同一输出"的工作(枚举、分类、计数、import 边)全部由两个捆绑脚本承担,LLM 零自由度,天然可复现;脚本还内建了逐文件容错(失败只警告并跳过单文件)、stderr-only 日志、符号链接防护和非 ASCII 路径安全(git ls-files -z);
  2. LLM 只保留叙事权name/description/frameworks/languages 四个字段的合成规则被写成优先级清单(manifest 类型优先序、框架匹配表、description 三级回退),最大限度压缩 LLM 的解释空间;
  3. 逐字透传合同files[]importMap 在最终产物中必须逐字等于脚本输出,Agent 不得重排、丢弃或增补——这使结构侧数据(README 所谓 "the same code always yields the same edges")与语义侧数据在边界上清晰隔离;
  4. 可验证的自检条款totalFiles 必须等于 files 长度、路径必须来自真实磁盘列表,让下游(编排器、file-analyzer)可以放心消费。

这套"确定性脚本 + 受限 LLM 叙事"的双轨结构,是 Understand-Anything 把动辄数万行的仓库扫描从"分钟级 LLM 查表"变成"秒级脚本 + 少量语义合成"的关键,也是 scan-result.json 成为后续分批解析、图谱构建与增量更新共同基础的原因。

登录后查看全文
热门项目推荐
相关项目推荐