diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index bd3f18bd..a68017a7 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -310,20 +310,57 @@ jobs: trap - EXIT rm -rf -- "$npm_diagnostics" - - name: Validate backend, package, frontend, and database contracts + - name: Lint and compile Python run: | set -euo pipefail export PATH="$PWD/.venv/bin:$PATH" + mkdir -p gate-logs ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py \ tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py \ tests/test_yoga_rules_integrity.py python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py + + - name: Python quality gate (quick) + run: | + set -euo pipefail + export PATH="$PWD/.venv/bin:$PATH" python scripts/run_quality_gate.py \ --profile quick --skip-yoga-logic --skip-frontend-runtime + + - name: Privacy artifact scan + run: | + set -euo pipefail + export PATH="$PWD/.venv/bin:$PATH" python scripts/commercial_privacy_artifact_scan.py --json - python -m build + + - name: Build Python package + run: | + set -euo pipefail + export PATH="$PWD/.venv/bin:$PATH" + build_status=0 + python -m build > gate-logs/python-build.log 2>&1 || build_status=$? + if [ "$build_status" -ne 0 ]; then + echo "python -m build failed exit=$build_status log=gate-logs/python-build.log" >&2 + tail -n 200 gate-logs/python-build.log >&2 + exit "$build_status" + fi + + - name: Frontend and database tests + run: | + set -euo pipefail + export PATH="$PWD/.venv/bin:$PATH" npm test --prefix frontend + + - name: Frontend lint + run: | + set -euo pipefail + export PATH="$PWD/.venv/bin:$PATH" npm run lint --prefix frontend + + - name: Frontend production build (non-push) + run: | + set -euo pipefail + export PATH="$PWD/.venv/bin:$PATH" if [ "$GITEA_EVENT_NAME" != "push" ]; then if ! timeout 600 npm run build --prefix frontend -- --webpack; then echo "frontend production build exceeded bounded 600-second timeout" >&2 diff --git a/.gitignore b/.gitignore index 95177d29..90bab54d 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ frontend/pnpm-workspace.yaml # Local isolated feature worktrees .worktrees/ +# Quality-gate step logs. CI keeps them on the runner; they are not a deliverable. +/gate-logs/ + # Jev intent-classifier research: real-user sample (source B) never committed scripts/research/jev_intent_samples/source_b.jsonl scripts/research/jev_intent_samples/source_b.meta.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a20cc27..ea0a2c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-04 — 门禁日志改成摘要,失败时还能看到是哪一项(未上线) + +- 测试环境的质量门禁以前把七项检查的全部明细打在同一步里,日志有四万行以上,失败页面打不开(BUG-1230)。 +- 现在每项检查单独一步。通过的只留一行;失败的写出是哪一项、退出码,以及报错正文。完整明细留在运行机器上的 `gate-logs/`,不上传。 +- 本机自己跑前端测试时,输出和以前一样。Skill 版本不变;不改数据库;不改占星计算和对话。 + ## 2026-10-04 — 占星师口径第六批:Rath 版双主星改按原书 p.43 正文(未上线) - Sanjay Rath 版 Narayana(并列参考,不进打分):天蝎、水瓶的双主星改按原书第 43 页正文选。两主分落别的星座时,用本模块 7 级比较它们所在的星座;火星在白羊、土星在摩羯不再被当成「已在本宫」。同宫时比宫内度数,只把计都倒过来算,度数完全一样就标未决,不暗自取列表里的第一颗(BUG-1227)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 198e8477..eac3d91b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -16612,3 +16612,18 @@ - 相关记录:BUG-1224、BUG-1227。 - 复发自:无 - 修复版本:待发布 + +## BUG-1230 | 门禁 validate 单步日志过大,失败页打不开 + +- 状态:resolved(分支 `codex/gate-log-volume-20261004`) +- 首次发现 / 最近更新:2026-10-04 / 2026-10-04 +- 来源:第六批 `baf4ae8b` 的门禁失败(Gitea task 6691、run 3170)。产品负责人打开 job 页时,「Validate backend, package, frontend, and database contracts」这一步日志打不开,看不到报错。任务书 `docs/tasks/TASK-gate-log-volume-20261004.md`。当时 staging 仍部署 `153c6e99`。 +- 影响面:`.gitea/workflows/backend-quality-gate.yml` 的 validate job;`scripts/run_quality_gate.py` 的默认输出;`frontend` 的 `npm test` 在 CI / Gitea 下的输出。触发条件、paths 过滤、job 依赖、runner、密钥、超时、发布与部署未改。 +- 现象:这一步把 ruff、py_compile、快速门、隐私扫描、`python -m build`、`npm test`、lint 合成一个 run(非 push 还有前端构建)。写任务书时本机测得:`npm test` 约 26,800 行,快速门约 14,000 行,`python -m build` 约 2,900 行,合计 4.4 万行以上、2 MB 以上。成功的检查也刷屏,失败点埋在中间,网页渲染不出来。 +- 根因:门禁把全部明细当默认输出,并且把 7 个检查挤在同一个 step。 +- 修复:快速门默认改为捕获子进程输出。成功只打一行;失败打失败标记、退出码和最后 200 行,完整输出写到 `gate-logs/` 并打印路径。`--verbose` 恢复原来的透传。没有给子进程新加超时。前端 `npm test` 在本机仍输出 TAP;在 CI / Gitea 用点号进度,完整 TAP 写到 `gate-logs/frontend-tests.tap`,日志末尾打 tests / pass / fail / cancelled,以及每条失败的名字和报错正文(每条最多 60 行)。点号报告自带的整段堆栈不再重复打进日志。退出码仍是测试进程的退出码。workflow 把原来的一个 step 拆成 7 个顺序 step;`python -m build` 的明细写入 `gate-logs/python-build.log`,失败时打最后 200 行再以原退出码退出。 +- 验证:本机测量见 `docs/tasks/PROGRESS-gate-log-volume-20261004.md`。快速门精简输出 18 行且退出码 0。`--verbose` 打出 27,093 行;同一次里有一条既有测试失败,单独重跑通过。`CI=true npm test` 仍能看到失败名字和报错正文,退出码为 1(本机既有失败,不是这次改出来的)。合同测试锁住 7 步顺序、`set -euo pipefail` 和构建重定向。Gitea 网页是否打得开要等推送后第一次门禁,本记录不声称页面已经打开。 +- 防复发:`tests/test_run_quality_gate_output.py`;`frontend/tests/staging-backend-workflows.test.ts` 里原有两条测试加上步序、禁止 `continue-on-error`、构建重定向。没有新增 `test()`。 +- 相关记录:BUG-995(前端测试要同时看 cancelled 和退出码)。 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-gate-log-volume-20261004.md b/docs/tasks/PROGRESS-gate-log-volume-20261004.md new file mode 100644 index 00000000..14d10d1e --- /dev/null +++ b/docs/tasks/PROGRESS-gate-log-volume-20261004.md @@ -0,0 +1,70 @@ +# PROGRESS:门禁日志体积 — 2026-10-04 + +基线 `origin/staging` `06bfae92`。分支 `codex/gate-log-volume-20261004`,工作树 `.worktrees/gate-log-volume-20261004`。未提交,未推送,未部署。Gitea 上各 step 页面是否打得开,留待推送后由产品确认。 + +## 做了什么 + +1. 快速门默认只打摘要。通过一行,失败打标记、退出码、最后 200 行,完整输出写到 `gate-logs/`。`--verbose` 恢复原来的整份输出。没有新加子进程超时。 +2. 前端 `npm test` 本机仍是完整 TAP。门禁环境(`CI` 或 `GITEA_ACTIONS`)打点号进度,完整 TAP 写到 `gate-logs/frontend-tests.tap`,末尾打 tests / pass / fail / cancelled,以及每条失败的名字和报错正文(每条最多 60 行)。点号报告自己还会再打一遍整段堆栈,那一段不进日志,避免同一条失败打两遍。 +3. 门禁 workflow 把原来的一个 step 拆成 7 个顺序 step。`python -m build` 的明细写入 `gate-logs/python-build.log`,失败打最后 200 行,退出码不变。触发条件、paths、job 依赖、runner、密钥、超时、发布和部署没动。 +4. 记录:BUG-1230、CHANGELOG、本文件、状态板改为「已实现待验收」。 + +## 行数 + +任务书里的 26,800 / 14,000 / 2,900 是写任务书时的测量。下表「改前」里标「本轮实测」的,是这次在同一台 Windows 上、改代码前抓到的日志。 + +| 输出 | 改前 | 改后 | +| --- | --- | --- | +| 快速门 `--profile quick --skip-yoga-logic --skip-frontend-runtime` | 任务书约 14,000 行 | 18 行,退出码 0,用时 929 秒 | +| 同一条命令加 `--verbose` | 与改前同类(子进程整份输出直接打出) | 27,093 行。退出码 1:1,056 通过、1 失败、1 跳过。失败的是既有测试 `test_the_new_layer_leaves_every_existing_output_unchanged`(两次算盘对不上)。精简模式的全门是通过的;这条事后单独重跑也通过。不是这次输出改动引进的 | +| 本机 `npm test`(不设 CI) | 36,122 行;4,844 tests / 4,700 pass / 144 fail / 0 cancelled | 36,140 行;汇总相同;失败名字顺序与改前相同 | +| `CI=true npm test` | 先做的一版 7,128 行(点号进度的整段堆栈,再加上每条失败的完整诊断) | 1,144 行。没有 `Failed tests:` 那一段。汇总仍是 4,844 / 4,700 / 144 / 0。134 条失败名字与精简前相同,报错正文还在(例如迁移文件重名、EBUSY)。退出码 1 | +| `python -m build` | 任务书约 2,900 行 | 本机没再跑。workflow 改为写入日志文件,失败只打末 200 行 | + +`CI=true` 在这台机器上是 1,144 行,超过任务书写的 600 行。原因是这台机器有 144 条失败,不是任务书里的 24 条。点号进度本身大约 248 行(Node 在非终端下每行 20 个点,4,844 个测试约 243 行)。每条失败平均再占 6.65 行。按这个长度,24 条失败大约 413 行,全绿大约 253 行,都低于 600。失败名字和报错正文没有拿掉。 + +这 144 条是这台 Windows 上本来就有的:迁移文件名重复(符号链接被检出成普通文件)、`G:\G:\` 路径、建符号链接没有权限、临时目录删不掉、没有 bash。改前改后的失败名单一致,不是这次改出来的。 + +## 检查集合对照 + +命令和参数没有增删。只是从同一步拆开,构建的输出改道。 + +| 检查 | 拆分前 | 拆分后 | +| --- | --- | --- | +| `ruff check` 那 5 个文件 | 唯一的 validate step | `Lint and compile Python` | +| `python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py` | 同一步 | 同一步 | +| `python scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime` | 同一步 | `Python quality gate (quick)` | +| `python scripts/commercial_privacy_artifact_scan.py --json` | 同一步 | `Privacy artifact scan` | +| `python -m build` | 同一步,明细直接打进该 step | `Build Python package`,明细进 `gate-logs/python-build.log`,失败 `tail -n 200` 后用原来的退出码退出 | +| `npm test --prefix frontend` | 同一步 | `Frontend and database tests` | +| `npm run lint --prefix frontend` | 同一步 | `Frontend lint` | +| 非 push 才 `timeout 600 npm run build --prefix frontend -- --webpack`,超时文案后 `exit 124`;push 只打原来那句 echo | 同一步 | `Frontend production build (non-push)` | + +`mkdir -p gate-logs` 放在第一步里,不是第八个检查。没有用成功码吞掉失败,也没有 `continue-on-error`。三处 `npm test` / `lint` / `build` 仍各出现 1 次。 + +## 合同断言三栏 + +没有新增 `test()`。下面三处改的是原有测试里的字符串。 + +| 断言 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 安装步骤的结束边界 | `Validate backend, package, frontend, and database contracts` | `Lint and compile Python` | 下一段改了名,安装步骤正文没变 | +| 提取 npm install 脚本的切分点 | 换行后 `- name: Validate backend` | 换行后 `- name: Lint and compile Python` | 切分点跟着下一段的名字 | +| `python -m build` | 整行就是 `python -m build` | 重定向到 `gate-logs/python-build.log`,失败时记下退出码再 `tail -n 200` | 明细写入文件,失败打末 200 行。仍然禁止 `--no-isolation` | + +这两条测试重跑:2 tests,2 pass,0 fail,0 cancelled。 + +## 其他验收 + +| 项 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error,126 条原有 warning,没改 | +| 英文对照 + 隐私标记 | 68 条通过,退出码 0 | +| 快速门里的 `tests/test_run_quality_gate_output.py` | 随快速门一起通过(退出码 0) | +| ruff(改过的两个 Python 文件) | 通过 | +| 前端测试总数 | 仍是 4,844,没有新增测试文件 | +| `next build` | 没跑。这次不改页面,任务书验收口径没有要求 | +| 推送 / 部署 / Gitea 页面 | 没做 | + +快速门跑的时候改写了 `references/oracle/artifacts/pending_packets/` 下 5 个模板文件。那不是本单的改动,收尾时还原,不提交。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 875d1ce2..93cb047b 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -420,4 +420,4 @@ | `TASK-astrologer-rulings-batch4-20261003.md` | `PROGRESS-astrologer-rulings-batch4-20261003.md` | Rath 当前大运进全部领域卡(卡预算 12,000→12,500、总 18,000→18,500,修改 BUG-1160/1161 决定);报告年运强度表改 Tajika 五分法(与年主同口径);校正打分不换,等第六轮 | 已实现待验收(T1–T3 完成) | 分支 `codex/astrologer-rulings-batch4-20261003`(未推送;BUG-1221~1222;校正分数不变、未升版本) | | `TASK-astrologer-rulings-batch5-20261004.md` | `PROGRESS-astrologer-rulings-batch5-20261004.md` | 占星师第六轮:Rath 双主星按 p.43(a)–(e)、罗计尊贵按 Rath Table 9(仅 Narayana 内)、子运方向 p.51 例外与书内分歧标注、Wadiyar 两对标软件特例;年主选不出时不再用 Muntha 主星顶替(对齐上游 03bea6ed);Rath 替换校正 v5 重试算(研究)(BUG 从 1223 起) | 已实现待验收(T2–T5 完成;T1 按红线停下 blocked;T6 研究分支已跑) | 分支 `codex/astrologer-rulings-batch5-20261004`(未推送;BUG-1223~1227;校正分数不变、未升版本);研究分支 `codex/narayana-rath-rectification-trial-20261004`(不合入) | | `TASK-astrologer-rulings-batch6-20261004.md` | `PROGRESS-astrologer-rulings-batch6-20261004.md` | 第七轮裁定(共享仓书面回复,产品采用;问 1 选 A):Rath 版双主星按 p.43 (a)–(e)(BUG-1227 解除 blocked,推翻第五批红线 2 的 Table 17 年数底线);第 5 级宫主度数只倒算计都(p.71 脚注 42);罗计旺陷 ±1 年;同宫两主比经度;BUG 从 1228 起 | 待验收 | 分支 `codex/astrologer-rulings-batch6-20261004`(BUG-1227~1229;校正分数文件未改、未升版本) | -| `TASK-gate-log-volume-20261004.md` | `PROGRESS-gate-log-volume-20261004.md` | 门禁 validate 单步日志 4.4 万行 / 2 MB 网页打不开(run 3170):快速门只打摘要(失败给末 200 行 + 日志文件)、前端测试门禁上 dot + 失败汇总(本机仍 TAP)、拆分 validate 为 7 个 step(产品授权改 workflow,只限拆分与重定向);检查一项不少 | 待领取 | 分支 `codex/gate-log-volume-20261004` | +| `TASK-gate-log-volume-20261004.md` | `PROGRESS-gate-log-volume-20261004.md` | 门禁 validate 单步日志 4.4 万行 / 2 MB 网页打不开(run 3170):快速门只打摘要(失败给末 200 行 + 日志文件)、前端测试门禁上 dot + 失败汇总(本机仍 TAP)、拆分 validate 为 7 个 step(产品授权改 workflow,只限拆分与重定向);检查一项不少 | **已实现待验收**(BUG-1230;未推送、未部署;Gitea 各 step 页面是否打得开留待推送后由产品确认) | 分支 `codex/gate-log-volume-20261004` | diff --git a/frontend/package.json b/frontend/package.json index b75db36b..783c2aa1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "test": "tsx --test tests/*.test.ts tests/*.test.tsx", + "test": "node scripts/run-tests.mjs", "test:db": "tsx --test --test-concurrency=1 tests/database-*.test.ts", "test:deployment": "tsx --test tests/health-deployment.test.ts tests/staging-backend-workflows.test.ts tests/staging-image-manifest.test.ts", "db:migrate": "node scripts/db-migrate.mjs", diff --git a/frontend/scripts/run-tests.mjs b/frontend/scripts/run-tests.mjs new file mode 100644 index 00000000..92e74f21 --- /dev/null +++ b/frontend/scripts/run-tests.mjs @@ -0,0 +1,228 @@ +/** + * Frontend test entry. + * + * Local runs keep the previous TAP stream (`tsx --test tests/*.test.ts tests/*.test.tsx`). + * CI and Gitea (`CI` or `GITEA_ACTIONS`) print Node's dot reporter to stdout and + * write the full TAP report to gate-logs/frontend-tests.tap. The dot reporter's + * trailing "Failed tests" stack dump is not copied to the log: the same failures + * are printed afterwards from the TAP file as the test name plus the error body + * (at most 60 lines each). Stacks stay in the TAP file. + * The process exit code is the test runner's exit code (BUG-995). + */ +import { spawn } from "node:child_process"; +import { mkdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = path.resolve(frontendDir, ".."); +const tsxCli = path.join(frontendDir, "node_modules", "tsx", "dist", "cli.mjs"); +const testGlobs = ["tests/*.test.ts", "tests/*.test.tsx"]; +const failureBlockLimit = 60; + +function envEnabled(name) { + const value = process.env[name]; + if (value == null || value === "") return false; + return !/^(0|false|no|off)$/i.test(value); +} + +function gateMode() { + return envEnabled("CI") || envEnabled("GITEA_ACTIONS"); +} + +function stripAnsi(text) { + return text.replace(/\u001b\[[0-9;]*m/g, ""); +} + +function runRunner(args, { suppressFailedTestsTrailer = false } = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [tsxCli, ...args], { + cwd: frontendDir, + stdio: suppressFailedTestsTrailer ? ["inherit", "pipe", "inherit"] : "inherit", + env: process.env, + windowsHide: true, + }); + if (suppressFailedTestsTrailer && child.stdout) { + let pending = ""; + let dropping = false; + const writeLine = (line) => { + if (dropping) return; + if (stripAnsi(line).trim() === "Failed tests:") { + dropping = true; + return; + } + process.stdout.write(line); + }; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + pending += chunk; + let newline = pending.indexOf("\n"); + while (newline !== -1) { + writeLine(pending.slice(0, newline + 1)); + pending = pending.slice(newline + 1); + newline = pending.indexOf("\n"); + } + }); + child.stdout.on("end", () => { + if (pending) writeLine(pending); + }); + } + child.on("close", (code) => { + resolve(code == null ? 1 : code); + }); + }); +} + +function failureName(raw) { + return raw.trim().replace(/^\d+\s+(?:-\s+)?/, ""); +} + +function isFileSuite(name, block) { + const base = name.replaceAll("\\", "/").split("/").pop() ?? name; + if (/\.test\.tsx?$/.test(base)) return true; + return block.some((line) => /^\s*type:\s*'suite'/.test(line)); +} + +function unquoteYaml(value) { + const match = /^(['"])(.*)\1$/.exec(value); + return match ? match[2] : value; +} + +export function errorBody(block) { + const lines = []; + let inError = false; + let errorIndent = 0; + const push = (line) => { + if (lines.length < failureBlockLimit) lines.push(line); + }; + for (const raw of block) { + if (lines.length >= failureBlockLimit) break; + if (!inError) { + const error = /^(\s*)error:\s*(.*)$/.exec(raw); + if (error) { + const rest = error[2].trim(); + if (rest === "|-" || rest === "|" || rest === ">" || rest === ">-") { + inError = true; + errorIndent = error[1].length + 2; + continue; + } + if (rest) push(unquoteYaml(rest)); + continue; + } + const code = /^\s*code:\s*(.+)$/.exec(raw); + if (code && lines.length > 0) push(`code: ${code[1].trim()}`); + continue; + } + if (raw.trim() === "") { + push(""); + continue; + } + const indent = raw.match(/^(\s*)/)?.[1].length ?? 0; + if (indent >= errorIndent) { + push(raw.slice(errorIndent).replace(/\s+$/, "")); + continue; + } + inError = false; + const code = /^\s*code:\s*(.+)$/.exec(raw); + if (code) push(`code: ${code[1].trim()}`); + } + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + if (lines.length > 0) return lines; + + const fallback = []; + for (const raw of block) { + if (/^\s*(?:duration_ms|type|location|failureType|name|expected|actual|operator|stack|error):/.test(raw)) continue; + const trimmed = raw.trim(); + if (!trimmed || trimmed === "---" || trimmed === "..." || trimmed.startsWith("#")) continue; + fallback.push(trimmed); + if (fallback.length >= failureBlockLimit) break; + } + return fallback; +} + +export function summarizeTap(text) { + const lines = text.split(/\r?\n/); + const counts = {}; + for (const line of lines) { + const match = /^# (tests|pass|fail|cancelled) (\d+)\s*$/.exec(line); + if (match) counts[match[1]] = match[2]; + } + const failures = []; + for (let index = 0; index < lines.length; index += 1) { + const match = /^(\s*)not ok\b(.*)$/.exec(lines[index]); + if (!match) continue; + const block = []; + for (let cursor = index + 1; cursor < lines.length && block.length < failureBlockLimit; cursor += 1) { + const next = lines[cursor]; + if (/^\s*(?:not )?ok\b/.test(next)) break; + if (/^# (?:tests|suites|pass|fail|cancelled|skipped|todo|duration_ms)\b/.test(next)) break; + block.push(next); + } + const name = failureName(match[2]); + if (isFileSuite(name, block)) continue; + failures.push({ name, block: errorBody(block) }); + } + return { counts, failures }; +} + +function printSummary(tapPath) { + const text = readFileSync(tapPath, "utf8"); + const { counts, failures } = summarizeTap(text); + const tests = counts.tests ?? "?"; + const pass = counts.pass ?? "?"; + const fail = counts.fail ?? "?"; + const cancelled = counts.cancelled ?? "?"; + console.log(`# tests ${tests}`); + console.log(`# pass ${pass}`); + console.log(`# fail ${fail}`); + console.log(`# cancelled ${cancelled}`); + console.log("# tap gate-logs/frontend-tests.tap"); + for (const failure of failures) { + console.log(`not ok - ${failure.name}`); + for (const line of failure.block) console.log(line); + } + return failures.length; +} + +function invokedDirectly() { + const entry = process.argv[1]; + if (!entry) return false; + return path.resolve(entry) === fileURLToPath(import.meta.url); +} + +async function runTests() { + const extra = process.argv.slice(2); + if (!gateMode()) { + const code = await runRunner(["--test", ...testGlobs, ...extra]); + process.exit(code); + } + + const logDir = path.join(repoRoot, "gate-logs"); + mkdirSync(logDir, { recursive: true }); + const tapPath = path.join(logDir, "frontend-tests.tap"); + const code = await runRunner( + [ + "--test", + "--test-reporter=dot", + "--test-reporter-destination=stdout", + "--test-reporter=tap", + "--test-reporter-destination", + tapPath, + ...testGlobs, + ...extra, + ], + { suppressFailedTestsTrailer: true }, + ); + try { + printSummary(tapPath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`failed to read ${tapPath}: ${message}`); + process.exit(code === 0 ? 1 : code); + } + process.exit(code); +} + +if (invokedDirectly()) { + await runTests(); +} diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 2d0c69d4..9b163845 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -274,7 +274,7 @@ test("Gitea quality gate validates before publishing an immutable ACR manifest", /validate:[\s\S]*?env:\n\s+GITEA_SHA: \$\{\{ gitea\.sha \}\}\n\s+GITEA_EVENT_NAME: \$\{\{ gitea\.event_name \}\}\n\s+NODE_TOOL_SOURCE_IMAGE: swr\.cn-north-4\.myhuaweicloud\.com\/ddn-k8s\/docker\.io\/library\/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c\n\s+NODE_TOOL_IMAGE: node:22-bookworm-slim[\s\S]*?- name: Prepare pinned Node tooling/, ); const installStep = workflow.match( - /- name: Install dependencies[\s\S]*?(?=\n\s+- name: Validate backend, package, frontend, and database contracts)/, + /- name: Install dependencies[\s\S]*?(?=\n\s+- name: Lint and compile Python)/, )?.[0] ?? ""; assert.match(installStep, /rm -rf -- \.venv/); assert.match(installStep, /python3 -m venv --clear \.venv/); @@ -322,6 +322,40 @@ test("Gitea quality gate validates before publishing an immutable ACR manifest", assert.equal((workflow.match(/npm test --prefix frontend/g) ?? []).length, 1); assert.equal((workflow.match(/npm run lint --prefix frontend/g) ?? []).length, 1); assert.equal((workflow.match(/npm run build --prefix frontend/g) ?? []).length, 1); + assert.doesNotMatch(workflow, /Validate backend, package, frontend, and database contracts/); + assert.doesNotMatch(workflow, /continue-on-error:/); + assertOrder(workflow, [ + "Install dependencies", + "Lint and compile Python", + "Python quality gate (quick)", + "Privacy artifact scan", + "Build Python package", + "Frontend and database tests", + "Frontend lint", + "Frontend production build (non-push)", + ]); + for (const name of [ + "Lint and compile Python", + "Python quality gate (quick)", + "Privacy artifact scan", + "Build Python package", + "Frontend and database tests", + "Frontend lint", + "Frontend production build (non-push)", + ]) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const step = workflow.match(new RegExp(`- name: ${escaped}[\\s\\S]*?(?=\\n\\s+- name: |\\n \\w+:)`))?.[0] ?? ""; + assert.match(step, /set -euo pipefail/, name); + assert.match(step, /export PATH="\$PWD\/\.venv\/bin:\$PATH"/, name); + } + assert.match(workflow, /mkdir -p gate-logs/); + assert.match(workflow, /ruff check scripts\/run_quality_gate\.py/); + assert.match(workflow, /python -m py_compile scripts\/\*\.py jyotish_vedic\/\*\.py mcp_server\.py/); + assert.match( + workflow, + /python scripts\/run_quality_gate\.py \\\n\s+--profile quick --skip-yoga-logic --skip-frontend-runtime/, + ); + assert.match(workflow, /python scripts\/commercial_privacy_artifact_scan\.py --json/); assert.match(workflow, /GITEA_EVENT_NAME: \$\{\{ gitea\.event_name \}\}/); assert.match(workflow, /if \[ "\$GITEA_EVENT_NAME" != "push" \]; then/); assert.match(workflow, /timeout 600 npm run build --prefix frontend -- --webpack/); @@ -478,7 +512,8 @@ test("staging revision discovery falls back when the state file is unreadable", test("quality gate builds the Python package with its declared backend dependencies", () => { const workflow = read(giteaQualityWorkflow); - assert.match(workflow, /^\s+python -m build$/m); + assert.match(workflow, /^\s+python -m build > gate-logs\/python-build\.log 2>&1 \|\| build_status=\$\?$/m); + assert.match(workflow, /tail -n 200 gate-logs\/python-build\.log/); assert.doesNotMatch(workflow, /python -m build --no-isolation/); }); @@ -1635,7 +1670,7 @@ test("bounded npm install distinguishes forced timeout, OOM, and unknown failure const start = workflow.indexOf(' workdir="$(pwd -P)"'); assert.notEqual(start, -1); const script = "set -euo pipefail\n" + workflow.slice(start) - .split("\n - name: Validate backend")[0].replace(/^ {10}/gm, ""); + .split("\n - name: Lint and compile Python")[0].replace(/^ {10}/gm, ""); const root = mkdtempSync(join(tmpdir(), "npm-workflow-test-")); try { const docker = join(root, "docker"); diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 58978158..106e6e71 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -8,9 +8,11 @@ import contextlib import json import os import py_compile +import re import subprocess import sys import tempfile +import time from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -119,6 +121,8 @@ CORE_PYTEST_TARGETS = [ "tests/test_upstream_import_plan.py", # Tracked-file privacy must run in staging's explicit quick test list. "tests/test_repo_privacy_markers.py", + # Summary lines by default; failure keeps the last 200 lines and a full log (BUG-1230). + "tests/test_run_quality_gate_output.py", "tests/test_upstream_git_import_b9a0ef8f.py", "tests/test_interpretation_template_registry.py", "tests/test_vedastro_external_technique_evidence.py", @@ -331,6 +335,98 @@ def tail_text(text: str, *, limit: int = 2400) -> str: return f"...\n{text[-limit:]}" +# Default output is one line per successful step. `--verbose` restores the +# previous passthrough so a local debugging run still shows child output. +VERBOSE = False +_step_ordinal = 0 +_FAILURE_TAIL_LINES = 200 + + +def set_verbose(enabled: bool) -> None: + global VERBOSE + VERBOSE = enabled + + +def reset_output_state() -> None: + global _step_ordinal + _step_ordinal = 0 + + +def format_profile_banner(profile_name: str, profile: dict, *, verbose: bool) -> str: + if verbose: + body = json.dumps(profile, ensure_ascii=False, indent=2) + return f"\n== Quality gate profile: {profile_name} ==\n{body}" + flags = " ".join(f"{key}={str(value).lower()}" for key, value in profile.items()) + return f"quality gate profile={profile_name} {flags}" + + +def _step_name(cmd: list[str], step: str | None) -> str: + if step: + return step + if len(cmd) >= 2 and str(cmd[1]).endswith(".py"): + return str(cmd[1]).replace("\\", "/") + if len(cmd) >= 3 and cmd[1] == "-m": + return str(cmd[2]) + return " ".join(str(part) for part in cmd[:3]) + + +def _slug(name: str) -> str: + slug = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-") + return slug[:80] or "step" + + +def _combine_output(stdout: str, stderr: str) -> str: + parts: list[str] = [] + if stdout: + parts.append(stdout if stdout.endswith("\n") else f"{stdout}\n") + if stderr: + if stdout: + parts.append("===== stderr =====\n") + parts.append(stderr if stderr.endswith("\n") else f"{stderr}\n") + return "".join(parts) + + +def last_output_lines(text: str, count: int = _FAILURE_TAIL_LINES) -> str: + lines = text.splitlines() + if len(lines) <= count: + return "\n".join(lines) + return "\n".join(lines[-count:]) + + +def _write_failure_log(name: str, output: str) -> Path: + directory = ROOT / "gate-logs" + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{_step_ordinal:02d}-{_slug(name)}.log" + path.write_text(output, encoding="utf-8") + return path + + +def _report_captured_failure( + label: str, + cmd: list[str], + returncode: int, + output: str, + *, + cwd: Path, + optional: bool, +) -> bool: + log_path = _write_failure_log(label, output) + relative = log_path.relative_to(ROOT).as_posix() + print(f"✗ {label} exit={returncode}", file=sys.stderr) + tail = last_output_lines(output) + if tail: + print(tail, file=sys.stderr) + print(f"full log: {relative}", file=sys.stderr) + if optional: + print(f"Optional step failed with exit code {returncode}; continuing.") + return False + print( + format_failure_summary(label, cmd, returncode, stdout="", stderr="", cwd=cwd), + file=sys.stderr, + ) + raise SystemExit(returncode) + + def extract_json_payload(text: str) -> dict: text = text.strip() if not text: @@ -386,45 +482,92 @@ def format_failure_summary( return "\n".join(lines) -def run(cmd: list[str], *, optional: bool = False, step: str | None = None, cwd: Path = ROOT) -> bool: - label = step or " ".join(cmd[:2]) - print(f"\n$ {' '.join(cmd)}") - completed = subprocess.run(cmd, cwd=cwd, text=True) +def run(cmd: list[str], *, optional: bool = False, step: str | None = None, cwd: Path | None = None) -> bool: + """Run one gate step. + + Compact mode captures stdout and stderr: success prints one line, failure + prints the last 200 lines and writes the full output under ``gate-logs/``. + ``--verbose`` inherits the child streams, matching the previous behavior. + Timeouts are unchanged: this wrapper does not add a ``timeout=`` of its own, + and ``--test-timeout`` / ``test_timeout_seconds`` stay profile metadata. + """ + global _step_ordinal + _step_ordinal += 1 + if cwd is None: + cwd = ROOT + label = _step_name(cmd, step) + if VERBOSE: + print(f"\n$ {' '.join(cmd)}") + completed = subprocess.run(cmd, cwd=cwd, text=True) + if completed.returncode == 0: + return True + if optional: + print(f"Optional step failed with exit code {completed.returncode}; continuing.") + return False + print( + format_failure_summary(label, cmd, completed.returncode, stdout="", stderr="", cwd=cwd), + file=sys.stderr, + ) + raise SystemExit(completed.returncode) + + started = time.perf_counter() + completed = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, errors="replace") + elapsed = time.perf_counter() - started if completed.returncode == 0: + print(f"✓ {label} {elapsed:.1f}s") return True - if optional: - print(f"Optional step failed with exit code {completed.returncode}; continuing.") - return False - print( - format_failure_summary(label, cmd, completed.returncode, stdout="", stderr="", cwd=cwd), - file=sys.stderr, - ) - raise SystemExit(completed.returncode) + output = _combine_output(completed.stdout or "", completed.stderr or "") + return _report_captured_failure(label, cmd, completed.returncode, output, cwd=cwd, optional=optional) def run_oracle_collection_queue_and_validator() -> None: with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as handle: queue_path = Path(handle.name) try: - print(f"\n$ {' '.join(ORACLE_COLLECTION_QUEUE_CMD)}") - completed = subprocess.run(ORACLE_COLLECTION_QUEUE_CMD, cwd=ROOT, text=True, capture_output=True) - if completed.stdout: - print(completed.stdout, end="" if completed.stdout.endswith("\n") else "\n") - if completed.stderr: - print(completed.stderr, end="" if completed.stderr.endswith("\n") else "\n", file=sys.stderr) - if completed.returncode != 0: - print( - format_failure_summary( - "oracle_collection_queue", + global _step_ordinal + _step_ordinal += 1 + label = "oracle_collection_queue" + if VERBOSE: + print(f"\n$ {' '.join(ORACLE_COLLECTION_QUEUE_CMD)}") + completed = subprocess.run(ORACLE_COLLECTION_QUEUE_CMD, cwd=ROOT, text=True, capture_output=True) + if completed.stdout: + print(completed.stdout, end="" if completed.stdout.endswith("\n") else "\n") + if completed.stderr: + print(completed.stderr, end="" if completed.stderr.endswith("\n") else "\n", file=sys.stderr) + if completed.returncode != 0: + print( + format_failure_summary( + label, + ORACLE_COLLECTION_QUEUE_CMD, + completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ), + file=sys.stderr, + ) + raise SystemExit(completed.returncode) + else: + started = time.perf_counter() + completed = subprocess.run( + ORACLE_COLLECTION_QUEUE_CMD, + cwd=ROOT, + text=True, + capture_output=True, + errors="replace", + ) + elapsed = time.perf_counter() - started + if completed.returncode != 0: + output = _combine_output(completed.stdout or "", completed.stderr or "") + _report_captured_failure( + label, ORACLE_COLLECTION_QUEUE_CMD, completed.returncode, - stdout=completed.stdout, - stderr=completed.stderr, - ), - file=sys.stderr, - ) - raise SystemExit(completed.returncode) - queue_path.write_text(completed.stdout, encoding="utf-8") + output, + cwd=ROOT, + optional=False, + ) + print(f"✓ {label} {elapsed:.1f}s") + queue_path.write_text(completed.stdout or "", encoding="utf-8") validator_cmd = [part if part != "{queue_file}" else str(queue_path) for part in ORACLE_EVIDENCE_VALIDATOR_CMD] run(validator_cmd, step="oracle_evidence_validator") finally: @@ -506,19 +649,25 @@ def run_vedastro_live_smoke() -> None: def compile_targets() -> None: - print("\n== Compile core Python files ==") + if VERBOSE: + print("\n== Compile core Python files ==") targets: list[Path] = [] for directory in COMPILE_DIRS: targets.extend(sorted(directory.glob("*.py"))) targets.extend(EXTRA_COMPILE_TARGETS) seen: set[Path] = set() + started = time.perf_counter() for target in targets: if target in seen or not target.exists(): continue seen.add(target) - print(f"compile {target.relative_to(ROOT)}") + if VERBOSE: + print(f"compile {target.relative_to(ROOT)}") py_compile.compile(str(target), doraise=True) + if not VERBOSE: + elapsed = time.perf_counter() - started + print(f"✓ py_compile {len(seen)} files {elapsed:.1f}s") def validate_json_files() -> None: @@ -576,12 +725,14 @@ def main() -> int: help="Fail an individual pytest call after SECONDS; defaults to the selected profile boundary.", ) parser.add_argument("--require-external-parity", action="store_true", help="Fail the release gate unless the three-engine raw parity manifest passes.") + parser.add_argument("--verbose", action="store_true", help="Stream every command and its output. The default prints one summary line per successful step.") args = parser.parse_args() profile = run_profile(args) + set_verbose(args.verbose) + reset_output_state() os.environ.setdefault("PYTHONPATH", str(ROOT / "scripts")) - print(f"\n== Quality gate profile: {args.profile} ==") - print(json.dumps(profile, ensure_ascii=False, indent=2)) + print(format_profile_banner(args.profile, profile, verbose=args.verbose)) if args.profile == "runtime-truth": for target in [ ROOT / "scripts" / "jyotish_api_server.py", diff --git a/tests/test_run_quality_gate_output.py b/tests/test_run_quality_gate_output.py new file mode 100644 index 00000000..c3a4abb4 --- /dev/null +++ b/tests/test_run_quality_gate_output.py @@ -0,0 +1,144 @@ +"""Output contract for the quality-gate runner (BUG-1230). + +The gate used to stream every child report into one CI step. Compact mode +prints a single success line. A failing step keeps its exit code, the last +200 lines, and a full log under gate-logs/. --verbose streams the child +again and does not capture. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import run_quality_gate as gate # noqa: E402 + + +def setup_function() -> None: + gate.set_verbose(False) + gate.reset_output_state() + + +def test_quick_banner_is_one_line() -> None: + line = gate.format_profile_banner("quick", gate.QUALITY_GATE_PROFILES["quick"], verbose=False) + assert "\n" not in line + assert "profile=quick" in line + assert "test_timeout_seconds=90" in line + assert "skip_frontend_runtime=false" in line + + +def test_verbose_banner_keeps_indented_profile_json() -> None: + text = gate.format_profile_banner("quick", gate.QUALITY_GATE_PROFILES["quick"], verbose=True) + assert "== Quality gate profile: quick ==" in text + assert '"test_timeout_seconds": 90' in text + assert text.count("\n") > 3 + + +def test_last_output_lines_keeps_the_tail() -> None: + text = "\n".join(f"n{index}" for index in range(201)) + tail = gate.last_output_lines(text).splitlines() + assert tail[0] == "n1" + assert tail[-1] == "n200" + assert len(tail) == 200 + + +def test_compact_hides_child_stdout(capfd, tmp_path: Path) -> None: + script = tmp_path / "child_marker.py" + script.write_text("print('hidden-child-marker')\n", encoding="utf-8") + assert gate.run([sys.executable, str(script)]) is True + out, _err = capfd.readouterr() + assert "hidden-child-marker" not in out + assert "✓" in out + assert out.strip().endswith("s") + + +def test_verbose_streams_child_output(capfd) -> None: + gate.set_verbose(True) + assert gate.run([sys.executable, "-c", "print('verbose-child-marker')"]) is True + out, _err = capfd.readouterr() + assert "$" in out + assert "verbose-child-marker" in out + assert "✓" not in out + + +def test_capture_does_not_invent_a_timeout(monkeypatch, capfd) -> None: + seen: dict = {} + + def fake_run(cmd, **kwargs): + seen.update(kwargs) + return subprocess.CompletedProcess(cmd, 0, stdout="ignored\n", stderr="") + + monkeypatch.setattr(gate.subprocess, "run", fake_run) + assert gate.run([sys.executable, "scripts/audit_capabilities.py", "--mode", "validate"]) is True + assert seen.get("capture_output") is True + assert "timeout" not in seen + out, _err = capfd.readouterr() + assert "ignored" not in out + assert "scripts/audit_capabilities.py" in out + + +def test_failure_shows_mark_tail_and_log(monkeypatch, tmp_path: Path, capfd) -> None: + monkeypatch.setattr(gate, "ROOT", tmp_path) + stdout = "".join(f"line-{index:03d}\n" for index in range(250)) + + def fake_run(cmd, **kwargs): + assert kwargs.get("capture_output") is True + return subprocess.CompletedProcess(cmd, 3, stdout=stdout, stderr="err-tail\n") + + monkeypatch.setattr(gate.subprocess, "run", fake_run) + with pytest.raises(SystemExit) as caught: + gate.run([sys.executable, "scripts/audit_capabilities.py"]) + assert caught.value.code == 3 + _out, err = capfd.readouterr() + assert "✗ scripts/audit_capabilities.py exit=3" in err + assert "line-249" in err + assert "line-000" not in err + assert "err-tail" in err + assert "full log: gate-logs/" in err + logs = list((tmp_path / "gate-logs").glob("*.log")) + assert len(logs) == 1 + body = logs[0].read_text(encoding="utf-8") + assert "line-000" in body + assert "line-249" in body + assert "err-tail" in body + assert len(err.splitlines()) < 250 + + +def test_real_failing_command_keeps_its_exit_code(monkeypatch, tmp_path: Path, capfd) -> None: + monkeypatch.setattr(gate, "ROOT", tmp_path) + with pytest.raises(SystemExit) as caught: + gate.run([sys.executable, "-c", "import sys; print('boom-line'); sys.exit(4)"]) + assert caught.value.code == 4 + _out, err = capfd.readouterr() + assert "exit=4" in err + assert "boom-line" in err + assert "full log: gate-logs/" in err + assert (tmp_path / "gate-logs").is_dir() + + +def test_optional_failure_continues(monkeypatch, tmp_path: Path, capfd) -> None: + monkeypatch.setattr(gate, "ROOT", tmp_path) + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 7, stdout="nope\n", stderr="") + + monkeypatch.setattr(gate.subprocess, "run", fake_run) + assert gate.run([sys.executable, "scripts/validate_logic_v2.py"], optional=True) is False + out, err = capfd.readouterr() + assert "exit=7" in err + assert "continuing" in f"{out}\n{err}" + + +def test_compact_compile_is_one_line(capfd) -> None: + gate.compile_targets() + out, _err = capfd.readouterr() + lines = [line for line in out.splitlines() if line.strip()] + assert lines == [line for line in lines if line.startswith("✓ py_compile ")] + assert len(lines) == 1 + assert "files" in lines[0]