@tea-agent/loop-agent 0.29.0 → 0.29.1

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 CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.29.1] - 2026-08-07
6
+
7
+ ### 重点更新
8
+
9
+ - 前端测试默认执行流程大幅精简,收敛至约 11 个静态节点,合并了多项检查与报告步骤
10
+ - 前端测试浏览器默认改为无头(headless)模式,减少环境干扰
11
+ - 优化证据缺失处理逻辑,非路径逃逸的异常不再强制阻断报告生成
12
+ - 修复文档说明被误判为可执行命令的问题,提升执行稳定性
13
+
14
+ ### 新增
15
+
16
+ - 新增前端测试标准场景物化节点,支持执行后针对阻塞或缺失证据进行最多两轮有界重跑
17
+ - 新增前端测试标准场景规范模板(frontend-test-standard-scenarios.v1.json)
18
+ - 前端测试独占节点支持 tools-only 写保护策略,在保留工具级写沙箱的同时跳过 Git 基线的硬失败限制
19
+
20
+ ### 改进
21
+
22
+ - 前端测试默认 DAG 结构精简至约 11 个静态节点:合并双重重跑、检查清单与清单文件、证据与结果、L5 与 HTML 报告
23
+ - 前端测试主 HTML 报告不再依赖回顾(retrospect)节点,回顾功能改为可选开启
24
+ - 权威清单文件(manifest.json)仅记录规范化后的测试用例,并在未配置重试时省略相关静态节点
25
+ - 强化运营 HTML 报告的状态过滤功能,并优化失败与阻塞原因的展示
26
+
27
+ ### 修复
28
+
29
+ - 修复前端测试检查清单将不可用或阻塞状态的说明文字误判为非法可执行命令的问题
30
+ - 修复前端测试收尾阶段因证据缺失或格式错误导致强制失败的问题,现仅对路径逃逸强制报错,其余降级为建议性提示
31
+ - 修复前端测试合并后清单文件物化异常的问题,确保仅写入规范化用例并恢复负向测试用例
32
+ - 回退误改的全局默认 Pi 模型(DEFAULT_PI_MODEL),将其恢复为 glm-5.2,模型路由统一交由 harness 复杂度矩阵处理
33
+
5
34
  ## [0.29.0] - 2026-08-07
6
35
 
7
36
  ### 重点更新
@@ -2217,7 +2217,7 @@ function buildTargetFeatureWorkflow(input) {
2217
2217
  '- `taskKind: "backend-test"` selects the dedicated backend test DAG. Its Pi nodes analyze requirements, generate and review backend cases, generate pytest, and retrospect on results; shell gate/execution nodes enforce the review verdict and run the target project\'s pytest. The backend test templates (`backend-test-dag.json` and the `backend-test-dag.*.prompt.md` files) ship inside the loop-agent package as static references and are projected to target projects under the governance `templates/` directory.',
2218
2218
  '- `taskKind: "knowledge-sync"` selects the Feature-scoped test-knowledge write-back DAG (collect → draft → validate → apply → pointer). Bind `featureId` in `task.json` (or hardConstraints / requirement text). It writes only under `features/<featureId>/…` after final verification evidence exists.',
2219
2219
  '- `taskKind: "knowledge-graph-bootstrap"` selects the business knowledge-graph bootstrap DAG (preflight → inventory → propose → validate → review → gate → promote → materialize). AI writes only `knowledge/bootstrap/staging/**`; promote is merge-new-only.',
2220
- '- `taskKind: "frontend-test"` selects the FE-test RAG DAG. It writes a traceable frontend RAG package and Markdown case manifest, then executes manifest cases serially with `playwright-cli` in isolated test environments and retains per-case evidence. It never generates pytest or Playwright source code. `frontendTest.maxCasesPerBatch` defaults to 20 (maximum 50); optional `maxTokensPerCase` and `maxTotalTokens` stop only later cases after a completed case\'s token usage is recorded, marking them `blocked: token-budget-exhausted`. Generated browser startup uses `playwright-cli open --browser=chrome --headed <base-url>`; the generic playwright-cli skill is unchanged.',
2220
+ '- `taskKind: "frontend-test"` selects the FE-test RAG DAG. It writes a traceable frontend RAG package and Markdown case manifest, then executes manifest cases serially with `playwright-cli` in isolated test environments and retains per-case evidence. It never generates pytest or Playwright source code. `frontendTest.maxCasesPerBatch` defaults to 20 (maximum 50); optional `maxTokensPerCase` and `maxTotalTokens` stop only later cases after a completed case\'s token usage is recorded, marking them `blocked: token-budget-exhausted`. Generated browser startup uses `playwright-cli open --browser=chrome --headless <base-url>`; the generic playwright-cli skill is unchanged.',
2221
2221
  "- Only eligible read-only Pi nodes (planner, scout, reviewer, verifier, closeout with no write-capable tool profile) receive the conservative automatic retry policy. Supervisor, implementer, writer, docs-only, dynamic, shell, static, and decision-gate nodes are not retried automatically. Eligible nodes cannot write repository files; the controller only records immutable attempt evidence under `.harness/dag-runs/<state>/<run-id>/<node-id>/attempt-<n>.json`.",
2222
2222
  "",
2223
2223
  "Use the package-backed public knowledge CLI for graph operations. Do not require target projects to run package-only kb runtime scripts:",
@@ -340,7 +340,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
340
340
  }
