@tea-agent/loop-agent 0.16.23 → 0.16.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -13
- package/dist/cli/command-definitions.js +13 -0
- package/dist/cli/program.js +4 -0
- package/dist/commands/coverage-report.js +50 -0
- package/dist/executors/shell-presets.js +27 -26
- package/dist/executors/shell-verification.js +2 -0
- package/dist/task/config-types.js +15 -3
- package/dist/workflows/dag/backend-test-analysis-contract.js +21 -8
- package/dist/workflows/dag/backend-test-coverage-contract.js +202 -0
- package/dist/workflows/dag/backend-test-execution-contract.js +84 -18
- package/dist/workflows/dag/backend-test-stability-contract.js +57 -0
- package/dist/workflows/dag/dynamic-runtime/map.js +129 -22
- package/dist/workflows/dag/init-hybrid.js +374 -272
- package/dist/workflows/dag/l5-report-metrics.js +36 -0
- package/dist/workflows/dag/types.js +7 -0
- package/docs/architecture/evolution.md +3 -1
- package/docs/local-development-environment.md +4 -0
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +2 -0
- package/docs/templates/backend-test-dag.json +40 -7
- package/docs/templates/backend-test-dag.retrospect.prompt.md +36 -7
- package/docs/templates/frontend-test-case-checklist.md +25 -0
- package/docs/templates/frontend-test-dag.json +4 -3
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,20 +1,9 @@
|
|
|
1
|
-
# 更新日志
|
|
2
|
-
|
|
3
1
|
## [Unreleased]
|
|
4
2
|
|
|
5
|
-
### 修复
|
|
6
|
-
|
|
7
|
-
- Backend-test 唯一 JSON object 提取器在扫描裸对象前会等长忽略完整 Markdown 行内代码中的对象形状示例(如 `{error:{code,message,type}}`);唯一真实 envelope 可正常物化,多个真实 JSON、fence 外额外对象与未闭合对象继续 fail-closed。
|
|
8
|
-
|
|
9
|
-
- Backend-test 节点 10 `backend-test-semantic-gate-shell` 不再用 Markdown `first-verdict-line` 读取 raw Pi 输出;改为同一 `semantic-initial` pipeline 按 task id 只读 `contracts/backend-test-semantic-review.json`,仅 `verdict=pass` 放行 pytest,`request-revision`/缺失/malformed 清晰 fail-closed;节点 09 仍只负责 materialize+trace,15 节点与单次 pytest 不变。
|
|
10
|
-
- 公开 Case Manifest schema(`docs/templates/backend-test-case-manifest.schema.json`)对齐 materializer dual-scope coverage:`coverageSummary` 在 `additionalProperties:false` 下 required 并声明 `global`/`inScope`(`total`/`covered`/`gapped`/`ratio`)与 `crossDomainGapCount`;回归测试以无依赖轻量 contract 锁定 schema↔canonical 一致性;不改 materializer/15 节点。
|
|
11
|
-
- Backend-test Case Manifest materializer 将模型顶层 `coverageSummary` 视为非权威:secret 扫描后剥离,再 strict parse 其余字段,并始终 `computeCaseManifestCoverageSummary` 写入 canonical summary;my-webapp 自定义/部分 summary 不再导致 invalid-output;prompt 禁止模型输出 coverageSummary;15 节点与单次 pytest 不变。
|
|
12
|
-
- Backend-test 裸 object 路径现消费 scanner 的 `unclosedObject`:当文本含一个完整裸 JSON object,且其前或后仍有未闭合裸 object 时一律 fail-closed,不再因 `spans.length === 1` 错误接受完整对象;Semantic Review / Classification / contracts envelope 共用该行为。
|
|
13
|
-
- Backend-test contracts composite pipeline (`validate-backend-test-contracts-shell`) 使用唯一确定性 envelope 提取:接受 pure JSON、唯一 fenced json、prose+唯一 fence、prose+唯一括号平衡裸 object;对无 JSON、多候选、未闭合、非 object、缺 `analysis`/`execution` fail-closed。提取后仍走 Analysis v2 / Execution v1 schema、source-binding 与 secret 门禁;不引入全局 Pi output rewrite,不将 `invalid-output` 加入自动 retry,不改 15 节点拓扑。
|
|
14
|
-
- Backend-test Semantic Review(08→09)与 Classification(12→13)materializer 复用同一 `extractUniqueJsonObject` scanner:接受 pure / whole-fence / prose+唯一 fence / prose+唯一裸 object;多候选、未闭合、非 object 与 strict schema 错误继续 fail-closed;不改 Case Manifest、Pi SDK/retry 或 15 节点拓扑。
|
|
15
|
-
|
|
16
3
|
### 改进
|
|
17
4
|
|
|
5
|
+
- GitHub Actions CI 降低私有仓分钟消耗:合并为单 job(避免两次 `npm ci`)、同分支/`PR` concurrency 取消旧 run、Draft PR 跳过完整 CI,且仅忽略 `docs/reports/**`、`docs/progress/**`、`docs/design/archive/**`、`docs/exec-plans/completed/**` 等纯运营文档路径;治理文档与代码门禁仍全量跑。
|
|
6
|
+
- 明确 **Compatibility / Operator Assist**:openCode 等主会话只编排已发布 `loop-agent` / `agent-worker` CLI 与只读诊断,不得绕过 CLI 直接改业务实现,失败只走 doctor / reconcile / human gate / CLI 重跑。`loop-agent init` 写入的 `AGENTS.md` managed block、包内 skills 与 website 快速开始/治理说明已对齐;架构决策见 `docs/decisions/0005-governed-operator-surface.md`(accepted)。已有目标项目用 `loop-agent init check-update` / `init update --apply-safe` 或 `init reconcile` 刷新 managed block。
|
|
18
7
|
- 前端测试 DAG 收紧体验:execution preflight 仅硬校验绝对非生产 `baseUrl`;用例 map 缩短为优先用 `playwright-cli` 执行;复盘合并执行证据审查且不依赖 outcome=pass,失败也能出报告。
|
|
19
8
|
- 前端测试用例生成现在会解析明确 `baseUrl`:优先读取任务源 `config.md` 中的前端 URL,缺失时默认 `http://localhost:5173`,并写入 RAG `context.md`;生成命令不得再保留 `<base-url>` 占位符。
|
|
20
9
|
- 前端浏览器测试 DAG 的复盘报告现写入 `testcase/frontend/reports/**`,不再要求 `docs/test-reports/**` 权限;任务执行约束可以安全禁止整个 `docs/**`,同时仍保留可审计的测试资产。
|
|
@@ -23,9 +12,22 @@
|
|
|
23
12
|
- Backend Test Analysis、Case Manifest、Semantic Review 与 Classification 的严格 JSON 契约进一步对齐,生成节点明确字段白名单、`sourceBinding` 与 evidence gap 约束,避免模型自定义字段导致确定性门禁失败。
|
|
24
13
|
- 新增升级后 init surface notifier:普通安全仓库命令结束后,当目标项目存在有效 `.harness/init-surface.json` 且 `controllerVersion` 与当前包版本不同时自动检测。surface 缺失/损坏、源仓库、未初始化目录与版本一致时直接跳过;`checkInitUpdate().ok` 时仅在无活跃运行时静默刷新 state;非 TTY 只向 stderr 输出 `loop-agent init reconcile --repo-root ...` 提示且不写入目标;TTY 且无 human decisions、无活跃 DAG/Worker 时可经明确 `y/yes` 同意后应用 deterministic safe actions;只有 model merge 时直接输出有边界指引,不询问“应用 0 个动作”。notifier 遵循全局 `--repo-root`、不写 stdout、不改变原命令退出码,CI、禁用环境变量、help/version、`--json`/`--markdown`、`init`、`run-dag`、`dag`、`loop`、`delegate`、`pi-prompt`、`cursor-prompt` 均跳过。
|
|
25
14
|
- 新增统一命令 `loop-agent init reconcile --repo-root <target>`:surface 缺失返回 `needs-baseline` 且零写入,存在 human decisions 返回 `needs-human-decision` 且零写入,活跃 DAG/Worker 或 Worker 状态无法确认时返回 `blocked-active-runtime` 且零写入;其余情况复用现有 `applyInitUpdate({ applySafe: true })` 并复查返回 `clean`/`needs-model-merge`/`needs-safe-update`,支持 `--json`/`--markdown`。
|
|
15
|
+
- 测试报告新增 L-5 指标消费与 Python coverage.py JSON、Java JaCoCo XML 的统一 Code Coverage v1 报告入口,覆盖通过率、AC/自动化覆盖率、稳定性、失败原因、缺陷、风险和回归建议;缺失覆盖率证据保持 `unavailable`,不改变 backend-test outcome gate。
|
|
16
|
+
- 现有 backend-test DAG 在 retrospective 之后新增独立的 `l5-metrics-pi` 指标节点;它基于前序测试报告和 run-owned 证据输出 `L-5 ready/not-ready`,不创建独立 L-5 DAG,也不改变 Result v1 outcome gate。
|
|
17
|
+
- 后端测试 DAG 从 38 个收敛为 24 个真实顶层节点;使用 fail-closed `runIf` 和复合 Shell capability 减少调度,同时保留双合同、Manifest、语义评审、JUnit、Result、分类、修复安全、追踪和最终 outcome 证据。三条可选修订/修复分支仍各最多执行一次。
|
|
18
|
+
- Backend Test Analysis、Case Manifest、Semantic Review 与 Classification 的严格 JSON 契约进一步对齐,生成和修订节点明确字段白名单、`sourceBinding` 与 evidence gap 约束,避免模型自定义字段导致确定性门禁失败。
|
|
26
19
|
|
|
27
20
|
### 修复
|
|
28
21
|
|
|
22
|
+
- Backend-test Analysis v2 intake 现在可确定性归一化模型常见的外部依赖 `type` / `target` 与风险 `type` 字段:dependency `target` 映射为 canonical `name`,扩展字段不落盘;dependency/risk 仍使用类别化白名单,未知字段和 `risks[].target` 继续 fail-closed,避免节点 02 因合法近似形态过早中断。
|
|
23
|
+
|
|
24
|
+
- Backend-test 唯一 JSON object 提取器在扫描裸对象前会等长忽略完整 Markdown 行内代码中的对象形状示例(如 `{error:{code,message,type}}`);唯一真实 envelope 可正常物化,多个真实 JSON、fence 外额外对象与未闭合对象继续 fail-closed。
|
|
25
|
+
- Backend-test 节点 10 `backend-test-semantic-gate-shell` 不再用 Markdown `first-verdict-line` 读取 raw Pi 输出;改为同一 `semantic-initial` pipeline 按 task id 只读 `contracts/backend-test-semantic-review.json`,仅 `verdict=pass` 放行 pytest,`request-revision`/缺失/malformed 清晰 fail-closed;节点 09 仍只负责 materialize+trace,15 节点与单次 pytest 不变。
|
|
26
|
+
- 公开 Case Manifest schema(`docs/templates/backend-test-case-manifest.schema.json`)对齐 materializer dual-scope coverage:`coverageSummary` 在 `additionalProperties:false` 下 required 并声明 `global`/`inScope`(`total`/`covered`/`gapped`/`ratio`)与 `crossDomainGapCount`;回归测试以无依赖轻量 contract 锁定 schema↔canonical 一致性;不改 materializer/15 节点。
|
|
27
|
+
- Backend-test Case Manifest materializer 将模型顶层 `coverageSummary` 视为非权威:secret 扫描后剥离,再 strict parse 其余字段,并始终 `computeCaseManifestCoverageSummary` 写入 canonical summary;my-webapp 自定义/部分 summary 不再导致 invalid-output;prompt 禁止模型输出 coverageSummary;15 节点与单次 pytest 不变。
|
|
28
|
+
- Backend-test 裸 object 路径现消费 scanner 的 `unclosedObject`:当文本含一个完整裸 JSON object,且其前或后仍有未闭合裸 object 时一律 fail-closed,不再因 `spans.length === 1` 错误接受完整对象;Semantic Review / Classification / contracts envelope 共用该行为。
|
|
29
|
+
- Backend-test contracts composite pipeline (`validate-backend-test-contracts-shell`) 使用唯一确定性 envelope 提取:接受 pure JSON、唯一 fenced json、prose+唯一 fence、prose+唯一括号平衡裸 object;对无 JSON、多候选、未闭合、非 object、缺 `analysis`/`execution` fail-closed。提取后仍走 Analysis v2 / Execution v1 schema、source-binding 与 secret 门禁;不引入全局 Pi output rewrite,不将 `invalid-output` 加入自动 retry,不改 15 节点拓扑。
|
|
30
|
+
- Backend-test Semantic Review(08→09)与 Classification(12→13)materializer 复用同一 `extractUniqueJsonObject` scanner:接受 pure / whole-fence / prose+唯一 fence / prose+唯一裸 object;多候选、未闭合、非 object 与 strict schema 错误继续 fail-closed;不改 Case Manifest、Pi SDK/retry 或 15 节点拓扑。
|
|
29
31
|
- 节点检查器「执行过程」中的事件时间戳现在同时显示本地日期与 24 小时制 `HH:mm:ss`(例如 `2026/7/16 08:09:10`),不再被裁掉时间部分;缺少时间戳时仍显示「未记录时间」,事件字段优先级(`timestamp` → `at` → `recordedAt`)保持不变。
|
|
30
32
|
- `frontend-test` 的 case review 现在是 fail-closed browser gate:只有 `VERDICT: pass` 才能物化 manifest 并启动动态 browser map;生成的 case 固定使用默认 browser session,要求每个子场景的 fixture/UI reset 和 fresh snapshot,并由确定性节点校验每个 case 的 `execution.md`、`case-result.json`、`caseId`、`status`、`evidencePaths` 及 blocked `blockedReason`。
|
|
31
33
|
- 前端实现计划/修订节点会注入当前包内权威 `frontend-implementation-contract-v1` Schema 与固定 source binding,避免模型猜测字段导致契约门禁失败。
|
|
@@ -37,6 +39,24 @@
|
|
|
37
39
|
- Pi SDK 执行长推理或大段结构化输出时不再把高频流式增量事件无界累积到内存;同一响应在多个生命周期事件中重复出现的 Token 用量只统计一次,避免 `Invalid string length` 和成本数据虚高。
|
|
38
40
|
- 后端测试复合执行节点继续保持 clean environment、失败分类和 fail-closed outcome,并为唯一 JUnit/initial Result、canonical Result、traceability 与 Observe 投影保留结构化运行证据。
|
|
39
41
|
|
|
42
|
+
## [0.16.25] - 2026-07-22
|
|
43
|
+
|
|
44
|
+
### 修复
|
|
45
|
+
|
|
46
|
+
- Backend-test pytest preflight 与执行体改为 `&&` fail-closed:preflight 失败不再因 `;` 继续跑 pytest(I27 曾出现 preflight 报 `WELCOME_BASE_URL`/testRoot 错配仍执行用例)。
|
|
47
|
+
- 本地 `npm start` / `node server.js` 类 `managed-command` 在 materialize 时降级为 `in-process`,清空 requiredEnv、seed server fixture,并把 scout `testRoot` 收敛到冻结根 `testcase/`;generate-pytest 提示要求 fixture 内启动服务,不再依赖 clean-env 注入 base URL。
|
|
48
|
+
|
|
49
|
+
## [0.16.24] - 2026-07-21
|
|
50
|
+
|
|
51
|
+
### 改进
|
|
52
|
+
|
|
53
|
+
- frontend-test 默认改为**短链 RAG**:`reviewMode` 默认 `off`(机械 `frontend-case-checklist-shell` 放行,不再默认双轮 LLM review 硬门);`strictOutcomeGate` 默认 `false`(outcome 全绿门 opt-in)。**流程验收=最终 retrospect 报告**,不以 case 100% 通过为条件。map 仅在 `dynamicExpansion.tolerateChildFailures=true`(frontend-test 默认开启)时将 child auth/timeout/executor 错误落 case-level blocked/failed evidence 且不炸 map closeout;其它 map 仍 fail-closed。
|
|
54
|
+
- Shell `verdictGate` 改为纯 `node -e` 单次调用,降低 Windows Git Bash 复合脚本崩溃风险。
|
|
55
|
+
|
|
56
|
+
### 修复
|
|
57
|
+
|
|
58
|
+
- Windows DAG Shell/verification 的 clean environment 现在保留 `APPDATA`,使 Python 3.12/pip 可按系统规则发现用户级 `site-packages` 与其中的 pytest;仍不会继承完整宿主环境或透传未授权 token/credential,backend-test 无需硬编码用户路径。
|
|
59
|
+
|
|
40
60
|
## [0.16.23] - 2026-07-21
|
|
41
61
|
|
|
42
62
|
### 修复
|
|
@@ -3,6 +3,7 @@ import { runDocsArchive } from "../commands/docs-archive.js";
|
|
|
3
3
|
import { runDocsAudit } from "../commands/docs-audit.js";
|
|
4
4
|
import { runEval } from "../commands/eval.js";
|
|
5
5
|
import { runCoverageAudit } from "../commands/coverage-audit.js";
|
|
6
|
+
import { runCoverageReport } from "../commands/coverage-report.js";
|
|
6
7
|
import { runExamples } from "../commands/examples.js";
|
|
7
8
|
import { runCloseout } from "../commands/closeout.js";
|
|
8
9
|
import { runHandoffCheck } from "../commands/handoff-check.js";
|
|
@@ -101,6 +102,7 @@ const PLAN_SUBCOMMANDS = ["list", "create", "complete", "check"];
|
|
|
101
102
|
const SPINE_SUBCOMMANDS = ["audit"];
|
|
102
103
|
const HANDOFF_SUBCOMMANDS = ["check", "coverage"];
|
|
103
104
|
const HANDOFF_USAGE = "handoff <check|coverage> [taskId]";
|
|
105
|
+
const COVERAGE_SUBCOMMANDS = ["report"];
|
|
104
106
|
const REFERENCE_SUBCOMMANDS = ["index"];
|
|
105
107
|
const STUDY_SUBCOMMANDS = ["init"];
|
|
106
108
|
const WORKTREE_SUBCOMMANDS = ["create", "list", "remove"];
|
|
@@ -378,6 +380,17 @@ export const COMMAND_DEFINITIONS = [
|
|
|
378
380
|
throw new Error(`usage: ${HANDOFF_USAGE}`);
|
|
379
381
|
},
|
|
380
382
|
},
|
|
383
|
+
{
|
|
384
|
+
name: "coverage",
|
|
385
|
+
adapter: "required",
|
|
386
|
+
tier: "operator",
|
|
387
|
+
intent: "Normalize Python coverage.py JSON or Java JaCoCo XML for test reports.",
|
|
388
|
+
usage: "coverage report --language python|java --input <path> [options]",
|
|
389
|
+
subcommands: [...COVERAGE_SUBCOMMANDS],
|
|
390
|
+
handler: async ({ repoRoot, subcommand, rest }) => {
|
|
391
|
+
await runCoverageReport(repoRoot, [subcommand, ...rest].filter(Boolean));
|
|
392
|
+
},
|
|
393
|
+
},
|
|
381
394
|
{
|
|
382
395
|
name: "goal",
|
|
383
396
|
adapter: "required",
|
package/dist/cli/program.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readPackageVersion } from "../shared/package-metadata.js";
|
|
|
5
5
|
import { resolveAdapter } from "../adapters/index.js";
|
|
6
6
|
import { runCloseout } from "../commands/closeout.js";
|
|
7
7
|
import { runCoverageAudit } from "../commands/coverage-audit.js";
|
|
8
|
+
import { runCoverageReport } from "../commands/coverage-report.js";
|
|
8
9
|
import { parseCursorPromptArgs, printCursorPromptUsage, runCursorPrompt, } from "../commands/cursor-prompt.js";
|
|
9
10
|
import { runDagApprove } from "../commands/dag-approve.js";
|
|
10
11
|
import { runDagFinalVerification } from "../commands/dag-final-verification.js";
|
|
@@ -205,6 +206,9 @@ async function runCommanderAction(ctx, command, subcommand, rest) {
|
|
|
205
206
|
return;
|
|
206
207
|
}
|
|
207
208
|
throw new Error("usage: handoff <check|coverage> [taskId]");
|
|
209
|
+
case "coverage":
|
|
210
|
+
await runCoverageReport(ctx.repoRoot, compactArgs([subcommand, ...rest]));
|
|
211
|
+
return;
|
|
208
212
|
case "goal":
|
|
209
213
|
await runGoal(ctx.repoRoot, subcommand, rest);
|
|
210
214
|
return;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { formatCoverageMarkdown, readCoverageArtifact } from "../workflows/dag/backend-test-coverage-contract.js";
|
|
4
|
+
function value(args, flag) {
|
|
5
|
+
const index = args.findIndex((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
6
|
+
if (index < 0)
|
|
7
|
+
return undefined;
|
|
8
|
+
const token = args[index];
|
|
9
|
+
return token.startsWith(`${flag}=`) ? token.slice(flag.length + 1) : args[index + 1];
|
|
10
|
+
}
|
|
11
|
+
function values(args, flag) {
|
|
12
|
+
return args.flatMap((arg, index) => {
|
|
13
|
+
if (arg === flag)
|
|
14
|
+
return args[index + 1] ? [args[index + 1]] : [];
|
|
15
|
+
return arg.startsWith(`${flag}=`) ? [arg.slice(flag.length + 1)] : [];
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export async function runCoverageReport(repoRoot, args) {
|
|
19
|
+
if (args[0] !== "report" || args.includes("--help")) {
|
|
20
|
+
console.log("usage: coverage report --language python|java --input <coverage.json|jacoco.xml> [--output <path>] [--json|--markdown] --requirement-id <AC-id> --source-scope <path[,path]> [--commit <sha>] [--expected-sha256 <sha256>]");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const input = value(args, "--input");
|
|
24
|
+
const language = value(args, "--language");
|
|
25
|
+
if (!input || (language !== "python" && language !== "java"))
|
|
26
|
+
throw new Error("coverage report requires --language python|java and --input");
|
|
27
|
+
const expectedSha = value(args, "--expected-sha256");
|
|
28
|
+
const sourcePaths = (value(args, "--source-scope") ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
29
|
+
const contract = await readCoverageArtifact(path.resolve(repoRoot, input), {
|
|
30
|
+
sourceScope: { requirementIds: values(args, "--requirement-id"), paths: sourcePaths },
|
|
31
|
+
commitSha: value(args, "--commit") ?? null,
|
|
32
|
+
toolVersion: value(args, "--tool-version") ?? null,
|
|
33
|
+
artifactPath: input,
|
|
34
|
+
});
|
|
35
|
+
if ((language === "python" && contract.language !== "python") || (language === "java" && contract.language !== "java"))
|
|
36
|
+
throw new Error("coverage artifact format does not match --language");
|
|
37
|
+
if (expectedSha && expectedSha !== contract.artifact.sha256)
|
|
38
|
+
throw new Error(`artifact SHA-256 mismatch: expected ${expectedSha}, got ${contract.artifact.sha256}`);
|
|
39
|
+
const markdown = args.includes("--markdown");
|
|
40
|
+
const output = value(args, "--output");
|
|
41
|
+
const rendered = markdown ? formatCoverageMarkdown(contract) : `${JSON.stringify(contract, null, 2)}\n`;
|
|
42
|
+
if (output) {
|
|
43
|
+
const outputPath = path.resolve(repoRoot, output);
|
|
44
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
45
|
+
await writeFile(outputPath, rendered, "utf8");
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
process.stdout.write(rendered);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -12,13 +12,6 @@ export function expandShellPreset(preset) {
|
|
|
12
12
|
function escapeShellSingleQuoted(value) {
|
|
13
13
|
return value.replace(/'/g, "'\\''");
|
|
14
14
|
}
|
|
15
|
-
const VERDICT_GATE_NODE_PARSER_FIRST_NON_EMPTY = 'node -e \'const fs=require("fs"); const raw=JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const text=raw.assistantText ?? raw.stdout ?? ""; const line=String(text).split(/\\r?\\n/).find((value) => value.trim().length > 0) ?? ""; process.stdout.write(line);\' "${FILE}" || true';
|
|
16
|
-
const VERDICT_GATE_NODE_PARSER_FIRST_VERDICT_LINE = 'node -e \'const fs=require("fs"); const raw=JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const text=raw.assistantText ?? raw.stdout ?? ""; const normalize=(value) => { const trimmed=String(value).trim(); const emphasized=trimmed.match(/^\\*{1,3}\\s*(VERDICT:[^*]+?)\\s*\\*{1,3}$/); return (emphasized ? emphasized[1] : trimmed).trim(); }; const line=String(text).split(/\\r?\\n/).map(normalize).find((value) => /^VERDICT:/.test(value)) ?? ""; process.stdout.write(line);\' "${FILE}" || true';
|
|
17
|
-
function verdictGateNodeParser(lineMode) {
|
|
18
|
-
return lineMode === "first-verdict-line"
|
|
19
|
-
? VERDICT_GATE_NODE_PARSER_FIRST_VERDICT_LINE
|
|
20
|
-
: VERDICT_GATE_NODE_PARSER_FIRST_NON_EMPTY;
|
|
21
|
-
}
|
|
22
15
|
/**
|
|
23
16
|
* Build a single shell command that reads `$HARNESS_DAG_RUN_DIR/<fromNodeId>.json`
|
|
24
17
|
* (injected by the shell executor from runtime meta), parses a single candidate
|
|
@@ -27,33 +20,41 @@ function verdictGateNodeParser(lineMode) {
|
|
|
27
20
|
* - first-non-empty: first non-empty line
|
|
28
21
|
* - first-verdict-line: first line matching /^VERDICT:/ after trim and optional whole-line Markdown emphasis normalization
|
|
29
22
|
*
|
|
30
|
-
*
|
|
23
|
+
* Implemented as one pure `node -e` invocation (no bash state/subshell chaining) so Windows
|
|
24
|
+
* Git Bash cannot crash the gate with STATUS_DLL_INIT_FAILED on long compound scripts.
|
|
31
25
|
*/
|
|
32
26
|
export function buildVerdictGateShellCommand(gate) {
|
|
33
27
|
const gateLabel = gate.label ?? `${gate.fromNodeId} verdict`;
|
|
34
28
|
const lineMode = gate.lineMode ?? "first-non-empty";
|
|
35
|
-
const parser = verdictGateNodeParser(lineMode);
|
|
36
29
|
const candidateNodeIds = [
|
|
37
30
|
gate.fromNodeId,
|
|
38
31
|
...(gate.fallbackFromNodeIds ?? []).filter((id) => id !== gate.fromNodeId),
|
|
39
32
|
];
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.join("
|
|
56
|
-
|
|
33
|
+
const config = {
|
|
34
|
+
label: gateLabel,
|
|
35
|
+
lineMode,
|
|
36
|
+
accept: gate.accept,
|
|
37
|
+
candidateNodeIds,
|
|
38
|
+
};
|
|
39
|
+
const configLiteral = escapeShellSingleQuoted(JSON.stringify(config));
|
|
40
|
+
const program = [
|
|
41
|
+
'const fs=require("fs");',
|
|
42
|
+
'const path=require("path");',
|
|
43
|
+
'const cfg=JSON.parse(process.argv[1]);',
|
|
44
|
+
'const runDir=process.env.HARNESS_DAG_RUN_DIR;',
|
|
45
|
+
'if(!runDir){console.error("missing HARNESS_DAG_RUN_DIR for "+cfg.label+" gate");process.exit(1);}',
|
|
46
|
+
'let file="";',
|
|
47
|
+
'for(const id of cfg.candidateNodeIds){const candidate=path.join(runDir,id+".json");if(fs.existsSync(candidate)){file=candidate;break;}}',
|
|
48
|
+
'if(!file){console.error("missing "+cfg.label+" JSON output (tried: "+cfg.candidateNodeIds.join(", ")+")");process.exit(1);}',
|
|
49
|
+
'const raw=JSON.parse(fs.readFileSync(file,"utf8"));',
|
|
50
|
+
'const text=String(raw.assistantText ?? raw.stdout ?? "");',
|
|
51
|
+
'const normalize=(value)=>{const trimmed=String(value).trim();const emphasized=trimmed.match(/^\\*{1,3}\\s*(VERDICT:[^*]+?)\\s*\\*{1,3}$/);return (emphasized?emphasized[1]:trimmed).trim();};',
|
|
52
|
+
'const lines=text.split(/\\r?\\n/).map(normalize);',
|
|
53
|
+
'const first=cfg.lineMode==="first-verdict-line"? (lines.find((value)=>/^VERDICT:/.test(value))??"") : (lines.find((value)=>value.length>0)??"");',
|
|
54
|
+
'if(!cfg.accept.includes(first)){console.error(cfg.label+" gate blocked: "+(first||"missing VERDICT line"));process.exit(1);}',
|
|
55
|
+
'process.stdout.write(first+"\\n");',
|
|
56
|
+
].join("");
|
|
57
|
+
return `node -e '${escapeShellSingleQuoted(program)}' '${configLiteral}'`;
|
|
57
58
|
}
|
|
58
59
|
/** Build a deterministic current-run gate over exact REQ-/BR-/AC- ids in node facts. */
|
|
59
60
|
export function buildRequirementCoverageGateShellCommand(gate) {
|
|
@@ -65,12 +65,24 @@ export const frontendMockConfigSchema = z.object({
|
|
|
65
65
|
serviceRoot: z.string().min(1).optional(),
|
|
66
66
|
verifyCommands: z.array(frontendMockVerifyCommandSchema).optional().default([]),
|
|
67
67
|
});
|
|
68
|
-
/** Batch limits for the browser-driven frontend test DAG.
|
|
69
|
-
* stop thresholds, not model-provider hard
|
|
68
|
+
/** Batch limits and gate profile for the browser-driven frontend test DAG.
|
|
69
|
+
* Token fields are post-case stop thresholds, not model-provider hard caps. */
|
|
70
70
|
export const frontendTestConfigSchema = z.object({
|
|
71
|
-
maxCasesPerBatch: z.number().int().min(1).max(50).optional()
|
|
71
|
+
maxCasesPerBatch: z.number().int().min(1).max(50).optional(),
|
|
72
72
|
maxTokensPerCase: z.number().int().positive().optional(),
|
|
73
73
|
maxTotalTokens: z.number().int().positive().optional(),
|
|
74
|
+
/**
|
|
75
|
+
* Case review topology:
|
|
76
|
+
* - off (default): mechanical checklist-shell only; no LLM review hard gate
|
|
77
|
+
* - advisory: reserved (same default path as off for now; LLM notes non-blocking)
|
|
78
|
+
* - blocking: legacy dual-review + final VERDICT: pass gate before materialize
|
|
79
|
+
*/
|
|
80
|
+
reviewMode: z.enum(["off", "advisory", "blocking"]).optional(),
|
|
81
|
+
/**
|
|
82
|
+
* When true, include quality outcome gate (all cases passed, 0 blocked).
|
|
83
|
+
* Default false: pipeline success is result materialize + retrospect report, not full green.
|
|
84
|
+
*/
|
|
85
|
+
strictOutcomeGate: z.boolean().optional(),
|
|
74
86
|
});
|
|
75
87
|
export const convergenceConfigSchema = z.object({
|
|
76
88
|
enabled: z.boolean().optional().default(false),
|
|
@@ -299,7 +299,7 @@ function coerceBoundary(value) {
|
|
|
299
299
|
return null;
|
|
300
300
|
return { field, constraint, sourceRef: firstSourceRef(record) };
|
|
301
301
|
}
|
|
302
|
-
const
|
|
302
|
+
const OPTIONAL_EVIDENCE_BASE_KEYS = [
|
|
303
303
|
"name",
|
|
304
304
|
"id",
|
|
305
305
|
"description",
|
|
@@ -308,21 +308,33 @@ const OPTIONAL_EVIDENCE_KNOWN_KEYS = new Set([
|
|
|
308
308
|
"title",
|
|
309
309
|
"sourceRef",
|
|
310
310
|
"sourceRefs",
|
|
311
|
+
];
|
|
312
|
+
const EXTERNAL_DEPENDENCY_INPUT_KEYS = new Set([
|
|
313
|
+
...OPTIONAL_EVIDENCE_BASE_KEYS,
|
|
314
|
+
"type",
|
|
315
|
+
"target",
|
|
316
|
+
"kind",
|
|
317
|
+
"required",
|
|
318
|
+
]);
|
|
319
|
+
const RISK_INPUT_KEYS = new Set([
|
|
320
|
+
...OPTIONAL_EVIDENCE_BASE_KEYS,
|
|
321
|
+
"type",
|
|
311
322
|
"severity",
|
|
312
323
|
"mitigation",
|
|
313
324
|
"level",
|
|
314
325
|
"impact",
|
|
315
|
-
"kind",
|
|
316
|
-
"required",
|
|
317
326
|
]);
|
|
318
|
-
function coerceOptionalEvidence(value) {
|
|
327
|
+
function coerceOptionalEvidence(value, kind) {
|
|
319
328
|
const record = asRecord(value);
|
|
320
329
|
if (!record)
|
|
321
330
|
return null;
|
|
331
|
+
const knownKeys = kind === "external-dependency"
|
|
332
|
+
? EXTERNAL_DEPENDENCY_INPUT_KEYS
|
|
333
|
+
: RISK_INPUT_KEYS;
|
|
322
334
|
// Fail closed on unknown keys so near-schema payloads cannot strip extras and pass.
|
|
323
335
|
for (const key of Object.keys(record)) {
|
|
324
|
-
if (!
|
|
325
|
-
throw new Error(
|
|
336
|
+
if (!knownKeys.has(key)) {
|
|
337
|
+
throw new Error(`${kind} evidence has unrecognized key: ${key}`);
|
|
326
338
|
}
|
|
327
339
|
}
|
|
328
340
|
const description = (typeof record.description === "string" && record.description.trim()) ||
|
|
@@ -334,6 +346,7 @@ function coerceOptionalEvidence(value) {
|
|
|
334
346
|
return null;
|
|
335
347
|
const out = { description };
|
|
336
348
|
const name = (typeof record.name === "string" && record.name.trim()) ||
|
|
349
|
+
(kind === "external-dependency" && typeof record.target === "string" && record.target.trim()) ||
|
|
337
350
|
(typeof record.id === "string" && record.id.trim()) ||
|
|
338
351
|
"";
|
|
339
352
|
if (name)
|
|
@@ -385,10 +398,10 @@ export function coerceBackendTestAnalysisInput(value) {
|
|
|
385
398
|
.map(coerceBoundary)
|
|
386
399
|
.filter((item) => item !== null);
|
|
387
400
|
next.externalDependencies = asArray(record.externalDependencies)
|
|
388
|
-
.map(coerceOptionalEvidence)
|
|
401
|
+
.map((item) => coerceOptionalEvidence(item, "external-dependency"))
|
|
389
402
|
.filter((item) => item !== null);
|
|
390
403
|
next.risks = asArray(record.risks)
|
|
391
|
-
.map(coerceOptionalEvidence)
|
|
404
|
+
.map((item) => coerceOptionalEvidence(item, "risk"))
|
|
392
405
|
.filter((item) => item !== null);
|
|
393
406
|
next.evidenceGaps = asArray(record.evidenceGaps)
|
|
394
407
|
.map(coerceEvidenceGap)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
export const CODE_COVERAGE_SCHEMA_ID = "code-coverage-v1";
|
|
6
|
+
const relativePath = z.string().min(1).refine((value) => !path.posix.isAbsolute(value) &&
|
|
7
|
+
!value.includes("\\") &&
|
|
8
|
+
!value.split("/").some((part) => part === "" || part === "." || part === ".."), "path must be a relative POSIX path");
|
|
9
|
+
export const coverageMetricSchema = z.object({
|
|
10
|
+
status: z.enum(["available", "unavailable"]),
|
|
11
|
+
covered: z.number().int().min(0).nullable(),
|
|
12
|
+
total: z.number().int().min(0).nullable(),
|
|
13
|
+
ratio: z.number().min(0).max(1).nullable(),
|
|
14
|
+
reason: z.string().min(1).nullable(),
|
|
15
|
+
}).strict();
|
|
16
|
+
export const codeCoverageContractSchema = z.object({
|
|
17
|
+
schemaVersion: z.literal(1),
|
|
18
|
+
schemaId: z.literal(CODE_COVERAGE_SCHEMA_ID),
|
|
19
|
+
language: z.enum(["python", "java"]),
|
|
20
|
+
format: z.enum(["coverage.py-json", "jacoco-xml"]),
|
|
21
|
+
tool: z.string().min(1),
|
|
22
|
+
toolVersion: z.string().min(1).nullable(),
|
|
23
|
+
sourceScope: z.object({
|
|
24
|
+
requirementIds: z.array(z.string().regex(/^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/)),
|
|
25
|
+
paths: z.array(relativePath),
|
|
26
|
+
}).strict(),
|
|
27
|
+
commitSha: z.string().regex(/^[a-f0-9]{7,64}$/).nullable(),
|
|
28
|
+
metrics: z.object({
|
|
29
|
+
line: coverageMetricSchema,
|
|
30
|
+
branch: coverageMetricSchema,
|
|
31
|
+
function: coverageMetricSchema,
|
|
32
|
+
}).strict(),
|
|
33
|
+
artifact: z.object({
|
|
34
|
+
path: relativePath,
|
|
35
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
36
|
+
}).strict(),
|
|
37
|
+
missingData: z.array(z.string().min(1)),
|
|
38
|
+
}).strict();
|
|
39
|
+
function metric(covered, total, reason) {
|
|
40
|
+
if (covered === null || total === null || total === 0) {
|
|
41
|
+
return { status: "unavailable", covered, total, ratio: null, reason: reason ?? (total === 0 ? "denominator-is-zero" : "metric-missing") };
|
|
42
|
+
}
|
|
43
|
+
return { status: "available", covered, total, ratio: covered / total, reason: null };
|
|
44
|
+
}
|
|
45
|
+
function numberValue(value) {
|
|
46
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
|
47
|
+
}
|
|
48
|
+
function safeArtifactPath(value) {
|
|
49
|
+
const normalized = value.replaceAll(path.sep, "/");
|
|
50
|
+
if (path.posix.isAbsolute(normalized) || normalized.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
|
51
|
+
throw new Error("artifact path must be a safe relative POSIX path");
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
function scopeMissing(scope) {
|
|
56
|
+
return scope.requirementIds.length === 0 ? ["source-scope-requirement-id-missing"] : [];
|
|
57
|
+
}
|
|
58
|
+
function baseContract(options, common) {
|
|
59
|
+
return {
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
schemaId: CODE_COVERAGE_SCHEMA_ID,
|
|
62
|
+
...common,
|
|
63
|
+
sourceScope: {
|
|
64
|
+
requirementIds: [...options.sourceScope.requirementIds],
|
|
65
|
+
paths: [...options.sourceScope.paths],
|
|
66
|
+
},
|
|
67
|
+
commitSha: options.commitSha,
|
|
68
|
+
artifact: { path: safeArtifactPath(options.artifactPath), sha256: options.artifactSha256 },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function parsePythonTotals(input, sourceScope) {
|
|
72
|
+
if (!input || typeof input !== "object")
|
|
73
|
+
throw new Error("coverage.py JSON must be an object");
|
|
74
|
+
const root = input;
|
|
75
|
+
const totals = root.totals;
|
|
76
|
+
if (!totals || typeof totals !== "object")
|
|
77
|
+
throw new Error("coverage.py JSON missing totals");
|
|
78
|
+
const files = root.files;
|
|
79
|
+
const scopedFiles = sourceScope.paths.length > 0 && files
|
|
80
|
+
? Object.entries(files).filter(([file]) => sourceScope.paths.includes(file.replaceAll("\\", "/")))
|
|
81
|
+
: [];
|
|
82
|
+
const usesScopedFiles = sourceScope.paths.length > 0;
|
|
83
|
+
const values = usesScopedFiles ? scopedFiles.map(([, value]) => value) : null;
|
|
84
|
+
const get = (key) => {
|
|
85
|
+
if (values) {
|
|
86
|
+
let sum = 0;
|
|
87
|
+
for (const file of values) {
|
|
88
|
+
const data = file && typeof file === "object" ? file : {};
|
|
89
|
+
const value = numberValue(data[key]);
|
|
90
|
+
if (value !== null)
|
|
91
|
+
sum += value;
|
|
92
|
+
}
|
|
93
|
+
return sum;
|
|
94
|
+
}
|
|
95
|
+
if (usesScopedFiles)
|
|
96
|
+
return null;
|
|
97
|
+
return numberValue(totals[key]);
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
line: metric(get("covered_lines"), get("num_statements"), null),
|
|
101
|
+
branch: metric(get("covered_branches"), get("num_branches"), "coverage.py branch data missing"),
|
|
102
|
+
function: { status: "unavailable", covered: null, total: null, ratio: null, reason: "coverage.py JSON has no function coverage" },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function parseJacocoCounters(xml) {
|
|
106
|
+
const report = xml.match(/<report\b[^>]*>([\s\S]*?)<\/report>/i)?.[1];
|
|
107
|
+
if (report === undefined)
|
|
108
|
+
throw new Error("JaCoCo XML missing report root");
|
|
109
|
+
const reportLevel = report.split(/<(?:group|package)\b/i)[0] ?? "";
|
|
110
|
+
const counters = new Map();
|
|
111
|
+
for (const match of reportLevel.matchAll(/<counter\s+[^>]*type="([A-Z]+)"[^>]*missed="(\d+)"[^>]*covered="(\d+)"[^>]*\/>/gi)) {
|
|
112
|
+
const missed = Number(match[2]);
|
|
113
|
+
const covered = Number(match[3]);
|
|
114
|
+
counters.set(match[1].toUpperCase(), { covered, total: covered + missed });
|
|
115
|
+
}
|
|
116
|
+
return counters;
|
|
117
|
+
}
|
|
118
|
+
export function parseCoveragePyJson(input, options) {
|
|
119
|
+
const root = input;
|
|
120
|
+
const totals = parsePythonTotals(input, options.sourceScope);
|
|
121
|
+
const missingData = [...scopeMissing(options.sourceScope)];
|
|
122
|
+
const files = input && typeof input === "object" ? input.files : undefined;
|
|
123
|
+
if (options.sourceScope.paths.length > 0 && (!files || !options.sourceScope.paths.some((file) => Object.hasOwn(files, file))))
|
|
124
|
+
missingData.push("source-scope-not-found-in-artifact");
|
|
125
|
+
if (totals.branch.status === "unavailable")
|
|
126
|
+
missingData.push("branch-coverage-unavailable");
|
|
127
|
+
missingData.push("function-coverage-unavailable");
|
|
128
|
+
return codeCoverageContractSchema.parse({
|
|
129
|
+
...baseContract(options, {
|
|
130
|
+
language: "python",
|
|
131
|
+
format: "coverage.py-json",
|
|
132
|
+
tool: "coverage.py",
|
|
133
|
+
toolVersion: typeof root?.meta?.version === "string" ? root.meta.version : options.toolVersion ?? null,
|
|
134
|
+
}),
|
|
135
|
+
metrics: totals,
|
|
136
|
+
missingData,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
export function parseJacocoXml(xml, options) {
|
|
140
|
+
if (!/<report\b/i.test(xml))
|
|
141
|
+
throw new Error("JaCoCo XML missing report root");
|
|
142
|
+
const counters = parseJacocoCounters(xml);
|
|
143
|
+
const line = counters.get("LINE");
|
|
144
|
+
const branch = counters.get("BRANCH");
|
|
145
|
+
const method = counters.get("METHOD");
|
|
146
|
+
const missingData = [...scopeMissing(options.sourceScope)];
|
|
147
|
+
if (!line)
|
|
148
|
+
missingData.push("line-coverage-unavailable");
|
|
149
|
+
if (!branch)
|
|
150
|
+
missingData.push("branch-coverage-unavailable");
|
|
151
|
+
if (!method)
|
|
152
|
+
missingData.push("method-coverage-unavailable");
|
|
153
|
+
return codeCoverageContractSchema.parse({
|
|
154
|
+
...baseContract(options, {
|
|
155
|
+
language: "java",
|
|
156
|
+
format: "jacoco-xml",
|
|
157
|
+
tool: "JaCoCo",
|
|
158
|
+
toolVersion: options.toolVersion ?? null,
|
|
159
|
+
}),
|
|
160
|
+
metrics: {
|
|
161
|
+
line: line ? metric(line.covered, line.total, null) : metric(null, null, "JaCoCo LINE counter missing"),
|
|
162
|
+
branch: branch ? metric(branch.covered, branch.total, null) : metric(null, null, "JaCoCo BRANCH counter missing"),
|
|
163
|
+
function: method ? metric(method.covered, method.total, null) : metric(null, null, "JaCoCo METHOD counter missing"),
|
|
164
|
+
},
|
|
165
|
+
missingData,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
export async function readCoverageArtifact(inputPath, options) {
|
|
169
|
+
const raw = await readFile(inputPath, "utf8");
|
|
170
|
+
const artifactSha256 = createHash("sha256").update(raw).digest("hex");
|
|
171
|
+
const artifactPath = options.artifactPath ?? path.basename(inputPath);
|
|
172
|
+
const parseOptions = { ...options, artifactPath, artifactSha256 };
|
|
173
|
+
if (options.sourceScope.paths.some((value) => value.includes("..")))
|
|
174
|
+
throw new Error("unsafe source scope path");
|
|
175
|
+
if (inputPath.toLowerCase().endsWith(".xml"))
|
|
176
|
+
return parseJacocoXml(raw, parseOptions);
|
|
177
|
+
return parseCoveragePyJson(JSON.parse(raw), parseOptions);
|
|
178
|
+
}
|
|
179
|
+
export function formatCoverageMarkdown(contract) {
|
|
180
|
+
const row = (name, value) => `| ${name} | ${value.covered ?? "—"} | ${value.total ?? "—"} | ${value.ratio === null ? "unavailable" : `${(value.ratio * 100).toFixed(2)}%`} | ${value.status} | ${value.reason ?? "—"} |`;
|
|
181
|
+
return [
|
|
182
|
+
"# Code Coverage Report",
|
|
183
|
+
"",
|
|
184
|
+
`- Schema: ${contract.schemaId} v${contract.schemaVersion}`,
|
|
185
|
+
`- Language: ${contract.language}`,
|
|
186
|
+
`- Tool: ${contract.tool}${contract.toolVersion ? ` ${contract.toolVersion}` : ""}`,
|
|
187
|
+
`- Commit: ${contract.commitSha ?? "unavailable"}`,
|
|
188
|
+
`- Requirement IDs: ${contract.sourceScope.requirementIds.join(", ") || "unavailable"}`,
|
|
189
|
+
`- Source scope: ${contract.sourceScope.paths.join(", ") || "unavailable"}`,
|
|
190
|
+
`- Artifact: ${contract.artifact.path}`,
|
|
191
|
+
`- Artifact SHA-256: ${contract.artifact.sha256}`,
|
|
192
|
+
"",
|
|
193
|
+
"| Metric | Covered | Total | Ratio | Status | Reason |",
|
|
194
|
+
"|---|---:|---:|---:|---|---|",
|
|
195
|
+
row("Line", contract.metrics.line),
|
|
196
|
+
row("Branch", contract.metrics.branch),
|
|
197
|
+
row("Function/Method", contract.metrics.function),
|
|
198
|
+
"",
|
|
199
|
+
`Missing data: ${contract.missingData.length ? contract.missingData.join(", ") : "none"}`,
|
|
200
|
+
"",
|
|
201
|
+
].join("\n");
|
|
202
|
+
}
|