@tea-agent/loop-agent 0.27.0 → 0.27.1-beta.0
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 +4 -0
- package/dist/executors/dag-pi-executor.js +18 -22
- package/dist/executors/shell-executor.js +41 -25
- package/dist/workflows/dag/backend-test-markdown-workflow.js +49 -23
- package/dist/workflows/dag/backend-test-pytest-collection.js +57 -9
- package/dist/workflows/dag/frontend-implementation-contract.js +72 -6
- package/dist/workflows/dag/frontend-prewrite-gate.js +35 -5
- package/dist/workflows/dag/frontend-test-l5-report.js +46 -7
- package/dist/workflows/dag/init-hybrid.js +64 -38
- package/dist/workflows/dag/output-protocol.js +23 -0
- package/docs/templates/backend-test-dag.json +2 -2
- package/docs/templates/frontend-implementation-contract.schema.json +2 -2
- package/harness.json +4 -4
- package/package.json +1 -1
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/skills/loop-agent/references/source-and-plan-practice.md +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
### 修复
|
|
6
|
+
|
|
7
|
+
- 修复 backend-test collection assess 在 Markdown 已声明安全 mapped `test_*.py`、但 pytest writer 漏生成文件时直接以 `invalid-output` 阻断 repair 的问题:initial facts 升级为 `backend-test-pytest-collection-v2`,可记录 expected/existing/missing scripts、`collectionAttempted=false` 与 `missing-mapped-pytest-script`,复用既有单次 bounded repair 创建精确缺失脚本,并由 final collection、scope、安全和 hash freshness 门禁重新授权业务执行
|
|
8
|
+
|
|
5
9
|
## [0.27.0] - 2026-08-03
|
|
6
10
|
|
|
7
11
|
### 重点更新
|
|
@@ -576,32 +576,19 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
576
576
|
export function validateWriterImplementationOutcome(text, changedFiles) {
|
|
577
577
|
const parsed = parseWriterImplementationOutcome(text);
|
|
578
578
|
const diagnostics = writerOutcomeDiagnostics(text, parsed, changedFiles.length);
|
|
579
|
-
|
|
579
|
+
// The workspace diff is authoritative. The model outcome is advisory and
|
|
580
|
+
// may be missing, malformed, or inconsistent without blocking a completed
|
|
581
|
+
// writer. An explicit blocked signal remains a hard failure.
|
|
582
|
+
if (parsed.candidates.some((candidate) => candidate.outcome === "blocked")) {
|
|
580
583
|
return {
|
|
581
584
|
ok: false,
|
|
582
|
-
reason: `writer outcome validation failed:
|
|
585
|
+
reason: `writer outcome validation failed: explicit blocked outcome; ${diagnostics}`,
|
|
583
586
|
};
|
|
584
587
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
reason: `writer outcome validation failed: IMPLEMENTATION_OUTCOME: blocked cannot complete successfully; ${diagnostics}`,
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
if (outcome === "changed" && changedFiles.length === 0) {
|
|
593
|
-
return {
|
|
594
|
-
ok: false,
|
|
595
|
-
reason: `writer outcome validation failed: changed outcome has an empty diff; ${diagnostics}`,
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
if (outcome === "already-satisfied" && changedFiles.length > 0) {
|
|
599
|
-
return {
|
|
600
|
-
ok: false,
|
|
601
|
-
reason: `writer outcome validation failed: already-satisfied outcome has a non-empty diff; ${diagnostics}`,
|
|
602
|
-
};
|
|
603
|
-
}
|
|
604
|
-
return { ok: true, outcome };
|
|
588
|
+
return {
|
|
589
|
+
ok: true,
|
|
590
|
+
outcome: changedFiles.length > 0 ? "changed" : "already-satisfied",
|
|
591
|
+
};
|
|
605
592
|
}
|
|
606
593
|
function parseWriterImplementationOutcome(text) {
|
|
607
594
|
const lines = text.split(/\r?\n/);
|
|
@@ -732,6 +719,15 @@ function normalizeProtocolLine(line, firstProtocolLine, nextLine) {
|
|
|
732
719
|
const value = normalizedProtocolValue(direct[1] ?? "");
|
|
733
720
|
return value ? `${firstProtocolLine} ${value}` : undefined;
|
|
734
721
|
}
|
|
722
|
+
// Writers sometimes place a short delivery sentence before the protocol
|
|
723
|
+
// line. Accept the protocol token when it appears inline, while preserving
|
|
724
|
+
// the candidate value so template repetitions still fail as unknown or
|
|
725
|
+
// conflicting outcomes.
|
|
726
|
+
const inline = normalizedLine.match(new RegExp(`${labelPattern}\\s*[::]\\s*(.+)$`, "i"));
|
|
727
|
+
if (inline) {
|
|
728
|
+
const value = normalizedProtocolValue(inline[1] ?? "");
|
|
729
|
+
return value ? `${firstProtocolLine} ${value}` : undefined;
|
|
730
|
+
}
|
|
735
731
|
const splitValue = normalizedLine.match(new RegExp(`^${labelPattern}\\s*[::]?\\s*$`, "i"));
|
|
736
732
|
if (splitValue && nextLine !== undefined) {
|
|
737
733
|
const value = normalizedProtocolValue(nextLine);
|
|
@@ -24,8 +24,8 @@ import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBa
|
|
|
24
24
|
import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
|
|
25
25
|
import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
|
|
26
26
|
import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport, } from "../workflows/dag/backend-test-result-contract.js";
|
|
27
|
-
import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
28
|
-
import { assessBackendPytestCollection, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
|
|
27
|
+
import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, resolveBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
28
|
+
import { assessBackendPytestCollection, assessMissingBackendPytestScripts, assertBackendPytestCollectionFresh, buildBackendPytestAssetInventory, materializeEffectiveBackendPytestCollection, readBackendPytestCollectionFacts, writeBackendPytestCollectionArtifacts, } from "../workflows/dag/backend-test-pytest-collection.js";
|
|
29
29
|
import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
|
|
30
30
|
import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
|
|
31
31
|
import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
|
|
@@ -515,31 +515,45 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
515
515
|
}
|
|
516
516
|
}
|
|
517
517
|
else if (pipeline === "markdown-collection-assess") {
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
518
|
+
const resolution = await resolveBackendTestMappedPytestScripts(input.cwd);
|
|
519
|
+
let facts;
|
|
520
|
+
if (resolution.missingScripts.length > 0) {
|
|
521
|
+
const inventory = await buildBackendPytestAssetInventory(input.cwd, resolution.existingScripts, { requireMappedScripts: false });
|
|
522
|
+
facts = assessMissingBackendPytestScripts({
|
|
523
|
+
mappedScripts: resolution.expectedScripts,
|
|
524
|
+
existingMappedScripts: resolution.existingScripts,
|
|
525
|
+
missingMappedScripts: resolution.missingScripts,
|
|
526
|
+
assetFiles: inventory.assetFiles,
|
|
527
|
+
inputHashes: inventory.inputHashes,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
const mappedScripts = resolution.existingScripts;
|
|
532
|
+
const inventory = await buildBackendPytestAssetInventory(input.cwd, mappedScripts);
|
|
533
|
+
const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
534
|
+
const targets = mappedScripts.map(shellQuote).join(" ");
|
|
535
|
+
const command = `PYTHONDONTWRITEBYTECODE=1 python -m pytest --collect-only -q -p no:cacheprovider ${targets}`;
|
|
536
|
+
const [result] = await executePipelineCommands(input, meta, [command]);
|
|
537
|
+
if (!result)
|
|
538
|
+
throw new Error("backend pytest collection command did not produce a result");
|
|
539
|
+
if (["spawn-error", "timeout", "termination-unconfirmed"].includes(result.failureCategory ?? "")) {
|
|
540
|
+
return {
|
|
541
|
+
ok: false,
|
|
542
|
+
stdout: result.stdout,
|
|
543
|
+
stderr: result.stderr || "backend pytest collection could not start",
|
|
544
|
+
failureCategory: result.failureCategory,
|
|
545
|
+
durationMs: Date.now() - started,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
facts = assessBackendPytestCollection({
|
|
549
|
+
phase: "initial",
|
|
550
|
+
mappedScripts,
|
|
551
|
+
inventory,
|
|
552
|
+
exitCode: result.exitCode ?? 2,
|
|
529
553
|
stdout: result.stdout,
|
|
530
|
-
stderr: result.stderr
|
|
531
|
-
|
|
532
|
-
durationMs: Date.now() - started,
|
|
533
|
-
};
|
|
554
|
+
stderr: result.stderr,
|
|
555
|
+
});
|
|
534
556
|
}
|
|
535
|
-
const facts = assessBackendPytestCollection({
|
|
536
|
-
phase: "initial",
|
|
537
|
-
mappedScripts,
|
|
538
|
-
inventory,
|
|
539
|
-
exitCode: result.exitCode ?? 2,
|
|
540
|
-
stdout: result.stdout,
|
|
541
|
-
stderr: result.stderr,
|
|
542
|
-
});
|
|
543
557
|
const artifacts = await writeBackendPytestCollectionArtifacts({
|
|
544
558
|
runDir: meta.runDir,
|
|
545
559
|
stem: "initial",
|
|
@@ -551,8 +565,10 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
551
565
|
stdout: JSON.stringify({
|
|
552
566
|
status: facts.status,
|
|
553
567
|
repairEligible: facts.repairEligible,
|
|
568
|
+
collectionAttempted: facts.collectionAttempted,
|
|
554
569
|
pytestExitCode: facts.pytestExitCode,
|
|
555
570
|
collectedItemCount: facts.collectedItemCount,
|
|
571
|
+
missingMappedScripts: facts.missingMappedScripts,
|
|
556
572
|
factsPath: "contracts/backend-test-pytest-collection-initial.json",
|
|
557
573
|
}),
|
|
558
574
|
stderr: "",
|
|
@@ -43,6 +43,17 @@ async function exists(filePath) {
|
|
|
43
43
|
return false;
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
async function mappedScriptExists(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
await access(filePath);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === "ENOENT")
|
|
53
|
+
return false;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
46
57
|
async function existingPaths(root, candidates) {
|
|
47
58
|
const found = [];
|
|
48
59
|
for (const candidate of candidates) {
|
|
@@ -685,9 +696,24 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
|
|
|
685
696
|
...(findings.length > 0 ? findings.map((finding) => `- ${finding}`) : ["- None"]),
|
|
686
697
|
].join("\n") + "\n");
|
|
687
698
|
}
|
|
688
|
-
export async function
|
|
699
|
+
export async function resolveBackendTestMappedPytestScripts(workspaceRoot) {
|
|
689
700
|
const files = await markdownFiles(workspaceRoot);
|
|
690
|
-
const
|
|
701
|
+
const expectedScripts = new Set();
|
|
702
|
+
const existingScripts = new Set();
|
|
703
|
+
const missingScripts = new Set();
|
|
704
|
+
const scriptOwners = new Map();
|
|
705
|
+
const recordScript = (script, owner, present) => {
|
|
706
|
+
const previousOwner = scriptOwners.get(script);
|
|
707
|
+
if (previousOwner && previousOwner !== owner) {
|
|
708
|
+
throw new Error(`conflicting Markdown pytest mapping: ${previousOwner} and ${owner} both map ${script}`);
|
|
709
|
+
}
|
|
710
|
+
scriptOwners.set(script, owner);
|
|
711
|
+
expectedScripts.add(script);
|
|
712
|
+
if (present)
|
|
713
|
+
existingScripts.add(script);
|
|
714
|
+
else
|
|
715
|
+
missingScripts.add(script);
|
|
716
|
+
};
|
|
691
717
|
for (const file of files) {
|
|
692
718
|
if (path.basename(file).toLowerCase() === "readme.md")
|
|
693
719
|
continue;
|
|
@@ -703,10 +729,11 @@ export async function collectBackendTestMappedPytestScripts(workspaceRoot) {
|
|
|
703
729
|
// Prefer explicit mappings, but recover to the deterministic module stem path
|
|
704
730
|
// when the model wrote the correct one-to-one file while Markdown still names
|
|
705
731
|
// a drifted script (common: health.md maps test_health.py but writer emitted
|
|
706
|
-
// test_be_health.py for BE-HEALTH.md, or the reverse).
|
|
732
|
+
// test_be_health.py for BE-HEALTH.md, or the reverse). If neither exists, the
|
|
733
|
+
// deterministic module path is the only path a bounded repair may create.
|
|
707
734
|
if (mappedFromModule.size === 0) {
|
|
708
|
-
if (await
|
|
709
|
-
|
|
735
|
+
if (await mappedScriptExists(path.resolve(workspaceRoot, expectedScript))) {
|
|
736
|
+
recordScript(expectedScript, relativeFile, true);
|
|
710
737
|
}
|
|
711
738
|
continue;
|
|
712
739
|
}
|
|
@@ -714,32 +741,31 @@ export async function collectBackendTestMappedPytestScripts(workspaceRoot) {
|
|
|
714
741
|
if (!isSafeBackendPytestScript(script, workspaceRoot)) {
|
|
715
742
|
throw new Error(`unsafe mapped pytest script: ${script}`);
|
|
716
743
|
}
|
|
717
|
-
if (await
|
|
718
|
-
|
|
744
|
+
if (await mappedScriptExists(path.resolve(workspaceRoot, script))) {
|
|
745
|
+
recordScript(script, relativeFile, true);
|
|
719
746
|
continue;
|
|
720
747
|
}
|
|
721
|
-
if (isSafeBackendPytestScript(expectedScript, workspaceRoot)
|
|
722
|
-
|
|
723
|
-
scripts.add(expectedScript);
|
|
724
|
-
continue;
|
|
748
|
+
if (!isSafeBackendPytestScript(expectedScript, workspaceRoot)) {
|
|
749
|
+
throw new Error(`unsafe mapped pytest script: ${expectedScript}`);
|
|
725
750
|
}
|
|
726
|
-
|
|
751
|
+
recordScript(expectedScript, relativeFile, await mappedScriptExists(path.resolve(workspaceRoot, expectedScript)));
|
|
727
752
|
}
|
|
728
753
|
}
|
|
729
|
-
if (
|
|
754
|
+
if (expectedScripts.size === 0) {
|
|
730
755
|
throw new Error("no pytest scripts are mapped by final Markdown cases");
|
|
731
756
|
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
757
|
+
return {
|
|
758
|
+
expectedScripts: unique(expectedScripts),
|
|
759
|
+
existingScripts: unique(existingScripts),
|
|
760
|
+
missingScripts: unique(missingScripts),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
export async function collectBackendTestMappedPytestScripts(workspaceRoot) {
|
|
764
|
+
const resolution = await resolveBackendTestMappedPytestScripts(workspaceRoot);
|
|
765
|
+
if (resolution.missingScripts.length > 0) {
|
|
766
|
+
throw new Error(`mapped pytest script is missing: ${resolution.missingScripts[0]}`);
|
|
741
767
|
}
|
|
742
|
-
return
|
|
768
|
+
return resolution.existingScripts;
|
|
743
769
|
}
|
|
744
770
|
function cleanCaseTitle(rawHeading, id) {
|
|
745
771
|
return rawHeading
|
|
@@ -11,22 +11,34 @@ export const backendPytestCollectionFindingSchema = z.object({
|
|
|
11
11
|
detail: z.string().min(1),
|
|
12
12
|
}).strict();
|
|
13
13
|
export const backendPytestCollectionFactsSchema = z.object({
|
|
14
|
-
schemaId: z.literal("backend-test-pytest-collection-
|
|
14
|
+
schemaId: z.literal("backend-test-pytest-collection-v2"),
|
|
15
15
|
phase: z.enum(["initial", "final", "effective"]),
|
|
16
16
|
status: z.enum(["PASS", "REPAIRABLE", "BLOCKED"]),
|
|
17
17
|
repairEligible: z.boolean(),
|
|
18
18
|
repairAttempt: z.number().int().min(0).max(1),
|
|
19
|
+
collectionAttempted: z.boolean(),
|
|
19
20
|
mappedScripts: z.array(z.string()).min(1),
|
|
20
|
-
|
|
21
|
+
existingMappedScripts: z.array(z.string()),
|
|
22
|
+
missingMappedScripts: z.array(z.string()),
|
|
23
|
+
assetFiles: z.array(z.string()),
|
|
21
24
|
inputHashes: z.record(z.string(), z.string().regex(SHA256)),
|
|
22
|
-
pytestExitCode: z.number().int(),
|
|
25
|
+
pytestExitCode: z.number().int().nullable(),
|
|
23
26
|
collectedItemCount: z.number().int().min(0),
|
|
24
27
|
collectedItemIds: z.array(z.string()),
|
|
25
28
|
findings: z.array(backendPytestCollectionFindingSchema),
|
|
26
29
|
stdoutExcerpt: z.string(),
|
|
27
30
|
stderrExcerpt: z.string(),
|
|
28
31
|
collectionSource: z.enum(["initial", "final"]).optional(),
|
|
29
|
-
}).strict()
|
|
32
|
+
}).strict().superRefine((facts, context) => {
|
|
33
|
+
if (facts.status === "PASS") {
|
|
34
|
+
if (!facts.collectionAttempted || facts.pytestExitCode !== 0 || facts.missingMappedScripts.length > 0 || facts.assetFiles.length === 0) {
|
|
35
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection PASS requires attempted collection, exit 0, complete mapped scripts and bound assets" });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!facts.collectionAttempted && facts.pytestExitCode !== null) {
|
|
39
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection without an attempt cannot have an exit code" });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
30
42
|
function repoRef(workspaceRoot, absolutePath) {
|
|
31
43
|
return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, "/");
|
|
32
44
|
}
|
|
@@ -56,10 +68,11 @@ async function walkPythonFiles(root) {
|
|
|
56
68
|
await visit(root);
|
|
57
69
|
return results;
|
|
58
70
|
}
|
|
59
|
-
export async function buildBackendPytestAssetInventory(workspaceRoot, mappedScripts) {
|
|
71
|
+
export async function buildBackendPytestAssetInventory(workspaceRoot, mappedScripts, options = {}) {
|
|
60
72
|
const normalizedScripts = [...new Set(mappedScripts.map((value) => value.replaceAll("\\", "/")))].sort();
|
|
61
|
-
if (normalizedScripts.length === 0)
|
|
73
|
+
if ((options.requireMappedScripts ?? true) && normalizedScripts.length === 0) {
|
|
62
74
|
throw new Error("backend pytest collection requires mapped scripts");
|
|
75
|
+
}
|
|
63
76
|
for (const script of normalizedScripts) {
|
|
64
77
|
if (!/^testcase(?:\/[A-Za-z0-9_.-]+)*\/test_[A-Za-z0-9_.-]+\.py$/.test(script) && !/^testcase\/test_[A-Za-z0-9_.-]+\.py$/.test(script)) {
|
|
65
78
|
throw new Error(`unsafe backend pytest mapped script: ${script}`);
|
|
@@ -123,12 +136,15 @@ export function assessBackendPytestCollection(input) {
|
|
|
123
136
|
const items = collectionItems(input.stdout);
|
|
124
137
|
if (input.exitCode === 0) {
|
|
125
138
|
return backendPytestCollectionFactsSchema.parse({
|
|
126
|
-
schemaId: "backend-test-pytest-collection-
|
|
139
|
+
schemaId: "backend-test-pytest-collection-v2",
|
|
127
140
|
phase: input.phase,
|
|
128
141
|
status: "PASS",
|
|
129
142
|
repairEligible: false,
|
|
130
143
|
repairAttempt: input.phase === "final" ? 1 : 0,
|
|
144
|
+
collectionAttempted: true,
|
|
131
145
|
mappedScripts: input.inventory.mappedScripts,
|
|
146
|
+
existingMappedScripts: input.inventory.mappedScripts,
|
|
147
|
+
missingMappedScripts: [],
|
|
132
148
|
assetFiles: input.inventory.assetFiles,
|
|
133
149
|
inputHashes: input.inventory.inputHashes,
|
|
134
150
|
pytestExitCode: input.exitCode,
|
|
@@ -141,12 +157,15 @@ export function assessBackendPytestCollection(input) {
|
|
|
141
157
|
}
|
|
142
158
|
const classification = classifyCollectionFailure(`${input.stdout}\n${input.stderr}`);
|
|
143
159
|
return backendPytestCollectionFactsSchema.parse({
|
|
144
|
-
schemaId: "backend-test-pytest-collection-
|
|
160
|
+
schemaId: "backend-test-pytest-collection-v2",
|
|
145
161
|
phase: input.phase,
|
|
146
162
|
status: classification.status,
|
|
147
163
|
repairEligible: input.phase === "initial" && classification.status === "REPAIRABLE",
|
|
148
164
|
repairAttempt: input.phase === "final" ? 1 : 0,
|
|
165
|
+
collectionAttempted: true,
|
|
149
166
|
mappedScripts: input.inventory.mappedScripts,
|
|
167
|
+
existingMappedScripts: input.inventory.mappedScripts,
|
|
168
|
+
missingMappedScripts: [],
|
|
150
169
|
assetFiles: input.inventory.assetFiles,
|
|
151
170
|
inputHashes: input.inventory.inputHashes,
|
|
152
171
|
pytestExitCode: input.exitCode,
|
|
@@ -162,6 +181,32 @@ export function assessBackendPytestCollection(input) {
|
|
|
162
181
|
stderrExcerpt: bounded(input.stderr),
|
|
163
182
|
});
|
|
164
183
|
}
|
|
184
|
+
export function assessMissingBackendPytestScripts(input) {
|
|
185
|
+
return backendPytestCollectionFactsSchema.parse({
|
|
186
|
+
schemaId: "backend-test-pytest-collection-v2",
|
|
187
|
+
phase: "initial",
|
|
188
|
+
status: "REPAIRABLE",
|
|
189
|
+
repairEligible: true,
|
|
190
|
+
repairAttempt: 0,
|
|
191
|
+
collectionAttempted: false,
|
|
192
|
+
mappedScripts: [...input.mappedScripts],
|
|
193
|
+
existingMappedScripts: [...input.existingMappedScripts],
|
|
194
|
+
missingMappedScripts: [...input.missingMappedScripts],
|
|
195
|
+
assetFiles: [...input.assetFiles],
|
|
196
|
+
inputHashes: input.inputHashes,
|
|
197
|
+
pytestExitCode: null,
|
|
198
|
+
collectedItemCount: 0,
|
|
199
|
+
collectedItemIds: [],
|
|
200
|
+
findings: input.missingMappedScripts.map((script) => ({
|
|
201
|
+
kind: "missing-mapped-pytest-script",
|
|
202
|
+
classification: "test-asset-defect",
|
|
203
|
+
repairability: "repairable",
|
|
204
|
+
detail: `Markdown-mapped generated pytest script is missing: ${script}`,
|
|
205
|
+
})),
|
|
206
|
+
stdoutExcerpt: "",
|
|
207
|
+
stderrExcerpt: "",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
165
210
|
export function renderBackendPytestCollectionReport(facts) {
|
|
166
211
|
return [
|
|
167
212
|
`# Backend pytest Collection ${facts.phase}`,
|
|
@@ -172,8 +217,11 @@ export function renderBackendPytestCollectionReport(facts) {
|
|
|
172
217
|
"",
|
|
173
218
|
`- Repair eligible: ${facts.repairEligible}`,
|
|
174
219
|
`- Repair attempt: ${facts.repairAttempt}`,
|
|
175
|
-
`-
|
|
220
|
+
`- Collection attempted: ${facts.collectionAttempted}`,
|
|
221
|
+
`- Pytest exit code: ${facts.pytestExitCode ?? "not-run"}`,
|
|
176
222
|
`- Mapped scripts: ${facts.mappedScripts.length}`,
|
|
223
|
+
`- Existing mapped scripts: ${facts.existingMappedScripts.length}`,
|
|
224
|
+
`- Missing mapped scripts: ${facts.missingMappedScripts.length}`,
|
|
177
225
|
`- Bound Python assets: ${facts.assetFiles.length}`,
|
|
178
226
|
`- Collected items: ${facts.collectedItemCount}`,
|
|
179
227
|
"",
|
|
@@ -155,7 +155,7 @@ export const frontendImplementationContractSchema = z
|
|
|
155
155
|
"not-needed",
|
|
156
156
|
]),
|
|
157
157
|
productionDefaultOff: z.literal(true),
|
|
158
|
-
activation: z.string().min(1),
|
|
158
|
+
activation: z.preprocess((value) => (value === "" || value === null ? "explicit activation boundary" : value), z.string().min(1)),
|
|
159
159
|
endpoints: z.array(z
|
|
160
160
|
.object({
|
|
161
161
|
method: z.enum([
|
|
@@ -168,7 +168,10 @@ export const frontendImplementationContractSchema = z
|
|
|
168
168
|
"OPTIONS",
|
|
169
169
|
]),
|
|
170
170
|
path: z.string().startsWith("/"),
|
|
171
|
-
fixture
|
|
171
|
+
// Models occasionally emit an empty fixture when Mock is
|
|
172
|
+
// intentionally not needed. Treat it like an omitted optional
|
|
173
|
+
// field; active Mock strategies still fail the refinement below.
|
|
174
|
+
fixture: z.preprocess((value) => (value === "" || value === null ? undefined : value), safePath.optional()),
|
|
172
175
|
consumer: safePath.optional(),
|
|
173
176
|
})
|
|
174
177
|
.strict()),
|
|
@@ -187,7 +190,7 @@ export const frontendImplementationContractSchema = z
|
|
|
187
190
|
type: z.enum(["static", "unit", "component", "integration", "mock"]),
|
|
188
191
|
commandLabel: z.string().min(1),
|
|
189
192
|
file: safePath,
|
|
190
|
-
symbol: z.string().min(1).optional(),
|
|
193
|
+
symbol: z.preprocess((value) => (value === "" || value === null ? undefined : value), z.string().min(1).optional()),
|
|
191
194
|
requirementIds: z.array(id),
|
|
192
195
|
uiStates: z.array(z.string().min(1)),
|
|
193
196
|
})
|
|
@@ -343,12 +346,75 @@ function secretIssues(value, at = "$", issues = []) {
|
|
|
343
346
|
}
|
|
344
347
|
export function extractFrontendImplementationJson(text) {
|
|
345
348
|
const trimmed = text.trim();
|
|
349
|
+
const parse = (source) => {
|
|
350
|
+
try {
|
|
351
|
+
return JSON.parse(source);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
// Models sometimes put ordinary ASCII quotes inside a JSON string
|
|
355
|
+
// (for example: `reason: "支持..."`). Repair only quotes that are
|
|
356
|
+
// clearly not structural: a closing quote is followed by JSON
|
|
357
|
+
// punctuation, while an embedded quote is followed by content.
|
|
358
|
+
let repaired = "";
|
|
359
|
+
let inString = false;
|
|
360
|
+
let escaped = false;
|
|
361
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
362
|
+
const character = source[index];
|
|
363
|
+
if (character !== '"') {
|
|
364
|
+
repaired += character;
|
|
365
|
+
if (inString && character === "\\" && !escaped)
|
|
366
|
+
escaped = true;
|
|
367
|
+
else
|
|
368
|
+
escaped = false;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (escaped) {
|
|
372
|
+
repaired += character;
|
|
373
|
+
escaped = false;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (!inString) {
|
|
377
|
+
inString = true;
|
|
378
|
+
repaired += character;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const next = source.slice(index + 1).trimStart()[0];
|
|
382
|
+
if ([",", "}", "]", ":"].includes(next ?? "") || source.slice(index + 1).trim() === "") {
|
|
383
|
+
inString = false;
|
|
384
|
+
repaired += character;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
repaired += "\\\"";
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
return JSON.parse(repaired);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
};
|
|
346
398
|
if (trimmed.startsWith("{") && trimmed.endsWith("}"))
|
|
347
|
-
return
|
|
399
|
+
return parse(trimmed);
|
|
348
400
|
const blocks = [...trimmed.matchAll(/```json\s*\n([\s\S]*?)\n```/gi)];
|
|
349
|
-
if (blocks.length
|
|
401
|
+
if (blocks.length === 0)
|
|
350
402
|
throw new Error("output must contain exactly one fenced json object");
|
|
351
|
-
|
|
403
|
+
const candidates = [];
|
|
404
|
+
for (const block of blocks) {
|
|
405
|
+
try {
|
|
406
|
+
const candidate = parse(block[1]);
|
|
407
|
+
if (candidate && typeof candidate === "object" && !Array.isArray(candidate))
|
|
408
|
+
candidates.push(candidate);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
// Ignore incomplete model scratch blocks. A later complete contract
|
|
412
|
+
// block may still be deterministically recoverable.
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (candidates.length !== 1)
|
|
416
|
+
throw new Error(`output must contain exactly one valid fenced json object (found ${candidates.length})`);
|
|
417
|
+
return candidates[0];
|
|
352
418
|
}
|
|
353
419
|
/**
|
|
354
420
|
* Build the authoritative frontend-implementation-contract sourceBinding from
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
|
|
4
|
-
import { frontendImplementationContractSchema, materializeFrontendImplementationContract, assertFrontendSourceBindingFresh, } from "./frontend-implementation-contract.js";
|
|
5
|
+
import { frontendImplementationContractSchema, FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, materializeFrontendImplementationContract, assertFrontendSourceBindingFresh, } from "./frontend-implementation-contract.js";
|
|
5
6
|
import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
|
|
6
7
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
7
8
|
async function selectNode(runDir, primary, fallbacks) {
|
|
@@ -46,7 +47,12 @@ function firstNonEmptyVerdictLine(text) {
|
|
|
46
47
|
// The prompt requires VERDICT to be the first non-empty line, but model
|
|
47
48
|
// output can still prepend a summary. Keep the protocol strict in the
|
|
48
49
|
// prompt while making the deterministic gate resilient to that drift.
|
|
49
|
-
|
|
50
|
+
const verdictLine = normalizedLines.find((line) => /^VERDICT:/.test(line)) ?? first;
|
|
51
|
+
if (/^VERDICT:\s*pass(?:\s*[.!?。!?]|\s+|$)/i.test(verdictLine))
|
|
52
|
+
return "VERDICT: pass";
|
|
53
|
+
if (/^VERDICT:\s*request-revision(?:\s*[.!?。!?]|\s+|$)/i.test(verdictLine))
|
|
54
|
+
return "VERDICT: request-revision";
|
|
55
|
+
return verdictLine;
|
|
50
56
|
}
|
|
51
57
|
function eventArgs(event) {
|
|
52
58
|
return event.args ?? event.toolInput ?? event.input ?? {};
|
|
@@ -122,6 +128,23 @@ async function checkOpenspecReadEvidence(input) {
|
|
|
122
128
|
}
|
|
123
129
|
return [...matched];
|
|
124
130
|
}
|
|
131
|
+
async function existingOpenspecCandidates(candidates, repoRoot) {
|
|
132
|
+
const existing = [];
|
|
133
|
+
for (const candidate of candidates) {
|
|
134
|
+
if (!isOpenspecSpecFilePath(candidate))
|
|
135
|
+
continue;
|
|
136
|
+
try {
|
|
137
|
+
const info = await stat(path.resolve(repoRoot, candidate));
|
|
138
|
+
if (info.isFile())
|
|
139
|
+
existing.push(candidate);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Model-generated typos and stale capability paths are not readable
|
|
143
|
+
// evidence candidates and must not create a false prewrite block.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return existing;
|
|
147
|
+
}
|
|
125
148
|
export async function runFrontendPrewriteGate(input) {
|
|
126
149
|
const workspaceRoot = input.workspaceRoot ?? input.repoRoot;
|
|
127
150
|
if (workspaceRoot) {
|
|
@@ -155,13 +178,20 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
155
178
|
if (missingIds.length > 0) {
|
|
156
179
|
throw new Error(`frontend prewrite gate missing requirement ids: ${missingIds.join(", ")}`);
|
|
157
180
|
}
|
|
158
|
-
const
|
|
181
|
+
const artifactPath = path.join(input.runDir, input.config.outputDir, input.config.artifactName);
|
|
182
|
+
const artifact = await stat(artifactPath)
|
|
183
|
+
.then(async () => ({
|
|
184
|
+
path: artifactPath,
|
|
185
|
+
schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
|
|
186
|
+
sha256: createHash("sha256").update(await readFile(artifactPath)).digest("hex"),
|
|
187
|
+
}))
|
|
188
|
+
.catch(() => materializeFrontendImplementationContract({
|
|
159
189
|
runDir: input.runDir,
|
|
160
190
|
fromNodeId: planNodeId,
|
|
161
191
|
artifactName: input.config.artifactName,
|
|
162
192
|
outputDir: input.config.outputDir,
|
|
163
193
|
sourceBinding: input.sourceBinding,
|
|
164
|
-
});
|
|
194
|
+
}));
|
|
165
195
|
const raw = JSON.parse(await readFile(artifact.path, "utf8"));
|
|
166
196
|
const contract = frontendImplementationContractSchema.parse(raw);
|
|
167
197
|
if (!input.config.allowedMockStrategies.includes(contract.mockApi.strategy)) {
|
|
@@ -175,7 +205,7 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
175
205
|
throw new Error(`frontend prewrite gate contract target is outside implementation writeSet: ${uncoveredTargets.join(", ")}`);
|
|
176
206
|
}
|
|
177
207
|
}
|
|
178
|
-
const candidatePaths = input.config.openspecCandidatePaths ?? [];
|
|
208
|
+
const candidatePaths = await existingOpenspecCandidates(input.config.openspecCandidatePaths ?? [], workspaceRoot ?? process.cwd());
|
|
179
209
|
const openspecReadPaths = await checkOpenspecReadEvidence({
|
|
180
210
|
runDir: input.runDir,
|
|
181
211
|
candidatePaths,
|
|
@@ -102,13 +102,52 @@ function renderMarkdown(result, metrics) {
|
|
|
102
102
|
].join("\n");
|
|
103
103
|
}
|
|
104
104
|
function renderHtml(result, metrics) {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
105
|
+
const m = metrics.metrics;
|
|
106
|
+
const decision = metrics.status === "ready"
|
|
107
|
+
? { text: "READY", color: "#15815c", bg: "rgba(21,129,92,.12)", border: "rgba(120,200,170,.4)", small: "所有 L-5 门禁达标" }
|
|
108
|
+
: { text: "NOT READY", color: "#d34661", bg: "rgba(211,70,97,.12)", border: "rgba(255,177,192,.4)", small: `${metrics.blockingItems.length} 个阻断项需要处理` };
|
|
109
|
+
const executed = result.totals.passed + result.totals.failed;
|
|
110
|
+
const passRate = m.passRate.ratio === null ? "—" : `${(m.passRate.ratio * 100).toFixed(2)}%`;
|
|
111
|
+
const acRate = m.acCoverage.ratio === null ? "—" : `${(m.acCoverage.ratio * 100).toFixed(2)}%`;
|
|
112
|
+
const automationRate = m.automationCoverage.ratio === null ? "—" : `${(m.automationCoverage.ratio * 100).toFixed(2)}%`;
|
|
113
|
+
const circ = 2 * Math.PI * 46;
|
|
114
|
+
const passLen = executed > 0 ? (result.totals.passed / executed) * circ : 0;
|
|
115
|
+
const failLen = executed > 0 ? (result.totals.failed / executed) * circ : 0;
|
|
116
|
+
const statusColor = (status) => status === "pass" ? "#15815c" : status === "fail" ? "#d34661" : "#c87918";
|
|
117
|
+
const statusBg = (status) => status === "pass" ? "#e7f7f0" : status === "fail" ? "#fff0f3" : "#fff7e8";
|
|
118
|
+
const gate = (label, metric, value, target, detail, evidence = "") => {
|
|
119
|
+
const color = statusColor(metric.status);
|
|
120
|
+
const width = metric.ratio === null ? 0 : Math.min(100, metric.ratio * 100);
|
|
121
|
+
const statusLabel = metric.status === "unavailable" ? "不可用" : metric.status === "pass" ? "达标" : "未达标";
|
|
122
|
+
const evidenceBlock = evidence
|
|
123
|
+
? `<details style="margin-top:10px;padding-top:9px;border-top:1px solid #e5ebf3"><summary style="color:#4775ef;font-size:12px;font-weight:750;cursor:pointer;list-style:none">查看依据</summary><div style="margin-top:7px;color:#718097;font-size:12px">${evidence}</div></details>`
|
|
124
|
+
: "";
|
|
125
|
+
return `<div style="padding:14px 15px;border:1px solid #e5ebf3;border-radius:11px;background:#fcfdff;margin-top:8px"><div style="display:flex;align-items:center;gap:10px"><span style="flex:1;color:#263c5e;font-size:13px;font-weight:750">${escapeHtml(label)}</span><strong style="color:${color};font-size:15px">${escapeHtml(value)}</strong><span style="padding:3px 9px;border-radius:999px;color:${color};background:${statusBg(metric.status)};font-size:11px;font-weight:800">${statusLabel}</span></div><div style="height:6px;margin:10px 0 7px;background:#e7edf5;border-radius:99px;overflow:hidden"><i style="display:block;height:100%;width:${width}%;border-radius:inherit;background:${color}"></i></div><div style="display:flex;justify-content:space-between;color:#718097;font-size:12px"><span>${escapeHtml(detail)}</span><span>${escapeHtml(target)}</span></div>${evidenceBlock}</div>`;
|
|
126
|
+
};
|
|
127
|
+
const cases = result.cases.length > 0
|
|
128
|
+
? result.cases.map((item) => {
|
|
129
|
+
const passed = item.status === "passed";
|
|
130
|
+
const color = passed ? "#15815c" : item.status === "blocked" ? "#c87918" : "#d34661";
|
|
131
|
+
const bg = passed ? "#e7f7f0" : item.status === "blocked" ? "#fff7e8" : "#fff0f3";
|
|
132
|
+
return `<details style="border:1px solid ${passed ? "#d7ece2" : "#ecd4d9"};border-radius:13px;background:${passed ? "#fbfffd" : "#fffdfd"};overflow:hidden;margin-top:10px"${passed ? "" : " open"}><summary style="display:flex;align-items:center;gap:10px;padding:15px;cursor:pointer;list-style:none"><span style="color:#17365d;font:700 12px ui-monospace,SFMono-Regular,Menlo,monospace">${escapeHtml(item.caseId)}</span><span style="flex:1;color:#17365d;font-size:14px;font-weight:750">${escapeHtml(item.caseContent.purpose)}</span><span style="padding:3px 9px;border-radius:999px;color:${color};background:${bg};font-size:11px;font-weight:800">${escapeHtml(item.status.toUpperCase())}</span></summary><div style="padding:0 15px 15px;border-top:1px solid #edf1f6"><div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;margin-top:14px"><div style="padding:9px 10px;border-radius:8px;background:#f6f8fa"><label style="display:block;color:#718097;font-size:11px">AC 映射</label><span style="display:block;margin-top:2px;color:#172033;font-size:12px;font-weight:650">${escapeHtml(item.acIds.join(", ") || "未关联")}</span></div><div style="padding:9px 10px;border-radius:8px;background:#f6f8fa"><label style="display:block;color:#718097;font-size:11px">浏览器证据</label><span style="display:block;margin-top:2px;color:#172033;font-size:12px;font-weight:650">${item.evidence.length} 条</span></div></div></div></details>`;
|
|
133
|
+
}).join("")
|
|
134
|
+
: `<div style="color:#718097;font-size:13px">无用例执行数据</div>`;
|
|
135
|
+
const blocking = metrics.blockingItems.length > 0
|
|
136
|
+
? metrics.blockingItems.map((item) => `<li style="margin:6px 0">${escapeHtml(item)}</li>`).join("")
|
|
137
|
+
: "<li>无阻断项</li>";
|
|
138
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none';style-src 'unsafe-inline'"><title>前端测试 · L-5</title><style>summary::-webkit-details-marker{display:none}@media(max-width:760px){.hero,.split,.gates{grid-template-columns:1fr!important}.kpis{grid-template-columns:repeat(2,1fr)!important}}@media(max-width:480px){.page{padding:24px 14px!important}.kpis{grid-template-columns:1fr!important}.hero-header{flex-direction:column!important}.case-meta{grid-template-columns:1fr!important}}</style></head><body style="margin:0;background:radial-gradient(circle at 8% 0,#edf4ff 0,transparent 36rem),#f5f7fb;color:#17243b;font:15px/1.6 Inter,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif"><div class="page" style="max-width:1180px;margin:0 auto;padding:42px 28px 72px">
|
|
139
|
+
<header class="hero-header" style="position:relative;overflow:hidden;padding:34px 38px;border:1px solid #294b78;border-radius:26px;background:linear-gradient(125deg,#102849 0%,#173d6d 57%,#245b91 100%);box-shadow:0 18px 40px rgba(16,40,73,.18);display:flex;align-items:flex-start;justify-content:space-between;gap:28px"><div><div style="color:#9fc6ee;font-size:11px;font-weight:800;letter-spacing:.2em">FRONTEND TEST · L-5 QUALITY VIEW</div><h1 style="margin:12px 0 10px;color:#f7fbff;font-size:clamp(30px,3.8vw,46px);font-weight:800;letter-spacing:-.055em;line-height:1.1">前端测试 L-5 报告</h1><p style="color:#bdd2e9;font-size:13px;margin:0">${escapeHtml(result.sourceBinding.taskId)} · ${result.totals.cases} 个用例</p></div><div style="min-width:220px;padding:16px 18px;border:1px solid ${decision.border};border-radius:18px;background:${decision.bg};box-shadow:0 8px 24px rgba(0,0,0,.08)"><div style="display:flex;align-items:center;gap:9px;color:${decision.color};font-size:17px;font-weight:800"><i style="width:11px;height:11px;border-radius:50%;background:${decision.color};box-shadow:0 0 0 5px ${decision.color}1a;display:inline-block"></i>L-5 ${decision.text}</div><small style="display:block;margin:7px 0 0 20px;color:${decision.color};opacity:.85;font-size:12px">${decision.small}</small></div></header>
|
|
140
|
+
<section class="kpis" style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:18px">
|
|
141
|
+
${[["测试通过率", passRate, `${result.totals.passed} / ${executed} · 目标 100%`, "#4775ef"], ["AC 验收覆盖", acRate, `${m.acCoverage.numerator ?? "—"} / ${m.acCoverage.denominator ?? "—"} · 目标 100%`, "#c87918"], ["自动化覆盖率", automationRate, `${m.automationCoverage.numerator ?? "—"} / ${m.automationCoverage.denominator ?? "—"} · 目标 ≥90%`, "#7589d9"], ["Critical 风险", String(m.criticalRisks.numerator ?? 0), m.criticalRisks.status === "pass" ? "无阻断项" : "阻断级风险", m.criticalRisks.status === "pass" ? "#15815c" : "#d34661"]].map(([label, value, foot, color]) => `<div style="position:relative;min-height:132px;padding:21px 22px;overflow:hidden;background:#fff;border:1px solid #e1e8f2;border-radius:15px;box-shadow:0 6px 18px rgba(25,53,92,.045)"><i style="position:absolute;top:0;left:0;right:0;height:3px;background:${color};display:block"></i><div style="color:#748198;font-size:13px;font-weight:650">${label}</div><div style="margin-top:19px;color:#13213a;font-size:35px;font-weight:800;letter-spacing:-.06em;line-height:1">${value}</div><div style="margin-top:9px;color:${color};font-size:12px">${foot}</div></div>`).join("")}
|
|
142
|
+
</section>
|
|
143
|
+
<section class="split" style="display:grid;grid-template-columns:1fr 1.35fr;gap:12px;margin-top:18px">
|
|
144
|
+
<div style="padding:20px 22px;border:1px solid #dfe7f1;border-radius:15px;background:rgba(255,255,255,.82);box-shadow:0 7px 20px rgba(25,53,92,.04)"><div><span style="color:#8796aa;font-size:10px;font-weight:800;letter-spacing:.16em">RUN OUTCOME</span><h2 style="margin:3px 0 0;color:#17365d;font-size:17px">本轮执行结果</h2></div><div style="position:relative;display:flex;align-items:center;gap:20px;margin-top:15px"><svg viewBox="0 0 120 120" style="width:116px;height:116px;flex:0 0 116px;transform:rotate(-90deg)" aria-label="${result.totals.passed} 通过,${result.totals.failed} 失败"><circle cx="60" cy="60" r="46" fill="none" stroke="#edf1f6" stroke-width="12"></circle><circle cx="60" cy="60" r="46" fill="none" stroke="#15815c" stroke-width="12" stroke-dasharray="${passLen.toFixed(2)} ${circ.toFixed(2)}" stroke-linecap="round"></circle><circle cx="60" cy="60" r="46" fill="none" stroke="#d34661" stroke-width="12" stroke-dasharray="${failLen.toFixed(2)} ${circ.toFixed(2)}" stroke-dashoffset="${(-passLen).toFixed(2)}"></circle></svg><div style="position:absolute;display:flex;width:116px;height:116px;flex-direction:column;align-items:center;justify-content:center;pointer-events:none"><strong style="color:#17365d;font-size:20px">${passRate}</strong><span style="color:#718097;font-size:10px">通过率</span></div><div style="display:grid;gap:6px;flex:1"><div style="color:#718097;font-size:12px">通过 <strong style="float:right;color:#17243b">${result.totals.passed}</strong></div><div style="color:#718097;font-size:12px">失败 <strong style="float:right;color:#17243b">${result.totals.failed}</strong></div><div style="color:#718097;font-size:12px">阻塞 <strong style="float:right;color:#17243b">${result.totals.blocked}</strong></div></div></div></div>
|
|
145
|
+
<div style="padding:20px 22px;border:1px solid #dfe7f1;border-radius:15px;background:rgba(255,255,255,.82);box-shadow:0 7px 20px rgba(25,53,92,.04)"><div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px"><div><span style="color:#8796aa;font-size:10px;font-weight:800;letter-spacing:.16em">QUALITY SIGNALS</span><h2 style="margin:3px 0 0;color:#17365d;font-size:17px">质量信号</h2></div><span style="color:#718097;font-size:11px">按 L-5 门槛判定</span></div>${gate("测试通过率", m.passRate, passRate, "目标 100%", `${result.totals.passed} / ${executed} 个已执行用例`, `失败 ${result.totals.failed},阻塞 ${result.totals.blocked}。<code style="color:#17365d;font:11px ui-monospace,SFMono-Regular,Menlo,monospace">frontend-test-result-v1</code>`)}${gate("AC 验收覆盖", m.acCoverage, acRate, "目标 100%", `${m.acCoverage.numerator ?? "—"} / ${m.acCoverage.denominator ?? "—"}`, "来自 frontend-test-result-v1 acceptanceCoverage")}${gate("自动化覆盖率", m.automationCoverage, automationRate, "目标 ≥90%", `${m.automationCoverage.numerator ?? "—"} / ${m.automationCoverage.denominator ?? "—"}`, "由已执行 Case 与总 Case 确定性计算")}${gate("用例跳过数", m.skipped, String(result.totals.blocked), "目标 0", `${result.totals.blocked} / ${result.totals.cases}`, m.skipped.reason ?? "无阻塞用例")}${gate("Line 代码覆盖", m.lineCoverage, m.lineCoverage.ratio === null ? "—" : metricValue(m.lineCoverage), "目标 ≥80%", m.lineCoverage.reason ?? "validated coverage artifact", "前端测试当前未提供可信代码覆盖产物")}${gate("Branch 代码覆盖", m.branchCoverage, m.branchCoverage.ratio === null ? "—" : metricValue(m.branchCoverage), "目标 ≥70%", m.branchCoverage.reason ?? "validated coverage artifact", "前端测试当前未提供可信代码覆盖产物")}</div>
|
|
146
|
+
</section>
|
|
147
|
+
<section class="gates" style="display:grid;grid-template-columns:1.28fr .86fr;gap:20px;margin-top:20px"><div style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px"><div style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px"><h2 style="margin:0;color:#17365d;font-size:19px">L-5 门禁摘要</h2><span style="color:#718097;font-size:13px">阻断项与执行门槛</span></div>${gate("测试通过率", m.passRate, passRate, "目标 100%", `${result.totals.passed} / ${executed}`, `失败 ${result.totals.failed},阻塞 ${result.totals.blocked}。<code style="color:#17365d;font:11px ui-monospace,SFMono-Regular,Menlo,monospace">frontend-test-result-v1</code>`)}${gate("AC 验收覆盖", m.acCoverage, acRate, "目标 100%", `${m.acCoverage.numerator ?? "—"} / ${m.acCoverage.denominator ?? "—"}`, "来自 frontend-test-result-v1 acceptanceCoverage")}${gate("自动化覆盖率", m.automationCoverage, automationRate, "目标 ≥90%", `${m.automationCoverage.numerator ?? "—"} / ${m.automationCoverage.denominator ?? "—"}`, "由已执行 Case 与总 Case 确定性计算")}${gate("阻塞/跳过用例", m.skipped, String(result.totals.blocked), "目标 0", `${result.totals.blocked} / ${result.totals.cases}`, m.skipped.reason ?? "无阻塞用例")}${gate("Line 代码覆盖", m.lineCoverage, m.lineCoverage.ratio === null ? "—" : metricValue(m.lineCoverage), "目标 ≥80%", m.lineCoverage.reason ?? "validated coverage artifact", "前端测试当前未提供可信代码覆盖产物")}${gate("Branch 代码覆盖", m.branchCoverage, m.branchCoverage.ratio === null ? "—" : metricValue(m.branchCoverage), "目标 ≥70%", m.branchCoverage.reason ?? "validated coverage artifact", "前端测试当前未提供可信代码覆盖产物")}</div><div style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px"><div style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px"><h2 style="margin:0;color:#17365d;font-size:19px">阻断项</h2><span style="color:#718097;font-size:13px">${metrics.blockingItems.length} 条</span></div><ul style="margin:0;padding-left:20px;color:#718097;font-size:12px">${blocking}</ul><div style="margin-top:16px;padding:12px;border-radius:10px;background:#fff7e8;color:#8b641f;font-size:12px">前端 DAG 当前没有可信的 line/branch coverage artifact,必须保持 unavailable,不得推断。</div></div></section>
|
|
148
|
+
<section style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:20px"><div style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:12px"><h2 style="margin:0;color:#17365d;font-size:19px">用例执行明细</h2><span style="color:#718097;font-size:13px">${result.cases.length} 条 · Result v1</span></div>${cases}</section>
|
|
149
|
+
<section style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:20px"><p style="margin:0;color:#718097;font-size:12px">报告由 frontend-test L-5 shell 节点基于 <code style="color:#17365d;font:11px ui-monospace,SFMono-Regular,Menlo,monospace">frontend-test-result-v1</code> 确定性渲染。L-5 判定依据:pass=100%、AC=100%、automation≥90%、line≥80%、branch≥70%、blocked=0 且无阻断级 Critical 风险。</p></section>
|
|
150
|
+
</div></body></html>`;
|
|
112
151
|
}
|
|
113
152
|
async function writePairAtomic(markdownPath, markdown, htmlPath, html) {
|
|
114
153
|
await mkdir(path.dirname(markdownPath), { recursive: true });
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { access, readdir, readFile, realpath } from "node:fs/promises";
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
6
5
|
import { assertValidDagSpec } from "./validate.js";
|
|
@@ -1079,37 +1078,10 @@ function isFrontendLintVerifyCommand(command) {
|
|
|
1079
1078
|
const text = `${command.label}\n${command.args.join(" ")}`;
|
|
1080
1079
|
return /\b(?:lint|eslint)\b/i.test(text);
|
|
1081
1080
|
}
|
|
1082
|
-
function isManagedCiWrapperVerifyCommand(command) {
|
|
1083
|
-
const text = `${command.label}\n${command.args.join(" ")}`.replaceAll("\\", "/");
|
|
1084
|
-
return /\bscripts\/ci(?:-tests)?\.sh\b/.test(text);
|
|
1085
|
-
}
|
|
1086
|
-
function repoHasNpmScript(repoRoot, scriptName) {
|
|
1087
|
-
if (!repoRoot)
|
|
1088
|
-
return false;
|
|
1089
|
-
const packagePath = path.join(repoRoot, "package.json");
|
|
1090
|
-
if (!existsSync(packagePath))
|
|
1091
|
-
return false;
|
|
1092
|
-
try {
|
|
1093
|
-
const decoded = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
1094
|
-
return typeof decoded.scripts?.[scriptName] === "string";
|
|
1095
|
-
}
|
|
1096
|
-
catch {
|
|
1097
|
-
return false;
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
1081
|
function partitionFrontendStaticVerifyCommands(input) {
|
|
1101
1082
|
const commands = input.commands ?? [];
|
|
1102
1083
|
const lintCommands = commands.filter(isFrontendLintVerifyCommand);
|
|
1103
1084
|
const staticCommands = commands.filter((command) => !isFrontendLintVerifyCommand(command));
|
|
1104
|
-
if (lintCommands.length === 0 &&
|
|
1105
|
-
commands.some(isManagedCiWrapperVerifyCommand) &&
|
|
1106
|
-
repoHasNpmScript(input.repoRoot, "lint")) {
|
|
1107
|
-
lintCommands.push({
|
|
1108
|
-
args: ["npm", "run", "lint"],
|
|
1109
|
-
cwd: input.repoRoot,
|
|
1110
|
-
label: "npm run lint",
|
|
1111
|
-
});
|
|
1112
|
-
}
|
|
1113
1085
|
return {
|
|
1114
1086
|
lint: {
|
|
1115
1087
|
...(lintCommands.length > 0 ? { commands: lintCommands } : {}),
|
|
@@ -1211,7 +1183,6 @@ async function discoverFrontendFallbackVerifyCommands(repoRoot) {
|
|
|
1211
1183
|
const staticCommands = firstExisting([
|
|
1212
1184
|
"typecheck",
|
|
1213
1185
|
"check-types",
|
|
1214
|
-
"lint",
|
|
1215
1186
|
"check",
|
|
1216
1187
|
"build",
|
|
1217
1188
|
]);
|
|
@@ -2455,9 +2426,62 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2455
2426
|
sourceContext,
|
|
2456
2427
|
].join("\n\n"),
|
|
2457
2428
|
},
|
|
2429
|
+
{
|
|
2430
|
+
id: "frontend-contract-json-pi",
|
|
2431
|
+
depends_on: [
|
|
2432
|
+
"frontend-plan-revision-pi",
|
|
2433
|
+
"frontend-plan-pi",
|
|
2434
|
+
"frontend-final-design-review-pi",
|
|
2435
|
+
"frontend-design-review-pi",
|
|
2436
|
+
],
|
|
2437
|
+
dependsPolicy: "all-or-condition-skip",
|
|
2438
|
+
role: "planner",
|
|
2439
|
+
executor: "pi",
|
|
2440
|
+
complexity: "MED",
|
|
2441
|
+
writePolicy: "read-only",
|
|
2442
|
+
outputMode: "structured-required",
|
|
2443
|
+
retryPolicy: STRUCTURED_REQUIRED_PI_RETRY_POLICY,
|
|
2444
|
+
allowedPaths: readOnlyPaths,
|
|
2445
|
+
forbiddenPaths,
|
|
2446
|
+
skills: FRONTEND_IMPLEMENTATION_SKILLS,
|
|
2447
|
+
outputContract: "Return exactly one JSON object conforming to frontend-implementation-contract-v1. No Markdown, prose, comments, or code fences.",
|
|
2448
|
+
subtask_prompt: [
|
|
2449
|
+
"Convert the effective reviewed frontend plan into the canonical frontend-implementation-contract-v1 JSON.",
|
|
2450
|
+
"Use frontend-plan-revision-pi when it is FINISHED; otherwise use frontend-plan-pi. Confirm the effective design review passed before producing the contract.",
|
|
2451
|
+
"Return only the JSON object. Do not wrap it in Markdown or a code fence. Do not add explanatory text.",
|
|
2452
|
+
"Preserve all requirement expectedOutcome, interaction trigger/expectedBehavior, target files, verification targets, Mock/API decisions, and Real Integration Gap from the effective plan.",
|
|
2453
|
+
frontendContractSchemaBlock,
|
|
2454
|
+
sourceContext,
|
|
2455
|
+
].join("\n\n"),
|
|
2456
|
+
},
|
|
2457
|
+
{
|
|
2458
|
+
id: "frontend-contract-json-validate-shell",
|
|
2459
|
+
depends_on: ["frontend-contract-json-pi"],
|
|
2460
|
+
role: "verifier",
|
|
2461
|
+
executor: "shell",
|
|
2462
|
+
complexity: "LOW",
|
|
2463
|
+
writePolicy: "read-only",
|
|
2464
|
+
allowedPaths: readOnlyPaths,
|
|
2465
|
+
forbiddenPaths,
|
|
2466
|
+
outputContract: "Validated frontend implementation contract artifact with schema ID and SHA-256.",
|
|
2467
|
+
subtask_prompt: "Materialize and validate the structured frontend contract before prewrite authorization.",
|
|
2468
|
+
shell: {
|
|
2469
|
+
commands: [],
|
|
2470
|
+
jsonArtifactGate: {
|
|
2471
|
+
fromNodeId: "frontend-contract-json-pi",
|
|
2472
|
+
schemaId: "frontend-implementation-contract-v1",
|
|
2473
|
+
artifactName: "frontend-implementation-contract.json",
|
|
2474
|
+
outputDir: "contracts",
|
|
2475
|
+
},
|
|
2476
|
+
cwd: ".",
|
|
2477
|
+
timeoutMs: 60000,
|
|
2478
|
+
},
|
|
2479
|
+
},
|
|
2458
2480
|
{
|
|
2459
2481
|
id: "frontend-prewrite-gate-shell",
|
|
2460
2482
|
depends_on: [
|
|
2483
|
+
"frontend-contract-json-pi",
|
|
2484
|
+
"frontend-contract-json-validate-shell",
|
|
2461
2485
|
"frontend-final-design-review-pi",
|
|
2462
2486
|
"frontend-design-review-pi",
|
|
2463
2487
|
"frontend-plan-revision-pi",
|
|
@@ -2476,8 +2500,8 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2476
2500
|
commands: [],
|
|
2477
2501
|
frontendPrewriteGate: {
|
|
2478
2502
|
schemaVersion: 1,
|
|
2479
|
-
planFromNodeId: "frontend-
|
|
2480
|
-
planFallbackFromNodeIds: ["frontend-plan-pi"],
|
|
2503
|
+
planFromNodeId: "frontend-contract-json-pi",
|
|
2504
|
+
planFallbackFromNodeIds: ["frontend-plan-revision-pi", "frontend-plan-pi"],
|
|
2481
2505
|
reviewFromNodeId: "frontend-final-design-review-pi",
|
|
2482
2506
|
reviewFallbackFromNodeIds: ["frontend-design-review-pi"],
|
|
2483
2507
|
requiredRequirementIds: requirementIds,
|
|
@@ -3635,7 +3659,7 @@ async function buildBackendTestHybridDag(sources) {
|
|
|
3635
3659
|
"Do not read source/**, add cases, reassign ACs, modify conftest/config/production code, use skip/xfail, swallow assertions, execute pytest, or emit JSON. For best-effort cleanup, catch only the narrow transport exception actually raised by the selected HTTP client (for example `requests.RequestException` or `urllib.error.URLError`); never use bare `except`, `Exception`, or `BaseException` with `pass`.",
|
|
3636
3660
|
].join("\n\n"),
|
|
3637
3661
|
};
|
|
3638
|
-
const collectionAssess = shellNode("assess-backend-pytest-collection-shell", [generatePytest.id], "markdown-collection-assess", "
|
|
3662
|
+
const collectionAssess = shellNode("assess-backend-pytest-collection-shell", [generatePytest.id], "markdown-collection-assess", "Resolve final Markdown-mapped scripts before any business test body execution. A safe deterministic mapped script that the pytest writer omitted is REPAIRABLE without starting pytest; otherwise run pytest collection only over existing mapped scripts. Materialize hash-bound PASS/REPAIRABLE/BLOCKED facts. Only missing mapped generated scripts and generated testcase-local syntax/import inconsistencies are repairable; dependency, plugin, production-module, environment, safety and unknown failures remain blocked.", "Run-owned reports/backend-test-pytest-collection-initial.md and contracts/backend-test-pytest-collection-initial.json with bounded diagnostics, asset hashes, collected item IDs and deterministic repair eligibility.", [], 120000);
|
|
3639
3663
|
const repairPytest = {
|
|
3640
3664
|
id: "repair-backend-pytest-collection-pi",
|
|
3641
3665
|
depends_on: [collectionAssess.id],
|
|
@@ -3663,7 +3687,7 @@ async function buildBackendTestHybridDag(sources) {
|
|
|
3663
3687
|
outputContract: "First non-empty line is IMPLEMENTATION_OUTCOME: changed|already-satisfied|blocked, followed by a concise repair summary. Modify only generated pytest scripts/helpers/factories and preserve every Markdown Case, Test Point, primary symbol and assertion meaning.",
|
|
3664
3688
|
subtask_prompt: [
|
|
3665
3689
|
"Repair the generated backend pytest asset as one bounded program using the direct upstream collection assessment. This is the only repair attempt and happens before any business test body execution.",
|
|
3666
|
-
"Fix only collection-proven generated testcase-local syntax, module path, missing symbol, circular import, fixture-name, decorator or parameterization inconsistencies. Inspect all affected importers and providers so the repair is cross-file consistent.",
|
|
3690
|
+
"Fix only collection-proven generated testcase-local defects: create the exact safe missing mapped test_*.py paths listed by the initial facts, or repair syntax, module path, missing symbol, circular import, fixture-name, decorator or parameterization inconsistencies. Inspect all affected importers and providers so the repair is cross-file consistent; do not create unrelated pytest scripts.",
|
|
3667
3691
|
"Preserve final testcase/md/** semantics, every Case ID, Rule/Test Point binding, primary symbol, parameter ID, expected status/body/schema assertion, HTTP logging, redaction and truncation behavior.",
|
|
3668
3692
|
"Do not read task source/** or reinterpret requirements. Do not modify Markdown, conftest, pytest config, production code or dependencies.",
|
|
3669
3693
|
"Do not add skip/skipif/xfail, remove tests, reduce collected items, loosen assertions, swallow exceptions, use try/except ImportError fallback, mutate sys.path/PYTHONPATH, or replace the real API with mocks.",
|
|
@@ -5287,7 +5311,7 @@ async function buildHybridDagForTemplate(sources, template) {
|
|
|
5287
5311
|
else if (template === "review-gated-dag")
|
|
5288
5312
|
spec = buildReviewGatedHybridDag(standard, sources);
|
|
5289
5313
|
else
|
|
5290
|
-
spec = buildSupervisedHybridDag(standard, sources);
|
|
5314
|
+
spec = await buildSupervisedHybridDag(standard, sources);
|
|
5291
5315
|
}
|
|
5292
5316
|
applyProjectGovernanceReview(spec, template, sources);
|
|
5293
5317
|
// New generate path always emits DagSpec v4 + bindings.
|
|
@@ -5721,10 +5745,12 @@ function buildWriteSetGateNode(sources) {
|
|
|
5721
5745
|
},
|
|
5722
5746
|
};
|
|
5723
5747
|
}
|
|
5724
|
-
function buildSoftVerifyNode(sources) {
|
|
5748
|
+
async function buildSoftVerifyNode(sources) {
|
|
5725
5749
|
const implementId = implementationNodeId();
|
|
5726
5750
|
const strategy = resolveDagVerifyStrategy(sources.taskConfig, "1");
|
|
5727
|
-
const fallbackCommands =
|
|
5751
|
+
const fallbackCommands = sources.repoRoot
|
|
5752
|
+
? (await discoverFrontendFallbackVerifyCommands(sources.repoRoot)).staticCommands
|
|
5753
|
+
: ["npm run typecheck"];
|
|
5728
5754
|
const focusedIntermediate = sources.verifyCommands?.intermediate.filter((command) => !isFullSuiteVerifyCommand(command));
|
|
5729
5755
|
const plannedIntermediate = applyMavenVerificationPlanning({
|
|
5730
5756
|
repoRoot: sources.repoRoot,
|
|
@@ -5966,7 +5992,7 @@ function resolveSupervisedConvergence(taskConfig) {
|
|
|
5966
5992
|
chainNodeIds: [...SUPERVISED_CONVERGENCE_CHAIN_NODE_IDS],
|
|
5967
5993
|
};
|
|
5968
5994
|
}
|
|
5969
|
-
function buildSupervisedHybridDag(standard, sources) {
|
|
5995
|
+
async function buildSupervisedHybridDag(standard, sources) {
|
|
5970
5996
|
const contract = getTaskOrThrow(standard, "contract-pi");
|
|
5971
5997
|
const scoutSrc = getTaskOrThrow(standard, "scout-src");
|
|
5972
5998
|
const scoutTests = getTaskOrThrow(standard, "scout-tests");
|
|
@@ -6018,7 +6044,7 @@ function buildSupervisedHybridDag(standard, sources) {
|
|
|
6018
6044
|
"final-write-set-audit-format-repair-pi",
|
|
6019
6045
|
],
|
|
6020
6046
|
}),
|
|
6021
|
-
buildSoftVerifyNode(sources),
|
|
6047
|
+
await buildSoftVerifyNode(sources),
|
|
6022
6048
|
buildProcessSupervisorNode(sources),
|
|
6023
6049
|
buildProcessGateNode(sources),
|
|
6024
6050
|
buildRepairNode(sources),
|
|
@@ -206,6 +206,29 @@ export function validateOutputProtocol(protocol, text) {
|
|
|
206
206
|
reason: `missing first non-empty line; expected one of: ${protocol.validLines.map((l) => JSON.stringify(l)).join(" or ")}`,
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
|
+
const isReviewVerdict = protocol.validLines.length === 2 && protocol.validLines.includes("VERDICT: pass") && protocol.validLines.includes("VERDICT: request-revision");
|
|
210
|
+
// Models frequently prepend a short explanation despite the protocol
|
|
211
|
+
// instruction. Recover only when there is exactly one unambiguous protocol
|
|
212
|
+
// line; conflicting or repeated verdicts remain fail-closed.
|
|
213
|
+
const candidates = isReviewVerdict ? text
|
|
214
|
+
.split("\n")
|
|
215
|
+
.map((line) => normalizeVerdictCandidateLine(line.trim()))
|
|
216
|
+
.filter((line) => protocol.validLines.includes(line)) : [];
|
|
217
|
+
const uniqueCandidates = [...new Set(candidates)];
|
|
218
|
+
if (uniqueCandidates.length > 1) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
failureCategory: "protocol-invalid",
|
|
222
|
+
reason: `conflicting protocol lines found: ${uniqueCandidates.map((line) => JSON.stringify(line)).join(" and ")}`,
|
|
223
|
+
firstNonEmptyLine: first,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (protocol.validLines.includes(first)) {
|
|
227
|
+
return { ok: true, matchedLine: first };
|
|
228
|
+
}
|
|
229
|
+
if (uniqueCandidates.length === 1) {
|
|
230
|
+
return { ok: true, matchedLine: uniqueCandidates[0] };
|
|
231
|
+
}
|
|
209
232
|
if (!protocol.validLines.includes(first)) {
|
|
210
233
|
return {
|
|
211
234
|
ok: false,
|
|
@@ -224,7 +224,7 @@
|
|
|
224
224
|
"artifacts/**"
|
|
225
225
|
],
|
|
226
226
|
"outputContract": "Run-owned reports/backend-test-pytest-collection-initial.md and contracts/backend-test-pytest-collection-initial.json with bounded diagnostics, asset hashes, collected item IDs and deterministic repair eligibility.",
|
|
227
|
-
"subtask_prompt": "
|
|
227
|
+
"subtask_prompt": "Resolve final Markdown-mapped scripts before any business test body execution. A safe deterministic mapped script that the pytest writer omitted is REPAIRABLE without starting pytest; otherwise run pytest collection only over existing mapped scripts. Materialize hash-bound PASS/REPAIRABLE/BLOCKED facts. Only missing mapped generated scripts and generated testcase-local syntax/import inconsistencies are repairable; dependency, plugin, production-module, environment, safety and unknown failures remain blocked.",
|
|
228
228
|
"shell": {
|
|
229
229
|
"commands": [],
|
|
230
230
|
"backendTestPipeline": "markdown-collection-assess",
|
|
@@ -266,7 +266,7 @@
|
|
|
266
266
|
"type": "implementation-outcome-v1"
|
|
267
267
|
},
|
|
268
268
|
"outputContract": "First non-empty line is IMPLEMENTATION_OUTCOME: changed|already-satisfied|blocked, followed by a concise repair summary. Modify only generated pytest scripts/helpers/factories and preserve every Markdown Case, Test Point, primary symbol and assertion meaning.",
|
|
269
|
-
"subtask_prompt": "Repair the generated backend pytest asset as one bounded program using the direct upstream collection assessment. This is the only repair attempt and happens before any business test body execution.\n\nFix only collection-proven generated testcase-local syntax, module path, missing symbol, circular import, fixture-name, decorator or parameterization inconsistencies. Inspect all affected importers and providers so the repair is cross-file consistent.\n\nPreserve final testcase/md/** semantics, every Case ID, Rule/Test Point binding, primary symbol, parameter ID, expected status/body/schema assertion, HTTP logging, redaction and truncation behavior.\n\nDo not read task source/** or reinterpret requirements. Do not modify Markdown, conftest, pytest config, production code or dependencies.\n\nDo not add skip/skipif/xfail, remove tests, reduce collected items, loosen assertions, swallow exceptions, use try/except ImportError fallback, mutate sys.path/PYTHONPATH, or replace the real API with mocks.\n\nDo not execute pytest; the deterministic effective collection gate owns the final collection attempt."
|
|
269
|
+
"subtask_prompt": "Repair the generated backend pytest asset as one bounded program using the direct upstream collection assessment. This is the only repair attempt and happens before any business test body execution.\n\nFix only collection-proven generated testcase-local defects: create the exact safe missing mapped test_*.py paths listed by the initial facts, or repair syntax, module path, missing symbol, circular import, fixture-name, decorator or parameterization inconsistencies. Inspect all affected importers and providers so the repair is cross-file consistent; do not create unrelated pytest scripts.\n\nPreserve final testcase/md/** semantics, every Case ID, Rule/Test Point binding, primary symbol, parameter ID, expected status/body/schema assertion, HTTP logging, redaction and truncation behavior.\n\nDo not read task source/** or reinterpret requirements. Do not modify Markdown, conftest, pytest config, production code or dependencies.\n\nDo not add skip/skipif/xfail, remove tests, reduce collected items, loosen assertions, swallow exceptions, use try/except ImportError fallback, mutate sys.path/PYTHONPATH, or replace the real API with mocks.\n\nDo not execute pytest; the deterministic effective collection gate owns the final collection attempt."
|
|
270
270
|
},
|
|
271
271
|
{
|
|
272
272
|
"id": "effective-backend-pytest-collection-gate-shell",
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"requirements": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "expectedOutcome", "implementationTargets", "verificationTargetIds"], "properties": { "id": { "$ref": "#/$defs/requirementId" }, "expectedOutcome": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "evidenceGap": { "$ref": "#/$defs/gap" } } } },
|
|
14
14
|
"uiStates": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["name", "applicable"], "properties": { "name": { "type": "string", "minLength": 1 }, "applicable": { "type": "boolean" }, "expectedBehavior": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "notApplicableReason": { "type": "string", "minLength": 1 } }, "allOf": [{ "if": { "properties": { "applicable": { "const": true } }, "required": ["applicable"] }, "then": { "required": ["expectedBehavior", "implementationTargets", "verificationTargetIds"], "properties": { "implementationTargets": { "minItems": 1 }, "verificationTargetIds": { "minItems": 1 } } } }, { "if": { "properties": { "applicable": { "const": false } }, "required": ["applicable"] }, "then": { "required": ["notApplicableReason"] } }] } },
|
|
15
15
|
"interactions": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["name", "trigger", "expectedBehavior", "implementationTargets", "verificationTargetIds"], "properties": { "name": { "type": "string", "minLength": 1 }, "trigger": { "type": "string", "minLength": 1 }, "expectedBehavior": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string" } } } } },
|
|
16
|
-
"mockApi": { "type": "object", "additionalProperties": false, "required": ["strategy", "productionDefaultOff", "activation", "endpoints"], "properties": { "strategy": { "enum": ["native", "browser-intercept", "request-adapter", "not-needed"] }, "productionDefaultOff": { "const": true }, "activation": { "type": "string", "minLength": 1 }, "endpoints": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["method", "path"], "properties": { "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] }, "path": { "type": "string", "pattern": "^/" }, "fixture": { "$ref": "#/$defs/path" }, "consumer": { "$ref": "#/$defs/path" } } } } } },
|
|
16
|
+
"mockApi": { "type": "object", "additionalProperties": false, "required": ["strategy", "productionDefaultOff", "activation", "endpoints"], "properties": { "strategy": { "enum": ["native", "browser-intercept", "request-adapter", "not-needed"] }, "productionDefaultOff": { "const": true }, "activation": { "type": ["string", "null"], "minLength": 1 }, "endpoints": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["method", "path"], "properties": { "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] }, "path": { "type": "string", "pattern": "^/" }, "fixture": { "anyOf": [{ "$ref": "#/$defs/path" }, { "type": "null" }] }, "consumer": { "anyOf": [{ "$ref": "#/$defs/path" }, { "type": "null" }] } } } } } },
|
|
17
17
|
"designEvidence": { "type": "object", "additionalProperties": false, "required": ["source", "paths", "conflicts"], "properties": { "source": { "type": "string", "minLength": 1 }, "paths": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "conflicts": { "type": "array", "items": { "type": "string", "minLength": 1 } } } },
|
|
18
|
-
"verificationTargets": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "type", "commandLabel", "file", "requirementIds", "uiStates"], "properties": { "id": { "type": "string", "minLength": 1 }, "type": { "enum": ["static", "unit", "component", "integration", "mock"] }, "commandLabel": { "type": "string", "minLength": 1 }, "file": { "$ref": "#/$defs/path" }, "symbol": { "type": "string", "minLength": 1 }, "requirementIds": { "type": "array", "items": { "$ref": "#/$defs/requirementId" } }, "uiStates": { "type": "array", "items": { "type": "string", "minLength": 1 } } } } },
|
|
18
|
+
"verificationTargets": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "type", "commandLabel", "file", "requirementIds", "uiStates"], "properties": { "id": { "type": "string", "minLength": 1 }, "type": { "enum": ["static", "unit", "component", "integration", "mock"] }, "commandLabel": { "type": "string", "minLength": 1 }, "file": { "$ref": "#/$defs/path" }, "symbol": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }, "requirementIds": { "type": "array", "items": { "$ref": "#/$defs/requirementId" } }, "uiStates": { "type": "array", "items": { "type": "string", "minLength": 1 } } } } },
|
|
19
19
|
"evidenceGaps": { "type": "array", "items": { "$ref": "#/$defs/gap" } }
|
|
20
20
|
},
|
|
21
21
|
"allOf": [{ "if": { "properties": { "mockApi": { "properties": { "strategy": { "enum": ["native", "browser-intercept", "request-adapter"] } } } } }, "then": { "properties": { "mockApi": { "properties": { "endpoints": { "minItems": 1, "items": { "required": ["method", "path", "fixture", "consumer"] } } } } } } }],
|
package/harness.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"workflowPolicy": {
|
|
7
7
|
"defaultImplementationWorkflow": "agent-dag",
|
|
8
8
|
"dag": {
|
|
9
|
-
|
|
9
|
+
"defaultEntry": "task advance",
|
|
10
10
|
"outputLanguage": "zh-CN",
|
|
11
11
|
"profileRouting": {
|
|
12
12
|
"minimal": "standard-dag",
|
|
@@ -58,9 +58,9 @@
|
|
|
58
58
|
"executors": {
|
|
59
59
|
"pi": {
|
|
60
60
|
"description": "Pi planning, review, diagnosis, and bounded writing when DAG toolProfile=write",
|
|
61
|
-
"LOW": "
|
|
62
|
-
"MED": "
|
|
63
|
-
"HIGH": "
|
|
61
|
+
"LOW": {"model": "deepseek/deepseek-v4-flash", "thinking": "high"},
|
|
62
|
+
"MED": {"model": "deepseek/deepseek-v4-flash", "thinking": "max"},
|
|
63
|
+
"HIGH": {"model": "deepseek/deepseek-v4-flash", "thinking": "max"}
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
}
|
package/package.json
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
> Backend-test Markdown-first:先由确定性环境 Shell 检查 clean env 中 Python/pytest、常见配置、conftest/fixture、test root、server entry 和 HTML renderer,失败时不消耗模型调用。随后 Pi 生成中文 README 索引与模块用例卡片并独立 Review `testcase/md/**`。第 4 节点只把前置条件、操作步骤、预期结果作为必选章节,并检查 Case ID、业务 AC、步骤/预期和占位措辞;不校验需求来源引用有效性或 Markdown sensitive-shaped 内容。pytest writer 为每次真实接口调用记录脱敏、有界的请求 method/URL/参数摘要和响应 status/body 摘要。第 6 节点只扫描每条 Case 明确映射的 pytest 脚本,同时支持模块级函数和 pytest class 方法,并把缺少请求/响应日志、递归脱敏或有界截断证据记录为 advisory。第 4/6 节点均写 PASS/FAIL findings 而不阻断后续;pytest 仍只运行一次,生成 JUnit,并把 Markdown 名称/场景/脚本映射与同一 JUnit 合成为按测试概览、质量校验、失败概览、用例执行明细和技术证据组织的中文 self-contained HTML 与 Markdown facts。最终 Pi 按固定简洁结构汇总 advisory 状态、执行事实和 L-5 结论。active 流程不要求模型生成 backend-test 业务 JSON。
|
|
22
22
|
|
|
23
|
-
显式专用 `taskKind` 保持兼容并优先于任务源分类。`backend-test` 选择固定 **12 个真实顶层节点**的 Markdown-first DAG:环境硬门、Markdown cases、独立 Review/修订、第 4 节点 advisory Markdown 校验、pytest 转换、initial collection assessment、仅 `REPAIRABLE` 时最多一次 generated-test repair、hash-bound effective collection、scoped traceability、facts-only manifest、单次业务 pytest + pytest-html/HTML/facts、最终 Markdown 报告与 L-5。绿色路径 collection 一次,repair
|
|
23
|
+
显式专用 `taskKind` 保持兼容并优先于任务源分类。`backend-test` 选择固定 **12 个真实顶层节点**的 Markdown-first DAG:环境硬门、Markdown cases、独立 Review/修订、第 4 节点 advisory Markdown 校验、pytest 转换、initial collection assessment、仅 `REPAIRABLE` 时最多一次 generated-test repair、hash-bound effective collection、scoped traceability、facts-only manifest、单次业务 pytest + pytest-html/HTML/facts、最终 Markdown 报告与 L-5。initial assessment 先解析 expected/existing/missing mapped scripts;writer 漏生成的安全唯一 mapped `test_*.py` 不启动 pytest而直接形成 `missing-mapped-pytest-script` REPAIRABLE facts,repair 只能创建该精确路径;映射齐全时才执行 initial collection。绿色路径 collection 一次,repair 路径在修复后执行一次 final collection,测试体始终只执行一次;依赖/plugin/生产模块/环境/安全/未知 collection error 不得 repair,assertion/API failure不触发 repair 或 rerun。历史 JSON contract/materializer 可继续读取旧 DAG,但新 runtime/template 不再生成模型业务 JSON。`knowledge-sync` 与 `knowledge-graph-bootstrap` 继续通过各自显式 taskKind 选择知识回写/图谱开荒 DAG。治理等级仍由 `minimal|standard|reviewed|supervised` 推断。
|
|
24
24
|
|
|
25
25
|
### DAG workflow 层级
|
|
26
26
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
很多用户只跑最短 runtime 命令,**漏掉** PRD 归档纪律与 `plan create`。二者**不是** `task advance` 的硬依赖,但在「有原始 PRD / 非微小实现」场景下应成为默认纪律。本文给出**何时用、何时可跳、命令顺序与案例**。
|
|
4
4
|
|
|
5
|
-
相关命令细节:`command-reference.md`(task advance / plan / status)。
|
|
5
|
+
相关命令细节:`command-reference.md`(task advance / plan / status)。
|
|
6
6
|
Task 目录布局:`task-workflow.md`。
|
|
7
7
|
仓库治理全文:目标仓 `governanceRoot` 下 `feature-workflow.md`(若有)。
|
|
8
8
|
|
|
@@ -102,31 +102,31 @@ loop-agent task advance <task-id> --approve-gate "write-set-review:<digest>" --j
|
|
|
102
102
|
### 案例 A — 用户丢来一份 PRD 文件(默认完整路径)
|
|
103
103
|
|
|
104
104
|
**信号**:`帮我按这个 PRD 实现…` + 附件/路径。
|
|
105
|
-
**做法**:非微小则先 `plan create`(或复用 active plan)→ **`task advance --prd`** → 审查 writeSet gate → `--approve-gate`。
|
|
105
|
+
**做法**:非微小则先 `plan create`(或复用 active plan)→ **`task advance --prd`** → 审查 writeSet gate → `--approve-gate`。
|
|
106
106
|
**验收**:`source/references/` 有原文;manifest hash 在;review 能三方对照。
|
|
107
107
|
|
|
108
108
|
### 案例 B — 聊天里三句话小需求(可跳过 PRD + plan)
|
|
109
109
|
|
|
110
110
|
**信号**:`把 X 按钮文案改成 Y`,单文件。
|
|
111
|
-
**做法**:`task advance --from-text` → approve gate…
|
|
111
|
+
**做法**:`task advance --from-text` → approve gate…
|
|
112
112
|
**不要**:为了「流程完整」空跑不存在的 PRD 文件步骤或堆一个空洞 plan。
|
|
113
113
|
|
|
114
114
|
### 案例 C — 源仓改 CLI 默认行为(必须 plan,PRD 视情况)
|
|
115
115
|
|
|
116
116
|
**信号**:行为变更、CHANGELOG、skills/init 多表面。
|
|
117
|
-
**做法**:**`plan create`** 先冻结契约与工作块 → 按块 `task advance` → 定向验证 → `plan complete`。
|
|
117
|
+
**做法**:**`plan create`** 先冻结契约与工作块 → 按块 `task advance` → 定向验证 → `plan complete`。
|
|
118
118
|
**验收**:active/completed 索引与 plan 正文一致;`plan check` / `task advance` 索引 preflight 不红。
|
|
119
119
|
|
|
120
120
|
### 案例 D — agent-worker / Feature 已 materialize source_docs
|
|
121
121
|
|
|
122
122
|
**信号**:TaskSpec 已把 `source_docs` 拷进 `source/references/`。
|
|
123
|
-
**做法**:通常 **不必再 import 同一文件**;检查 references + 派生 `需求.md` 顶部「冲突以 references 为准」→ 补边界 → `task advance`。
|
|
123
|
+
**做法**:通常 **不必再 import 同一文件**;检查 references + 派生 `需求.md` 顶部「冲突以 references 为准」→ 补边界 → `task advance`。
|
|
124
124
|
**仍建议**:产品线级大功能在 Feature / 仓库层有 plan 或 Feature Packet 记录。
|
|
125
125
|
|
|
126
126
|
### 案例 E — 用户说「loop-agent 帮我完成 XXX」无附件
|
|
127
127
|
|
|
128
128
|
**信号**:强路由进 DAG,但无 PRD 路径。
|
|
129
|
-
**做法**:主会话 **先问清**是否有 PRD 文件;有则 `--prd`;无则 `--from-text` 写入共识并标来源 → 判断微小 vs 非微小决定是否 `plan create` → 再 `task advance`。
|
|
129
|
+
**做法**:主会话 **先问清**是否有 PRD 文件;有则 `--prd`;无则 `--from-text` 写入共识并标来源 → 判断微小 vs 非微小决定是否 `plan create` → 再 `task advance`。
|
|
130
130
|
**禁止**:主会话直接写业务代码代替 lifecycle。
|
|
131
131
|
|
|
132
132
|
## 宿主 agent 检查清单(编排时)
|
|
@@ -141,7 +141,7 @@ loop-agent task advance <task-id> --approve-gate "write-set-review:<digest>" --j
|
|
|
141
141
|
|
|
142
142
|
## 与「主路径」文档的关系
|
|
143
143
|
|
|
144
|
-
`SKILL.md` / `command-reference.md` 的 **主路径** 仍是最短 runtime 闭环(便于抄命令)。
|
|
144
|
+
`SKILL.md` / `command-reference.md` 的 **主路径** 仍是最短 runtime 闭环(便于抄命令)。
|
|
145
145
|
**系统化默认**应读作:
|
|
146
146
|
|
|
147
147
|
```text
|