Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
daaabf07fc
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.turbo/
|
.turbo/
|
||||||
|
.kt-workspace/
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
12
README.md
12
README.md
@ -18,7 +18,7 @@
|
|||||||
- 静态检查 NapCat 设备身份护栏:确认 API 仍保留 QQNT 可见 hostname、实体 OUI 风格 MAC、QQNT `machine-info` 写入、持久化 runtime dir 和 `DB_TIMEZONE` 默认值。
|
- 静态检查 NapCat 设备身份护栏:确认 API 仍保留 QQNT 可见 hostname、实体 OUI 风格 MAC、QQNT `machine-info` 写入、持久化 runtime dir 和 `DB_TIMEZONE` 默认值。
|
||||||
- 生成 NapCatQQ 上游同步与 runtime 发布自动化入口:MCP/CLI 默认 dry-run,覆盖 upstream audit、sync candidate review、runtime readiness、remote dev handoff 和 NAS Codex bootstrap plan;upstream audit 在显式 `execute=true` 且 `useCodex=true` 时可运行 Codex,NAS Codex bootstrap 在显式 `execute=true` 时只返回目录准备、nvm 管理的 Node 22.14.0 / pnpm 10.28.2 / Codex CLI 安装计划,不复制 secrets、不写 systemd、不触发发布。
|
- 生成 NapCatQQ 上游同步与 runtime 发布自动化入口:MCP/CLI 默认 dry-run,覆盖 upstream audit、sync candidate review、runtime readiness、remote dev handoff 和 NAS Codex bootstrap plan;upstream audit 在显式 `execute=true` 且 `useCodex=true` 时可运行 Codex,NAS Codex bootstrap 在显式 `execute=true` 时只返回目录准备、nvm 管理的 Node 22.14.0 / pnpm 10.28.2 / Codex CLI 安装计划,不复制 secrets、不写 systemd、不触发发布。
|
||||||
- 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。
|
- 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。
|
||||||
- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口。
|
- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口;MCP 不可用时用 `pnpm run change-doc-sync -- --project <project>` 作为 CLI fallback。
|
||||||
- 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。
|
- 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。
|
||||||
- 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、`.kt-workspace/db-sync` 转储、备份库和行数校验。
|
- 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、`.kt-workspace/db-sync` 转储、备份库和行数校验。
|
||||||
- 生成或执行部署观测:把 Jenkins build、`build.xml` SCM revision、日志尾部、K8s Deployment、Pod、`/health/runtime` 和任务 smoke 汇总成 `.kt-workspace/test-artifacts/deploy-observation` 下的运行态证据;Jenkins 状态以日志尾部最终 `Finished:` 为准,日志存在但缺少最终态时不认定发布完成。
|
- 生成或执行部署观测:把 Jenkins build、`build.xml` SCM revision、日志尾部、K8s Deployment、Pod、`/health/runtime` 和任务 smoke 汇总成 `.kt-workspace/test-artifacts/deploy-observation` 下的运行态证据;Jenkins 状态以日志尾部最终 `Finished:` 为准,日志存在但缺少最终态时不认定发布完成。
|
||||||
@ -26,7 +26,7 @@
|
|||||||
- 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。
|
- 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。
|
||||||
- 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮,模板目录永久保留;CLI 默认 dry-run,真实清理必须显式传 `--execute`。
|
- 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮,模板目录永久保留;CLI 默认 dry-run,真实清理必须显式传 `--execute`。
|
||||||
- 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`,提醒锁文件、核心表格组件、API 时间序列化 KtDateTime 列/DTO 装饰器入口、部署链路和 Vue TSX 插槽写法等高风险改动。
|
- 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`,提醒锁文件、核心表格组件、API 时间序列化 KtDateTime 列/DTO 装饰器入口、部署链路和 Vue TSX 插槽写法等高风险改动。
|
||||||
- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、QQBot 插件 registry/controller smoke 未 mock 具体插件实现风险、QQBot OneBot/QQ 登录态边界回归、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时和清理前 pending 回归、普通处理中状态误吃旧验证码日志回归、根目录生成产物、`TASKS.md` 最近记录字段结构和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签和明显测试占位 token,避免历史误报污染上下文;任何文件改动后都要跑一遍。
|
- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、QQBot 插件 registry/controller smoke 未 mock 具体插件实现风险、QQBot OneBot/QQ 登录态边界回归、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时和清理前 pending 回归、普通处理中状态误吃旧验证码日志回归、根目录生成产物、`TASKS.md` 最近记录字段结构、最近记录 3 条上限和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签和明显测试占位 token,避免历史误报污染上下文;任何文件改动后都要跑一遍。
|
||||||
- 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。
|
- 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。
|
||||||
- 生成提交前检查清单:校验 KT commit message 约定。
|
- 生成提交前检查清单:校验 KT commit message 约定。
|
||||||
|
|
||||||
@ -40,6 +40,7 @@ pnpm run self-test
|
|||||||
pnpm run obsidian-context -- --module ktWorkflow
|
pnpm run obsidian-context -- --module ktWorkflow
|
||||||
pnpm run obsidian-validate
|
pnpm run obsidian-validate
|
||||||
pnpm run obsidian-sync
|
pnpm run obsidian-sync
|
||||||
|
pnpm run change-doc-sync -- --project root
|
||||||
pnpm run workstream-closeout -- --title "发布闭环" --verification "Jenkins SUCCESS" --doc-sync "无需文档更新" --cleanup "cleanup-history dry-run deleted=0" --cleanup-final-deleted 0 --review "global-review findings=0" --superpowers-review "Superpowers reviewer completed; no Critical/Important findings" --problem "无新卡点" --solution "无新增稳定解法"
|
pnpm run workstream-closeout -- --title "发布闭环" --verification "Jenkins SUCCESS" --doc-sync "无需文档更新" --cleanup "cleanup-history dry-run deleted=0" --cleanup-final-deleted 0 --review "global-review findings=0" --superpowers-review "Superpowers reviewer completed; no Critical/Important findings" --problem "无新卡点" --solution "无新增稳定解法"
|
||||||
pnpm run napcat-upstream-audit -- --artifact-root .kt-workspace/test-artifacts/napcat-upstream-sync
|
pnpm run napcat-upstream-audit -- --artifact-root .kt-workspace/test-artifacts/napcat-upstream-sync
|
||||||
pnpm run napcat-sync-candidate-review
|
pnpm run napcat-sync-candidate-review
|
||||||
@ -59,9 +60,10 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
|||||||
|
|
||||||
- `ci/jenkins/KT-NapCatQQ-Upstream-Sync.Jenkinsfile` 是 NapCatQQ 上游审计入口,默认只调用 ktWorkflow 的 `napcat-upstream-audit` dry-run,保留人工审查边界。
|
- `ci/jenkins/KT-NapCatQQ-Upstream-Sync.Jenkinsfile` 是 NapCatQQ 上游审计入口,默认只调用 ktWorkflow 的 `napcat-upstream-audit` dry-run,保留人工审查边界。
|
||||||
- Codex 上游审计必须同时设置 `RUN_CODEX_AUDIT=true` 并填写 `CONFIRM_CODEX_WORKSPACE_WRITE=RUN_CODEX_WORKSPACE_WRITE_AUDIT`;这是因为 Codex runner 使用 workspace-write sandbox,不能作为定时任务默认行为。
|
- Codex 上游审计必须同时设置 `RUN_CODEX_AUDIT=true` 并填写 `CONFIRM_CODEX_WORKSPACE_WRITE=RUN_CODEX_WORKSPACE_WRITE_AUDIT`;这是因为 Codex runner 使用 workspace-write sandbox,不能作为定时任务默认行为。
|
||||||
- `ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile` 是运行时发布入口,先调用 `napcat-runtime-release-readiness`,再通过显式参数触发运行时镜像构建和 API promotion;下游 job 使用白名单 choice,避免任意 Jenkins job 名被传入。
|
- `ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile` 是运行时发布入口,先调用 `napcat-runtime-release-readiness`,再通过显式参数触发运行时镜像构建和 API promotion;下游 job 使用白名单 choice,避免任意 Jenkins job 名被传入,并把 upstream release commit / NapCat base image digest 透传到镜像构建证据。
|
||||||
|
- `ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile` 是运行时镜像构建下游,只从 NAS 本地裸仓库 clone 经过审查的 NapCatQQ fork ref,构建 `NapCat.Shell`,先要求 NAS API 工作区 clean,并从 Jenkins API main checkout 的只读 `.git` reference 做 `fetch + merge --ff-only`,再调用 API 仓库 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 staged context,执行 `docker build` 和容器内 `/ci/napcat-desktop-cn/verify.sh`;由于该下游需要访问挂载的宿主 Docker socket,内层 runner 固定 `--user 0:0`,镜像验证容器按账号容器同款 `SYS_ADMIN` / seccomp / apparmor 参数和 `NAPCAT_REQUIRE_DEVICE_PROFILE=1` 启动,但它不触发 API promotion、不执行 `kubectl`、不合并或推送 Git。
|
||||||
- 生产 API promotion 除了 `PROMOTE_API_RUNTIME=true` 和明确 image/profile 参数外,还必须填写 `CONFIRM_API_PROMOTION_TARGET=PROMOTE_KT_TEMPLATE_API_MAIN`。
|
- 生产 API promotion 除了 `PROMOTE_API_RUNTIME=true` 和明确 image/profile 参数外,还必须填写 `CONFIRM_API_PROMOTION_TARGET=PROMOTE_KT_TEMPLATE_API_MAIN`。
|
||||||
- 两个模板默认使用 NAS 工作区 `/vol1/docker/kt-codex/workspace/KT`,运行证据落在 `/vol1/docker/kt-codex/artifacts` 下。
|
- 三个模板默认使用 NAS 工作区 `/vol1/docker/kt-codex/workspace/KT`,运行证据落在 `/vol1/docker/kt-codex/artifacts` 下。
|
||||||
- 模板只保存入口和参数形状,不记录 Jenkins credential、Token、SSH key 或其他真实凭据。
|
- 模板只保存入口和参数形状,不记录 Jenkins credential、Token、SSH key 或其他真实凭据。
|
||||||
|
|
||||||
## MCP 客户端配置
|
## MCP 客户端配置
|
||||||
@ -132,7 +134,7 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
|||||||
| 脚本 | 用途 |
|
| 脚本 | 用途 |
|
||||||
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `pnpm run admin-login` | 使用可见 Edge 打开 Admin 登录页,填写账号密码,拖动滑块,保存登录态和截图。默认账号来自初始化数据 `admin/123456`,生产或个人账号用 `KT_ADMIN_USERNAME` / `KT_ADMIN_PASSWORD` 或 CLI 参数覆盖。 |
|
| `pnpm run admin-login` | 使用可见 Edge 打开 Admin 登录页,填写账号密码,拖动滑块,保存登录态和截图。默认账号来自初始化数据 `admin/123456`,生产或个人账号用 `KT_ADMIN_USERNAME` / `KT_ADMIN_PASSWORD` 或 CLI 参数覆盖。 |
|
||||||
| `pnpm run global-review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,默认仅对变更文件做内容深扫,输出 JSON 复审报告,并校验 `TASKS.md` 最近记录只保留范围、关键词、验证字段、NapCat `latest` 漂移、QQBot 重型服务单测导入风险、QQBot 插件 registry/controller smoke 未 mock 具体插件实现风险、OneBot/QQ 登录态边界、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时及清理前 pending 回归,以及普通处理中状态误吃旧验证码日志回归;明显测试占位 token 不报凭据风险,确认误报时优先升级 `src/tools/review.ts`,不要把误报沉积到上下文。 |
|
| `pnpm run global-review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,默认仅对变更文件做内容深扫,输出 JSON 复审报告,并校验 `TASKS.md` 最近记录只保留最新 3 条详细记录,以及每条记录只保留范围、关键词、验证字段;同时校验 NapCat `latest` 漂移、QQBot 重型服务单测导入风险、QQBot 插件 registry/controller smoke 未 mock 具体插件实现风险、OneBot/QQ 登录态边界、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时及清理前 pending 回归,以及普通处理中状态误吃旧验证码日志回归;明显测试占位 token 不报凭据风险,确认误报时优先升级 `src/tools/review.ts`,不要把误报沉积到上下文。 |
|
||||||
| `pnpm run napcat-upstream-audit` | 默认 dry-run 输出 NapCatQQ 上游 release 审计分类、只读 Git 命令和 artifact 路径;显式 `--execute --use-codex` 才运行 Codex。 |
|
| `pnpm run napcat-upstream-audit` | 默认 dry-run 输出 NapCatQQ 上游 release 审计分类、只读 Git 命令和 artifact 路径;显式 `--execute --use-codex` 才运行 Codex。 |
|
||||||
| `pnpm run napcat-sync-candidate-review` | 输出 sync candidate review 的 prompt/context/只读命令骨架,不执行 Codex 或 Git 写操作。 |
|
| `pnpm run napcat-sync-candidate-review` | 输出 sync candidate review 的 prompt/context/只读命令骨架,不执行 Codex 或 Git 写操作。 |
|
||||||
| `pnpm run napcat-runtime-release-readiness` | 输出 runtime release readiness 的 prompt/context/只读检查骨架,不构建镜像、不调用 kubectl、不发布。 |
|
| `pnpm run napcat-runtime-release-readiness` | 输出 runtime release readiness 的 prompt/context/只读检查骨架,不构建镜像、不调用 kubectl、不发布。 |
|
||||||
|
|||||||
212
ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile
Normal file
212
ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
pipeline {
|
||||||
|
agent { label 'kt-node-agent' }
|
||||||
|
|
||||||
|
options {
|
||||||
|
buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '30'))
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
skipDefaultCheckout(true)
|
||||||
|
timestamps()
|
||||||
|
timeout(time: 90, unit: 'MINUTES')
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
string(name: 'NAPCAT_FORK_REF', defaultValue: 'main', description: 'Reviewed NapCatQQ fork branch, tag, or commit.')
|
||||||
|
string(name: 'UPSTREAM_RELEASE_TAG', defaultValue: 'unknown', description: 'Reviewed upstream release tag for marker metadata.')
|
||||||
|
string(name: 'UPSTREAM_RELEASE_COMMIT', defaultValue: '5c18a62530d87dbadf53d267002894faa6ca7e90', description: 'Reviewed upstream release commit for marker metadata.')
|
||||||
|
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', defaultValue: '', description: 'Explicit runtime image tag to build, for example kt-napcat-desktop-cn:desktop-cn-v9.')
|
||||||
|
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', defaultValue: '', description: 'Explicit desktop profile version paired with the runtime image tag.')
|
||||||
|
string(name: 'NAPCAT_BASE_IMAGE_DIGEST', defaultValue: 'mlikiowa/napcat-docker@sha256:9254ec12af101576c5eeb4910847abd1d219297bc6d9a35c52511e12500f0f45', description: 'Immutable NapCat base image digest used for Docker build.')
|
||||||
|
string(name: 'KT_ARTIFACT_ROOT', defaultValue: '/vol1/docker/kt-codex/artifacts/napcat-runtime-release', description: 'NAS artifact root for runtime release evidence.')
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
KT_WORKSPACE_ROOT = '/vol1/docker/kt-codex/workspace/KT'
|
||||||
|
API_ROOT = '/vol1/docker/kt-codex/workspace/KT/Node/kt-template-online-api'
|
||||||
|
API_GIT_REFERENCE_HOST = '/vol1/docker/volumes/kt-node-agent-workdir/_data/workspace/KT-Template_KT-Template-API_main/.git'
|
||||||
|
API_GIT_REFERENCE = '/ci/kt-template-online-api-main.git'
|
||||||
|
NAPCAT_GIT_REPOSITORY = '/vol1/docker/kt-codex/git/NapCatQQ.git'
|
||||||
|
KT_WORKFLOW_RUNNER_IMAGE = 'kt-jenkins-node-agent:current'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Validate parameters') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
requireRuntimeImageParameters(
|
||||||
|
params.NAPCAT_FORK_REF,
|
||||||
|
params.UPSTREAM_RELEASE_COMMIT,
|
||||||
|
params.QQBOT_NAPCAT_IMAGE_OVERRIDE,
|
||||||
|
params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE,
|
||||||
|
params.NAPCAT_BASE_IMAGE_DIGEST
|
||||||
|
)
|
||||||
|
requireImageProfileMatch(
|
||||||
|
params.QQBOT_NAPCAT_IMAGE_OVERRIDE,
|
||||||
|
params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build and verify runtime image') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -e
|
||||||
|
docker run --rm \
|
||||||
|
-e API_ROOT="$API_ROOT" \
|
||||||
|
-e API_GIT_REFERENCE="$API_GIT_REFERENCE" \
|
||||||
|
-e BUILD_TAG="$BUILD_TAG" \
|
||||||
|
-e BUILD_URL="$BUILD_URL" \
|
||||||
|
-e COREPACK_HOME=/vol1/docker/kt-codex/runtime/corepack \
|
||||||
|
-e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \
|
||||||
|
-e KT_WORKSPACE_ROOT="$KT_WORKSPACE_ROOT" \
|
||||||
|
-e NAPCAT_BASE_IMAGE_DIGEST="$NAPCAT_BASE_IMAGE_DIGEST" \
|
||||||
|
-e NAPCAT_FORK_REF="$NAPCAT_FORK_REF" \
|
||||||
|
-e NAPCAT_GIT_REPOSITORY="$NAPCAT_GIT_REPOSITORY" \
|
||||||
|
-e QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE="$QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE" \
|
||||||
|
-e QQBOT_NAPCAT_IMAGE_OVERRIDE="$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
|
||||||
|
-e UPSTREAM_RELEASE_COMMIT="$UPSTREAM_RELEASE_COMMIT" \
|
||||||
|
-e UPSTREAM_RELEASE_TAG="$UPSTREAM_RELEASE_TAG" \
|
||||||
|
-v /vol1/docker/kt-codex:/vol1/docker/kt-codex \
|
||||||
|
-v "$API_GIT_REFERENCE_HOST":"$API_GIT_REFERENCE":ro \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--user 0:0 \
|
||||||
|
--entrypoint bash "$KT_WORKFLOW_RUNNER_IMAGE" -lc '
|
||||||
|
set -euo pipefail
|
||||||
|
source /vol1/docker/kt-codex/runtime/nvm/nvm.sh
|
||||||
|
corepack prepare pnpm@10.28.2 --activate
|
||||||
|
ARTIFACTS_BASE_REAL=$(realpath -m /vol1/docker/kt-codex/artifacts)
|
||||||
|
ARTIFACT_ROOT_REAL=$(realpath -m "$KT_ARTIFACT_ROOT")
|
||||||
|
case "$ARTIFACT_ROOT_REAL" in
|
||||||
|
"$ARTIFACTS_BASE_REAL"/*) ;;
|
||||||
|
*)
|
||||||
|
echo "KT_ARTIFACT_ROOT must stay under /vol1/docker/kt-codex/artifacts" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
BUILD_ID_SAFE=$(printf "%s" "${BUILD_TAG:-manual}" | tr "/ " "__")
|
||||||
|
ARTIFACT_DIR="$ARTIFACT_ROOT_REAL/runtime-image-build"
|
||||||
|
BUILD_WORKDIR="$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE"
|
||||||
|
NAPCAT_ROOT="$BUILD_WORKDIR/NapCatQQ"
|
||||||
|
STAGE_OUT=".kt-workspace/napcat-desktop-cn-build-runtime-image"
|
||||||
|
VERIFY_NAME="kt-napcat-runtime-image-verify-$BUILD_ID_SAFE"
|
||||||
|
cleanup() {
|
||||||
|
docker rm -f "$VERIFY_NAME" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$BUILD_WORKDIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
git config --global --add safe.directory "$NAPCAT_GIT_REPOSITORY"
|
||||||
|
git config --global --add safe.directory "$API_ROOT"
|
||||||
|
rm -rf "$ARTIFACT_DIR" "$BUILD_WORKDIR"
|
||||||
|
mkdir -p "$ARTIFACT_DIR" "$BUILD_WORKDIR"
|
||||||
|
if [ -n "$(git -C "$API_ROOT" status --short)" ]; then
|
||||||
|
echo "API_ROOT must be clean before runtime image build: $API_ROOT" >&2
|
||||||
|
git -C "$API_ROOT" status --short >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
test -d "$API_GIT_REFERENCE"
|
||||||
|
git -C "$API_ROOT" fetch "$API_GIT_REFERENCE" refs/remotes/origin/main
|
||||||
|
git -C "$API_ROOT" checkout main
|
||||||
|
git -C "$API_ROOT" merge --ff-only FETCH_HEAD
|
||||||
|
git -C "$API_ROOT" rev-parse HEAD > "$ARTIFACT_DIR/api-root-commit.txt"
|
||||||
|
git clone "$NAPCAT_GIT_REPOSITORY" "$NAPCAT_ROOT"
|
||||||
|
cd "$NAPCAT_ROOT"
|
||||||
|
git checkout --detach "$NAPCAT_FORK_REF"
|
||||||
|
git rev-parse HEAD > "$ARTIFACT_DIR/fork-commit.txt"
|
||||||
|
git status --short > "$ARTIFACT_DIR/fork-status.txt"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm --filter napcat-webui-frontend run build
|
||||||
|
pnpm run build:shell
|
||||||
|
pnpm run build:framework
|
||||||
|
|
||||||
|
cd "$API_ROOT"
|
||||||
|
node scripts/napcat-desktop-cn-stage-build.mjs \
|
||||||
|
--napcat-root "$NAPCAT_ROOT" \
|
||||||
|
--out "$STAGE_OUT" \
|
||||||
|
--upstream-release-tag "$UPSTREAM_RELEASE_TAG" \
|
||||||
|
--upstream-release-commit "$UPSTREAM_RELEASE_COMMIT" \
|
||||||
|
--napcat-base-image-digest "$NAPCAT_BASE_IMAGE_DIGEST" \
|
||||||
|
--jenkins-build-url "$BUILD_URL" \
|
||||||
|
| tee "$ARTIFACT_DIR/stage-build.json"
|
||||||
|
cp "$STAGE_OUT/ci/napcat-desktop-cn/fork-artifact.json" "$ARTIFACT_DIR/fork-artifact.json"
|
||||||
|
docker build \
|
||||||
|
--build-arg NAPCAT_BASE_IMAGE="$NAPCAT_BASE_IMAGE_DIGEST" \
|
||||||
|
-t "$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
|
||||||
|
-f "$STAGE_OUT/ci/napcat-desktop-cn/Dockerfile" \
|
||||||
|
"$STAGE_OUT"
|
||||||
|
docker run -d --name "$VERIFY_NAME" \
|
||||||
|
--cap-add SYS_ADMIN \
|
||||||
|
--security-opt apparmor=unconfined \
|
||||||
|
--security-opt seccomp=unconfined \
|
||||||
|
-e NAPCAT_REQUIRE_DEVICE_PROFILE=1 \
|
||||||
|
"$QQBOT_NAPCAT_IMAGE_OVERRIDE" >/dev/null
|
||||||
|
sleep 3
|
||||||
|
docker exec "$VERIFY_NAME" sh /ci/napcat-desktop-cn/verify.sh
|
||||||
|
docker image inspect "$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
|
||||||
|
--format "tag={{index .RepoTags 0}} id={{.Id}} size={{.Size}} created={{.Created}} repoDigests={{json .RepoDigests}}" \
|
||||||
|
| tee "$ARTIFACT_DIR/image.txt"
|
||||||
|
'
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
sh '''
|
||||||
|
set +e
|
||||||
|
docker run --rm \
|
||||||
|
-e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \
|
||||||
|
-e WORKSPACE="$WORKSPACE" \
|
||||||
|
-v /vol1/docker/kt-codex:/vol1/docker/kt-codex \
|
||||||
|
-v kt-node-agent-workdir:/home/jenkins/agent \
|
||||||
|
--entrypoint bash "$KT_WORKFLOW_RUNNER_IMAGE" -lc '
|
||||||
|
set +e
|
||||||
|
rm -rf "$WORKSPACE/artifacts"
|
||||||
|
mkdir -p "$WORKSPACE/artifacts/napcat-runtime-image-build"
|
||||||
|
cp -a "$KT_ARTIFACT_ROOT/runtime-image-build/." "$WORKSPACE/artifacts/napcat-runtime-image-build/" 2>/dev/null || true
|
||||||
|
'
|
||||||
|
'''
|
||||||
|
archiveArtifacts artifacts: 'artifacts/napcat-runtime-image-build/**', allowEmptyArchive: true, fingerprint: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops runtime image builds unless all immutable release parameters are present.
|
||||||
|
* @param forkRef Reviewed fork branch, tag, or commit that is cloned into the disposable build workspace.
|
||||||
|
* @param upstreamReleaseCommit Reviewed upstream release commit stored in the image marker.
|
||||||
|
* @param imageTag Reviewed image tag that will be created on NAS Docker.
|
||||||
|
* @param profile Reviewed desktop profile version paired with the image tag.
|
||||||
|
* @param baseImageDigest Immutable upstream/base Docker image digest in `repo@sha256` form.
|
||||||
|
* @return Nothing; aborts this pipeline through `error` when required values are missing.
|
||||||
|
*/
|
||||||
|
void requireRuntimeImageParameters(String forkRef, String upstreamReleaseCommit, String imageTag, String profile, String baseImageDigest) {
|
||||||
|
if (!forkRef?.trim()) {
|
||||||
|
error('NAPCAT_FORK_REF is required before runtime image build.')
|
||||||
|
}
|
||||||
|
if (!upstreamReleaseCommit?.trim()) {
|
||||||
|
error('UPSTREAM_RELEASE_COMMIT is required before runtime image build.')
|
||||||
|
}
|
||||||
|
if (!imageTag?.trim()) {
|
||||||
|
error('QQBOT_NAPCAT_IMAGE_OVERRIDE is required before runtime image build.')
|
||||||
|
}
|
||||||
|
if (!profile?.trim()) {
|
||||||
|
error('QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE is required before runtime image build.')
|
||||||
|
}
|
||||||
|
if (!baseImageDigest?.trim()?.contains('@sha256:')) {
|
||||||
|
error('NAPCAT_BASE_IMAGE_DIGEST must be an immutable repo@sha256 digest.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the promoted image tag and desktop profile cannot drift silently.
|
||||||
|
* @param imageTag Runtime image tag, expected to end with the profile tag suffix.
|
||||||
|
* @param profile Runtime desktop profile value passed to API promotion.
|
||||||
|
* @return Nothing; aborts this pipeline through `error` when values drift.
|
||||||
|
*/
|
||||||
|
void requireImageProfileMatch(String imageTag, String profile) {
|
||||||
|
if (!imageTag?.trim()?.endsWith(":${profile?.trim()}")) {
|
||||||
|
error('QQBOT_NAPCAT_IMAGE_OVERRIDE tag suffix must match QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE.')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,8 @@ pipeline {
|
|||||||
parameters {
|
parameters {
|
||||||
string(name: 'NAPCAT_FORK_REF', defaultValue: 'main', description: 'Reviewed NapCatQQ fork branch, tag, or commit.')
|
string(name: 'NAPCAT_FORK_REF', defaultValue: 'main', description: 'Reviewed NapCatQQ fork branch, tag, or commit.')
|
||||||
string(name: 'UPSTREAM_RELEASE_TAG', defaultValue: '', description: 'Reviewed upstream release tag, when applicable.')
|
string(name: 'UPSTREAM_RELEASE_TAG', defaultValue: '', description: 'Reviewed upstream release tag, when applicable.')
|
||||||
|
string(name: 'UPSTREAM_RELEASE_COMMIT', defaultValue: '5c18a62530d87dbadf53d267002894faa6ca7e90', description: 'Reviewed upstream release commit used for runtime marker metadata.')
|
||||||
|
string(name: 'NAPCAT_BASE_IMAGE_DIGEST', defaultValue: 'mlikiowa/napcat-docker@sha256:9254ec12af101576c5eeb4910847abd1d219297bc6d9a35c52511e12500f0f45', description: 'Immutable NapCat base image digest used by the runtime image build.')
|
||||||
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', defaultValue: '', description: 'Explicit runtime image tag to build or promote, for example kt-napcat-desktop-cn:desktop-cn-v9.')
|
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', defaultValue: '', description: 'Explicit runtime image tag to build or promote, for example kt-napcat-desktop-cn:desktop-cn-v9.')
|
||||||
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', defaultValue: '', description: 'Explicit API desktop profile version override.')
|
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', defaultValue: '', description: 'Explicit API desktop profile version override.')
|
||||||
booleanParam(name: 'BUILD_RUNTIME_IMAGE', defaultValue: false, description: 'When true, trigger the reviewed Jenkins runtime image build/publish job.')
|
booleanParam(name: 'BUILD_RUNTIME_IMAGE', defaultValue: false, description: 'When true, trigger the reviewed Jenkins runtime image build/publish job.')
|
||||||
@ -38,6 +40,8 @@ pipeline {
|
|||||||
-e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \
|
-e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \
|
||||||
-e NAPCAT_FORK_REF="$NAPCAT_FORK_REF" \
|
-e NAPCAT_FORK_REF="$NAPCAT_FORK_REF" \
|
||||||
-e UPSTREAM_RELEASE_TAG="$UPSTREAM_RELEASE_TAG" \
|
-e UPSTREAM_RELEASE_TAG="$UPSTREAM_RELEASE_TAG" \
|
||||||
|
-e UPSTREAM_RELEASE_COMMIT="$UPSTREAM_RELEASE_COMMIT" \
|
||||||
|
-e NAPCAT_BASE_IMAGE_DIGEST="$NAPCAT_BASE_IMAGE_DIGEST" \
|
||||||
-e QQBOT_NAPCAT_IMAGE_OVERRIDE="$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
|
-e QQBOT_NAPCAT_IMAGE_OVERRIDE="$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
|
||||||
-e QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE="$QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE" \
|
-e QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE="$QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE" \
|
||||||
-e COREPACK_HOME=/vol1/docker/kt-codex/runtime/corepack \
|
-e COREPACK_HOME=/vol1/docker/kt-codex/runtime/corepack \
|
||||||
@ -72,6 +76,8 @@ pipeline {
|
|||||||
parameters: [
|
parameters: [
|
||||||
string(name: 'NAPCAT_FORK_REF', value: params.NAPCAT_FORK_REF),
|
string(name: 'NAPCAT_FORK_REF', value: params.NAPCAT_FORK_REF),
|
||||||
string(name: 'UPSTREAM_RELEASE_TAG', value: params.UPSTREAM_RELEASE_TAG),
|
string(name: 'UPSTREAM_RELEASE_TAG', value: params.UPSTREAM_RELEASE_TAG),
|
||||||
|
string(name: 'UPSTREAM_RELEASE_COMMIT', value: params.UPSTREAM_RELEASE_COMMIT),
|
||||||
|
string(name: 'NAPCAT_BASE_IMAGE_DIGEST', value: params.NAPCAT_BASE_IMAGE_DIGEST),
|
||||||
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', value: params.QQBOT_NAPCAT_IMAGE_OVERRIDE),
|
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', value: params.QQBOT_NAPCAT_IMAGE_OVERRIDE),
|
||||||
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', value: params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE),
|
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', value: params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE),
|
||||||
string(name: 'KT_ARTIFACT_ROOT', value: "${env.KT_ARTIFACT_ROOT}/napcat-runtime-release")
|
string(name: 'KT_ARTIFACT_ROOT', value: "${env.KT_ARTIFACT_ROOT}/napcat-runtime-release")
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
"description": "Reusable MCP workflow tools for the KT workspace.",
|
"description": "Reusable MCP workflow tools for the KT workspace.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts",
|
"admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts",
|
||||||
|
"change-doc-sync": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --change-doc-sync",
|
||||||
"cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history",
|
"cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history",
|
||||||
"deploy-observation": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --deploy-observation",
|
"deploy-observation": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --deploy-observation",
|
||||||
"global-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --global-review",
|
"global-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --global-review",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ChangeDocSyncInput,
|
||||||
DeployObservationInput,
|
DeployObservationInput,
|
||||||
GlobalCodeReviewInput,
|
GlobalCodeReviewInput,
|
||||||
NapcatRemoteDevHandoffInput,
|
NapcatRemoteDevHandoffInput,
|
||||||
@ -35,6 +36,23 @@ export function parseGlobalReviewCliArgs(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the change documentation sync CLI mode into the same input shape as the MCP tool.
|
||||||
|
* @param argv - Full process argv including `--change-doc-sync` and optional project/file flags.
|
||||||
|
* @returns Project key plus explicitly supplied changed files; absent files make the tool infer git status.
|
||||||
|
*/
|
||||||
|
export function parseChangeDocSyncCliArgs(argv: string[]): ChangeDocSyncInput {
|
||||||
|
return {
|
||||||
|
changedFiles: readOptionList(argv, [
|
||||||
|
'--changed-file',
|
||||||
|
'--changed-files',
|
||||||
|
'--file',
|
||||||
|
'--files',
|
||||||
|
]),
|
||||||
|
project: readOption(argv, ['--project']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readOption(argv: string[], names: string[]): string | undefined {
|
function readOption(argv: string[], names: string[]): string | undefined {
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const keyValue = argv.find((item) => item.startsWith(`${name}=`));
|
const keyValue = argv.find((item) => item.startsWith(`${name}=`));
|
||||||
|
|||||||
219
src/selfTest.ts
219
src/selfTest.ts
@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
parseChangeDocSyncCliArgs,
|
||||||
parseNasCodexBootstrapCliArgs,
|
parseNasCodexBootstrapCliArgs,
|
||||||
parseDeployObservationCliArgs,
|
parseDeployObservationCliArgs,
|
||||||
parseGlobalReviewCliArgs,
|
parseGlobalReviewCliArgs,
|
||||||
@ -408,6 +409,9 @@ export async function runSelfTest(): Promise<void> {
|
|||||||
if (!packageJson.scripts?.["napcat-remote-dev-handoff"]) {
|
if (!packageJson.scripts?.["napcat-remote-dev-handoff"]) {
|
||||||
throw new Error("NapCat remote handoff npm script self-check failed");
|
throw new Error("NapCat remote handoff npm script self-check failed");
|
||||||
}
|
}
|
||||||
|
if (!packageJson.scripts?.["change-doc-sync"]) {
|
||||||
|
throw new Error("change-doc-sync npm script self-check failed");
|
||||||
|
}
|
||||||
const registeredByFakeServer: string[] = [];
|
const registeredByFakeServer: string[] = [];
|
||||||
const fakeMcpServer = {
|
const fakeMcpServer = {
|
||||||
/**
|
/**
|
||||||
@ -524,6 +528,12 @@ export async function runSelfTest(): Promise<void> {
|
|||||||
),
|
),
|
||||||
"utf8",
|
"utf8",
|
||||||
).replace(/\r\n/g, "\n");
|
).replace(/\r\n/g, "\n");
|
||||||
|
const runtimeImageBuildJenkinsTemplate = readFileSync(
|
||||||
|
resolveInsideRoot(
|
||||||
|
"mcp/ktWorkflow/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile",
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
).replace(/\r\n/g, "\n");
|
||||||
for (const expected of [
|
for (const expected of [
|
||||||
"RUN_CODEX_AUDIT",
|
"RUN_CODEX_AUDIT",
|
||||||
"CONFIRM_CODEX_WORKSPACE_WRITE",
|
"CONFIRM_CODEX_WORKSPACE_WRITE",
|
||||||
@ -626,6 +636,10 @@ export async function runSelfTest(): Promise<void> {
|
|||||||
runtimeReleaseJenkinsTemplate,
|
runtimeReleaseJenkinsTemplate,
|
||||||
"NapCat runtime",
|
"NapCat runtime",
|
||||||
);
|
);
|
||||||
|
assertNapCatJenkinsUsesNasAgent(
|
||||||
|
runtimeImageBuildJenkinsTemplate,
|
||||||
|
"NapCat runtime image build",
|
||||||
|
);
|
||||||
/**
|
/**
|
||||||
* Verifies that a NapCat Jenkins template runs ktWorkflow inside a helper container with NAS mounts.
|
* Verifies that a NapCat Jenkins template runs ktWorkflow inside a helper container with NAS mounts.
|
||||||
* @param template - Full Jenkinsfile text that Jenkins will execute.
|
* @param template - Full Jenkinsfile text that Jenkins will execute.
|
||||||
@ -658,6 +672,44 @@ export async function runSelfTest(): Promise<void> {
|
|||||||
runtimeReleaseJenkinsTemplate,
|
runtimeReleaseJenkinsTemplate,
|
||||||
"NapCat runtime",
|
"NapCat runtime",
|
||||||
);
|
);
|
||||||
|
assertNapCatJenkinsUsesWorkflowRunner(
|
||||||
|
runtimeImageBuildJenkinsTemplate,
|
||||||
|
"NapCat runtime image build",
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* Ensures destructive Jenkins artifact cleanup is bounded by canonical NAS paths.
|
||||||
|
* @param template - Full Jenkinsfile text whose shell guard will run before `rm -rf`.
|
||||||
|
* @param name - Human-readable template name for the self-test failure.
|
||||||
|
* @returns Nothing; throws when the guard accepts raw `..` path prefixes.
|
||||||
|
*/
|
||||||
|
const assertNapCatJenkinsCanonicalArtifactRoot = (
|
||||||
|
template: string,
|
||||||
|
name: string,
|
||||||
|
): void => {
|
||||||
|
for (const expected of [
|
||||||
|
'ARTIFACTS_BASE_REAL=$(realpath -m /vol1/docker/kt-codex/artifacts)',
|
||||||
|
'ARTIFACT_ROOT_REAL=$(realpath -m "$KT_ARTIFACT_ROOT")',
|
||||||
|
'case "$ARTIFACT_ROOT_REAL" in',
|
||||||
|
'"$ARTIFACTS_BASE_REAL"/*) ;;',
|
||||||
|
'ARTIFACT_DIR="$ARTIFACT_ROOT_REAL/runtime-image-build"',
|
||||||
|
'BUILD_WORKDIR="$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE"',
|
||||||
|
]) {
|
||||||
|
if (!template.includes(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`${name} Jenkins template must canonicalize artifact roots before cleanup: ${expected}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (template.includes('case "$KT_ARTIFACT_ROOT" in')) {
|
||||||
|
throw new Error(
|
||||||
|
`${name} Jenkins template must not validate destructive cleanup paths with a raw prefix check`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assertNapCatJenkinsCanonicalArtifactRoot(
|
||||||
|
runtimeImageBuildJenkinsTemplate,
|
||||||
|
"NapCat runtime image build",
|
||||||
|
);
|
||||||
for (const forbidden of ["git merge", "git push", "docker build", "kubectl"]) {
|
for (const forbidden of ["git merge", "git push", "docker build", "kubectl"]) {
|
||||||
if (upstreamSyncJenkinsTemplate.includes(forbidden)) {
|
if (upstreamSyncJenkinsTemplate.includes(forbidden)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -666,6 +718,8 @@ export async function runSelfTest(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const expected of [
|
for (const expected of [
|
||||||
|
"UPSTREAM_RELEASE_COMMIT",
|
||||||
|
"NAPCAT_BASE_IMAGE_DIGEST",
|
||||||
"choice(name: 'RUNTIME_BUILD_JOB'",
|
"choice(name: 'RUNTIME_BUILD_JOB'",
|
||||||
"choice(name: 'API_PROMOTION_JOB'",
|
"choice(name: 'API_PROMOTION_JOB'",
|
||||||
"CONFIRM_API_PROMOTION_TARGET",
|
"CONFIRM_API_PROMOTION_TARGET",
|
||||||
@ -748,6 +802,56 @@ export async function runSelfTest(): Promise<void> {
|
|||||||
"NapCat runtime Jenkins API promotion confirmation must run before API build job",
|
"NapCat runtime Jenkins API promotion confirmation must run before API build job",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (const expected of [
|
||||||
|
"NAPCAT_FORK_REF",
|
||||||
|
"NAPCAT_GIT_REPOSITORY = '/vol1/docker/kt-codex/git/NapCatQQ.git'",
|
||||||
|
"QQBOT_NAPCAT_IMAGE_OVERRIDE",
|
||||||
|
"QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE",
|
||||||
|
"NAPCAT_BASE_IMAGE_DIGEST",
|
||||||
|
"requireRuntimeImageParameters(",
|
||||||
|
"requireImageProfileMatch(",
|
||||||
|
"-v /var/run/docker.sock:/var/run/docker.sock",
|
||||||
|
"--user 0:0",
|
||||||
|
"API_GIT_REFERENCE_HOST",
|
||||||
|
"API_GIT_REFERENCE",
|
||||||
|
"-v \"$API_GIT_REFERENCE_HOST\":\"$API_GIT_REFERENCE\":ro",
|
||||||
|
"--cap-add SYS_ADMIN",
|
||||||
|
"--security-opt apparmor=unconfined",
|
||||||
|
"--security-opt seccomp=unconfined",
|
||||||
|
"-e NAPCAT_REQUIRE_DEVICE_PROFILE=1",
|
||||||
|
"git -C \"$API_ROOT\" fetch \"$API_GIT_REFERENCE\" refs/remotes/origin/main",
|
||||||
|
"git -C \"$API_ROOT\" merge --ff-only FETCH_HEAD",
|
||||||
|
"\"$ARTIFACT_DIR/api-root-commit.txt\"",
|
||||||
|
"git clone \"$NAPCAT_GIT_REPOSITORY\" \"$NAPCAT_ROOT\"",
|
||||||
|
"BUILD_WORKDIR=\"$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE\"",
|
||||||
|
"KT_ARTIFACT_ROOT must stay under /vol1/docker/kt-codex/artifacts",
|
||||||
|
"scripts/napcat-desktop-cn-stage-build.mjs",
|
||||||
|
"--napcat-root \"$NAPCAT_ROOT\"",
|
||||||
|
"--napcat-base-image-digest \"$NAPCAT_BASE_IMAGE_DIGEST\"",
|
||||||
|
"docker build",
|
||||||
|
"docker exec \"$VERIFY_NAME\" sh /ci/napcat-desktop-cn/verify.sh",
|
||||||
|
"archiveArtifacts artifacts: 'artifacts/napcat-runtime-image-build/**'",
|
||||||
|
]) {
|
||||||
|
if (!runtimeImageBuildJenkinsTemplate.includes(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`NapCat runtime image build Jenkins template missing: ${expected}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const forbidden of [
|
||||||
|
"PROMOTE_API_RUNTIME",
|
||||||
|
"CONFIRM_API_PROMOTION_TARGET",
|
||||||
|
"kubectl",
|
||||||
|
"git push",
|
||||||
|
"git merge",
|
||||||
|
"/vol1/docker/kt-codex/workspace/KT/GitHub/NapCatQQ",
|
||||||
|
]) {
|
||||||
|
if (runtimeImageBuildJenkinsTemplate.includes(forbidden)) {
|
||||||
|
throw new Error(
|
||||||
|
`NapCat runtime image build Jenkins template forbidden text leaked: ${forbidden}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const codexExecutionArtifactRoot = resolveInsideRoot(
|
const codexExecutionArtifactRoot = resolveInsideRoot(
|
||||||
".kt-workspace/test-artifacts/napcat-codex-runner-self-test",
|
".kt-workspace/test-artifacts/napcat-codex-runner-self-test",
|
||||||
);
|
);
|
||||||
@ -1018,6 +1122,78 @@ export async function runSelfTest(): Promise<void> {
|
|||||||
) {
|
) {
|
||||||
throw new Error("TASKS record governance self-check failed");
|
throw new Error("TASKS record governance self-check failed");
|
||||||
}
|
}
|
||||||
|
const taskRecordLimitFindings = findTaskRecordGovernanceFindings([
|
||||||
|
"## 最近记录",
|
||||||
|
"### 2026-06-13:记录一",
|
||||||
|
"- 范围:root",
|
||||||
|
"- 关键词:TASKS 治理",
|
||||||
|
"- 验证:global-review",
|
||||||
|
"### 2026-06-12:记录二",
|
||||||
|
"- 范围:root",
|
||||||
|
"- 关键词:TASKS 治理",
|
||||||
|
"- 验证:global-review",
|
||||||
|
"### 2026-06-11:记录三",
|
||||||
|
"- 范围:root",
|
||||||
|
"- 关键词:TASKS 治理",
|
||||||
|
"- 验证:global-review",
|
||||||
|
"### 2026-06-10:记录四",
|
||||||
|
"- 范围:root",
|
||||||
|
"- 关键词:TASKS 治理",
|
||||||
|
"- 验证:global-review",
|
||||||
|
"## 历史索引",
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
!taskRecordLimitFindings.some(
|
||||||
|
(item) => item.category === "tasks-recent-record-limit",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("TASKS recent record limit self-check failed");
|
||||||
|
}
|
||||||
|
const taskHardLimitFindings = findTaskRecordGovernanceFindings([
|
||||||
|
"# TASKS.md",
|
||||||
|
"",
|
||||||
|
"## 当前上下文",
|
||||||
|
"",
|
||||||
|
"## 维护规则",
|
||||||
|
"",
|
||||||
|
"## 最近记录",
|
||||||
|
"### 2026-07-03:记录一",
|
||||||
|
"- 范围:root",
|
||||||
|
`- 关键词:${"TASKS 治理".repeat(60)}`,
|
||||||
|
" - 子弹列表塞长细节",
|
||||||
|
"补充验证:不允许用补充段绕过三字段结构。",
|
||||||
|
"- 验证:global-review",
|
||||||
|
"## 历史索引",
|
||||||
|
"",
|
||||||
|
"### Root Governance / Workflow",
|
||||||
|
`- 2026-07-03:${"历史索引也不能塞长流水。".repeat(20)}`,
|
||||||
|
...Array.from({ length: 700 }, () => "填充总体大小上限"),
|
||||||
|
]);
|
||||||
|
for (const category of [
|
||||||
|
"tasks-file-size-limit",
|
||||||
|
"tasks-record-field-too-long",
|
||||||
|
"tasks-record-freeform-line",
|
||||||
|
"tasks-history-item-too-long",
|
||||||
|
]) {
|
||||||
|
if (!taskHardLimitFindings.some((item) => item.category === category)) {
|
||||||
|
throw new Error(`TASKS hard limit self-check failed: ${category}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const taskSectionFindings = findTaskRecordGovernanceFindings([
|
||||||
|
"# TASKS.md",
|
||||||
|
"",
|
||||||
|
"## 当前上下文",
|
||||||
|
"## 最近记录",
|
||||||
|
"## 维护规则",
|
||||||
|
"## 历史索引",
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
!taskSectionFindings.some(
|
||||||
|
(item) => item.category === "tasks-section-order",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("TASKS section order self-check failed");
|
||||||
|
}
|
||||||
|
|
||||||
const napcatImageFindings = findNapcatImageGovernanceFindings(
|
const napcatImageFindings = findNapcatImageGovernanceFindings(
|
||||||
["QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest"],
|
["QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest"],
|
||||||
@ -2052,6 +2228,33 @@ export async function runSelfTest(): Promise<void> {
|
|||||||
if ((obsidianValidation.summary as { errors?: number }).errors !== 0) {
|
if ((obsidianValidation.summary as { errors?: number }).errors !== 0) {
|
||||||
throw new Error("obsidian validation self-check failed");
|
throw new Error("obsidian validation self-check failed");
|
||||||
}
|
}
|
||||||
|
const obsidianChecks = obsidianValidation.checks as {
|
||||||
|
base?: { requiredViews?: number; views?: number };
|
||||||
|
canvas?: { fileNodeCount?: number; groupCount?: number; indexLinkCount?: number };
|
||||||
|
moduleFrontmatter?: number;
|
||||||
|
nestedDocs?: number;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!obsidianChecks.base ||
|
||||||
|
obsidianChecks.base.views !== obsidianChecks.base.requiredViews
|
||||||
|
) {
|
||||||
|
throw new Error("obsidian base view self-check failed");
|
||||||
|
}
|
||||||
|
if (!obsidianChecks.canvas || obsidianChecks.canvas.groupCount !== 5) {
|
||||||
|
throw new Error("obsidian canvas lane self-check failed");
|
||||||
|
}
|
||||||
|
if (obsidianChecks.canvas.fileNodeCount !== 0) {
|
||||||
|
throw new Error("obsidian canvas preview-node self-check failed");
|
||||||
|
}
|
||||||
|
if (!obsidianChecks.canvas.indexLinkCount) {
|
||||||
|
throw new Error("obsidian canvas index-link self-check failed");
|
||||||
|
}
|
||||||
|
if (!obsidianChecks.moduleFrontmatter) {
|
||||||
|
throw new Error("obsidian module frontmatter self-check failed");
|
||||||
|
}
|
||||||
|
if (obsidianChecks.nestedDocs !== 0) {
|
||||||
|
throw new Error("obsidian nested docs guard self-check failed");
|
||||||
|
}
|
||||||
|
|
||||||
const cleanupCliParser = {
|
const cleanupCliParser = {
|
||||||
defaultDryRun: parseCliCleanupArgs(["node", "server", "--cleanup-history"])
|
defaultDryRun: parseCliCleanupArgs(["node", "server", "--cleanup-history"])
|
||||||
@ -2101,6 +2304,22 @@ export async function runSelfTest(): Promise<void> {
|
|||||||
],
|
],
|
||||||
project: "api",
|
project: "api",
|
||||||
});
|
});
|
||||||
|
const docSyncCli = parseChangeDocSyncCliArgs([
|
||||||
|
"node",
|
||||||
|
"server",
|
||||||
|
"--change-doc-sync",
|
||||||
|
"--project",
|
||||||
|
"api",
|
||||||
|
"--file",
|
||||||
|
"src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts",
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
docSyncCli.project !== "api" ||
|
||||||
|
docSyncCli.changedFiles?.[0] !==
|
||||||
|
"src/modules/qqbot/plugin-platform/application/task/qqbot-plugin-task.service.ts"
|
||||||
|
) {
|
||||||
|
throw new Error("doc sync CLI parser self-check failed");
|
||||||
|
}
|
||||||
const docSyncRequiredDocs = docSyncBangDream.requiredDocs as Array<{
|
const docSyncRequiredDocs = docSyncBangDream.requiredDocs as Array<{
|
||||||
path: string;
|
path: string;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
parseChangeDocSyncCliArgs,
|
||||||
parseNasCodexBootstrapCliArgs,
|
parseNasCodexBootstrapCliArgs,
|
||||||
parseDeployObservationCliArgs,
|
parseDeployObservationCliArgs,
|
||||||
parseGlobalReviewCliArgs,
|
parseGlobalReviewCliArgs,
|
||||||
@ -17,6 +18,7 @@ import {
|
|||||||
} from './core/cli.js';
|
} from './core/cli.js';
|
||||||
import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js';
|
import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js';
|
||||||
import { buildWorkstreamCloseout } from './tools/closeout.js';
|
import { buildWorkstreamCloseout } from './tools/closeout.js';
|
||||||
|
import { buildChangeDocSync } from './tools/docSync.js';
|
||||||
import { buildDeployObservation } from './tools/deployObservation.js';
|
import { buildDeployObservation } from './tools/deployObservation.js';
|
||||||
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
|
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
|
||||||
import { buildGlobalCodeReview } from './tools/review.js';
|
import { buildGlobalCodeReview } from './tools/review.js';
|
||||||
@ -63,6 +65,14 @@ if (process.argv.includes('--cleanup-history')) {
|
|||||||
console.log(JSON.stringify(readObsidianContext(parseObsidianContextCliArgs(process.argv)), null, 2));
|
console.log(JSON.stringify(readObsidianContext(parseObsidianContextCliArgs(process.argv)), null, 2));
|
||||||
} else if (process.argv.includes('--obsidian-sync')) {
|
} else if (process.argv.includes('--obsidian-sync')) {
|
||||||
console.log(JSON.stringify(syncObsidianWorkflow(parseObsidianSyncCliArgs(process.argv)), null, 2));
|
console.log(JSON.stringify(syncObsidianWorkflow(parseObsidianSyncCliArgs(process.argv)), null, 2));
|
||||||
|
} else if (process.argv.includes('--change-doc-sync')) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
await buildChangeDocSync(parseChangeDocSyncCliArgs(process.argv)),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else if (process.argv.includes('--deploy-observation')) {
|
} else if (process.argv.includes('--deploy-observation')) {
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
|
|||||||
@ -34,9 +34,9 @@ const docSyncRules: DocSyncRule[] = [
|
|||||||
reason: 'KtTable 组件或迁移规则变更,需要同步专项模块和 skill。',
|
reason: 'KtTable 组件或迁移规则变更,需要同步专项模块和 skill。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
docs: ['Vue/kt-blog-web/README.md', 'docs/obsidian/modules/KT 模块 - Blog Web.md', 'skills/kt-blog-argon-workflow/SKILL.md'],
|
docs: ['Vue/kt-blog-web/README.md', 'docs/obsidian/modules/KT 模块 - Blog Web.md', 'skills/kt-component-specialist/SKILL.md'],
|
||||||
match: /Vue\/kt-blog-web\/|blog|argon/i,
|
match: /Vue\/kt-blog-web\/|blog|argon/i,
|
||||||
reason: 'Blog/Argon 能力变更,需要同步博客 README、模块页和专项 skill。',
|
reason: 'Blog/Argon 能力变更,需要同步博客 README、模块页和根内专项 skill;已安装 Codex skill 仅作为执行时加载入口。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
docs: ['Plugins/fnos-k8s-dashboard-fpk/README.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'docs/obsidian/modules/KT 模块 - Plugins.md'],
|
docs: ['Plugins/fnos-k8s-dashboard-fpk/README.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'docs/obsidian/modules/KT 模块 - Plugins.md'],
|
||||||
@ -44,12 +44,25 @@ const docSyncRules: DocSyncRule[] = [
|
|||||||
reason: 'fnOS K8s 插件变更,需要同步插件 README/AGENTS 和 Obsidian 模块。',
|
reason: 'fnOS K8s 插件变更,需要同步插件 README/AGENTS 和 Obsidian 模块。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
docs: ['mcp/ktWorkflow/README.md', 'AGENTS.md', 'TASKS.md', 'docs/kt-testing-workflows.md', 'docs/obsidian/KT 工作流工程化.md'],
|
docs: [
|
||||||
|
'mcp/ktWorkflow/README.md',
|
||||||
|
'AGENTS.md',
|
||||||
|
'TASKS.md',
|
||||||
|
'docs/kt-stabilized-runtime-rules.md',
|
||||||
|
'docs/kt-testing-workflows.md',
|
||||||
|
'docs/obsidian/KT 工作流工程化.md',
|
||||||
|
],
|
||||||
match: /mcp\/ktWorkflow|src\/tools\/|src\/registerTools|src\/core|workflow|cleanup|review|selfTest/i,
|
match: /mcp\/ktWorkflow|src\/tools\/|src\/registerTools|src\/core|workflow|cleanup|review|selfTest/i,
|
||||||
reason: 'ktWorkflow 或自动化规则变更,需要同步 README、根规则、测试流程和当前任务记录。',
|
reason: 'ktWorkflow 或自动化规则变更,需要同步 README、根规则、测试流程和当前任务记录。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
docs: ['AGENTS.md', 'SKILLS.md', 'docs/kt-context-semantics.md', 'docs/obsidian/modules/KT 模块 - Root Governance.md'],
|
docs: [
|
||||||
|
'AGENTS.md',
|
||||||
|
'SKILLS.md',
|
||||||
|
'docs/kt-context-semantics.md',
|
||||||
|
'docs/kt-stabilized-runtime-rules.md',
|
||||||
|
'docs/obsidian/modules/KT 模块 - Root Governance.md',
|
||||||
|
],
|
||||||
match: /AGENTS\.md|SKILLS\.md|skills\/.*\/SKILL\.md|docs\/kt-context-semantics\.md/,
|
match: /AGENTS\.md|SKILLS\.md|skills\/.*\/SKILL\.md|docs\/kt-context-semantics\.md/,
|
||||||
reason: '上下文治理或 skill 规则变更,需要同步根治理文档和 Obsidian 模块。',
|
reason: '上下文治理或 skill 规则变更,需要同步根治理文档和 Obsidian 模块。',
|
||||||
},
|
},
|
||||||
@ -118,6 +131,7 @@ export async function buildChangeDocSync(input: ChangeDocSyncInput = {}): Promis
|
|||||||
return {
|
return {
|
||||||
changedFiles,
|
changedFiles,
|
||||||
commands: [
|
commands: [
|
||||||
|
'pnpm --dir mcp/ktWorkflow run change-doc-sync -- --project <project>',
|
||||||
'pnpm --dir mcp/ktWorkflow run obsidian-context -- --query <module>',
|
'pnpm --dir mcp/ktWorkflow run obsidian-context -- --query <module>',
|
||||||
'pnpm --dir mcp/ktWorkflow run obsidian-validate',
|
'pnpm --dir mcp/ktWorkflow run obsidian-validate',
|
||||||
'pnpm --dir mcp/ktWorkflow run global-review',
|
'pnpm --dir mcp/ktWorkflow run global-review',
|
||||||
|
|||||||
@ -21,6 +21,8 @@ interface CanvasNode {
|
|||||||
file?: string;
|
file?: string;
|
||||||
height?: number;
|
height?: number;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
label?: string;
|
||||||
|
text?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
x?: number;
|
x?: number;
|
||||||
@ -44,7 +46,9 @@ interface ObsidianSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BookmarkItem {
|
interface BookmarkItem {
|
||||||
|
items?: BookmarkItem[];
|
||||||
path?: string;
|
path?: string;
|
||||||
|
title?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,11 +177,33 @@ const expectedIgnoredRoots = [
|
|||||||
'.git/',
|
'.git/',
|
||||||
'.kt-workspace/',
|
'.kt-workspace/',
|
||||||
'Node/',
|
'Node/',
|
||||||
'Plugins/',
|
|
||||||
'Vue/',
|
'Vue/',
|
||||||
|
'Plugins/',
|
||||||
'mcp/',
|
'mcp/',
|
||||||
|
'GitHub/',
|
||||||
|
'Other/',
|
||||||
|
'workspace-assets/',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const requiredBaseViewNames = [
|
||||||
|
'开工路由',
|
||||||
|
'模块-仓库矩阵',
|
||||||
|
'工作流入口',
|
||||||
|
'验证与收尾',
|
||||||
|
'运行态与远程排查',
|
||||||
|
'文档归口',
|
||||||
|
];
|
||||||
|
|
||||||
|
const requiredCanvasGroupLabels = [
|
||||||
|
'00 当前上下文',
|
||||||
|
'10 路由索引',
|
||||||
|
'20 模块仓库',
|
||||||
|
'30 工作流执行',
|
||||||
|
'40 验证与固化',
|
||||||
|
];
|
||||||
|
|
||||||
|
const canvasTextNodeMaxChars = 180;
|
||||||
|
|
||||||
const defaultContextPaths = [
|
const defaultContextPaths = [
|
||||||
'docs/obsidian/KT 知识图谱.md',
|
'docs/obsidian/KT 知识图谱.md',
|
||||||
'docs/obsidian/modules/KT 模块索引.md',
|
'docs/obsidian/modules/KT 模块索引.md',
|
||||||
@ -297,6 +323,12 @@ function readTextFile(relativePath: string): string {
|
|||||||
return readFileSync(resolveInsideRoot(relativePath), 'utf8');
|
return readFileSync(resolveInsideRoot(relativePath), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the Obsidian display title from frontmatter, the first H1, or the file name.
|
||||||
|
* @param content - Markdown file content read from the KT vault.
|
||||||
|
* @param fallback - Workspace-relative file path used when title metadata is absent.
|
||||||
|
* @returns The title shown in Obsidian context output.
|
||||||
|
*/
|
||||||
function getMarkdownTitle(content: string, fallback: string): string {
|
function getMarkdownTitle(content: string, fallback: string): string {
|
||||||
return (
|
return (
|
||||||
content.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '') ||
|
content.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '') ||
|
||||||
@ -305,6 +337,11 @@ function getMarkdownTitle(content: string, fallback: string): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts tags from the YAML frontmatter subset used by KT Obsidian notes.
|
||||||
|
* @param content - Markdown content that may start with frontmatter.
|
||||||
|
* @returns Tag values without additional normalization.
|
||||||
|
*/
|
||||||
function getMarkdownTags(content: string): string[] {
|
function getMarkdownTags(content: string): string[] {
|
||||||
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
|
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
|
||||||
if (!frontmatter) return [];
|
if (!frontmatter) return [];
|
||||||
@ -316,6 +353,59 @@ function getMarkdownTags(content: string): string[] {
|
|||||||
.filter((tag): tag is string => Boolean(tag));
|
.filter((tag): tag is string => Boolean(tag));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes matching quote wrappers from a YAML-like scalar used by KT Obsidian metadata.
|
||||||
|
* @param value - Raw scalar value from frontmatter or a Base view line.
|
||||||
|
* @returns The unquoted scalar value.
|
||||||
|
*/
|
||||||
|
function stripYamlQuotes(value: string): string {
|
||||||
|
return value.trim().replace(/^["']|["']$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the small frontmatter subset that KT uses for Obsidian routing fields.
|
||||||
|
* @param content - Markdown content with optional leading frontmatter.
|
||||||
|
* @returns Frontmatter fields as scalar strings or string arrays.
|
||||||
|
*/
|
||||||
|
function parseFrontmatterFields(content: string): Map<string, string | string[]> {
|
||||||
|
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
const fields = new Map<string, string | string[]>();
|
||||||
|
if (!frontmatter?.[1]) return fields;
|
||||||
|
|
||||||
|
const lines = frontmatter[1].split(/\r?\n/);
|
||||||
|
let activeArrayKey: string | null = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const arrayItem = line.match(/^\s+-\s+(.+)$/);
|
||||||
|
if (arrayItem?.[1] && activeArrayKey) {
|
||||||
|
const current = fields.get(activeArrayKey);
|
||||||
|
const values = Array.isArray(current) ? current : [];
|
||||||
|
values.push(stripYamlQuotes(arrayItem[1]));
|
||||||
|
fields.set(activeArrayKey, values);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyValue = line.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
|
||||||
|
if (!keyValue?.[1]) {
|
||||||
|
activeArrayKey = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = keyValue[1];
|
||||||
|
const rawValue = keyValue[2]?.trim() || '';
|
||||||
|
if (!rawValue) {
|
||||||
|
fields.set(key, []);
|
||||||
|
activeArrayKey = key;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.set(key, stripYamlQuotes(rawValue));
|
||||||
|
activeArrayKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
function extractWikiLinks(content: string): string[] {
|
function extractWikiLinks(content: string): string[] {
|
||||||
return [...content.matchAll(/\[\[([^\]\n]+)\]\]/g)]
|
return [...content.matchAll(/\[\[([^\]\n]+)\]\]/g)]
|
||||||
.map((match) => match[1]?.split('|')[0]?.split('#')[0]?.trim())
|
.map((match) => match[1]?.split('|')[0]?.split('#')[0]?.trim())
|
||||||
@ -380,6 +470,27 @@ function buildContextTerms(input: ObsidianContextInput): string[] {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts linked index targets from text nodes so Canvas can stay readable without file previews.
|
||||||
|
* @param nodes - Parsed JSON Canvas nodes from the KT relationship graph.
|
||||||
|
* @returns Text-node link summaries used by obsidian-context output.
|
||||||
|
*/
|
||||||
|
function extractCanvasIndexLinks(nodes: CanvasNode[]): Array<{ color?: string; id?: string; links: string[] }> {
|
||||||
|
return nodes
|
||||||
|
.filter((node) => node.type === 'text' && node.text)
|
||||||
|
.map((node) => ({
|
||||||
|
color: node.color,
|
||||||
|
id: node.id,
|
||||||
|
links: extractWikiLinks(node.text || ''),
|
||||||
|
}))
|
||||||
|
.filter((node) => node.links.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a lightweight Canvas summary for context routing without embedding full note previews.
|
||||||
|
* @param includeCanvas - Explicit false disables the Canvas summary for callers that need smaller output.
|
||||||
|
* @returns Canvas node/edge counts plus indexed wikilink targets, or null when disabled/unreadable.
|
||||||
|
*/
|
||||||
function readCanvasSummary(includeCanvas: boolean | undefined): Record<string, unknown> | null {
|
function readCanvasSummary(includeCanvas: boolean | undefined): Record<string, unknown> | null {
|
||||||
if (includeCanvas === false) return null;
|
if (includeCanvas === false) return null;
|
||||||
|
|
||||||
@ -398,6 +509,7 @@ function readCanvasSummary(includeCanvas: boolean | undefined): Record<string, u
|
|||||||
return {
|
return {
|
||||||
edges: Array.isArray(canvas.edges) ? canvas.edges.length : 0,
|
edges: Array.isArray(canvas.edges) ? canvas.edges.length : 0,
|
||||||
fileNodes,
|
fileNodes,
|
||||||
|
indexLinks: extractCanvasIndexLinks(nodes),
|
||||||
nodes: nodes.length,
|
nodes: nodes.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -676,15 +788,27 @@ function rectanglesOverlap(a: CanvasNode, b: CanvasNode): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { edgeCount: number; nodeCount: number } {
|
/**
|
||||||
|
* Validates JSON Canvas IDs, file targets, edge references, lane groups, and optional visual overlap.
|
||||||
|
* @param findings - Shared finding accumulator for the current Obsidian validation run.
|
||||||
|
* @param checkLayout - Whether non-group node overlap should be reported as a warning.
|
||||||
|
* @returns Basic Canvas counts for command output summaries.
|
||||||
|
*/
|
||||||
|
function validateCanvas(
|
||||||
|
findings: ObsidianFinding[],
|
||||||
|
checkLayout: boolean,
|
||||||
|
): { edgeCount: number; fileNodeCount: number; groupCount: number; indexLinkCount: number; nodeCount: number } {
|
||||||
const canvasPath = 'docs/obsidian/KT 关系图谱.canvas';
|
const canvasPath = 'docs/obsidian/KT 关系图谱.canvas';
|
||||||
const canvas = readJsonFile<CanvasDocument>(canvasPath, findings);
|
const canvas = readJsonFile<CanvasDocument>(canvasPath, findings);
|
||||||
if (!canvas) return { edgeCount: 0, nodeCount: 0 };
|
if (!canvas) return { edgeCount: 0, fileNodeCount: 0, groupCount: 0, indexLinkCount: 0, nodeCount: 0 };
|
||||||
|
|
||||||
const nodes = Array.isArray(canvas.nodes) ? canvas.nodes : [];
|
const nodes = Array.isArray(canvas.nodes) ? canvas.nodes : [];
|
||||||
const edges = Array.isArray(canvas.edges) ? canvas.edges : [];
|
const edges = Array.isArray(canvas.edges) ? canvas.edges : [];
|
||||||
|
const fileNodeCount = nodes.filter((node) => node.type === 'file').length;
|
||||||
|
const indexLinkCount = extractCanvasIndexLinks(nodes).reduce((count, node) => count + node.links.length, 0);
|
||||||
const nodeIds = new Set<string>();
|
const nodeIds = new Set<string>();
|
||||||
const edgeIds = new Set<string>();
|
const edgeIds = new Set<string>();
|
||||||
|
const groupLabels = new Set(nodes.filter((node) => node.type === 'group').map((node) => node.label).filter(Boolean));
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (!node.id) {
|
if (!node.id) {
|
||||||
@ -708,6 +832,26 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed
|
|||||||
}
|
}
|
||||||
nodeIds.add(node.id);
|
nodeIds.add(node.id);
|
||||||
|
|
||||||
|
if (node.type === 'file') {
|
||||||
|
findings.push({
|
||||||
|
check: 'canvas-node',
|
||||||
|
level: 'error',
|
||||||
|
message: 'KT 关系图谱必须使用文本索引节点,不能使用会嵌入全文预览的 file 节点。',
|
||||||
|
path: canvasPath,
|
||||||
|
target: node.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'text' && (node.text?.length || 0) > canvasTextNodeMaxChars) {
|
||||||
|
findings.push({
|
||||||
|
check: 'canvas-node',
|
||||||
|
level: 'error',
|
||||||
|
message: 'Canvas 文本节点过长,可能在 Obsidian 中出现内部滚动条。',
|
||||||
|
path: canvasPath,
|
||||||
|
target: `${node.id || 'unknown'}:${node.text?.length || 0}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (node.type === 'file' && node.file && !existsSync(resolveInsideRoot(node.file))) {
|
if (node.type === 'file' && node.file && !existsSync(resolveInsideRoot(node.file))) {
|
||||||
findings.push({
|
findings.push({
|
||||||
check: 'canvas-file',
|
check: 'canvas-file',
|
||||||
@ -752,6 +896,18 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requiredCanvasGroupLabels
|
||||||
|
.filter((label) => !groupLabels.has(label))
|
||||||
|
.forEach((label) => {
|
||||||
|
findings.push({
|
||||||
|
check: 'canvas-group',
|
||||||
|
level: 'error',
|
||||||
|
message: 'Canvas 缺少工程任务流泳道。',
|
||||||
|
path: canvasPath,
|
||||||
|
target: label,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (checkLayout) {
|
if (checkLayout) {
|
||||||
const visibleNodes = nodes.filter((node) => node.type !== 'group');
|
const visibleNodes = nodes.filter((node) => node.type !== 'group');
|
||||||
for (let index = 0; index < visibleNodes.length; index += 1) {
|
for (let index = 0; index < visibleNodes.length; index += 1) {
|
||||||
@ -771,9 +927,57 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { edgeCount: edges.length, nodeCount: nodes.length };
|
return {
|
||||||
|
edgeCount: edges.length,
|
||||||
|
fileNodeCount,
|
||||||
|
groupCount: groupLabels.size,
|
||||||
|
indexLinkCount,
|
||||||
|
nodeCount: nodes.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts Obsidian Base view names from the repository-owned YAML subset without adding a parser dependency.
|
||||||
|
* @param content - Text content of `KT 文档矩阵.base`.
|
||||||
|
* @returns View names declared below the `views:` section.
|
||||||
|
*/
|
||||||
|
function extractBaseViewNames(content: string): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
let inViews = false;
|
||||||
|
let currentView = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (/^views:\s*$/.test(line)) {
|
||||||
|
inViews = true;
|
||||||
|
currentView = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inViews && /^[A-Za-z_][A-Za-z0-9_-]*:\s*/.test(line)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!inViews) continue;
|
||||||
|
|
||||||
|
if (/^\s*-\s+type:\s+/.test(line)) {
|
||||||
|
currentView = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = line.match(/^\s+name:\s+(.+)$/)?.[1];
|
||||||
|
if (currentView && name) {
|
||||||
|
names.push(stripYamlQuotes(name));
|
||||||
|
currentView = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the engineering Base shape used by the KT Obsidian document matrix.
|
||||||
|
* @param findings - Shared finding accumulator for the current Obsidian validation run.
|
||||||
|
* @returns Base section and view summary for command output.
|
||||||
|
*/
|
||||||
function validateBaseFile(findings: ObsidianFinding[]): Record<string, unknown> {
|
function validateBaseFile(findings: ObsidianFinding[]): Record<string, unknown> {
|
||||||
const basePath = 'docs/obsidian/KT 文档矩阵.base';
|
const basePath = 'docs/obsidian/KT 文档矩阵.base';
|
||||||
if (!existsSync(resolveInsideRoot(basePath))) {
|
if (!existsSync(resolveInsideRoot(basePath))) {
|
||||||
@ -799,13 +1003,94 @@ function validateBaseFile(findings: ObsidianFinding[]): Record<string, unknown>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viewNames = extractBaseViewNames(content);
|
||||||
|
requiredBaseViewNames
|
||||||
|
.filter((viewName) => !viewNames.includes(viewName))
|
||||||
|
.forEach((viewName) => {
|
||||||
|
findings.push({
|
||||||
|
check: 'base-view',
|
||||||
|
level: 'error',
|
||||||
|
message: 'Base 缺少工程控制视图。',
|
||||||
|
path: basePath,
|
||||||
|
target: viewName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasCardsView: content.includes('type: cards'),
|
hasCardsView: content.includes('type: cards'),
|
||||||
hasTableView: content.includes('type: table'),
|
hasTableView: content.includes('type: table'),
|
||||||
views: [...content.matchAll(/^\s*-\s+type:\s+/gm)].length,
|
requiredViews: requiredBaseViewNames.length,
|
||||||
|
viewNames,
|
||||||
|
views: viewNames.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that module MOC files carry routing metadata for Base views and `obsidian-context`.
|
||||||
|
* @param findings - Shared finding accumulator for the current Obsidian validation run.
|
||||||
|
* @returns Number of module notes inspected.
|
||||||
|
*/
|
||||||
|
function validateModuleFrontmatter(findings: ObsidianFinding[]): number {
|
||||||
|
const moduleFiles = listFiles(resolveInsideRoot('docs/obsidian/modules'), new Set(['.md']))
|
||||||
|
.map(relativeToWorkspace)
|
||||||
|
.filter((file) => file !== 'docs/obsidian/modules/KT 模块索引.md');
|
||||||
|
const routeFields = ['rule_anchors', 'code_anchors', 'workflow_entries', 'validation_entries'];
|
||||||
|
|
||||||
|
for (const file of moduleFiles) {
|
||||||
|
const fields = parseFrontmatterFields(readTextFile(file));
|
||||||
|
for (const requiredField of ['kt_type', 'module']) {
|
||||||
|
const value = fields.get(requiredField);
|
||||||
|
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||||
|
findings.push({
|
||||||
|
check: 'module-frontmatter',
|
||||||
|
level: 'error',
|
||||||
|
message: '模块页缺少 Obsidian 工程路由 frontmatter 字段。',
|
||||||
|
path: file,
|
||||||
|
target: requiredField,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRouteField = routeFields.some((field) => {
|
||||||
|
const value = fields.get(field);
|
||||||
|
return Array.isArray(value) ? value.length > 0 : Boolean(value);
|
||||||
|
});
|
||||||
|
if (!hasRouteField) {
|
||||||
|
findings.push({
|
||||||
|
check: 'module-frontmatter',
|
||||||
|
level: 'error',
|
||||||
|
message: '模块页没有 rule/code/workflow/validation 任一工程锚点。',
|
||||||
|
path: file,
|
||||||
|
target: routeFields.join('|'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return moduleFiles.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects accidental nested Obsidian documents created by resolving wiki links from inside `docs/obsidian`.
|
||||||
|
* @param findings - Shared finding accumulator for the current Obsidian validation run.
|
||||||
|
* @returns Number of nested files found, or 1 when only the invalid directory exists.
|
||||||
|
*/
|
||||||
|
function validateNoNestedObsidianDocs(findings: ObsidianFinding[]): number {
|
||||||
|
const nestedRoot = 'docs/obsidian/docs';
|
||||||
|
const absoluteRoot = resolveInsideRoot(nestedRoot);
|
||||||
|
if (!existsSync(absoluteRoot)) return 0;
|
||||||
|
|
||||||
|
const nestedFiles = listFiles(absoluteRoot, new Set(['.base', '.canvas', '.json', '.md']))
|
||||||
|
.map(relativeToWorkspace);
|
||||||
|
findings.push({
|
||||||
|
check: 'obsidian-nested-docs',
|
||||||
|
level: 'error',
|
||||||
|
message: 'docs/obsidian 下出现误嵌套 docs 目录;Obsidian 索引只能放在 docs/obsidian 根层和 modules 层。',
|
||||||
|
path: nestedRoot,
|
||||||
|
target: nestedFiles.join(', ') || nestedRoot,
|
||||||
|
});
|
||||||
|
return nestedFiles.length || 1;
|
||||||
|
}
|
||||||
|
|
||||||
function validateStaleReferences(findings: ObsidianFinding[]): number {
|
function validateStaleReferences(findings: ObsidianFinding[]): number {
|
||||||
const scanFiles = listFiles(workspaceRoot, new Set(['.md', '.ps1', '.sh', '.ts', '.yaml', '.yml']));
|
const scanFiles = listFiles(workspaceRoot, new Set(['.md', '.ps1', '.sh', '.ts', '.yaml', '.yml']));
|
||||||
let hitCount = 0;
|
let hitCount = 0;
|
||||||
@ -855,6 +1140,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record
|
|||||||
|
|
||||||
const base = validateBaseFile(findings);
|
const base = validateBaseFile(findings);
|
||||||
const canvas = validateCanvas(findings, options.checkLayout);
|
const canvas = validateCanvas(findings, options.checkLayout);
|
||||||
|
const moduleFrontmatterCount = validateModuleFrontmatter(findings);
|
||||||
|
const nestedDocsCount = validateNoNestedObsidianDocs(findings);
|
||||||
const wikiLinkCount = validateWikiLinks(findings);
|
const wikiLinkCount = validateWikiLinks(findings);
|
||||||
const markdownLinkCount = options.includeMarkdownLinks ? validateMarkdownLinks(findings) : 0;
|
const markdownLinkCount = options.includeMarkdownLinks ? validateMarkdownLinks(findings) : 0;
|
||||||
const staleReferenceCount = options.includeStaleReferences ? validateStaleReferences(findings) : 0;
|
const staleReferenceCount = options.includeStaleReferences ? validateStaleReferences(findings) : 0;
|
||||||
@ -867,6 +1154,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record
|
|||||||
canvas,
|
canvas,
|
||||||
configFiles: obsidianConfigFiles.length,
|
configFiles: obsidianConfigFiles.length,
|
||||||
markdownLinks: markdownLinkCount,
|
markdownLinks: markdownLinkCount,
|
||||||
|
moduleFrontmatter: moduleFrontmatterCount,
|
||||||
|
nestedDocs: nestedDocsCount,
|
||||||
staleReferences: staleReferenceCount,
|
staleReferences: staleReferenceCount,
|
||||||
wikiLinks: wikiLinkCount,
|
wikiLinks: wikiLinkCount,
|
||||||
},
|
},
|
||||||
@ -954,6 +1243,18 @@ function readOptionalJson<T>(relativePath: string): T | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collects file paths from Obsidian bookmarks, including grouped bookmark sections.
|
||||||
|
* @param items - Bookmark items from `.obsidian/bookmarks.json`.
|
||||||
|
* @returns File paths referenced by bookmark entries.
|
||||||
|
*/
|
||||||
|
function collectBookmarkPaths(items: BookmarkItem[]): string[] {
|
||||||
|
return items.flatMap((item) => [
|
||||||
|
...(item.path ? [item.path] : []),
|
||||||
|
...(item.items ? collectBookmarkPaths(item.items) : []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function buildSyncFindings(): ObsidianFinding[] {
|
function buildSyncFindings(): ObsidianFinding[] {
|
||||||
const findings: ObsidianFinding[] = [];
|
const findings: ObsidianFinding[] = [];
|
||||||
const missingFiles = expectedObsidianFiles.filter((file) => !existsSync(resolveInsideRoot(file)));
|
const missingFiles = expectedObsidianFiles.filter((file) => !existsSync(resolveInsideRoot(file)));
|
||||||
@ -968,7 +1269,7 @@ function buildSyncFindings(): ObsidianFinding[] {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const bookmarks = readOptionalJson<BookmarkSettings>('.obsidian/bookmarks.json');
|
const bookmarks = readOptionalJson<BookmarkSettings>('.obsidian/bookmarks.json');
|
||||||
const bookmarkPaths = new Set((bookmarks?.items || []).map((item) => item.path).filter(Boolean));
|
const bookmarkPaths = new Set(collectBookmarkPaths(bookmarks?.items || []));
|
||||||
requiredBookmarkPaths
|
requiredBookmarkPaths
|
||||||
.filter((file) => !bookmarkPaths.has(file))
|
.filter((file) => !bookmarkPaths.has(file))
|
||||||
.forEach((file) => {
|
.forEach((file) => {
|
||||||
|
|||||||
@ -68,6 +68,16 @@ const napcatImageAssignmentPattern =
|
|||||||
/^\s*QQBOT_NAPCAT_IMAGE\s*=\s*([^#\s]+)\s*(?:#.*)?$/;
|
/^\s*QQBOT_NAPCAT_IMAGE\s*=\s*([^#\s]+)\s*(?:#.*)?$/;
|
||||||
const taskRecordAllowedFields = new Set(["范围", "关键词", "验证"]);
|
const taskRecordAllowedFields = new Set(["范围", "关键词", "验证"]);
|
||||||
const taskRecordRequiredFields = ["范围", "关键词", "验证"] as const;
|
const taskRecordRequiredFields = ["范围", "关键词", "验证"] as const;
|
||||||
|
const taskRequiredSecondLevelSections = [
|
||||||
|
"当前上下文",
|
||||||
|
"维护规则",
|
||||||
|
"最近记录",
|
||||||
|
"历史索引",
|
||||||
|
] as const;
|
||||||
|
const taskFileMaxBytes = 14 * 1024;
|
||||||
|
const taskHistoryItemMaxChars = 180;
|
||||||
|
const taskRecordFieldMaxChars = 360;
|
||||||
|
const taskRecentRecordLimit = 3;
|
||||||
|
|
||||||
interface ReviewFinding {
|
interface ReviewFinding {
|
||||||
category: string;
|
category: string;
|
||||||
@ -92,11 +102,22 @@ interface ReviewProjectResult {
|
|||||||
trackedSensitiveFiles: string[];
|
trackedSensitiveFiles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the root task index for compact recent-record governance.
|
||||||
|
* @param lines - Current `TASKS.md` content split by line, used to inspect the `## 最近记录` section without rereading files.
|
||||||
|
* @returns Review findings for missing fields, extra fields, or too many detailed recent task records.
|
||||||
|
*/
|
||||||
export function findTaskRecordGovernanceFindings(
|
export function findTaskRecordGovernanceFindings(
|
||||||
lines: string[],
|
lines: string[],
|
||||||
): ReviewFinding[] {
|
): ReviewFinding[] {
|
||||||
const findings: ReviewFinding[] = [];
|
const findings: ReviewFinding[] = [];
|
||||||
|
findings.push(...findTaskFileShapeFindings(lines));
|
||||||
|
|
||||||
const recentStart = lines.findIndex((line) => line.trim() === "## 最近记录");
|
const recentStart = lines.findIndex((line) => line.trim() === "## 最近记录");
|
||||||
|
const historyStart = lines.findIndex((line) => line.trim() === "## 历史索引");
|
||||||
|
if (historyStart >= 0) {
|
||||||
|
findings.push(...findTaskHistoryIndexFindings(lines, historyStart));
|
||||||
|
}
|
||||||
if (recentStart < 0) return findings;
|
if (recentStart < 0) return findings;
|
||||||
|
|
||||||
const nextSectionOffset = lines
|
const nextSectionOffset = lines
|
||||||
@ -104,6 +125,22 @@ export function findTaskRecordGovernanceFindings(
|
|||||||
.findIndex((line) => /^##\s+/.test(line.trim()));
|
.findIndex((line) => /^##\s+/.test(line.trim()));
|
||||||
const recentEnd =
|
const recentEnd =
|
||||||
nextSectionOffset < 0 ? lines.length : recentStart + 1 + nextSectionOffset;
|
nextSectionOffset < 0 ? lines.length : recentStart + 1 + nextSectionOffset;
|
||||||
|
const recordHeadings = lines
|
||||||
|
.slice(recentStart + 1, recentEnd)
|
||||||
|
.map((line, offset) => ({ line, lineNumber: recentStart + 2 + offset }))
|
||||||
|
.filter((item) => item.line.trim().startsWith("### "));
|
||||||
|
if (recordHeadings.length > taskRecentRecordLimit) {
|
||||||
|
findings.push({
|
||||||
|
category: "tasks-recent-record-limit",
|
||||||
|
file: "TASKS.md",
|
||||||
|
line: recordHeadings[taskRecentRecordLimit]?.lineNumber,
|
||||||
|
level: "P2",
|
||||||
|
message: `TASKS 最近记录只允许保留 ${taskRecentRecordLimit} 条详细记录,当前为 ${recordHeadings.length} 条。`,
|
||||||
|
project: "root",
|
||||||
|
suggestion:
|
||||||
|
"保留最新 3 条详细记录,其余迁入「历史索引」的关键词条目。",
|
||||||
|
});
|
||||||
|
}
|
||||||
let currentRecord: {
|
let currentRecord: {
|
||||||
fields: Set<string>;
|
fields: Set<string>;
|
||||||
heading: string;
|
heading: string;
|
||||||
@ -142,13 +179,38 @@ export function findTaskRecordGovernanceFindings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!currentRecord) continue;
|
if (!currentRecord) continue;
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
const fieldMatch = line.match(/^-\s*([^::]+)[::]/);
|
const fieldMatch = line.match(/^-\s*([^::]+)[::]/);
|
||||||
if (!fieldMatch) continue;
|
if (!fieldMatch) {
|
||||||
|
findings.push({
|
||||||
|
category: "tasks-record-freeform-line",
|
||||||
|
file: "TASKS.md",
|
||||||
|
line: index + 1,
|
||||||
|
level: "P2",
|
||||||
|
message: `TASKS 最近记录「${currentRecord.heading}」包含非三字段正文。`,
|
||||||
|
project: "root",
|
||||||
|
suggestion:
|
||||||
|
"最近记录只允许三条 bullet:范围、关键词、验证;补充段、子列表、表格、代码块全部迁入 docs 或历史索引。",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const field = fieldMatch[1].trim();
|
const field = fieldMatch[1].trim();
|
||||||
if (taskRecordAllowedFields.has(field)) {
|
if (taskRecordAllowedFields.has(field)) {
|
||||||
currentRecord.fields.add(field);
|
currentRecord.fields.add(field);
|
||||||
|
if (line.length > taskRecordFieldMaxChars) {
|
||||||
|
findings.push({
|
||||||
|
category: "tasks-record-field-too-long",
|
||||||
|
file: "TASKS.md",
|
||||||
|
line: index + 1,
|
||||||
|
level: "P2",
|
||||||
|
message: `TASKS 最近记录字段「${field}」过长,当前 ${line.length} 字,限制 ${taskRecordFieldMaxChars} 字。`,
|
||||||
|
project: "root",
|
||||||
|
suggestion:
|
||||||
|
"把长证据、长复现和长命令移入 docs 或历史索引短关键词,最近记录只保留摘要。",
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,6 +230,97 @@ export function findTaskRecordGovernanceFindings(
|
|||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the whole task index shape before section-specific recent-record checks.
|
||||||
|
* @param lines - Current `TASKS.md` content split by line.
|
||||||
|
* @returns Review findings for total size or required second-level section drift.
|
||||||
|
*/
|
||||||
|
function findTaskFileShapeFindings(lines: string[]): ReviewFinding[] {
|
||||||
|
const findings: ReviewFinding[] = [];
|
||||||
|
const byteLength = Buffer.byteLength(lines.join("\n"), "utf8");
|
||||||
|
if (byteLength > taskFileMaxBytes) {
|
||||||
|
findings.push({
|
||||||
|
category: "tasks-file-size-limit",
|
||||||
|
file: "TASKS.md",
|
||||||
|
level: "P2",
|
||||||
|
message: `TASKS.md 只能作为压缩索引,当前 ${byteLength} bytes,限制 ${taskFileMaxBytes} bytes。`,
|
||||||
|
project: "root",
|
||||||
|
suggestion:
|
||||||
|
"删除流水账、长验证、截图清单和完整命令;必要细节放入 docs,TASKS 只保留索引。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionHeadings = lines
|
||||||
|
.map((line, index) => ({ line, lineNumber: index + 1 }))
|
||||||
|
.filter((item) => /^##\s+/.test(item.line.trim()));
|
||||||
|
const actualSections = sectionHeadings.map((item) =>
|
||||||
|
item.line.trim().replace(/^##\s+/, ""),
|
||||||
|
);
|
||||||
|
const expectedSections = [...taskRequiredSecondLevelSections];
|
||||||
|
const hasExactSectionOrder =
|
||||||
|
actualSections.length === expectedSections.length &&
|
||||||
|
expectedSections.every((section, index) => actualSections[index] === section);
|
||||||
|
if (!hasExactSectionOrder) {
|
||||||
|
findings.push({
|
||||||
|
category: "tasks-section-order",
|
||||||
|
file: "TASKS.md",
|
||||||
|
line: sectionHeadings[0]?.lineNumber ?? 1,
|
||||||
|
level: "P2",
|
||||||
|
message: `TASKS.md 二级章节必须固定为 ${expectedSections.join(" -> ")}。`,
|
||||||
|
project: "root",
|
||||||
|
suggestion:
|
||||||
|
"不要新增临时章节、补充章节或任务分组;把额外内容压缩进最近记录三字段或历史索引分类 bullet。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the compact history index so old work cannot become another long-form log sink.
|
||||||
|
* @param lines - Current `TASKS.md` content split by line.
|
||||||
|
* @param historyStart - Zero-based line index of `## 历史索引`.
|
||||||
|
* @returns Review findings for long or free-form history index content.
|
||||||
|
*/
|
||||||
|
function findTaskHistoryIndexFindings(
|
||||||
|
lines: string[],
|
||||||
|
historyStart: number,
|
||||||
|
): ReviewFinding[] {
|
||||||
|
const findings: ReviewFinding[] = [];
|
||||||
|
for (let index = historyStart + 1; index < lines.length; index += 1) {
|
||||||
|
const line = lines[index].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
if (line.startsWith("### ")) continue;
|
||||||
|
|
||||||
|
if (!line.startsWith("- ")) {
|
||||||
|
findings.push({
|
||||||
|
category: "tasks-history-freeform-line",
|
||||||
|
file: "TASKS.md",
|
||||||
|
line: index + 1,
|
||||||
|
level: "P2",
|
||||||
|
message: "TASKS 历史索引只能使用分类标题和单行关键词 bullet。",
|
||||||
|
project: "root",
|
||||||
|
suggestion:
|
||||||
|
"删除段落、代码块、表格和补充说明;历史索引只保留可检索关键词。",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.length <= taskHistoryItemMaxChars) continue;
|
||||||
|
findings.push({
|
||||||
|
category: "tasks-history-item-too-long",
|
||||||
|
file: "TASKS.md",
|
||||||
|
line: index + 1,
|
||||||
|
level: "P2",
|
||||||
|
message: `TASKS 历史索引条目过长,当前 ${line.length} 字,限制 ${taskHistoryItemMaxChars} 字。`,
|
||||||
|
project: "root",
|
||||||
|
suggestion:
|
||||||
|
"把旧任务压缩成日期、主题和关键词;长背景移入 docs 或删除。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
export function findNapcatImageGovernanceFindings(
|
export function findNapcatImageGovernanceFindings(
|
||||||
lines: string[],
|
lines: string[],
|
||||||
context: {
|
context: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user