341
341
  let beforeStatus;
342
342
  let beforePathFingerprints;
343
- if (isWriteTask) {
343
+ /** tools-only: keep SDK path sandbox; skip git baseline + post-diff write-guard hard fail. */
344
+ const skipGitWriteGuard = isWriteTask && input.task.writeGuardPolicy === "tools-only";
345
+ if (isWriteTask && !skipGitWriteGuard) {
344
346
  try {
345
347
  beforeStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-before" });
346
348
  const beforeSnapshot = snapshotGitStatusPorcelain(beforeStatus);
@@ -197,15 +197,21 @@ function validateOpenArgs(args, baseUrl) {
197
197
  for (const arg of args) {
198
198
  assertNoControlMeta(arg);
199
199
  assertNoSessionFlag(arg);
200
- if (arg === "--browser=chrome" || arg === "--headed") {
200
+ if (arg === "--browser=chrome" || arg === "--headless") {
201
201
  flags.add(arg);
202
202
  normalized.push(arg);
203
203
  continue;
204
204
  }
205
+ // Accept legacy --headed but normalize to headless for frontend-test CI/dogfood.
206
+ if (arg === "--headed") {
207
+ flags.add("--headless");
208
+ if (!normalized.includes("--headless"))
209
+ normalized.push("--headless");
210
+ continue;
211
+ }
205
212
  if (arg.startsWith("--browser=") ||
206
- arg === "--browser" ||
207
- arg === "--headless") {
208
- throw new PlaywrightCliPolicyError("open-browser-flags", "open must use --browser=chrome --headed only");
213
+ arg === "--browser") {
214
+ throw new PlaywrightCliPolicyError("open-browser-flags", "open must use --browser=chrome --headless only");
209
215
  }
210
216
  if (/^[a-z][a-z0-9+.-]*:/i.test(arg) || arg.startsWith("http")) {
211
217
  url = arg;
@@ -213,12 +219,12 @@ function validateOpenArgs(args, baseUrl) {
213
219
  }
214
220
  throw new PlaywrightCliPolicyError("open-args", `unsupported open argument: ${arg}`);
215
221
  }
216
- if (!flags.has("--browser=chrome") || !flags.has("--headed")) {
222
+ if (!flags.has("--browser=chrome") || !flags.has("--headless")) {
217
223
  // controller injects required flags when missing from model args
218
224
  if (!flags.has("--browser=chrome"))
219
225
  normalized.unshift("--browser=chrome");
220
- if (!flags.has("--headed"))
221
- normalized.push("--headed");
226
+ if (!flags.has("--headless"))
227
+ normalized.push("--headless");
222
228
  }
223
229
  if (!url) {
224
230
  throw new PlaywrightCliPolicyError("open-url-required", "open requires an absolute http(s) URL");
@@ -509,7 +515,7 @@ export function preparePlaywrightCliArgv(input, ctx) {
509
515
  catch {
510
516
  throw new PlaywrightCliPolicyError("goto-url-invalid", "goto requires a URL relative to the controller baseUrl");
511
517
  }
512
- const openLike = validateOpenArgs(["--browser=chrome", "--headed", target], ctx.baseUrl);
518
+ const openLike = validateOpenArgs(["--browser=chrome", "--headless", target], ctx.baseUrl);
513
519
  return openLike[openLike.length - 1];
514
520
  });
515
521
  if (args.length !== 1) {
@@ -18,6 +18,7 @@ import { materializeFrontendImplementationContract } from "../workflows/dag/fron
18
18
  import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
19
19
  import { renderFrontendTestL5Report } from "../workflows/dag/frontend-test-l5-report.js";
20
20
  import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
21
+ import { materializeFrontendTestCaseManifest } from "../workflows/dag/frontend-test-case-manifest.js";
21
22
  import { renderFrontendTestHtmlReport } from "../workflows/dag/frontend-test-html-report.js";
22
23
  import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
23
24
  import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
@@ -1826,6 +1827,149 @@ async function executeFrontendTestEvidenceValidation(input) {
1826
1827
  };
1827
1828
  }
1828
1829
  }
1830
+ async function executeFrontendTestCaseManifest(input, meta) {
1831
+ const started = Date.now();
1832
+ try {
1833
+ const gate = input.task.shell?.frontendTestCaseManifest ?? {};
1834
+ const declaredAcIds = gate.declaredAcIds ??
1835
+ (meta.spec.sourceBinding?.requirementIds ?? []).filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
1836
+ const checklist = await validateFrontendCaseChecklist({
1837
+ workspaceRoot: input.cwd,
1838
+ declaredAcIds,
1839
+ });
1840
+ if (checklist.issues.length) {
1841
+ return {
1842
+ ok: false,
1843
+ stdout: "",
1844
+ stderr: `frontend-test checklist blocked: ${JSON.stringify(checklist.issues)}`,
1845
+ failureCategory: "invalid-output",
1846
+ durationMs: Date.now() - started,
1847
+ };
1848
+ }
1849
+ const materialized = await materializeFrontendTestCaseManifest({
1850
+ workspaceRoot: input.cwd,
1851
+ maxCases: gate.maxCases,
1852
+ declaredAcIds,
1853
+ });
1854
+ return {
1855
+ ok: true,
1856
+ stdout: `${JSON.stringify({ cases: materialized.cases })}
1857
+ `,
1858
+ stderr: "",
1859
+ failureCategory: "success",
1860
+ durationMs: Date.now() - started,
1861
+ };
1862
+ }
1863
+ catch (error) {
1864
+ return {
1865
+ ok: false,
1866
+ stdout: "",
1867
+ stderr: error instanceof Error ? error.message : String(error),
1868
+ failureCategory: "invalid-output",
1869
+ durationMs: Date.now() - started,
1870
+ };
1871
+ }
1872
+ }
1873
+ /** Path-escape / cross-case evidence errors must hard-fail finalize (same class as validate.hardFail). */
1874
+ function isFrontendTestEvidencePathHardError(error) {
1875
+ const message = error instanceof Error ? error.message : String(error);
1876
+ return /unsafe evidence|escapes (?:evidence root|its allowed root)|evidence realpath|cross-case/i.test(message);
1877
+ }
1878
+ /**
1879
+ * Lean finalize: evidence validate + result materialize in one shell.
1880
+ * - Case-level missing/malformed evidence is advisory (issues / advisoryFindings);
1881
+ * the node still succeeds so reports can render incomplete/failed outcomes.
1882
+ * - Path escape / unsafe evidence roots hard-fail the node.
1883
+ * - Controller/contract errors (missing sourceBinding, empty manifest) still fail closed.
1884
+ */
1885
+ async function executeFrontendTestResultFinalize(input, meta) {
1886
+ const started = Date.now();
1887
+ let advisoryStdout = "";
1888
+ try {
1889
+ const evidence = await validateFrontendCaseEvidence({
1890
+ workspaceRoot: input.cwd,
1891
+ });
1892
+ advisoryStdout = `frontend case evidence validation cases=${evidence.cases} findings=${evidence.issues.length}${evidence.issues.length ? ` issues=${JSON.stringify(evidence.issues)}` : ""}`;
1893
+ // Advisory findings must not block materialize; only hardFail (path escape) does.
1894
+ if (evidence.hardFail) {
1895
+ return {
1896
+ ok: false,
1897
+ stdout: advisoryStdout,
1898
+ stderr: `frontend-test evidence hard-fail: ${JSON.stringify(evidence.issues)}`,
1899
+ failureCategory: "nonzero-exit",
1900
+ durationMs: Date.now() - started,
1901
+ };
1902
+ }
1903
+ const artifact = await materializeFrontendTestResult({
1904
+ runDir: meta.runDir,
1905
+ workspaceRoot: input.cwd,
1906
+ artifactName: "frontend-test-result.json",
1907
+ outputDir: "contracts",
1908
+ sourceBinding: meta.spec.sourceBinding,
1909
+ });
1910
+ return {
1911
+ ok: true,
1912
+ stdout: `${advisoryStdout}\nStructured artifact: ${artifact.path}\nSchema: ${artifact.schemaId}\nSHA-256: ${artifact.sha256}`,
1913
+ stderr: "",
1914
+ failureCategory: "success",
1915
+ durationMs: Date.now() - started,
1916
+ };
1917
+ }
1918
+ catch (error) {
1919
+ const detail = error instanceof Error ? error.message : String(error);
1920
+ if (isFrontendTestEvidencePathHardError(error)) {
1921
+ return {
1922
+ ok: false,
1923
+ stdout: advisoryStdout,
1924
+ stderr: `frontend-test evidence hard-fail: ${detail}`,
1925
+ failureCategory: "nonzero-exit",
1926
+ durationMs: Date.now() - started,
1927
+ };
1928
+ }
1929
+ return {
1930
+ ok: false,
1931
+ stdout: advisoryStdout,
1932
+ stderr: detail,
1933
+ failureCategory: "invalid-output",
1934
+ durationMs: Date.now() - started,
1935
+ };
1936
+ }
1937
+ }
1938
+ async function executeFrontendTestReports(input, meta) {
1939
+ const started = Date.now();
1940
+ try {
1941
+ const writeL5 = input.task.shell?.frontendTestReports?.l5 !== false;
1942
+ const lines = [];
1943
+ if (writeL5) {
1944
+ const l5 = await renderFrontendTestL5Report({
1945
+ workspaceRoot: input.cwd,
1946
+ runDir: meta.runDir,
1947
+ });
1948
+ lines.push(`Frontend L-5 report: ${l5.htmlPath}\nMarkdown: ${l5.markdownPath}\nStatus: ${l5.metrics.status}`);
1949
+ }
1950
+ const report = await renderFrontendTestHtmlReport({
1951
+ workspaceRoot: input.cwd,
1952
+ runDir: meta.runDir,
1953
+ });
1954
+ lines.push(`Frontend test report: ${report.htmlPath}\nMarkdown: ${report.markdownPath}\nOutcome: ${report.outcome}\nCases: ${report.caseCount}`);
1955
+ return {
1956
+ ok: true,
1957
+ stdout: lines.join("\n"),
1958
+ stderr: "",
1959
+ failureCategory: "success",
1960
+ durationMs: Date.now() - started,
1961
+ };
1962
+ }
1963
+ catch (error) {
1964
+ return {
1965
+ ok: false,
1966
+ stdout: "",
1967
+ stderr: error instanceof Error ? error.message : String(error),
1968
+ failureCategory: "invalid-output",
1969
+ durationMs: Date.now() - started,
1970
+ };
1971
+ }
1972
+ }
1829
1973
  async function executeFrontendLintBaseline(input, meta) {
1830
1974
  const started = Date.now();
1831
1975
  const shell = input.task.shell;
@@ -2062,15 +2206,24 @@ export async function executeDagShellNode(input, meta) {
2062
2206
  if (shell?.frontendTestCaseChecklist) {
2063
2207
  return executeFrontendTestCaseChecklist(input, meta);
2064
2208
  }
2209
+ if (shell?.frontendTestCaseManifest) {
2210
+ return executeFrontendTestCaseManifest(input, meta);
2211
+ }
2065
2212
  if (shell?.frontendTestEvidenceValidation) {
2066
2213
  return executeFrontendTestEvidenceValidation(input);
2067
2214
  }
2215
+ if (shell?.frontendTestResultFinalize) {
2216
+ return executeFrontendTestResultFinalize(input, meta);
2217
+ }
2068
2218
  if (shell?.frontendTestL5Report) {
2069
2219
  return executeFrontendTestL5Report(input, meta);
2070
2220
  }
2071
2221
  if (shell?.frontendTestHtmlReport) {
2072
2222
  return executeFrontendTestHtmlReport(input, meta);
2073
2223
  }
2224
+ if (shell?.frontendTestReports) {
2225
+ return executeFrontendTestReports(input, meta);
2226
+ }
2074
2227
  if (shell?.backendTestPipeline) {
2075
2228
  return executeBackendTestPipelineWithWriteGuard(input, meta);
2076
2229
  }
@@ -83,6 +83,27 @@ export const frontendTestConfigSchema = z.object({
83
83
  * Default false: pipeline success is result materialize + retrospect report, not full green.
84
84
  */
85
85
  strictOutcomeGate: z.boolean().optional(),
86
+ /**
87
+ * Maximum rerun attempts for blocked or missing-result frontend cases.
88
+ * Defaults to 2; the static graph always carries a single rerun select+map
89
+ * pair and bounds candidates within that pair via this value.
90
+ */
91
+ maxRerunAttempts: z.number().int().min(0).max(4).optional(),
92
+ /**
93
+ * Report chain toggles for the lean frontend-test DAG.
94
+ * - retrospect: opt-in Pi retrospective node (default off). Pipeline success
95
+ * is frontend-test-result-v1 + main HTML/MD report, not the retrospect.
96
+ * - l5: write the deterministic L-5 dashboard from the merged reports node
97
+ * (default true; the merged reports node always renders the main
98
+ * HTML/MD report regardless of this flag).
99
+ */
100
+ reports: z
101
+ .object({
102
+ retrospect: z.boolean().optional(),
103
+ l5: z.boolean().optional(),
104
+ })
105
+ .strict()
106
+ .optional(),
86
107
  });
87
108
  export const convergenceConfigSchema = z.object({
88
109
  enabled: z.boolean().optional().default(false),
@@ -6,6 +6,9 @@ const LIST_ITEM = /^\s*(?:[-+*]|\d+[.)])\s+(.+)$/;
6
6
  const INDENTED_CODE = /^(?: {4,}|\t+)(\S.*)$/;
7
7
  const EXPLICIT_COMMAND = /^(?:(?:shell|terminal)(?:\s+command)?|command|run command|execute command)\s*:\s*(.+)$/i;
8
8
  const BLOCKED_REASON_PREFIX = /^(?:(?:blocked|forbidden|reject(?:ed)?|disallow(?:ed)?|prohibited)(?:\s+reason)?\s*(?::|\bbecause\b)|(?:do not|must not|never)\s+(?:run|execute|use)\b)/i;
9
+ /** Standalone blockedReason / environment tokens (not executable commands). */
10
+ const BLOCKED_REASON_TOKEN = /^(?:playwright-cli-unavailable|frontend-base-url-unreachable|curl-unavailable|browser-command-capability-unavailable|browser-command-evidence-missing|token-budget-exhausted|current-user-data-unavailable|data-ownership-unverifiable|safe-test-data-setup-unavailable|invalid-evidence-shape)(?:\s|$|[.,;:)`'"\]])/i;
11
+ const BLOCKED_PROSE_HINT = /(?:blockedReason|blocked\s*reason|environmentProbe|evidenceDir|\bunavailable\b|不可用|写\s*blocked|写入\s*blocked|伪命令|自然语言|元数据|fail-closed|探测失败)/i;
9
12
  const IMPERATIVE_COMMAND = /^(?:(?:run|execute|use)(?:\s+(?:the\s+)?(?:(?:shell|terminal)\s+)?command)?|in\s+(?:the\s+)?(?:shell|terminal|console)\s*,?\s*(?:run|execute|use))\s*:?\s+(.+)$/i;
10
13
  const INLINE_CODE_STEP = /^`([^`\r\n]+)`[.!?]?$/;
11
14
  const AUTOMATION_EXECUTABLE = /^(?:playwright-cli\b|playwright\b|@playwright\/test\b|npx\b|npm\b|pnpm\b|yarn\b|bunx?\b|node(?:js)?\b|python(?:3)?\b|bash\b|sh\b|zsh\b|fish\b|powershell\b|pwsh\b|cmd(?:\.exe)?\b|cypress\b|selenium\b|webdriverio\b|chromedriver\b|google-chrome\b|chrome\b|firefox\b|curl\b|wget\b)/i;
@@ -31,7 +34,53 @@ function isExecutableFence(language) {
31
34
  return COMMAND_FENCE_LANGUAGE.test(language.trim());
32
35
  }
33
36
  function isBlockedReason(value) {
34
- return BLOCKED_REASON_PREFIX.test(value.trim());
37
+ const trimmed = value.trim();
38
+ if (!trimmed)
39
+ return false;
40
+ if (BLOCKED_REASON_PREFIX.test(trimmed))
41
+ return true;
42
+ // Bare reason enum / metadata line (BugPilot blocked sections).
43
+ if (BLOCKED_REASON_TOKEN.test(trimmed.replace(/^`+|`+$/g, "")))
44
+ return true;
45
+ if (/^blockedReason\s*[:=]/i.test(trimmed))
46
+ return true;
47
+ return false;
48
+ }
49
+ /**
50
+ * Non-executable prose that mentions playwright-cli / reason tokens for blocked
51
+ * paths (e.g. "playwright-cli 不可用时… blockedReason: …"). Must not enter the
52
+ * command allowlist gate.
53
+ */
54
+ function isNonExecutableBlockedProse(value) {
55
+ const trimmed = value.trim();
56
+ if (!trimmed)
57
+ return false;
58
+ if (isBlockedReason(trimmed))
59
+ return true;
60
+ const normalized = normalizeCommand(trimmed);
61
+ const bare = normalized.replace(/^`+|`+$/g, "").trim();
62
+ if (BLOCKED_REASON_TOKEN.test(bare))
63
+ return true;
64
+ // "playwright-cli 不可用…" / "playwright-cli unavailable…" documentation.
65
+ if (/^playwright-cli\b/i.test(normalized) &&
66
+ BLOCKED_PROSE_HINT.test(normalized) &&
67
+ !/^playwright-cli\s+[a-z][a-z0-9-]*\b/i.test(normalized)) {
68
+ return true;
69
+ }
70
+ const pw = normalized.match(/^playwright-cli\s+(\S+)([\s\S]*)$/i);
71
+ if (pw) {
72
+ const first = pw[1].replace(/^[`'"]+|[`'":,,。→]+$/g, "");
73
+ if (!isPlaywrightCliCommand(first.toLowerCase())) {
74
+ // Non-verb after playwright-cli in prose (e.g. 不可用 / unavailable).
75
+ return /[\u4e00-\u9fff]/.test(first) || BLOCKED_PROSE_HINT.test(normalized) || !/^[a-z][a-z0-9-]*$/i.test(first);
76
+ }
77
+ }
78
+ // Reason token embedded without looking like a real CLI invocation.
79
+ if (BLOCKED_PROSE_HINT.test(trimmed) &&
80
+ !/^playwright-cli\s+[a-z][a-z0-9-]*(\s+--|\s+https?:|\s*$)/i.test(normalized)) {
81
+ return true;
82
+ }
83
+ return false;
35
84
  }
36
85
  function stripLeadingCommandWrappers(command) {
37
86
  let remaining = command.trim();
@@ -64,17 +113,30 @@ function isCommandLikeExecutable(command) {
64
113
  }
65
114
  function executableListStep(value) {
66
115
  const trimmed = value.trim();
116
+ if (isNonExecutableBlockedProse(trimmed))
117
+ return null;
67
118
  const explicit = trimmed.match(EXPLICIT_COMMAND);
68
- if (explicit)
69
- return isBlockedReason(explicit[1]) ? null : normalizeCommand(explicit[1]);
119
+ if (explicit) {
120
+ if (isBlockedReason(explicit[1]) || isNonExecutableBlockedProse(explicit[1]))
121
+ return null;
122
+ return normalizeCommand(explicit[1]);
123
+ }
70
124
  const imperative = trimmed.match(IMPERATIVE_COMMAND);
71
- if (imperative)
125
+ if (imperative) {
126
+ if (isNonExecutableBlockedProse(imperative[1]))
127
+ return null;
72
128
  return normalizeCommand(imperative[1]);
129
+ }
73
130
  const inlineCode = trimmed.match(INLINE_CODE_STEP);
74
- if (inlineCode)
131
+ if (inlineCode) {
132
+ if (isNonExecutableBlockedProse(inlineCode[1]))
133
+ return null;
75
134
  return normalizeCommand(inlineCode[1]);
135
+ }
76
136
  const prompted = /^[$>]\s*\S/.test(trimmed);
77
137
  const normalized = normalizeCommand(trimmed);
138
+ if (isNonExecutableBlockedProse(normalized))
139
+ return null;
78
140
  if (prompted || isCommandLikeExecutable(normalized))
79
141
  return normalized;
80
142
  return null;
@@ -127,12 +189,15 @@ function extractExecutableInstructions(markdown) {
127
189
  if (fenceLanguage !== null) {
128
190
  if (!isExecutableFence(fenceLanguage) || /^(?:#|\/\/)/.test(trimmed))
129
191
  continue;
130
- instructions.push({ command: normalizeCommand(trimmed), lineNumber });
192
+ // Fenced blocks are strict: only skip pure reason-token / blockedReason lines.
193
+ if (isBlockedReason(trimmed) || BLOCKED_REASON_TOKEN.test(normalizeCommand(trimmed).replace(/^`+|`+$/g, "")))
194
+ continue;
195
+ instructions.push({ command: normalizeCommand(trimmed), lineNumber, fromFence: true });
131
196
  continue;
132
197
  }
133
198
  const listed = line.match(LIST_ITEM);
134
199
  if (listed) {
135
- if (isBlockedReason(listed[1]))
200
+ if (isBlockedReason(listed[1]) || isNonExecutableBlockedProse(listed[1]))
136
201
  continue;
137
202
  const command = executableListStep(listed[1]);
138
203
  if (command !== null)
@@ -141,17 +206,20 @@ function extractExecutableInstructions(markdown) {
141
206
  }
142
207
  const indented = line.match(INDENTED_CODE);
143
208
  if (indented) {
144
- if (isBlockedReason(indented[1]))
209
+ if (isBlockedReason(indented[1]) || isNonExecutableBlockedProse(indented[1]))
145
210
  continue;
146
211
  const explicitIndented = indented[1].match(EXPLICIT_COMMAND);
147
- if (explicitIndented && isBlockedReason(explicitIndented[1]))
212
+ if (explicitIndented && (isBlockedReason(explicitIndented[1]) || isNonExecutableBlockedProse(explicitIndented[1])))
213
+ continue;
214
+ const fromStep = executableListStep(indented[1]);
215
+ if (fromStep === null)
148
216
  continue;
149
- instructions.push({ command: executableListStep(indented[1]) ?? normalizeCommand(indented[1]), lineNumber });
217
+ instructions.push({ command: fromStep, lineNumber });
150
218
  continue;
151
219
  }
152
220
  const explicit = trimmed.match(EXPLICIT_COMMAND);
153
221
  if (explicit) {
154
- if (isBlockedReason(explicit[1]))
222
+ if (isBlockedReason(explicit[1]) || isNonExecutableBlockedProse(explicit[1]))
155
223
  continue;
156
224
  instructions.push({ command: normalizeCommand(explicit[1]), lineNumber });
157
225
  continue;
@@ -163,9 +231,16 @@ function extractExecutableInstructions(markdown) {
163
231
  return instructions;
164
232
  }
165
233
  function commandGateIssue(input) {
234
+ // Outside fences, blocked/unavailable prose must never become a gate failure.
235
+ if (!input.instruction.fromFence && isNonExecutableBlockedProse(input.instruction.command)) {
236
+ return null;
237
+ }
238
+ if (input.instruction.fromFence && isBlockedReason(input.instruction.command)) {
239
+ return null;
240
+ }
166
241
  const commandMatch = input.instruction.command.match(/^playwright-cli\s+([^\s`]+)/i);
167
242
  if (commandMatch) {
168
- const command = commandMatch[1].toLowerCase();
243
+ const command = commandMatch[1].toLowerCase().replace(/^[`'"]+|[`'":,,。→]+$/g, "");
169
244
  if (isPlaywrightCliCommand(command) && !hasShellControl(input.instruction.command))
170
245
  return null;
171
246
  if (isPlaywrightCliCommand(command)) {
@@ -178,6 +253,10 @@ function commandGateIssue(input) {
178
253
  detail: "shell control or additional executable fragments are not allowed after playwright-cli commands",
179
254
  };
180
255
  }
256
+ // Fenced bad verbs stay rejected; unfenced non-verbs already filtered as prose.
257
+ if (!input.instruction.fromFence && isNonExecutableBlockedProse(input.instruction.command)) {
258
+ return null;
259
+ }
181
260
  return {
182
261
  ruleId: "playwright-cli-command-not-allowed",
183
262
  caseId: input.caseId,
@@ -210,7 +289,7 @@ export async function validateFrontendCaseChecklist(input) {
210
289
  const issues = [];
211
290
  const caseIdRe = /^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;
212
291
  const acIdRe = /^AC(?:-[A-Z0-9]+)+$/i;
213
- const openRe = /playwright-cli\s+open\s+--browser=chrome\s+--headed\s+https?:\/\/\S+/i;
292
+ const openRe = /playwright-cli\s+open\s+--browser=chrome\s+--headless\s+https?:\/\/\S+/i;
214
293
  const productionHostRe = /(^|[.-])(prod|production)([.-]|$)/i;
215
294
  for (const raw of manifest.cases) {
216
295
  const item = raw;
@@ -231,8 +310,8 @@ export async function validateFrontendCaseChecklist(input) {
231
310
  issues.push({ ruleId: "case-path-mismatch", caseId: id, casePath, detail: `${casePath} must equal ${expectedPath}` });
232
311
  const body = await readFile(absolute, "utf8");
233
312
  if (!openRe.test(body))
234
- issues.push({ ruleId: "open-prefix", caseId: id, casePath, detail: "missing playwright-cli open --browser=chrome --headed <absolute-url>" });
235
- const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s+--headed\s+(https?:\/\/\S+)/i);
313
+ issues.push({ ruleId: "open-prefix", caseId: id, casePath, detail: "missing playwright-cli open --browser=chrome --headless <absolute-url>" });
314
+ const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s+--headless\s+(https?:\/\/\S+)/i);
236
315
  if (match) {
237
316
  try {
238
317
  const url = new URL(match[1].replace(/[)\]},.\"'`]+$/, ""));
@@ -0,0 +1,104 @@
1
+ import { readFile, rename, unlink, writeFile, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const DIMENSIONS = new Set(["core", "boundary", "flow", "backend"]);
4
+ function fail(ruleId, detail) {
5
+ const msg = JSON.stringify({ ruleId, detail: String(detail ?? "") });
6
+ console.error(`frontend-test manifest blocked: ${msg}`);
7
+ throw new Error(`frontend-test manifest blocked: ${ruleId}${detail ? `: ${detail}` : ""}`);
8
+ }
9
+ /**
10
+ * Validate manifest.draft.json and atomically materialize
11
+ * testcase/frontend/cases/manifest.json via temp+rename, then delete the draft.
12
+ *
13
+ * Writes only a normalized { schemaVersion: 1, cases } payload — never the raw
14
+ * draft object — so generator typos in casePath/evidenceDir/extra fields cannot
15
+ * leak into the authoritative manifest.
16
+ *
17
+ * unknown-ac is enforced only when declaredAcIds is non-empty (sourceBinding
18
+ * present). When empty, ac-id-shape still applies.
19
+ */
20
+ export async function materializeFrontendTestCaseManifest(input) {
21
+ const draft = path.join(input.workspaceRoot, "testcase/frontend/cases/manifest.draft.json");
22
+ const file = path.join(input.workspaceRoot, "testcase/frontend/cases/manifest.json");
23
+ const tmp = `${file}.tmp`;
24
+ const maxCases = input.maxCases ?? 32;
25
+ const declaredAc = new Set(input.declaredAcIds ?? []);
26
+ let manifest;
27
+ try {
28
+ manifest = JSON.parse(await readFile(draft, "utf8"));
29
+ }
30
+ catch {
31
+ fail("draft-missing", `missing ${draft}`);
32
+ }
33
+ if (manifest.schemaVersion !== 1)
34
+ fail("draft-schema", "schemaVersion must be 1");
35
+ if (!Array.isArray(manifest.cases) || manifest.cases.length === 0)
36
+ fail("draft-empty-cases", "cases must be a non-empty array");
37
+ if (manifest.cases.length > maxCases)
38
+ fail("map-capacity-exceeded", `cases=${manifest.cases.length} exceeds maxCasesPerBatch/maxExpandedNodes=${maxCases}; raise frontendTest.maxCasesPerBatch or shrink the suite`);
39
+ const seen = new Set();
40
+ const seenCasePath = new Set();
41
+ const seenEvidenceDir = new Set();
42
+ const acIdRe = /^AC(?:-[A-Z0-9]+)+$/i;
43
+ const cases = [];
44
+ for (const raw of manifest.cases) {
45
+ const c = raw;
46
+ if (!c ||
47
+ typeof c.caseId !== "string" ||
48
+ !/^FE-[A-Za-z0-9][A-Za-z0-9-]*$/.test(c.caseId))
49
+ fail("case-id-shape", `caseId must be FE-*, never AC-FE-*: ${String(c?.caseId)}`);
50
+ if (/^AC-/i.test(c.caseId))
51
+ fail("case-id-is-ac", `caseId must not be an acceptance id: ${c.caseId}`);
52
+ if (seen.has(c.caseId))
53
+ fail("duplicate-case-id", c.caseId);
54
+ seen.add(c.caseId);
55
+ if (typeof c.dimension !== "string" || !DIMENSIONS.has(c.dimension))
56
+ fail("invalid-dimension", String(c.dimension));
57
+ if (!Array.isArray(c.acIds) ||
58
+ c.acIds.length === 0 ||
59
+ c.acIds.some((a) => typeof a !== "string" || !a.trim()))
60
+ fail("ac-mapping", `invalid acIds for ${c.caseId}`);
61
+ const acIds = [];
62
+ for (const ac of c.acIds) {
63
+ if (typeof ac !== "string" || !acIdRe.test(ac))
64
+ fail("ac-id-shape", `acIds entry must be AC-* acceptance id, not caseId: ${ac}`);
65
+ if (declaredAc.size > 0 && !declaredAc.has(ac))
66
+ fail("unknown-ac", `${ac} not in sourceBinding; repair generator input or AC list`);
67
+ acIds.push(ac);
68
+ }
69
+ const casePath = `testcase/frontend/cases/${c.caseId}.md`;
70
+ const evidenceDir = `testcase/frontend/evidence/${c.caseId}/`;
71
+ for (const [k, v] of [
72
+ ["casePath", casePath],
73
+ ["evidenceDir", evidenceDir],
74
+ ]) {
75
+ if (path.isAbsolute(v) || v.includes(".."))
76
+ fail("unsafe-path", `${k}: ${v}`);
77
+ }
78
+ const casePathAbs = path.join(input.workspaceRoot, casePath);
79
+ const body = await readFile(casePathAbs).catch(() => null);
80
+ if (body === null)
81
+ fail("case-file-missing", `missing case file ${casePath} (filename must equal caseId.md)`);
82
+ if (seenCasePath.has(casePath))
83
+ fail("duplicate-case-path", casePath);
84
+ seenCasePath.add(casePath);
85
+ if (seenEvidenceDir.has(evidenceDir))
86
+ fail("duplicate-evidence-dir", evidenceDir);
87
+ seenEvidenceDir.add(evidenceDir);
88
+ cases.push({
89
+ caseId: c.caseId,
90
+ casePath,
91
+ evidenceDir,
92
+ dimension: c.dimension,
93
+ acIds,
94
+ });
95
+ }
96
+ const payload = `${JSON.stringify({ schemaVersion: 1, cases }, null, 2)}\n`;
97
+ await mkdir(path.dirname(tmp), { recursive: true });
98
+ await writeFile(tmp, payload, "utf8");
99
+ await rename(tmp, file);
100
+ await unlink(draft).catch(() => {
101
+ /* draft already absent */
102
+ });
103
+ return { cases, manifestPath: file };
104
+ }