@tea-agent/loop-agent 0.39.0-beta.5 → 0.39.0-beta.7
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 +13 -1
- package/dist/build-stamp.json +3 -3
- package/dist/commands/client-recovery.js +3 -0
- package/dist/commands/task-advance.js +19 -0
- package/dist/executors/dag-pi-executor.js +138 -36
- package/dist/executors/pi-executor.js +7 -0
- package/dist/executors/pi-playwright-cli-tool.js +23 -16
- package/dist/executors/shell-executor.js +26 -9
- package/dist/shared/dag-failure-category.js +150 -0
- package/dist/task/config-types.js +6 -0
- package/dist/task/frontend-project-capability.js +19 -0
- package/dist/task/source-references.js +22 -1
- package/dist/worker/console/chat/operation-card.js +8 -1
- package/dist/worker/console/chat/routes.js +5 -3
- package/dist/worker/console/inspect-split.js +18 -0
- package/dist/worker/console/operation-run-facts.js +19 -3
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-zsNyvGaH.js → abnfDiagram-N423BO3Z-bPer1waP.js} +1 -1
- package/dist/worker/console/static/assets/{arc-BDjZ5kE1.js → arc-BLdkwucW.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BkgaujnN.js → architectureDiagram-T3A2C74G-Bw4wo4I1.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CqxOJi0j.js → blockDiagram-VBNYF7ZC-DUUkc0gr.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-C2JHLdRi.js → c4Diagram-5PPSVZJV-BC3O0nob.js} +1 -1
- package/dist/worker/console/static/assets/channel-AbFtVnFm.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DF_YSvOS.js → chunk-2GRJ4B5K-Dd4g2nti.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-D3Ort5Vo.js → chunk-2Q5K7J3B-s2EDm7-n.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-XfAfZeUC.js → chunk-5RXB4S5H-GqtHM2yQ.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-SLBibhf5.js → chunk-5VM5RSS4-BzRFrN8j.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Dz29Ye_f.js → chunk-6Q2QTUOP-Bw9nFjCJ.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-DB2G4H8V.js → chunk-GF5L2VYU-B4n-03Qm.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CIoGkxdu.js → chunk-JWPE2WC7-Dt75ZdBD.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-CG78FGkn.js → chunk-KBJHAD2P-B_afMhHB.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-ZDHAvXPd.js → chunk-RYQCIY6F-Bd8Rqqg9.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BKkkC4VA.js → chunk-XXDRQBXY-9boOMji4.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-n1yf2Jcv.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-n1yf2Jcv.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-SposUZsO.js → cose-bilkent-JH36ORCC-BtCycfCc.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BSCtQpD7.js → cynefin-VYW2F7L2-eTOts9L1.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DgFHrw9l.js → cynefinDiagram-MW4NZA55-Cibd2mjg.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BVBasYbj.js → dagre-VZM6K2ZE-CDjDZtDV.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-6r_4rN_c.js → diagram-7IWD3JNH-Btexn-JJ.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-JmuaB41P.js → diagram-B4RE2ZJO-Cyl8sdpM.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-xXvAY3sk.js → diagram-LBJQPF4R-Ev-83E1t.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D2B3lY2o.js → diagram-Q27KOJAE-41LqE0Mc.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-tK54tMEX.js → diagram-UB23O5K3-BC39T5R3.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-CNtELzV-.js → ebnfDiagram-BXEA7PRR-DzcNRuri.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-C1xKMKcW.js → erDiagram-JOGREHBK-B5Frvvnz.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CZ7dlPMz.js → flowDiagram-UKHOOZJN-BapxROIM.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BqGb42Xe.js → ganttDiagram-PKOTCBZU-Cd4d4wQ0.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-D_BlXEtt.js → gitGraphDiagram-DS77QQ5N-CrL6buFW.js} +1 -1
- package/dist/worker/console/static/assets/index-DBh_x_Ek.js +406 -0
- package/dist/worker/console/static/assets/index-DwOGauxU.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Vce2vmIe.js → infoDiagram-6WML65LV-BrSiNxyd.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DDwVBAvI.js → ishikawaDiagram-WSZJBQD7-gvlvY-n9.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-y_Ew-wv4.js → journeyDiagram-NVQOT4AX-D-YaPzRQ.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CjXJFp1K.js → kanban-definition-27J2QSJJ-BsmDv9E_.js} +1 -1
- package/dist/worker/console/static/assets/{linear-1itMst1Q.js → linear-UWaoDN3h.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-C-D3ZRUa.js → mermaid.core-BlvpGVG8.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BiYywmZc.js → mindmap-definition-FAOFIHXS-CUCIP5CP.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-l2fydNWA.js → pegDiagram-VL7TDLO6-CB1aQUAK.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-Dz0hNbYW.js → pieDiagram-7S7Q4E2Y-BwehHxhf.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-BujI_mLZ.js → quadrantDiagram-CIZ2JOQS-CFz30APB.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-pWe84beb.js → railroadDiagram-AXF67PYL-BwNmw1i6.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-ByYu2frg.js → requirementDiagram-LRYGKXZP-jg-tPXlI.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CoPnilFP.js → sankeyDiagram-W5VNT64P-BlyReDKl.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-1Md2LnS0.js → sequenceDiagram-SI44F4Z6-DBUzW1f3.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DqqqAoSH.js → sizeCapture-X5ZJPWSS-BYcI2RAr.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-C4sGkf--.js → stateDiagram-OKZ733FA-D7AiyJdo.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BBSH5c8m.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-ByxJrUzW.js → swimlanes-SLNWSIFB-BHGt7Kkv.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D2F7-U4H.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-D8MLgM0O.js → timeline-definition-Z64GVDOM-OPjooWcX.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DnBis-wZ.js → vennDiagram-T6HMQDX7-ggsWDvrO.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-uuyYT7UO.js → wardleyDiagram-T6FBY63Y-DjNe-BjZ.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-BjgabhwK.js → xychartDiagram-ELKLHX3M-Byp63Hei.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +38 -2
- package/dist/worker/console/static-src/app/useRunProgress.js +46 -0
- package/dist/worker/console/static-src/operator-chat/cards/failure-category-advice.js +25 -0
- package/dist/worker/console/static-src/operator-chat/input-history.js +76 -0
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +13 -1
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +29 -9
- package/dist/worker/console/static-src/operator-chat/useComposer.js +32 -0
- package/dist/worker/console/static-src/pages/tasks/run-id-resolution.js +79 -0
- package/dist/worker/console/static-src/pages/tasks/run-ownership-verify.js +23 -0
- package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +110 -0
- package/dist/worker/materialize/harness-task-lineage.js +5 -2
- package/dist/worker/observability/read-model.js +22 -0
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +29 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
- package/dist/workflows/dag/backend-test-pytest-collection.js +87 -4
- package/dist/workflows/dag/backend-test-writer-completeness.js +10 -5
- package/dist/workflows/dag/dynamic-runtime/loop-until.js +1 -1
- package/dist/workflows/dag/dynamic-runtime/map.js +1 -1
- package/dist/workflows/dag/failure-category.js +7 -128
- package/dist/workflows/dag/frontend-design-policy.js +31 -7
- package/dist/workflows/dag/frontend-implementation-contract.js +78 -3
- package/dist/workflows/dag/frontend-review-findings.js +238 -0
- package/dist/workflows/dag/frontend-shadow-dual-write.js +24 -10
- package/dist/workflows/dag/frontend-shape.js +5 -3
- package/dist/workflows/dag/frontend-test-case-checklist.js +4 -2
- package/dist/workflows/dag/frontend-test-case-manifest.js +6 -4
- package/dist/workflows/dag/frontend-test-case-quality.js +6 -3
- package/dist/workflows/dag/frontend-test-environment-probe.js +10 -7
- package/dist/workflows/dag/frontend-test-html-report.js +3 -1
- package/dist/workflows/dag/frontend-test-l5-report.js +3 -1
- package/dist/workflows/dag/frontend-test-layout.js +159 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +28 -14
- package/dist/workflows/dag/frontend-test-standard-scenarios.js +3 -1
- package/dist/workflows/dag/init-hybrid.js +149 -15
- package/dist/workflows/dag/node-execution.js +18 -1
- package/dist/workflows/dag/report.js +37 -1
- package/dist/workflows/dag/rerun-feedback.js +101 -1
- package/dist/workflows/dag/retry-policy.js +31 -3
- package/dist/workflows/dag/runner.js +46 -1
- package/dist/workflows/dag/types.js +12 -0
- package/dist/workflows/dag/validate.js +32 -22
- package/docs/templates/agent-dag-report.schema.json +8 -2
- package/docs/templates/agent-dag.schema.json +1 -1
- package/docs/templates/frontend-test-case-checklist.md +2 -2
- package/package.json +1 -1
- package/skills/fe-test-ui-scout/SKILL.md +4 -4
- package/skills/fe-test-ui-scout/references/ledger-schema.md +1 -1
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/playwright-cli-case-generator/SKILL.md +10 -8
- package/dist/worker/console/static/assets/channel-BuqaGKAT.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DNdSHjH9.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DNdSHjH9.js +0 -1
- package/dist/worker/console/static/assets/index-D5zGX6fG.js +0 -404
- package/dist/worker/console/static/assets/index-DBbhESQ_.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8rmop_9.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B0ky-kBM.js +0 -8
|
@@ -12,7 +12,7 @@ import { readTypedEventStoreFromJsonl } from "./frontend-typed-event-store.js";
|
|
|
12
12
|
// Cycle-safe value imports: the check functions are hoisted declarations used
|
|
13
13
|
// only at call time, so the contract module and the policy module can depend
|
|
14
14
|
// on each other without module-init ordering issues.
|
|
15
|
-
import { checkDependencies, checkUiDesignCoverage, checkUiStateAttribution, } from "./frontend-design-policy.js";
|
|
15
|
+
import { checkDependencies, checkTargetPaths, checkUiDesignCoverage, checkUiStateAttribution, } from "./frontend-design-policy.js";
|
|
16
16
|
import { canonicalContractRel, classifyStructuredContractFailure, formatStructuredArtifactPointer, persistRuntimeSkeleton, } from "./structured-output-repair.js";
|
|
17
17
|
import { isConfigurationVerificationFile, verificationSymbolMatchesContent, } from "./frontend-verification-trace.js";
|
|
18
18
|
export const frontendNormalizationActionSchema = z.enum([
|
|
@@ -142,6 +142,35 @@ async function deriveAllowedDependenciesFromRun(runDir) {
|
|
|
142
142
|
}
|
|
143
143
|
return [...deps];
|
|
144
144
|
}
|
|
145
|
+
/** Read the frozen implementation writeSet patterns from run.json so the plan
|
|
146
|
+
* validator can front-load checkTargetPaths (write-set-too-large) before the
|
|
147
|
+
* design-policy shell terminates the run. */
|
|
148
|
+
async function deriveWriteSetFromRun(runDir) {
|
|
149
|
+
const specPath = path.join(runDir, "run.json");
|
|
150
|
+
let raw;
|
|
151
|
+
try {
|
|
152
|
+
raw = await readFile(specPath, "utf8");
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
let spec;
|
|
158
|
+
try {
|
|
159
|
+
spec = JSON.parse(raw);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
const writeSet = new Set();
|
|
165
|
+
for (const task of spec.tasks ?? []) {
|
|
166
|
+
const patterns = task.shell?.frontendDesignPolicy?.implementationWriteSet;
|
|
167
|
+
if (patterns) {
|
|
168
|
+
for (const pattern of patterns)
|
|
169
|
+
writeSet.add(pattern);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return [...writeSet];
|
|
173
|
+
}
|
|
145
174
|
export const FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID = "frontend-implementation-contract-v1";
|
|
146
175
|
export const FRONTEND_IMPLEMENTATION_CONTRACT_PLAN_PATCH_SCHEMA_ID = "frontend-implementation-contract-plan-patch-v1";
|
|
147
176
|
/**
|
|
@@ -278,7 +307,10 @@ const uiComponentChoiceSchema = z
|
|
|
278
307
|
component: z.string().min(1),
|
|
279
308
|
decision: z.enum(["specified", "reuse-existing", "new"]),
|
|
280
309
|
specReference: uiComponentSpecReferenceSchema.nullable().optional(),
|
|
281
|
-
|
|
310
|
+
// Optional: the decision enum already carries the semantic (specified →
|
|
311
|
+
// specReference, new → deviation, reuse-existing → no rationale needed).
|
|
312
|
+
// Omit for reuse-existing to keep plan output small.
|
|
313
|
+
rationale: z.string().min(1).optional(),
|
|
282
314
|
})
|
|
283
315
|
.strict();
|
|
284
316
|
const uiComponentChoicesSchema = z.array(uiComponentChoiceSchema);
|
|
@@ -306,8 +338,50 @@ function stripNullValuesDeep(value) {
|
|
|
306
338
|
}
|
|
307
339
|
return value;
|
|
308
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Normalize a model-emitted `uiComponentChoices` where a `new` choice carries a
|
|
343
|
+
* degenerate empty `specReference` object (`{ path: "", section: "", line:
|
|
344
|
+
* null }`) instead of the required `null`. The model intent is unambiguous
|
|
345
|
+
* (new components must not carry a specReference; the empty object is a format
|
|
346
|
+
* slip, not a real spec hit), so dropping the empty object before validation
|
|
347
|
+
* avoids a full plan retry on an otherwise-correct emission. A `specReference`
|
|
348
|
+
* with a non-empty `path` is preserved (it is a genuine `specified` hit).
|
|
349
|
+
*/
|
|
350
|
+
export function normalizeNewComponentSpecReference(value) {
|
|
351
|
+
if (!Array.isArray(value))
|
|
352
|
+
return value;
|
|
353
|
+
return value.map((item) => {
|
|
354
|
+
if (!item || typeof item !== "object")
|
|
355
|
+
return item;
|
|
356
|
+
const record = item;
|
|
357
|
+
if (record.decision !== "new")
|
|
358
|
+
return item;
|
|
359
|
+
const ref = record.specReference;
|
|
360
|
+
if (!ref || typeof ref !== "object")
|
|
361
|
+
return item;
|
|
362
|
+
const refRecord = ref;
|
|
363
|
+
const path = typeof refRecord.path === "string" ? refRecord.path.trim() : "";
|
|
364
|
+
if (path.length > 0)
|
|
365
|
+
return item;
|
|
366
|
+
// Empty/degenerate specReference on a new choice → absent (null intent).
|
|
367
|
+
const { specReference: _dropped, ...rest } = record;
|
|
368
|
+
return rest;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
309
371
|
export const frontendImplementationContractSchema = z
|
|
310
|
-
.preprocess(
|
|
372
|
+
.preprocess((value) => {
|
|
373
|
+
const record = asRecord(value);
|
|
374
|
+
if (!record)
|
|
375
|
+
return stripNullValuesDeep(value);
|
|
376
|
+
return stripNullValuesDeep({
|
|
377
|
+
...record,
|
|
378
|
+
...(record.uiComponentChoices !== undefined
|
|
379
|
+
? {
|
|
380
|
+
uiComponentChoices: normalizeNewComponentSpecReference(record.uiComponentChoices),
|
|
381
|
+
}
|
|
382
|
+
: {}),
|
|
383
|
+
});
|
|
384
|
+
}, z
|
|
311
385
|
.object({
|
|
312
386
|
schemaVersion: z.literal(1),
|
|
313
387
|
sourceBinding: z
|
|
@@ -1971,6 +2045,7 @@ export async function validateFrontendPlanPatchNodeOutput(input) {
|
|
|
1971
2045
|
...checkUiDesignCoverage(analysis.canonical),
|
|
1972
2046
|
...checkUiStateAttribution(analysis.canonical),
|
|
1973
2047
|
...checkDependencies(analysis.canonical, await deriveAllowedDependenciesFromRun(input.runDir)),
|
|
2048
|
+
...checkTargetPaths(analysis.canonical, await deriveWriteSetFromRun(input.runDir)),
|
|
1974
2049
|
];
|
|
1975
2050
|
if (policyPreFindings.length > 0)
|
|
1976
2051
|
throw new Error(`frontend plan policy pre-check failed (fix these plan facts, then re-commit and finalize): ${policyPreFindings
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { readTypedEventStoreFromJsonl, } from "./frontend-typed-event-store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Frontend review findings → downstream work (direction A).
|
|
6
|
+
*
|
|
7
|
+
* When `frontend-review-pi` commits `request_review_changes`, the implementation
|
|
8
|
+
* must not silently finish: the run becomes "not passed" (partial_failed +
|
|
9
|
+
* terminalReason=review-request-changes), and the committed findings are
|
|
10
|
+
* rendered into a downstream-task PRD draft that a later DAG round can consume
|
|
11
|
+
* instead of repeating the previous flow. Rendering is deterministic (pure);
|
|
12
|
+
* only the JSONL read and PRD write are IO.
|
|
13
|
+
*/
|
|
14
|
+
export const FRONTEND_REVIEW_FINDINGS_PRD_REL = "contracts/frontend-review-findings-prd.md";
|
|
15
|
+
/** Review node id whose committed typed facts drive the findings PRD. */
|
|
16
|
+
export const FRONTEND_REVIEW_NODE_ID = "frontend-review-pi";
|
|
17
|
+
/** Deterministic issueCategory → failure owner mapping (A+B AC-009 §4.10). */
|
|
18
|
+
export function classifyReviewIssueCategoryFailureOwner(issueCategory) {
|
|
19
|
+
switch (issueCategory) {
|
|
20
|
+
case "implementation-mismatch":
|
|
21
|
+
return "implementation";
|
|
22
|
+
case "approved-design-defect":
|
|
23
|
+
return "plan";
|
|
24
|
+
case "target-surface-defect":
|
|
25
|
+
return "scout";
|
|
26
|
+
case "contract-requirement-gap":
|
|
27
|
+
return "contract";
|
|
28
|
+
case "unknown":
|
|
29
|
+
default:
|
|
30
|
+
return "unknown";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const API_SIGNAL_RE = /\b(api|fetch|axios|graphql|endpoint|远程|接口)\b/i;
|
|
34
|
+
/** Negations for the API signal. CJK "无 API/无接口" has no \b boundary, so
|
|
35
|
+
* it is matched separately from the ASCII no api/not-needed forms. */
|
|
36
|
+
const API_NEGATION_RE = /(?:^|[^\w])(?:no\s+api|not-needed)(?:$|[^\w])|无\s*api|无\s*接口/i;
|
|
37
|
+
const SMALL_SIGNAL_WORDS = [
|
|
38
|
+
"单文件局部修复",
|
|
39
|
+
"局部样式",
|
|
40
|
+
"文案调整",
|
|
41
|
+
"无远程接口",
|
|
42
|
+
];
|
|
43
|
+
function topLevelDir(p) {
|
|
44
|
+
const normalized = p.replace(/\\/g, "/");
|
|
45
|
+
const first = normalized.split("/").filter(Boolean)[0] ?? "";
|
|
46
|
+
return first;
|
|
47
|
+
}
|
|
48
|
+
/** Collect unique non-empty finding `file` values (sorted, de-duplicated). */
|
|
49
|
+
function findingTargetFiles(fact) {
|
|
50
|
+
const files = new Set();
|
|
51
|
+
for (const finding of fact.findings) {
|
|
52
|
+
if (typeof finding.file === "string" && finding.file.trim()) {
|
|
53
|
+
files.add(finding.file.trim());
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...files].sort();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Deterministically derive the fix scope for the downstream task. Same fact
|
|
60
|
+
* always yields the same scope. The shape mirrors `resolveFrontendTaskShape`
|
|
61
|
+
* signals (concentrated paths, no remote API → micro/small) so that when the
|
|
62
|
+
* PRD draft is used as the next task's requirement, the frontend DAG generator
|
|
63
|
+
* routes a small fix to micro/small instead of the full 11-node standard
|
|
64
|
+
* topology.
|
|
65
|
+
*/
|
|
66
|
+
export function deriveFrontendReviewFixScope(fact) {
|
|
67
|
+
const targetFiles = findingTargetFiles(fact);
|
|
68
|
+
const blob = fact.findings
|
|
69
|
+
.map((f) => `${f.issue} ${f.requiredChange ?? ""} ${f.file ?? ""}`)
|
|
70
|
+
.join("\n");
|
|
71
|
+
const hasApiSignal = API_SIGNAL_RE.test(blob) && !API_NEGATION_RE.test(blob);
|
|
72
|
+
const topDirs = new Set(targetFiles.map(topLevelDir));
|
|
73
|
+
const concentrated = targetFiles.length > 0 && topDirs.size <= 1 && targetFiles.length <= 4;
|
|
74
|
+
let shape;
|
|
75
|
+
let complexity;
|
|
76
|
+
let reason;
|
|
77
|
+
if (targetFiles.length === 0) {
|
|
78
|
+
shape = "standard";
|
|
79
|
+
complexity = "medium";
|
|
80
|
+
reason =
|
|
81
|
+
"findings carry no file references; scope unknown, keep standard topology";
|
|
82
|
+
}
|
|
83
|
+
else if (hasApiSignal) {
|
|
84
|
+
shape = "standard";
|
|
85
|
+
complexity = "medium";
|
|
86
|
+
reason = `findings reference a remote API signal; standard topology required (files=${targetFiles.length})`;
|
|
87
|
+
}
|
|
88
|
+
else if (concentrated && targetFiles.length <= 1) {
|
|
89
|
+
shape = "micro";
|
|
90
|
+
complexity = "trivial";
|
|
91
|
+
reason = "single-file concentrated fix without remote API";
|
|
92
|
+
}
|
|
93
|
+
else if (concentrated) {
|
|
94
|
+
shape = "small";
|
|
95
|
+
complexity = "low";
|
|
96
|
+
reason = `concentrated fix across ${targetFiles.length} file(s) in one top-level directory`;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
shape = "standard";
|
|
100
|
+
complexity = "medium";
|
|
101
|
+
reason = `fix spans ${targetFiles.length} file(s) across ${topDirs.size} top-level directories; standard topology`;
|
|
102
|
+
}
|
|
103
|
+
const allowedPaths = [
|
|
104
|
+
...new Set([
|
|
105
|
+
...targetFiles,
|
|
106
|
+
...targetFiles.map((f) => `${topLevelDir(f)}/**`),
|
|
107
|
+
]),
|
|
108
|
+
].sort();
|
|
109
|
+
return {
|
|
110
|
+
shape,
|
|
111
|
+
complexity,
|
|
112
|
+
targetFiles,
|
|
113
|
+
allowedPaths,
|
|
114
|
+
smallSignals: [...SMALL_SIGNAL_WORDS],
|
|
115
|
+
hasApiSignal,
|
|
116
|
+
reason,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Read the committed `request_review_changes` fact from the review node's typed
|
|
121
|
+
* event store. Returns undefined when no committed request fact exists (e.g.
|
|
122
|
+
* approve_review, missing store, or an unreadable store).
|
|
123
|
+
*/
|
|
124
|
+
export async function readCommittedReviewRequestFact(input) {
|
|
125
|
+
const reviewNodeId = input.reviewNodeId ?? FRONTEND_REVIEW_NODE_ID;
|
|
126
|
+
let records;
|
|
127
|
+
try {
|
|
128
|
+
records = await readTypedEventStoreFromJsonl(path.join(input.runDir, reviewNodeId, "review-typed-facts.jsonl"));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const committed = records.filter((record) => record.phase === "committed");
|
|
134
|
+
if (committed.length === 0)
|
|
135
|
+
return undefined;
|
|
136
|
+
// A committed approve_review means the review passed; only a committed
|
|
137
|
+
// request_review_changes carries a "not passed" verdict.
|
|
138
|
+
const request = committed.find((record) => record.fact.kind === "request_review_changes");
|
|
139
|
+
if (!request)
|
|
140
|
+
return undefined;
|
|
141
|
+
const fact = request.fact;
|
|
142
|
+
if (typeof fact !== "object" || fact === null)
|
|
143
|
+
return undefined;
|
|
144
|
+
return fact;
|
|
145
|
+
}
|
|
146
|
+
function bulletList(items) {
|
|
147
|
+
if (!items || items.length === 0)
|
|
148
|
+
return "_(none)_";
|
|
149
|
+
return items.map((item) => `- ${item}`).join("\n");
|
|
150
|
+
}
|
|
151
|
+
function findingLines(findings) {
|
|
152
|
+
if (findings.length === 0)
|
|
153
|
+
return "_(none)_";
|
|
154
|
+
return findings
|
|
155
|
+
.map((finding) => {
|
|
156
|
+
const location = [finding.file, finding.line]
|
|
157
|
+
.filter((value) => value !== undefined)
|
|
158
|
+
.join(":");
|
|
159
|
+
const prefix = location ? `[${location}] ` : "";
|
|
160
|
+
const requiredChange = finding.requiredChange
|
|
161
|
+
? `\n - requiredChange: ${finding.requiredChange}`
|
|
162
|
+
: "";
|
|
163
|
+
return `- ${prefix}${finding.severity}: ${finding.issue}${requiredChange}`;
|
|
164
|
+
})
|
|
165
|
+
.join("\n");
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Deterministically render a downstream-task PRD draft from a committed
|
|
169
|
+
* `request_review_changes` fact. Same fact always renders byte-identical
|
|
170
|
+
* output; the parent run id and review node id are the only context.
|
|
171
|
+
*/
|
|
172
|
+
export function renderFrontendReviewFindingsPrd(input) {
|
|
173
|
+
const { parentRunId, fact } = input;
|
|
174
|
+
const reviewNodeId = input.reviewNodeId ?? FRONTEND_REVIEW_NODE_ID;
|
|
175
|
+
const restartPhase = classifyReviewIssueCategoryFailureOwner(fact.issueCategory);
|
|
176
|
+
const scope = deriveFrontendReviewFixScope(fact);
|
|
177
|
+
const targetSummary = scope.targetFiles.length > 0
|
|
178
|
+
? scope.targetFiles.join(", ")
|
|
179
|
+
: fact.issueCategory;
|
|
180
|
+
return [
|
|
181
|
+
`# 修复 review 发现(${scope.shape}):${targetSummary}`,
|
|
182
|
+
"",
|
|
183
|
+
"## 背景",
|
|
184
|
+
"",
|
|
185
|
+
`- parentRunId: \`${parentRunId}\``,
|
|
186
|
+
`- reviewNodeId: \`${reviewNodeId}\``,
|
|
187
|
+
`- typedVerdict: \`request_review_changes\``,
|
|
188
|
+
`- issueCategory: \`${fact.issueCategory}\``,
|
|
189
|
+
`- restartPhase/failureOwner: \`${restartPhase}\``,
|
|
190
|
+
"",
|
|
191
|
+
"## 修复范围(供下一轮任务生成路由)",
|
|
192
|
+
"",
|
|
193
|
+
`- 建议 shape:\`${scope.shape}\`(micro=7 / small=10 / standard=11 节点)`,
|
|
194
|
+
`- 建议 complexity:\`${scope.complexity}\``,
|
|
195
|
+
`- 推导依据:${scope.reason}`,
|
|
196
|
+
...(scope.hasApiSignal ? ["- ⚠️ findings 含远程 API/接口信号,走 standard 拓扑"] : []),
|
|
197
|
+
scope.targetFiles.length > 0
|
|
198
|
+
? [`- 目标文件:${scope.targetFiles.map((f) => `\`${f}\``).join(", ")}`]
|
|
199
|
+
: ["- 目标文件:_(findings 未提供 file 引用,需人工补全)_"],
|
|
200
|
+
scope.allowedPaths.length > 0
|
|
201
|
+
? [`- 建议 allowedPaths:${scope.allowedPaths.map((p) => `\`${p}\``).join(", ")}`]
|
|
202
|
+
: [],
|
|
203
|
+
scope.shape === "micro" || scope.shape === "small"
|
|
204
|
+
? [`- 范围信号:${scope.smallSignals.join("、")}(供 shape 分类器识别,避免退化为全节点流程)`]
|
|
205
|
+
: [],
|
|
206
|
+
"",
|
|
207
|
+
"## 问题清单",
|
|
208
|
+
"",
|
|
209
|
+
findingLines(fact.findings),
|
|
210
|
+
"",
|
|
211
|
+
"## 证据引用",
|
|
212
|
+
"",
|
|
213
|
+
bulletList(fact.evidenceRefs),
|
|
214
|
+
"",
|
|
215
|
+
"## 修复目标",
|
|
216
|
+
"",
|
|
217
|
+
`- 按 issueCategory 映射的重启阶段(${restartPhase})定点修复,不复用上一轮未通过的实现。`,
|
|
218
|
+
"- 逐条关闭上面「问题清单」中的 findings,并保留上一轮已通过的验证目标继续有效。",
|
|
219
|
+
"- 完成修复后重新走 frontend DAG 的 implement → verify → review 链,直到 review 提交 approve_review。",
|
|
220
|
+
"",
|
|
221
|
+
].join("\n");
|
|
222
|
+
}
|
|
223
|
+
/** Write the findings PRD draft under the run dir (run-owned artifact). */
|
|
224
|
+
export async function writeFrontendReviewFindingsPrd(input) {
|
|
225
|
+
const markdown = renderFrontendReviewFindingsPrd(input);
|
|
226
|
+
const abs = path.join(input.runDir, FRONTEND_REVIEW_FINDINGS_PRD_REL);
|
|
227
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
228
|
+
await writeFile(abs, markdown, "utf8");
|
|
229
|
+
return FRONTEND_REVIEW_FINDINGS_PRD_REL;
|
|
230
|
+
}
|
|
231
|
+
export async function readFrontendReviewFindingsPrd(runDir) {
|
|
232
|
+
try {
|
|
233
|
+
return await readFile(path.join(runDir, FRONTEND_REVIEW_FINDINGS_PRD_REL), "utf8");
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -291,15 +291,17 @@ export function assemblePlanPatchFromCommittedFacts(records) {
|
|
|
291
291
|
if (routes.length > 0)
|
|
292
292
|
patch.targets = { routes };
|
|
293
293
|
}
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
294
|
+
const componentChoices = facts.filter((fact) => fact.kind === "component-choice");
|
|
295
|
+
const collectedChoices = componentChoices.flatMap((fact) => Array.isArray(fact.uiComponentChoices)
|
|
296
|
+
? fact.uiComponentChoices.filter(isRecord)
|
|
297
|
+
: []);
|
|
298
|
+
const stylingStrategy = componentChoices
|
|
299
|
+
.map((fact) => asString(fact.stylingStrategy))
|
|
300
|
+
.find((value) => value.length > 0);
|
|
301
|
+
if (collectedChoices.length > 0)
|
|
302
|
+
patch.uiComponentChoices = collectedChoices;
|
|
303
|
+
if (stylingStrategy)
|
|
304
|
+
patch.stylingStrategy = stylingStrategy;
|
|
303
305
|
const stateFlow = facts.find((fact) => fact.kind === "state-flow");
|
|
304
306
|
if (stateFlow) {
|
|
305
307
|
if (Array.isArray(stateFlow.uiStates)) {
|
|
@@ -330,7 +332,19 @@ export function assemblePlanPatchFromCommittedFacts(records) {
|
|
|
330
332
|
const requirements = facts
|
|
331
333
|
.filter((fact) => fact.kind === "plan-requirement")
|
|
332
334
|
.map((fact) => fact.entry)
|
|
333
|
-
.filter(isRecord)
|
|
335
|
+
.filter(isRecord)
|
|
336
|
+
.map((entry) => {
|
|
337
|
+
// The canonical contract schema requires a non-empty
|
|
338
|
+
// expectedOutcome, but the plan should not be forced to re-write
|
|
339
|
+
// full outcome prose for every requirement (the contract node
|
|
340
|
+
// already captured requirement semantics). Derive a deterministic
|
|
341
|
+
// placeholder from the requirement id when omitted.
|
|
342
|
+
if (isRecord(entry) && !asString(entry.expectedOutcome)) {
|
|
343
|
+
const id = asString(entry.id) || "requirement";
|
|
344
|
+
return { ...entry, expectedOutcome: `implied by contract: ${id}` };
|
|
345
|
+
}
|
|
346
|
+
return entry;
|
|
347
|
+
});
|
|
334
348
|
if (requirements.length > 0)
|
|
335
349
|
patch.requirements = requirements;
|
|
336
350
|
const verificationTargets = facts
|
|
@@ -29,7 +29,7 @@ const SHAPE_ESCALATION_PATTERNS = [
|
|
|
29
29
|
];
|
|
30
30
|
const SHAPE_SMALL_PATTERNS = [
|
|
31
31
|
{ id: "single-component", re: /single\s+component|单一组件|局部样式|style\s+tweak|css\s+only|文案|copy\s+change/i },
|
|
32
|
-
{ id: "no-remote", re: /no\s+api
|
|
32
|
+
{ id: "no-remote", re: /no\s+api|无\s*api|无\s*接口|本地状态|local\s+state\s+only|pure\s+ui/i },
|
|
33
33
|
];
|
|
34
34
|
const SHAPE_SPLIT_REQUIRED_PATTERN = /拆分|split\s+(?:requirement|task|需求)|large\s+requirement|大型需求|多步骤需求/i;
|
|
35
35
|
const EXECUTABLE_SHAPE_RANK = {
|
|
@@ -77,7 +77,8 @@ function resolveBaselineFrontendTaskShape(input) {
|
|
|
77
77
|
const spread = pathSpreadScore(paths);
|
|
78
78
|
const concentrated = spread <= 1 && paths.length <= 4;
|
|
79
79
|
const hasApiSignal = /\b(api|fetch|axios|graphql|endpoint|远程|接口)\b/i.test(blob) &&
|
|
80
|
-
|
|
80
|
+
!(/(?:^|[^\w])(?:no\s+api|not-needed)(?:$|[^\w])/i.test(blob) ||
|
|
81
|
+
/无\s*api|无\s*接口/i.test(blob));
|
|
81
82
|
const complexity = (input.complexity ?? "").toLowerCase();
|
|
82
83
|
// split-required wins over everything: it is a termination/escalation
|
|
83
84
|
// signal, not an executable topology.
|
|
@@ -258,7 +259,8 @@ export function deriveFrontendRuntimeShapeFacts(input) {
|
|
|
258
259
|
const escalationSignals = collectEscalationSignals(blob, supervised);
|
|
259
260
|
const paths = input.allowedPaths ?? [];
|
|
260
261
|
const hasApiSignal = /\b(api|fetch|axios|graphql|endpoint|远程|接口)\b/i.test(blob) &&
|
|
261
|
-
|
|
262
|
+
!(/(?:^|[^\w])(?:no\s+api|not-needed)(?:$|[^\w])/i.test(blob) ||
|
|
263
|
+
/无\s*api|无\s*接口/i.test(blob));
|
|
262
264
|
const requiresFull = escalationSignals.length > 0 || pathSpreadScore(paths) > 1 || hasApiSignal;
|
|
263
265
|
return {
|
|
264
266
|
splitRequired: input.splitRequired,
|
|
@@ -2,6 +2,7 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { isPlaywrightCliCommand } from "../../shared/playwright-cli-command-policy.js";
|
|
4
4
|
import { isTruncatedFrontendAcId, isTruncatedWhenUndeclaredFrontendAcId, } from "./frontend-test-result-contract.js";
|
|
5
|
+
import { defaultFrontendTestLayout, frontendTestCasePath, } from "./frontend-test-layout.js";
|
|
5
6
|
const COMMAND_FENCE_LANGUAGE = /^(?:|bash|sh|shell|zsh|fish|console|terminal|command|cmd|powershell|pwsh|javascript|js|typescript|ts|python|py)$/i;
|
|
6
7
|
const LIST_ITEM = /^\s*(?:[-+*]|\d+[.)])\s+(.+)$/;
|
|
7
8
|
const INDENTED_CODE = /^(?: {4,}|\t+)(\S.*)$/;
|
|
@@ -290,7 +291,8 @@ function commandGateIssue(input) {
|
|
|
290
291
|
};
|
|
291
292
|
}
|
|
292
293
|
export async function validateFrontendCaseChecklist(input) {
|
|
293
|
-
const
|
|
294
|
+
const layout = input.layout ?? defaultFrontendTestLayout();
|
|
295
|
+
const root = path.join(input.workspaceRoot, layout.casesDir);
|
|
294
296
|
const draft = path.join(root, "manifest.draft.json");
|
|
295
297
|
const final = path.join(root, "manifest.json");
|
|
296
298
|
const manifestPath = await exists(draft) ? draft : await exists(final) ? final : "";
|
|
@@ -319,7 +321,7 @@ export async function validateFrontendCaseChecklist(input) {
|
|
|
319
321
|
if (typeof item?.caseId === "string" && /^AC-/i.test(item.caseId))
|
|
320
322
|
issues.push({ ruleId: "case-id-is-ac", caseId: id, detail: "caseId must not be an acceptance id" });
|
|
321
323
|
const casePath = typeof item?.casePath === "string" ? item.casePath : "";
|
|
322
|
-
const expectedPath = typeof item?.caseId === "string" ?
|
|
324
|
+
const expectedPath = typeof item?.caseId === "string" ? frontendTestCasePath(layout, item.caseId) : "";
|
|
323
325
|
const absolute = path.resolve(input.workspaceRoot, casePath);
|
|
324
326
|
const relative = path.relative(input.workspaceRoot, absolute);
|
|
325
327
|
if (!casePath || relative.startsWith("..") || path.isAbsolute(relative) || !(await exists(absolute))) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile, rename, unlink, writeFile, mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { isTruncatedFrontendAcId, isTruncatedWhenUndeclaredFrontendAcId, } from "./frontend-test-result-contract.js";
|
|
4
|
+
import { defaultFrontendTestLayout, frontendTestCasePath, frontendTestEvidenceDir, frontendTestManifestDraftPath, frontendTestManifestPath, } from "./frontend-test-layout.js";
|
|
4
5
|
const DIMENSIONS = new Set(["core", "boundary", "flow", "backend"]);
|
|
5
6
|
function fail(ruleId, detail) {
|
|
6
7
|
const msg = JSON.stringify({ ruleId, detail: String(detail ?? "") });
|
|
@@ -19,8 +20,9 @@ function fail(ruleId, detail) {
|
|
|
19
20
|
* present). When empty, ac-id-shape still applies.
|
|
20
21
|
*/
|
|
21
22
|
export async function materializeFrontendTestCaseManifest(input) {
|
|
22
|
-
const
|
|
23
|
-
const
|
|
23
|
+
const layout = input.layout ?? defaultFrontendTestLayout();
|
|
24
|
+
const draft = path.join(input.workspaceRoot, frontendTestManifestDraftPath(layout));
|
|
25
|
+
const file = path.join(input.workspaceRoot, frontendTestManifestPath(layout));
|
|
24
26
|
const tmp = `${file}.tmp`;
|
|
25
27
|
const maxCases = input.maxCases ?? 32;
|
|
26
28
|
const declaredAc = new Set(input.declaredAcIds ?? []);
|
|
@@ -71,8 +73,8 @@ export async function materializeFrontendTestCaseManifest(input) {
|
|
|
71
73
|
fail("unknown-ac", `${ac} not in sourceBinding; repair generator input or AC list`);
|
|
72
74
|
acIds.push(ac);
|
|
73
75
|
}
|
|
74
|
-
const casePath =
|
|
75
|
-
const evidenceDir =
|
|
76
|
+
const casePath = frontendTestCasePath(layout, c.caseId);
|
|
77
|
+
const evidenceDir = frontendTestEvidenceDir(layout, c.caseId);
|
|
76
78
|
for (const [k, v] of [
|
|
77
79
|
["casePath", casePath],
|
|
78
80
|
["evidenceDir", evidenceDir],
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { markdownSection } from "./frontend-test-markdown.js";
|
|
4
|
+
import { defaultFrontendTestLayout, frontendTestCasePath, } from "./frontend-test-layout.js";
|
|
4
5
|
const CASE_ID = /^FE-[A-Za-z0-9]+-\d{3}-(?:core|boundary|flow|backend)$/i;
|
|
5
6
|
const AC_ID = /^AC(?:-[A-Z0-9]+)+$/i;
|
|
6
7
|
const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|结果正确|页面正常|按实际情况处理|验证成功/i;
|
|
@@ -15,7 +16,8 @@ const SECTION_ALIASES = {
|
|
|
15
16
|
function hasList(value) { return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value); }
|
|
16
17
|
function hasAssertion(value) { return /(?:status|状态|包含|显示|等于|为|可见|不可见|跳转|错误|成功|失败|should|expect|assert|must)/i.test(value); }
|
|
17
18
|
export async function validateFrontendCaseContent(input) {
|
|
18
|
-
const
|
|
19
|
+
const layout = input.layout ?? defaultFrontendTestLayout();
|
|
20
|
+
const root = path.join(input.workspaceRoot, layout.casesDir);
|
|
19
21
|
const manifestPath = path.join(root, "manifest.json");
|
|
20
22
|
const findings = [];
|
|
21
23
|
let manifest;
|
|
@@ -49,7 +51,7 @@ export async function validateFrontendCaseContent(input) {
|
|
|
49
51
|
findings.push({ ruleId: "unknown-ac", caseId: id, detail: `${ac} is not declared by the task` });
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
|
-
const file = path.join(input.workspaceRoot, item?.casePath ||
|
|
54
|
+
const file = path.join(input.workspaceRoot, item?.casePath || frontendTestCasePath(layout, id));
|
|
53
55
|
let body = "";
|
|
54
56
|
try {
|
|
55
57
|
body = await readFile(file, "utf8");
|
|
@@ -89,7 +91,8 @@ function render(findings) {
|
|
|
89
91
|
}
|
|
90
92
|
export async function writeFrontendCaseQualityReports(input) {
|
|
91
93
|
const result = await validateFrontendCaseContent(input);
|
|
92
|
-
const
|
|
94
|
+
const layout = input.layout ?? defaultFrontendTestLayout();
|
|
95
|
+
const dir = path.join(input.workspaceRoot, input.outputDir ?? layout.reportsDir);
|
|
93
96
|
await mkdir(dir, { recursive: true });
|
|
94
97
|
await writeFile(path.join(dir, "frontend-test-case-quality-advisory.md"), result.markdown, "utf8");
|
|
95
98
|
await writeFile(path.join(dir, "frontend-test-case-quality-advisory.html"), result.html, "utf8");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { defaultFrontendTestLayout, frontendTestContextRel, frontendTestProbeRel, } from "./frontend-test-layout.js";
|
|
4
5
|
export const FRONTEND_TEST_CONTEXT_REL = "testcase/frontend/rag/context.md";
|
|
5
6
|
export const FRONTEND_TEST_PROBE_REL = "testcase/frontend/rag/environment-probe.json";
|
|
6
7
|
const CONNECTION_REFUSED_HINT = "被测服务未在 baseUrl 监听(connection refused)。请先启动本地前端(例如 scripts/serve.sh 或项目约定的 npm start),确认可访问后再从 materialize-frontend-test-execution-shell 续跑。";
|
|
@@ -65,9 +66,9 @@ function resolveBaseUrl(context) {
|
|
|
65
66
|
}
|
|
66
67
|
return null;
|
|
67
68
|
}
|
|
68
|
-
async function writeProbe(workspaceRoot, probe) {
|
|
69
|
-
const probePath = path.join(workspaceRoot,
|
|
70
|
-
const contextPath = path.join(workspaceRoot,
|
|
69
|
+
async function writeProbe(workspaceRoot, probe, layout) {
|
|
70
|
+
const probePath = path.join(workspaceRoot, frontendTestProbeRel(layout));
|
|
71
|
+
const contextPath = path.join(workspaceRoot, frontendTestContextRel(layout));
|
|
71
72
|
await mkdir(path.dirname(probePath), { recursive: true });
|
|
72
73
|
await writeFile(probePath, `${JSON.stringify(probe, null, 2)}\n`, "utf8");
|
|
73
74
|
let context = "";
|
|
@@ -84,14 +85,16 @@ async function writeProbe(workspaceRoot, probe) {
|
|
|
84
85
|
await writeFile(contextPath, next, "utf8");
|
|
85
86
|
}
|
|
86
87
|
export async function probeFrontendTestEnvironment(input) {
|
|
88
|
+
const layout = input.layout ?? defaultFrontendTestLayout();
|
|
87
89
|
const curl = input.curl ?? defaultCurlRunner;
|
|
88
|
-
const
|
|
90
|
+
const contextRel = frontendTestContextRel(layout);
|
|
91
|
+
const contextPath = path.join(input.workspaceRoot, contextRel);
|
|
89
92
|
let context;
|
|
90
93
|
try {
|
|
91
94
|
context = await readFile(contextPath, "utf8");
|
|
92
95
|
}
|
|
93
96
|
catch {
|
|
94
|
-
return { ok: false, stderr: `missing ${
|
|
97
|
+
return { ok: false, stderr: `missing ${contextRel}` };
|
|
95
98
|
}
|
|
96
99
|
const rawUrl = resolveBaseUrl(context);
|
|
97
100
|
if (!rawUrl) {
|
|
@@ -139,7 +142,7 @@ export async function probeFrontendTestEnvironment(input) {
|
|
|
139
142
|
...(extra.hint ? { hint: extra.hint } : {}),
|
|
140
143
|
...(extra.baseUrlSource ? { baseUrlSource: extra.baseUrlSource } : {}),
|
|
141
144
|
};
|
|
142
|
-
await writeProbe(input.workspaceRoot, probe);
|
|
145
|
+
await writeProbe(input.workspaceRoot, probe, layout);
|
|
143
146
|
const payload = {
|
|
144
147
|
blockedReason: reason,
|
|
145
148
|
baseUrl: probe.baseUrlRedacted,
|
|
@@ -218,7 +221,7 @@ export async function probeFrontendTestEnvironment(input) {
|
|
|
218
221
|
curlExit: result.status,
|
|
219
222
|
errorClass: null,
|
|
220
223
|
};
|
|
221
|
-
await writeProbe(input.workspaceRoot, probe);
|
|
224
|
+
await writeProbe(input.workspaceRoot, probe, layout);
|
|
222
225
|
return {
|
|
223
226
|
ok: true,
|
|
224
227
|
stdout: `frontend-test-execution-v1 validated contextBaseUrl=${safe} source=${baseUrlSource} probe=reachable method=${used} httpStatus=${statusNum}`,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { frontendTestResultContractSchema } from "./frontend-test-result-contract.js";
|
|
4
|
+
import { defaultFrontendTestLayout, } from "./frontend-test-layout.js";
|
|
4
5
|
function escapeHtml(value) {
|
|
5
6
|
return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
6
7
|
}
|
|
@@ -80,7 +81,8 @@ async function writePairAtomic(markdownPath, markdown, htmlPath, html) {
|
|
|
80
81
|
export async function renderFrontendTestHtmlReport(input) {
|
|
81
82
|
const resultPath = path.join(input.runDir, "contracts", "frontend-test-result.json");
|
|
82
83
|
const result = frontendTestResultContractSchema.parse(JSON.parse(await readFile(resultPath, "utf8")));
|
|
83
|
-
const
|
|
84
|
+
const layout = input.layout ?? defaultFrontendTestLayout();
|
|
85
|
+
const outputDir = path.join(input.workspaceRoot, layout.reportsDir);
|
|
84
86
|
const markdownPath = path.join(outputDir, "frontend-test-report.md");
|
|
85
87
|
const htmlPath = path.join(outputDir, "frontend-test-report.html");
|
|
86
88
|
const outcomeLabel = result.outcome === "passed" ? "测试通过" : result.outcome === "failed" ? "测试失败" : "测试未完成";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { frontendTestResultContractSchema, } from "./frontend-test-result-contract.js";
|
|
4
|
+
import { defaultFrontendTestLayout, } from "./frontend-test-layout.js";
|
|
4
5
|
function ratioMetric(numerator, denominator, threshold, label) {
|
|
5
6
|
if (denominator === 0) {
|
|
6
7
|
return { numerator, denominator, ratio: null, threshold, status: "unavailable", reason: `${label}-denominator-is-zero` };
|
|
@@ -169,7 +170,8 @@ export async function renderFrontendTestL5Report(input) {
|
|
|
169
170
|
const resultPath = path.join(input.runDir, "contracts", "frontend-test-result.json");
|
|
170
171
|
const result = frontendTestResultContractSchema.parse(JSON.parse(await readFile(resultPath, "utf8")));
|
|
171
172
|
const metrics = computeFrontendL5ReportMetrics(result);
|
|
172
|
-
const
|
|
173
|
+
const layout = input.layout ?? defaultFrontendTestLayout();
|
|
174
|
+
const outputDir = path.join(input.workspaceRoot, layout.reportsDir);
|
|
173
175
|
const markdownPath = path.join(outputDir, "frontend-test-l5-dashboard.md");
|
|
174
176
|
const htmlPath = path.join(outputDir, "frontend-test-l5-dashboard.html");
|
|
175
177
|
await writePairAtomic(markdownPath, renderMarkdown(result, metrics), htmlPath, renderHtml(result, metrics));
|