From 114eef31955407bcd275f69b6819def54e69f353 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 26 Jun 2026 10:03:18 +0800 Subject: [PATCH 1/6] =?UTF-8?q?ci:=20=E8=A1=A5=E9=BD=90NapCat=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=95=9C=E5=83=8F=E6=9E=84=E5=BB=BA=E4=B8=8B?= =?UTF-8?q?=E6=B8=B8Job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 5 +- ...T-NapCatQQ-Runtime-Image-Build.Jenkinsfile | 190 ++++++++++++++++++ .../KT-NapCatQQ-Runtime-Release.Jenkinsfile | 6 + src/selfTest.ts | 89 ++++++++ 5 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile diff --git a/.gitignore b/.gitignore index 66b2bbd..d43741f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ dist/ .turbo/ +.kt-workspace/ *.log diff --git a/README.md b/README.md index 8763d40..0b0b13c 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,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,保留人工审查边界。 - 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`,调用 API 仓库 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 staged context,执行 `docker build` 和容器内 `/ci/napcat-desktop-cn/verify.sh`;它不触发 API promotion、不执行 `kubectl`、不合并或推送 Git。 - 生产 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 或其他真实凭据。 ## MCP 客户端配置 diff --git a/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile b/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile new file mode 100644 index 0000000..a642682 --- /dev/null +++ b/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile @@ -0,0 +1,190 @@ +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' + 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 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 /var/run/docker.sock:/var/run/docker.sock \ + --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 + + rm -rf "$ARTIFACT_DIR" "$BUILD_WORKDIR" + mkdir -p "$ARTIFACT_DIR" "$BUILD_WORKDIR" + 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" "$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.') + } +} diff --git a/ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile b/ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile index 6882964..e1ffc14 100644 --- a/ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile +++ b/ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile @@ -12,6 +12,8 @@ pipeline { parameters { 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_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_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.') @@ -38,6 +40,8 @@ pipeline { -e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \ -e NAPCAT_FORK_REF="$NAPCAT_FORK_REF" \ -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_DESKTOP_PROFILE_VERSION_OVERRIDE="$QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE" \ -e COREPACK_HOME=/vol1/docker/kt-codex/runtime/corepack \ @@ -72,6 +76,8 @@ pipeline { parameters: [ string(name: 'NAPCAT_FORK_REF', value: params.NAPCAT_FORK_REF), 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_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") diff --git a/src/selfTest.ts b/src/selfTest.ts index 6fa1eca..db6b594 100644 --- a/src/selfTest.ts +++ b/src/selfTest.ts @@ -524,6 +524,12 @@ export async function runSelfTest(): Promise { ), "utf8", ).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 [ "RUN_CODEX_AUDIT", "CONFIRM_CODEX_WORKSPACE_WRITE", @@ -626,6 +632,10 @@ export async function runSelfTest(): Promise { runtimeReleaseJenkinsTemplate, "NapCat runtime", ); + assertNapCatJenkinsUsesNasAgent( + runtimeImageBuildJenkinsTemplate, + "NapCat runtime image build", + ); /** * Verifies that a NapCat Jenkins template runs ktWorkflow inside a helper container with NAS mounts. * @param template - Full Jenkinsfile text that Jenkins will execute. @@ -658,6 +668,44 @@ export async function runSelfTest(): Promise { runtimeReleaseJenkinsTemplate, "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"]) { if (upstreamSyncJenkinsTemplate.includes(forbidden)) { throw new Error( @@ -666,6 +714,8 @@ export async function runSelfTest(): Promise { } } for (const expected of [ + "UPSTREAM_RELEASE_COMMIT", + "NAPCAT_BASE_IMAGE_DIGEST", "choice(name: 'RUNTIME_BUILD_JOB'", "choice(name: 'API_PROMOTION_JOB'", "CONFIRM_API_PROMOTION_TARGET", @@ -748,6 +798,45 @@ export async function runSelfTest(): Promise { "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", + "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( ".kt-workspace/test-artifacts/napcat-codex-runner-self-test", ); From 1ad2baf37fbd14d15e4248b2abcdde2bad1c330f Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 26 Jun 2026 14:56:27 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=A8=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=90=8C=E6=AD=A5=E5=92=8CObsidian=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- package.json | 1 + src/core/cli.ts | 18 +++ src/selfTest.ts | 47 +++++++ src/server.ts | 10 ++ src/tools/docSync.ts | 22 ++- src/tools/obsidian.ts | 313 +++++++++++++++++++++++++++++++++++++++++- 7 files changed, 403 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0b0b13c..68cbc64 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - 静态检查 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、不触发发布。 - 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。 -- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口。 +- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口;MCP 不可用时用 `pnpm run change-doc-sync -- --project ` 作为 CLI fallback。 - 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。 - 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 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:` 为准,日志存在但缺少最终态时不认定发布完成。 @@ -40,6 +40,7 @@ pnpm run self-test pnpm run obsidian-context -- --module ktWorkflow pnpm run obsidian-validate 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 napcat-upstream-audit -- --artifact-root .kt-workspace/test-artifacts/napcat-upstream-sync pnpm run napcat-sync-candidate-review diff --git a/package.json b/package.json index 97563c0..bf92ecb 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "description": "Reusable MCP workflow tools for the KT workspace.", "scripts": { "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", "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", diff --git a/src/core/cli.ts b/src/core/cli.ts index 76e10a6..d54b7b3 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -1,4 +1,5 @@ import type { + ChangeDocSyncInput, DeployObservationInput, GlobalCodeReviewInput, 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 { for (const name of names) { const keyValue = argv.find((item) => item.startsWith(`${name}=`)); diff --git a/src/selfTest.ts b/src/selfTest.ts index db6b594..5600cd4 100644 --- a/src/selfTest.ts +++ b/src/selfTest.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import path from "node:path"; import { + parseChangeDocSyncCliArgs, parseNasCodexBootstrapCliArgs, parseDeployObservationCliArgs, parseGlobalReviewCliArgs, @@ -408,6 +409,9 @@ export async function runSelfTest(): Promise { if (!packageJson.scripts?.["napcat-remote-dev-handoff"]) { 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 fakeMcpServer = { /** @@ -2141,6 +2145,33 @@ export async function runSelfTest(): Promise { if ((obsidianValidation.summary as { errors?: number }).errors !== 0) { 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 = { defaultDryRun: parseCliCleanupArgs(["node", "server", "--cleanup-history"]) @@ -2190,6 +2221,22 @@ export async function runSelfTest(): Promise { ], 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<{ path: string; }>; diff --git a/src/server.ts b/src/server.ts index d01a93d..cb764f9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { + parseChangeDocSyncCliArgs, parseNasCodexBootstrapCliArgs, parseDeployObservationCliArgs, parseGlobalReviewCliArgs, @@ -17,6 +18,7 @@ import { } from './core/cli.js'; import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js'; import { buildWorkstreamCloseout } from './tools/closeout.js'; +import { buildChangeDocSync } from './tools/docSync.js'; import { buildDeployObservation } from './tools/deployObservation.js'; import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.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)); } else if (process.argv.includes('--obsidian-sync')) { 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')) { console.log( JSON.stringify( diff --git a/src/tools/docSync.ts b/src/tools/docSync.ts index 410310d..5193f07 100644 --- a/src/tools/docSync.ts +++ b/src/tools/docSync.ts @@ -34,9 +34,9 @@ const docSyncRules: DocSyncRule[] = [ 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, - 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'], @@ -44,12 +44,25 @@ const docSyncRules: DocSyncRule[] = [ 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, 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/, reason: '上下文治理或 skill 规则变更,需要同步根治理文档和 Obsidian 模块。', }, @@ -118,6 +131,7 @@ export async function buildChangeDocSync(input: ChangeDocSyncInput = {}): Promis return { changedFiles, commands: [ + 'pnpm --dir mcp/ktWorkflow run change-doc-sync -- --project ', 'pnpm --dir mcp/ktWorkflow run obsidian-context -- --query ', 'pnpm --dir mcp/ktWorkflow run obsidian-validate', 'pnpm --dir mcp/ktWorkflow run global-review', diff --git a/src/tools/obsidian.ts b/src/tools/obsidian.ts index e9bee8e..c0a0927 100644 --- a/src/tools/obsidian.ts +++ b/src/tools/obsidian.ts @@ -21,6 +21,8 @@ interface CanvasNode { file?: string; height?: number; id?: string; + label?: string; + text?: string; type?: string; width?: number; x?: number; @@ -44,7 +46,9 @@ interface ObsidianSettings { } interface BookmarkItem { + items?: BookmarkItem[]; path?: string; + title?: string; type?: string; } @@ -173,11 +177,33 @@ const expectedIgnoredRoots = [ '.git/', '.kt-workspace/', 'Node/', - 'Plugins/', 'Vue/', + 'Plugins/', 'mcp/', + 'GitHub/', + 'Other/', + 'workspace-assets/', ]; +const requiredBaseViewNames = [ + '开工路由', + '模块-仓库矩阵', + '工作流入口', + '验证与收尾', + '运行态与远程排查', + '文档归口', +]; + +const requiredCanvasGroupLabels = [ + '00 当前上下文', + '10 路由索引', + '20 模块仓库', + '30 工作流执行', + '40 验证与固化', +]; + +const canvasTextNodeMaxChars = 180; + const defaultContextPaths = [ 'docs/obsidian/KT 知识图谱.md', 'docs/obsidian/modules/KT 模块索引.md', @@ -297,6 +323,12 @@ function readTextFile(relativePath: string): string { 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 { return ( 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[] { const frontmatter = content.match(/^---\n([\s\S]*?)\n---/); if (!frontmatter) return []; @@ -316,6 +353,59 @@ function getMarkdownTags(content: string): string[] { .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 { + const frontmatter = content.match(/^---\n([\s\S]*?)\n---/); + const fields = new Map(); + 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[] { return [...content.matchAll(/\[\[([^\]\n]+)\]\]/g)] .map((match) => match[1]?.split('|')[0]?.split('#')[0]?.trim()) @@ -380,6 +470,27 @@ function buildContextTerms(input: ObsidianContextInput): string[] { .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 | null { if (includeCanvas === false) return null; @@ -398,6 +509,7 @@ function readCanvasSummary(includeCanvas: boolean | undefined): Record(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 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(); const edgeIds = new Set(); + const groupLabels = new Set(nodes.filter((node) => node.type === 'group').map((node) => node.label).filter(Boolean)); for (const node of nodes) { if (!node.id) { @@ -708,6 +832,26 @@ function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { ed } 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))) { findings.push({ 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) { const visibleNodes = nodes.filter((node) => node.type !== 'group'); 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 { const basePath = 'docs/obsidian/KT 文档矩阵.base'; if (!existsSync(resolveInsideRoot(basePath))) { @@ -799,13 +1003,94 @@ function validateBaseFile(findings: ObsidianFinding[]): Record } } + 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 { hasCardsView: content.includes('type: cards'), 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 { const scanFiles = listFiles(workspaceRoot, new Set(['.md', '.ps1', '.sh', '.ts', '.yaml', '.yml'])); let hitCount = 0; @@ -855,6 +1140,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record const base = validateBaseFile(findings); const canvas = validateCanvas(findings, options.checkLayout); + const moduleFrontmatterCount = validateModuleFrontmatter(findings); + const nestedDocsCount = validateNoNestedObsidianDocs(findings); const wikiLinkCount = validateWikiLinks(findings); const markdownLinkCount = options.includeMarkdownLinks ? validateMarkdownLinks(findings) : 0; const staleReferenceCount = options.includeStaleReferences ? validateStaleReferences(findings) : 0; @@ -867,6 +1154,8 @@ export function validateObsidianVault(input: ObsidianValidateInput = {}): Record canvas, configFiles: obsidianConfigFiles.length, markdownLinks: markdownLinkCount, + moduleFrontmatter: moduleFrontmatterCount, + nestedDocs: nestedDocsCount, staleReferences: staleReferenceCount, wikiLinks: wikiLinkCount, }, @@ -954,6 +1243,18 @@ function readOptionalJson(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[] { const findings: ObsidianFinding[] = []; const missingFiles = expectedObsidianFiles.filter((file) => !existsSync(resolveInsideRoot(file))); @@ -968,7 +1269,7 @@ function buildSyncFindings(): ObsidianFinding[] { }); const bookmarks = readOptionalJson('.obsidian/bookmarks.json'); - const bookmarkPaths = new Set((bookmarks?.items || []).map((item) => item.path).filter(Boolean)); + const bookmarkPaths = new Set(collectBookmarkPaths(bookmarks?.items || [])); requiredBookmarkPaths .filter((file) => !bookmarkPaths.has(file)) .forEach((file) => { From aa6e81f4c25b64ec4ee05d23ace52eae49b8ea70 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 26 Jun 2026 17:32:06 +0800 Subject: [PATCH 3/6] =?UTF-8?q?ci:=20=E4=BF=AE=E5=A4=8DNapCat=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=95=9C=E5=83=8F=E6=9E=84=E5=BB=BA=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=AE=89=E5=85=A8=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile | 1 + 1 file changed, 1 insertion(+) diff --git a/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile b/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile index a642682..00479c2 100644 --- a/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile +++ b/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile @@ -90,6 +90,7 @@ pipeline { } trap cleanup EXIT + git config --global --add safe.directory "$NAPCAT_GIT_REPOSITORY" rm -rf "$ARTIFACT_DIR" "$BUILD_WORKDIR" mkdir -p "$ARTIFACT_DIR" "$BUILD_WORKDIR" git clone "$NAPCAT_GIT_REPOSITORY" "$NAPCAT_ROOT" From 0c6eed7539545068732aa49f92f6da29bdeec128 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 26 Jun 2026 18:05:19 +0800 Subject: [PATCH 4/6] =?UTF-8?q?ci:=20=E4=BF=AE=E5=A4=8DNapCat=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=95=9C=E5=83=8F=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- ...T-NapCatQQ-Runtime-Image-Build.Jenkinsfile | 23 ++++++++++++++++++- src/selfTest.ts | 11 +++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 68cbc64..c73b480 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ 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,保留人工审查边界。 - 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 名被传入,并把 upstream release commit / NapCat base image digest 透传到镜像构建证据。 -- `ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile` 是运行时镜像构建下游,只从 NAS 本地裸仓库 clone 经过审查的 NapCatQQ fork ref,构建 `NapCat.Shell`,调用 API 仓库 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 staged context,执行 `docker build` 和容器内 `/ci/napcat-desktop-cn/verify.sh`;它不触发 API promotion、不执行 `kubectl`、不合并或推送 Git。 +- `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`。 - 三个模板默认使用 NAS 工作区 `/vol1/docker/kt-codex/workspace/KT`,运行证据落在 `/vol1/docker/kt-codex/artifacts` 下。 - 模板只保存入口和参数形状,不记录 Jenkins credential、Token、SSH key 或其他真实凭据。 diff --git a/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile b/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile index 00479c2..763de95 100644 --- a/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile +++ b/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile @@ -22,6 +22,8 @@ pipeline { 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' } @@ -51,6 +53,7 @@ pipeline { 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 \ @@ -64,7 +67,9 @@ pipeline { -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 @@ -91,8 +96,19 @@ pipeline { 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" @@ -118,7 +134,12 @@ pipeline { -t "$QQBOT_NAPCAT_IMAGE_OVERRIDE" \ -f "$STAGE_OUT/ci/napcat-desktop-cn/Dockerfile" \ "$STAGE_OUT" - docker run -d --name "$VERIFY_NAME" "$QQBOT_NAPCAT_IMAGE_OVERRIDE" >/dev/null + 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" \ diff --git a/src/selfTest.ts b/src/selfTest.ts index 5600cd4..4e31e6d 100644 --- a/src/selfTest.ts +++ b/src/selfTest.ts @@ -811,6 +811,17 @@ export async function runSelfTest(): Promise { "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", From 71d9ac7e1018ad1ccc6c3d1853ab7920c1f1beea Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 27 Jun 2026 11:15:58 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E5=9B=BA=E5=8C=96TASKS=E6=9C=80?= =?UTF-8?q?=E8=BF=91=E8=AE=B0=E5=BD=95=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- src/selfTest.ts | 27 +++++++++++++++++++++++++++ src/tools/review.ts | 22 ++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c73b480..172519c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。 - 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮,模板目录永久保留;CLI 默认 dry-run,真实清理必须显式传 `--execute`。 - 检查 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`,确认后再落盘。 - 生成提交前检查清单:校验 KT commit message 约定。 @@ -134,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 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-sync-candidate-review` | 输出 sync candidate review 的 prompt/context/只读命令骨架,不执行 Codex 或 Git 写操作。 | | `pnpm run napcat-runtime-release-readiness` | 输出 runtime release readiness 的 prompt/context/只读检查骨架,不构建镜像、不调用 kubectl、不发布。 | diff --git a/src/selfTest.ts b/src/selfTest.ts index 4e31e6d..30375de 100644 --- a/src/selfTest.ts +++ b/src/selfTest.ts @@ -1122,6 +1122,33 @@ export async function runSelfTest(): Promise { ) { 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 napcatImageFindings = findNapcatImageGovernanceFindings( ["QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest"], diff --git a/src/tools/review.ts b/src/tools/review.ts index 02e1484..c3ce3b6 100644 --- a/src/tools/review.ts +++ b/src/tools/review.ts @@ -68,6 +68,7 @@ const napcatImageAssignmentPattern = /^\s*QQBOT_NAPCAT_IMAGE\s*=\s*([^#\s]+)\s*(?:#.*)?$/; const taskRecordAllowedFields = new Set(["范围", "关键词", "验证"]); const taskRecordRequiredFields = ["范围", "关键词", "验证"] as const; +const taskRecentRecordLimit = 3; interface ReviewFinding { category: string; @@ -92,6 +93,11 @@ interface ReviewProjectResult { 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( lines: string[], ): ReviewFinding[] { @@ -104,6 +110,22 @@ export function findTaskRecordGovernanceFindings( .findIndex((line) => /^##\s+/.test(line.trim())); const recentEnd = 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: { fields: Set; heading: string; From 366a7a1606c91ff1ef405efb8cee8d9549b56be2 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 3 Jul 2026 13:53:20 +0800 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20=E5=A2=9E=E5=BC=BATASKS=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E6=B2=BB=E7=90=86=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/selfTest.ts | 45 +++++++++++++++ src/tools/review.ts | 133 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/src/selfTest.ts b/src/selfTest.ts index 30375de..43f6b71 100644 --- a/src/selfTest.ts +++ b/src/selfTest.ts @@ -1149,6 +1149,51 @@ export async function runSelfTest(): Promise { ) { 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( ["QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest"], diff --git a/src/tools/review.ts b/src/tools/review.ts index c3ce3b6..67759b7 100644 --- a/src/tools/review.ts +++ b/src/tools/review.ts @@ -68,6 +68,15 @@ const napcatImageAssignmentPattern = /^\s*QQBOT_NAPCAT_IMAGE\s*=\s*([^#\s]+)\s*(?:#.*)?$/; const taskRecordAllowedFields = new Set(["范围", "关键词", "验证"]); const taskRecordRequiredFields = ["范围", "关键词", "验证"] as const; +const taskRequiredSecondLevelSections = [ + "当前上下文", + "维护规则", + "最近记录", + "历史索引", +] as const; +const taskFileMaxBytes = 14 * 1024; +const taskHistoryItemMaxChars = 180; +const taskRecordFieldMaxChars = 360; const taskRecentRecordLimit = 3; interface ReviewFinding { @@ -102,7 +111,13 @@ export function findTaskRecordGovernanceFindings( lines: string[], ): ReviewFinding[] { const findings: ReviewFinding[] = []; + findings.push(...findTaskFileShapeFindings(lines)); + 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; const nextSectionOffset = lines @@ -164,13 +179,38 @@ export function findTaskRecordGovernanceFindings( } if (!currentRecord) continue; + if (!line) continue; 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(); if (taskRecordAllowedFields.has(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; } @@ -190,6 +230,97 @@ export function findTaskRecordGovernanceFindings( 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( lines: string[], context: {