@tea-agent/loop-agent 0.25.2 → 0.25.4
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/AGENTS.md +1 -0
- package/CHANGELOG.md +37 -0
- package/dist/application/dag/args.js +21 -1
- package/dist/application/dag/run-dag.js +1 -0
- package/dist/cli/command-definitions.js +2 -2
- package/dist/cli/program.js +84 -64
- package/dist/commands/client-recovery.js +657 -0
- package/dist/commands/init.js +283 -82
- package/dist/commands/run-dag-progress.js +109 -0
- package/dist/commands/run-dag.js +16 -5
- package/dist/executors/shell-executor.js +32 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +88 -15
- package/dist/workflows/dag/backend-test-result-contract.js +35 -9
- package/dist/workflows/dag/frontend-test-case-checklist.js +71 -0
- package/dist/workflows/dag/frontend-test-html-report.js +77 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +44 -1
- package/dist/workflows/dag/init-hybrid.js +9 -9
- package/dist/workflows/dag/types.js +4 -0
- package/dist/workflows/dag/validate.js +3 -1
- package/docs/architecture/runtime-boundaries.md +13 -0
- package/docs/init-surface.manifest.json +6 -2
- package/docs/templates/agent-dag.schema.json +6 -0
- package/docs/templates/backend-test-dag.json +3 -3
- package/docs/templates/backend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.json +31 -4
- package/harness.json +3 -2
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +2 -1
- package/skills/loop-agent/references/command-reference.md +2 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export const DEFAULT_RUN_DAG_PROGRESS_INTERVAL_MS = 30_000;
|
|
2
|
+
function formatDuration(durationMs) {
|
|
3
|
+
const totalSeconds = Math.max(0, Math.floor(durationMs / 1_000));
|
|
4
|
+
const hours = Math.floor(totalSeconds / 3_600);
|
|
5
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
6
|
+
const seconds = totalSeconds % 60;
|
|
7
|
+
if (hours > 0)
|
|
8
|
+
return `${hours}h${String(minutes).padStart(2, "0")}m`;
|
|
9
|
+
if (minutes > 0)
|
|
10
|
+
return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
11
|
+
return `${seconds}s`;
|
|
12
|
+
}
|
|
13
|
+
function countStatuses(state) {
|
|
14
|
+
const counts = { finished: 0, running: 0, pending: 0, error: 0, skipped: 0 };
|
|
15
|
+
for (const node of Object.values(state.nodes)) {
|
|
16
|
+
if (node.status === "FINISHED")
|
|
17
|
+
counts.finished += 1;
|
|
18
|
+
else if (node.status === "RUNNING")
|
|
19
|
+
counts.running += 1;
|
|
20
|
+
else if (node.status === "ERROR")
|
|
21
|
+
counts.error += 1;
|
|
22
|
+
else if (node.status === "SKIPPED")
|
|
23
|
+
counts.skipped += 1;
|
|
24
|
+
else
|
|
25
|
+
counts.pending += 1;
|
|
26
|
+
}
|
|
27
|
+
return `finished=${counts.finished} running=${counts.running} pending=${counts.pending} error=${counts.error} skipped=${counts.skipped}`;
|
|
28
|
+
}
|
|
29
|
+
function nodeElapsedMs(node, now) {
|
|
30
|
+
const startedAt = node.startedAt ? Date.parse(node.startedAt) : Number.NaN;
|
|
31
|
+
return Number.isFinite(startedAt) ? Math.max(0, now - startedAt) : 0;
|
|
32
|
+
}
|
|
33
|
+
function activeNodeSummary(state, now) {
|
|
34
|
+
const running = Object.values(state.nodes).filter((node) => node.status === "RUNNING");
|
|
35
|
+
if (running.length === 0)
|
|
36
|
+
return "nodes=none";
|
|
37
|
+
return running
|
|
38
|
+
.map((node) => {
|
|
39
|
+
const details = [
|
|
40
|
+
`${node.id}(${formatDuration(nodeElapsedMs(node, now))}`,
|
|
41
|
+
node.livenessStatus ? `liveness=${node.livenessStatus}` : undefined,
|
|
42
|
+
node.currentAttempt ? `attempt=${node.currentAttempt}` : undefined,
|
|
43
|
+
]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join(" ");
|
|
46
|
+
return `${details})`;
|
|
47
|
+
})
|
|
48
|
+
.join(",");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Human-readable CLI progress written to stderr. The final run-dag JSON remains
|
|
52
|
+
* the only stdout payload, so machine callers can continue parsing stdout.
|
|
53
|
+
*/
|
|
54
|
+
export function createRunDagProgressObserver(options = {}) {
|
|
55
|
+
const intervalMs = options.intervalMs ?? DEFAULT_RUN_DAG_PROGRESS_INTERVAL_MS;
|
|
56
|
+
const write = options.write ?? ((text) => process.stderr.write(text));
|
|
57
|
+
const now = options.now ?? Date.now;
|
|
58
|
+
let latestState;
|
|
59
|
+
let timer;
|
|
60
|
+
const emit = (message) => {
|
|
61
|
+
try {
|
|
62
|
+
write(`[run-dag] ${message}\n`);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Progress is a derived CLI view and must never decide DAG execution.
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const stop = () => {
|
|
69
|
+
if (timer)
|
|
70
|
+
clearInterval(timer);
|
|
71
|
+
timer = undefined;
|
|
72
|
+
};
|
|
73
|
+
const start = () => {
|
|
74
|
+
if (timer || intervalMs <= 0)
|
|
75
|
+
return;
|
|
76
|
+
timer = setInterval(() => {
|
|
77
|
+
if (!latestState)
|
|
78
|
+
return;
|
|
79
|
+
emit(`heartbeat run=${latestState.runId} ${countStatuses(latestState)} ${activeNodeSummary(latestState, now())}`);
|
|
80
|
+
}, intervalMs);
|
|
81
|
+
timer.unref?.();
|
|
82
|
+
};
|
|
83
|
+
const observer = {
|
|
84
|
+
onRunStart: (state) => {
|
|
85
|
+
latestState = state;
|
|
86
|
+
emit(`started run=${state.runId} nodes=${Object.keys(state.nodes).length} title=${JSON.stringify(state.title)}`);
|
|
87
|
+
start();
|
|
88
|
+
},
|
|
89
|
+
onNodeStart: (nodeId, state) => {
|
|
90
|
+
latestState = state;
|
|
91
|
+
const node = state.nodes[nodeId];
|
|
92
|
+
emit(`node-started run=${state.runId} node=${nodeId} executor=${node?.executor ?? "unknown"} ${countStatuses(state)}`);
|
|
93
|
+
},
|
|
94
|
+
onNodeOutput: (_nodeId, _chunk, state) => {
|
|
95
|
+
latestState = state;
|
|
96
|
+
},
|
|
97
|
+
onNodeFinish: (nodeId, state) => {
|
|
98
|
+
latestState = state;
|
|
99
|
+
const node = state.nodes[nodeId];
|
|
100
|
+
emit(`node-finished run=${state.runId} node=${nodeId} status=${node?.status ?? "unknown"} duration=${formatDuration(node?.durationMs ?? 0)} ${countStatuses(state)}`);
|
|
101
|
+
},
|
|
102
|
+
onRunFinish: (state) => {
|
|
103
|
+
latestState = state;
|
|
104
|
+
stop();
|
|
105
|
+
emit(`finished run=${state.runId} status=${state.status} ${countStatuses(state)}`);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
return { observer, dispose: stop };
|
|
109
|
+
}
|
package/dist/commands/run-dag.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { parseRunDagArgs } from "../application/dag/args.js";
|
|
2
2
|
import { runDagUseCase } from "../application/dag/run-dag.js";
|
|
3
|
+
import { createRunDagProgressObserver } from "./run-dag-progress.js";
|
|
3
4
|
export { parseRunDagArgs };
|
|
4
5
|
export async function runRunDag(repoRoot, rawArgs) {
|
|
5
6
|
const parsed = parseRunDagArgs(rawArgs, repoRoot);
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
const { quiet, progressIntervalMs, ...runInput } = parsed;
|
|
8
|
+
const progress = quiet || parsed.dryRun || parsed.initOnly
|
|
9
|
+
? undefined
|
|
10
|
+
: createRunDagProgressObserver({ intervalMs: progressIntervalMs });
|
|
11
|
+
try {
|
|
12
|
+
const result = await runDagUseCase({
|
|
13
|
+
repoRoot,
|
|
14
|
+
...runInput,
|
|
15
|
+
observer: progress?.observer,
|
|
16
|
+
});
|
|
17
|
+
console.log(JSON.stringify(result, null, 2));
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
progress?.dispose();
|
|
21
|
+
}
|
|
11
22
|
}
|
|
@@ -10,6 +10,8 @@ import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend
|
|
|
10
10
|
import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
|
|
11
11
|
import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
|
|
12
12
|
import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
|
|
13
|
+
import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
|
|
14
|
+
import { renderFrontendTestHtmlReport } from "../workflows/dag/frontend-test-html-report.js";
|
|
13
15
|
import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
|
|
14
16
|
import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "../workflows/dag/frontend-worktree-diff.js";
|
|
15
17
|
import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
|
|
@@ -1143,6 +1145,30 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
1143
1145
|
};
|
|
1144
1146
|
}
|
|
1145
1147
|
}
|
|
1148
|
+
async function executeFrontendTestCaseChecklist(input, meta) {
|
|
1149
|
+
const started = Date.now();
|
|
1150
|
+
try {
|
|
1151
|
+
const declaredAcIds = (meta.spec.sourceBinding?.requirementIds ?? []).filter((id) => /^AC(?:-[A-Z0-9]+)+$/i.test(id));
|
|
1152
|
+
const result = await validateFrontendCaseChecklist({ workspaceRoot: input.cwd, declaredAcIds });
|
|
1153
|
+
if (result.issues.length) {
|
|
1154
|
+
return { ok: false, stdout: "", stderr: `frontend-test checklist blocked: ${JSON.stringify(result.issues)}`, failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
1155
|
+
}
|
|
1156
|
+
return { ok: true, stdout: `frontend-test checklist ok cases=${result.caseCount} source=${path.basename(result.manifestPath)}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
1157
|
+
}
|
|
1158
|
+
catch (error) {
|
|
1159
|
+
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
async function executeFrontendTestHtmlReport(input, meta) {
|
|
1163
|
+
const started = Date.now();
|
|
1164
|
+
try {
|
|
1165
|
+
const output = await renderFrontendTestHtmlReport({ workspaceRoot: input.cwd, runDir: meta.runDir });
|
|
1166
|
+
return { ok: true, stdout: `Frontend test report: ${output.htmlPath}\nMarkdown: ${output.markdownPath}\nOutcome: ${output.outcome}\nCases: ${output.caseCount}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
1167
|
+
}
|
|
1168
|
+
catch (error) {
|
|
1169
|
+
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1146
1172
|
async function executeFrontendTestEvidenceValidation(input) {
|
|
1147
1173
|
const started = Date.now();
|
|
1148
1174
|
try {
|
|
@@ -1275,9 +1301,15 @@ export async function executeDagShellNode(input, meta) {
|
|
|
1275
1301
|
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
1276
1302
|
}
|
|
1277
1303
|
}
|
|
1304
|
+
if (shell?.frontendTestCaseChecklist) {
|
|
1305
|
+
return executeFrontendTestCaseChecklist(input, meta);
|
|
1306
|
+
}
|
|
1278
1307
|
if (shell?.frontendTestEvidenceValidation) {
|
|
1279
1308
|
return executeFrontendTestEvidenceValidation(input);
|
|
1280
1309
|
}
|
|
1310
|
+
if (shell?.frontendTestHtmlReport) {
|
|
1311
|
+
return executeFrontendTestHtmlReport(input, meta);
|
|
1312
|
+
}
|
|
1281
1313
|
if (shell?.backendTestPipeline) {
|
|
1282
1314
|
return executeBackendTestPipelineWithWriteGuard(input, meta);
|
|
1283
1315
|
}
|
|
@@ -7,7 +7,11 @@ import path from "node:path";
|
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { parseJacocoXml, } from "./backend-test-coverage-contract.js";
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
|
-
|
|
10
|
+
// Canonical contract is three digits (BE-MODULE-001). Readers accept legacy/
|
|
11
|
+
// model-drift two-digit suffixes and canonicalize them in memory so one bad
|
|
12
|
+
// heading width cannot collapse the whole manifest/report to zero cases.
|
|
13
|
+
const CASE_ID = /\bBE-[A-Z0-9_-]+-\d{2,3}\b/g;
|
|
14
|
+
const CANONICAL_CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/;
|
|
11
15
|
const AC_ID = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
|
|
12
16
|
const SECRET = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization)\s*[:=]\s*\S+/i;
|
|
13
17
|
const SECRET_ASSIGNMENT = /\b(password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization|cookie|set-cookie|credential)\b\s*[:=]\s*([^\r\n,;]+)/gi;
|
|
@@ -178,12 +182,27 @@ async function markdownFiles(root) {
|
|
|
178
182
|
.map((entry) => path.join(directory, entry.name))
|
|
179
183
|
.sort();
|
|
180
184
|
}
|
|
185
|
+
function canonicalBackendCaseId(value) {
|
|
186
|
+
const upper = value.toUpperCase();
|
|
187
|
+
const match = upper.match(/^(BE-[A-Z0-9_-]+)-(\d{2,3})$/);
|
|
188
|
+
if (!match)
|
|
189
|
+
return upper;
|
|
190
|
+
const modulePart = match[1]
|
|
191
|
+
.replaceAll("_", "-")
|
|
192
|
+
.replace(/-+/g, "-")
|
|
193
|
+
.replace(/-$/, "");
|
|
194
|
+
return `${modulePart}-${match[2].padStart(3, "0")}`;
|
|
195
|
+
}
|
|
196
|
+
function caseIdsInText(value) {
|
|
197
|
+
return unique((value.match(CASE_ID) ?? []).map(canonicalBackendCaseId));
|
|
198
|
+
}
|
|
181
199
|
function splitCases(markdown) {
|
|
182
200
|
const headings = [
|
|
183
|
-
...markdown.matchAll(/^##\s+(BE-[A-Z0-
|
|
201
|
+
...markdown.matchAll(/^##\s+(BE-[A-Z0-9_-]+-\d{2,3})\b.*$/gm),
|
|
184
202
|
];
|
|
185
203
|
return headings.map((match, index) => ({
|
|
186
|
-
id: match[1],
|
|
204
|
+
id: canonicalBackendCaseId(match[1]),
|
|
205
|
+
rawId: match[1],
|
|
187
206
|
body: markdown.slice(match.index, headings[index + 1]?.index ?? markdown.length),
|
|
188
207
|
}));
|
|
189
208
|
}
|
|
@@ -396,6 +415,9 @@ export async function validateBackendMarkdownCases(input) {
|
|
|
396
415
|
}
|
|
397
416
|
for (const testCase of cases) {
|
|
398
417
|
caseCount += 1;
|
|
418
|
+
if (!CANONICAL_CASE_ID.test(testCase.rawId)) {
|
|
419
|
+
findings.push(`${testCase.rawId} uses a non-canonical Case ID; use ${testCase.id} (hyphen-separated module, three-digit sequence)`);
|
|
420
|
+
}
|
|
399
421
|
const previous = seen.get(testCase.id);
|
|
400
422
|
if (previous) {
|
|
401
423
|
findings.push(`duplicate case id ${testCase.id}: ${previous} and ${relativeFile}`);
|
|
@@ -545,8 +567,10 @@ function testFunctionRegion(input) {
|
|
|
545
567
|
.join("\n");
|
|
546
568
|
}
|
|
547
569
|
function symbolCaseId(symbol) {
|
|
548
|
-
const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{3})(?:_|$)/i);
|
|
549
|
-
return match
|
|
570
|
+
const match = symbol.match(/^test_(BE(?:_[A-Z0-9]+)+?_\d{2,3})(?:_|$)/i);
|
|
571
|
+
return match
|
|
572
|
+
? canonicalBackendCaseId(match[1].replaceAll("_", "-"))
|
|
573
|
+
: undefined;
|
|
550
574
|
}
|
|
551
575
|
export async function validateBackendMarkdownTraceability(workspaceRoot) {
|
|
552
576
|
const files = await markdownFiles(workspaceRoot);
|
|
@@ -606,7 +630,7 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
|
|
|
606
630
|
functionIndex: match.index,
|
|
607
631
|
functionHeaderEnd: match.index + match[0].length,
|
|
608
632
|
});
|
|
609
|
-
const associatedIds = new Set(region
|
|
633
|
+
const associatedIds = new Set(caseIdsInText(region));
|
|
610
634
|
const fromSymbol = symbolCaseId(symbol);
|
|
611
635
|
if (fromSymbol)
|
|
612
636
|
associatedIds.add(fromSymbol);
|
|
@@ -711,7 +735,8 @@ export async function collectBackendTestMappedPytestScripts(workspaceRoot) {
|
|
|
711
735
|
function cleanCaseTitle(rawHeading, id) {
|
|
712
736
|
return rawHeading
|
|
713
737
|
.replace(/^##\s+/, "")
|
|
714
|
-
|
|
738
|
+
// Strip either canonical or compatible two-digit heading ID.
|
|
739
|
+
.replace(/^BE-[A-Z0-9_-]+-\d{2,3}\s*(?:[||—–-]\s*)?/i, "")
|
|
715
740
|
.trim() || id;
|
|
716
741
|
}
|
|
717
742
|
export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
|
|
@@ -755,7 +780,7 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
|
|
|
755
780
|
for (const match of content.matchAll(/^(\s*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
|
|
756
781
|
const symbol = match[2];
|
|
757
782
|
const region = testFunctionRegion({ source: content, functionIndex: match.index, functionHeaderEnd: match.index + match[0].length });
|
|
758
|
-
const ids = new Set(region
|
|
783
|
+
const ids = new Set(caseIdsInText(region));
|
|
759
784
|
const fromSymbol = symbolCaseId(symbol);
|
|
760
785
|
if (fromSymbol)
|
|
761
786
|
ids.add(fromSymbol);
|
|
@@ -772,13 +797,13 @@ export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
|
|
|
772
797
|
return [...catalog.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
773
798
|
}
|
|
774
799
|
function junitCaseId(name) {
|
|
775
|
-
return symbolCaseId(name) ?? name
|
|
800
|
+
return symbolCaseId(name) ?? caseIdsInText(name)[0];
|
|
776
801
|
}
|
|
777
802
|
/** Prefer function-name Case ID; fall back to first BE-* in free text (docstring/log). */
|
|
778
803
|
function extractCaseIdFromText(value) {
|
|
779
804
|
if (!value?.trim())
|
|
780
805
|
return undefined;
|
|
781
|
-
return junitCaseId(value) ?? value
|
|
806
|
+
return junitCaseId(value) ?? caseIdsInText(value)[0];
|
|
782
807
|
}
|
|
783
808
|
function resolveReportCaseId(result, catalog) {
|
|
784
809
|
const fromName = extractCaseIdFromText(result.name);
|
|
@@ -1175,6 +1200,43 @@ function renderHttpCallCard(call) {
|
|
|
1175
1200
|
: "";
|
|
1176
1201
|
return `<div style="border:1px solid #eef0f3;border-radius:10px;padding:12px 14px;margin-top:10px;background:#fcfdff">${header}${requestBlock}${responseBlock}</div>`;
|
|
1177
1202
|
}
|
|
1203
|
+
function analyzeBackendTestFailure(result) {
|
|
1204
|
+
const evidence = [result.message, result.details].filter(Boolean).join("\n");
|
|
1205
|
+
const statusPatterns = [
|
|
1206
|
+
{ pattern: /实际(?:\s*HTTP)?\s*状态码\s*[::]?\s*(\d{3})[\s\S]{0,80}?预期(?:\s*HTTP)?\s*状态码?\s*[::]?\s*(\d{3})/i, actualGroup: 1, expectedGroup: 2 },
|
|
1207
|
+
{ pattern: /预期(?:\s*HTTP)?\s*状态码\s*[::]?\s*(\d{3})[\s\S]{0,80}?实际(?:\s*HTTP)?\s*状态码?\s*[::]?\s*(\d{3})/i, actualGroup: 2, expectedGroup: 1 },
|
|
1208
|
+
{ pattern: /assert\s+(\d{3})\s*==\s*(\d{3})/i, actualGroup: 1, expectedGroup: 2 },
|
|
1209
|
+
{ pattern: /expected\s*[:=]?\s*(\d{3})[\s\S]{0,80}?(?:actual|received|got)\s*[:=]?\s*(\d{3})/i, actualGroup: 2, expectedGroup: 1 },
|
|
1210
|
+
];
|
|
1211
|
+
for (const candidate of statusPatterns) {
|
|
1212
|
+
const match = evidence.match(candidate.pattern);
|
|
1213
|
+
if (match) {
|
|
1214
|
+
const actual = match[candidate.actualGroup];
|
|
1215
|
+
const expected = match[candidate.expectedGroup];
|
|
1216
|
+
if (actual && expected) {
|
|
1217
|
+
return `HTTP 状态码不符合预期:期望 ${expected},实际 ${actual}。建议核对接口错误码契约、异常映射与测试前置数据。`;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
const contentTypeMatch = evidence.match(/assert\s+(['"])(.*?)\1\.startswith\(\s*(['"])application\/json\3\s*\)/i);
|
|
1222
|
+
if (contentTypeMatch) {
|
|
1223
|
+
const actual = contentTypeMatch[2];
|
|
1224
|
+
const actualLabel = actual ? `为 ${actual}` : "为空值";
|
|
1225
|
+
return `响应 Content-Type 不符合预期:期望以 application/json 开头,实际${actualLabel}。建议核对接口响应头设置。`;
|
|
1226
|
+
}
|
|
1227
|
+
const assertionEvidence = (result.details ?? "")
|
|
1228
|
+
.split(/\r?\n/)
|
|
1229
|
+
.map((line) => line.trim().replace(/^E\s+/, ""))
|
|
1230
|
+
.find((line) => /^assert\b/i.test(line));
|
|
1231
|
+
const rawMessage = /^\d+:\s*AssertionError\s*$/i.test(result.message?.trim() ?? "") && assertionEvidence
|
|
1232
|
+
? assertionEvidence
|
|
1233
|
+
: result.message || assertionEvidence || humanStatus(result.status);
|
|
1234
|
+
const message = rawMessage.replace(/\s+/g, " ").trim().slice(0, 240);
|
|
1235
|
+
if (result.status === "error") {
|
|
1236
|
+
return `测试执行发生错误:${message}。建议优先检查 fixture、依赖导入、环境连接与运行时异常。`;
|
|
1237
|
+
}
|
|
1238
|
+
return `断言未满足:${message}。建议结合失败详情及请求/响应证据核对接口行为与用例预期。`;
|
|
1239
|
+
}
|
|
1178
1240
|
export function renderBackendTestHtml(input) {
|
|
1179
1241
|
const failed = input.parsed.failed + input.parsed.errors > 0;
|
|
1180
1242
|
const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
|
|
@@ -1183,7 +1245,15 @@ export function renderBackendTestHtml(input) {
|
|
|
1183
1245
|
const traceability = parseAdvisorySummary(input.traceabilitySummary);
|
|
1184
1246
|
const failures = input.parsed.cases.filter((result) => result.status !== "passed");
|
|
1185
1247
|
const headColor = failed ? { fg: "#b42318", bg: "linear-gradient(135deg,#fef3f2 0,#fee4e2 100%)", border: "#fda29b", icon: "✗" } : { fg: "#067647", bg: "linear-gradient(135deg,#ecfdf3 0,#d1fadf 100%)", border: "#abefc6", icon: "✓" };
|
|
1186
|
-
const
|
|
1248
|
+
const orderedCases = input.parsed.cases
|
|
1249
|
+
.map((result, index) => ({ result, index }))
|
|
1250
|
+
.sort((left, right) => {
|
|
1251
|
+
const leftFailed = left.result.status === "failure" || left.result.status === "error" ? 0 : 1;
|
|
1252
|
+
const rightFailed = right.result.status === "failure" || right.result.status === "error" ? 0 : 1;
|
|
1253
|
+
return leftFailed - rightFailed || left.index - right.index;
|
|
1254
|
+
})
|
|
1255
|
+
.map(({ result }) => result);
|
|
1256
|
+
const caseCards = orderedCases.map((result) => {
|
|
1187
1257
|
const caseId = resolveReportCaseId(result, catalog);
|
|
1188
1258
|
const item = caseId === "未关联" ? undefined : catalog.get(caseId);
|
|
1189
1259
|
const io = requestResponseSummary(result);
|
|
@@ -1201,20 +1271,23 @@ export function renderBackendTestHtml(input) {
|
|
|
1201
1271
|
const metricCard = (label, value, valueColor = "#172033") => `<div style="padding:14px 16px;border:1px solid #e3e8ef;border-radius:12px;background:#fbfcfe"><span style="color:#667085;font-size:0.78rem">${escapeHtml(label)}</span><div style="font-size:22px;font-weight:700;color:${valueColor}">${value}</div></div>`;
|
|
1202
1272
|
const metrics = `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;margin-top:14px">${metricCard("用例总数", input.parsed.tests)}${metricCard("通过", input.parsed.passed, "#067647")}${metricCard("失败", input.parsed.failed)}${metricCard("错误", input.parsed.errors)}${metricCard("跳过", input.parsed.skipped)}<div style="padding:14px 16px;border:1px solid #e3e8ef;border-radius:12px;background:#fbfcfe"><span style="color:#667085;font-size:0.78rem">通过率</span><div style="font-size:22px;font-weight:700;color:#067647">${(passRate * 100).toFixed(2)}%</div><span style="color:#667085;font-size:0.78rem">耗时 ${formatDuration(input.parsed.durationMs)}</span></div></div>`;
|
|
1203
1273
|
const summaryBanner = `<div style="display:flex;align-items:center;gap:16px;padding:16px 18px;border-radius:14px;background:${headColor.bg};border:1px solid ${headColor.border}"><div style="flex:0 0 auto;width:44px;height:44px;border-radius:999px;background:${headColor.fg};display:flex;align-items:center;justify-content:center;color:#fff;font-size:24px;font-weight:900;box-shadow:0 4px 12px ${headColor.fg}4d">${headColor.icon}</div><div><div style="color:${headColor.fg};font-size:1.05rem;font-weight:800;line-height:1.3">${failed ? "本轮测试未通过" : "本轮测试通过"}</div><div style="color:${headColor.fg};font-size:0.88rem;margin-top:2px">${failed ? `${input.parsed.failed + input.parsed.errors} 条用例失败或错误` : `${input.parsed.passed} 条用例全部执行成功`}</div></div></div>`;
|
|
1204
|
-
const qualityBlock = `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">质量校验</div><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:8px">${renderQualityCard("Markdown 用例校验", caseValidation)}${renderQualityCard("Markdown → pytest 追溯", traceability)}</div></div>`;
|
|
1274
|
+
const qualityBlock = `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">质量校验</div><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:8px">${renderQualityCard("Markdown 用例校验", caseValidation, input.caseValidationSummary)}${renderQualityCard("Markdown → pytest 追溯", traceability, input.traceabilitySummary)}</div></div>`;
|
|
1205
1275
|
const failureOverview = failures.length > 0
|
|
1206
1276
|
? `<div style="margin-top:18px"><div style="color:#17365d;font-size:16px;font-weight:700;margin:0 4px 6px">失败概览</div><div style="display:grid;gap:10px;margin-top:8px">${failures.map((result) => {
|
|
1207
1277
|
const caseId = resolveReportCaseId(result, catalog);
|
|
1208
1278
|
const c = statusColor(result.status);
|
|
1209
|
-
return `<div style="border-left:4px solid ${c.fg};background:${c.bg};padding:10px 12px;border-radius:8px"><div style="font-weight:600;color:#172033;font-size:0.88rem">${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</div><div style="color:${c.fg};font-size:0.82rem;margin-top:
|
|
1279
|
+
return `<div style="border-left:4px solid ${c.fg};background:${c.bg};padding:10px 12px;border-radius:8px"><div style="font-weight:600;color:#172033;font-size:0.88rem">${escapeHtml(caseId)} · ${escapeHtml(catalog.get(caseId)?.title ?? result.name)}</div><div style="color:${c.fg};font-size:0.82rem;margin-top:4px"><strong>原始失败:</strong>${escapeHtml(result.message || humanStatus(result.status))}</div><div style="color:#475467;font-size:0.82rem;margin-top:5px"><strong style="color:#17365d">原因分析:</strong>${escapeHtml(analyzeBackendTestFailure(result))}</div></div>`;
|
|
1210
1280
|
}).join("")}</div></div>`
|
|
1211
1281
|
: "";
|
|
1212
1282
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)}</title></head><body style="margin:0;background:linear-gradient(180deg,#eef2f7 0,#f7f9fc 40rem);color:#172033;font-family:system-ui,'Microsoft YaHei','PingFang SC',sans-serif;line-height:1.6"><div style="max-width:1000px;margin:0 auto;padding:32px 22px 56px"><div style="padding:8px 0 4px"><div style="color:#315f93;font-weight:700;letter-spacing:0.06em;font-size:0.76rem">后端自动化测试报告</div><h1 style="margin:6px 0 0;color:#17365d;font-size:24px;line-height:1.35">${escapeHtml(input.title)}</h1><p style="color:#667085;margin:8px 0 0;font-size:0.9rem">执行事实来自同一次 pytest 执行;用例默认折叠,点击任意用例可展开执行日志(接口、请求参数、响应结果)与检查点。</p></div><div style="background:#fff;border:1px solid #e3e8ef;border-radius:16px;padding:18px 20px;margin-top:16px;box-shadow:0 1px 2px rgba(16,24,40,0.04)">${summaryBanner}${metrics}</div><div style="background:#fff;border:1px solid #e3e8ef;border-radius:16px;padding:18px 20px;margin-top:16px;box-shadow:0 1px 2px rgba(16,24,40,0.04)">${qualityBlock}<div style="margin-top:12px;padding:10px 12px;background:#f6f8fa;border-radius:8px;font-size:0.82rem;color:#475467"><strong style="color:#667085">执行环境</strong>:${escapeHtml((input.environmentSummary || "不可用").split("\n").slice(0, 3).join(" ").slice(0, 200))}</div></div>${failureOverview}<div style="margin-top:18px"><div style="display:flex;align-items:baseline;justify-content:space-between;margin:0 4px 6px"><span style="color:#17365d;font-size:16px;font-weight:700">用例执行明细</span><span style="color:#98a2b3;font-size:0.8rem">共 ${input.parsed.cases.length} 条 · 点击展开</span></div>${caseCards || '<div style="padding:16px;border:1px dashed #e3e8ef;border-radius:12px;color:#667085;background:#fbfcfe;text-align:center">未发现可展示的用例。</div>'}</div></div></body></html>`;
|
|
1213
1283
|
}
|
|
1214
|
-
function renderQualityCard(title, summary) {
|
|
1284
|
+
function renderQualityCard(title, summary, rawSummary) {
|
|
1215
1285
|
const colors = summary.status === "PASS" ? { fg: "#067647", bg: "#ecfdf3" } : summary.status === "FAIL" ? { fg: "#b42318", bg: "#fef3f2" } : { fg: "#9a6700", bg: "#fffaeb" };
|
|
1216
1286
|
const label = summary.status === "Unavailable" ? "不可用" : summary.status;
|
|
1217
|
-
|
|
1287
|
+
const details = rawSummary?.trim()
|
|
1288
|
+
? `<details style="margin-top:9px;padding-top:8px;border-top:1px solid #e3e8ef"><summary style="cursor:pointer;color:#315f93;font-size:0.78rem;font-weight:700">查看详情 · ${escapeHtml(title)}详情</summary><pre style="white-space:pre-wrap;overflow-wrap:anywhere;background:#f6f8fa;color:#1f2937;padding:10px 12px;border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.74rem;margin:7px 0 0;border:1px solid #eceef2;line-height:1.55">${escapeHtml(rawSummary.trim())}</pre></details>`
|
|
1289
|
+
: "";
|
|
1290
|
+
return `<div style="padding:12px 14px;border:1px solid #e3e8ef;border-radius:10px;background:#fbfcfe"><div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px"><strong style="color:#17365d;font-size:0.88rem">${escapeHtml(title)}</strong><span style="display:inline-block;padding:2px 9px;border-radius:999px;font-weight:700;font-size:0.72rem;color:${colors.fg};background:${colors.bg}">${label}</span></div><div style="color:#667085;font-size:0.78rem">Findings:${summary.findings ?? "不可用"}</div><div style="color:#475467;font-size:0.8rem;margin-top:2px">${escapeHtml(summary.firstFinding)}</div>${details}</div>`;
|
|
1218
1291
|
}
|
|
1219
1292
|
/**
|
|
1220
1293
|
* Dump JaCoCo execution data over TCP from a JaCoCo tcpserver agent, convert
|
|
@@ -359,12 +359,36 @@ const HTML_ENTITY_MAP = {
|
|
|
359
359
|
"<": "<",
|
|
360
360
|
">": ">",
|
|
361
361
|
""": '"',
|
|
362
|
-
""": '"',
|
|
363
|
-
"'": "'",
|
|
364
362
|
"'": "'",
|
|
365
363
|
};
|
|
364
|
+
/** Decode one HTML entity layer. Safe for the outer data-jsonblob attribute. */
|
|
366
365
|
function decodeHtmlEntities(value) {
|
|
367
|
-
return value.replace(/&(?:amp|lt|gt|quot
|
|
366
|
+
return value.replace(/&(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);/gi, (entity) => {
|
|
367
|
+
const named = HTML_ENTITY_MAP[entity.toLowerCase()];
|
|
368
|
+
if (named !== undefined)
|
|
369
|
+
return named;
|
|
370
|
+
const hex = entity.match(/^&#x([0-9a-f]+);$/i)?.[1];
|
|
371
|
+
const decimal = entity.match(/^&#(\d+);$/)?.[1];
|
|
372
|
+
const codePoint = hex
|
|
373
|
+
? Number.parseInt(hex, 16)
|
|
374
|
+
: decimal
|
|
375
|
+
? Number.parseInt(decimal, 10)
|
|
376
|
+
: Number.NaN;
|
|
377
|
+
return Number.isFinite(codePoint)
|
|
378
|
+
? String.fromCodePoint(codePoint)
|
|
379
|
+
: entity;
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/** Decode nested entities only after JSON parsing; bounded to avoid overwork. */
|
|
383
|
+
function decodeHtmlEntitiesDeep(value) {
|
|
384
|
+
let current = value;
|
|
385
|
+
for (let index = 0; index < 3; index += 1) {
|
|
386
|
+
const decoded = decodeHtmlEntities(current);
|
|
387
|
+
if (decoded === current)
|
|
388
|
+
break;
|
|
389
|
+
current = decoded;
|
|
390
|
+
}
|
|
391
|
+
return current;
|
|
368
392
|
}
|
|
369
393
|
function parseDurationLabelMs(raw) {
|
|
370
394
|
if (!raw)
|
|
@@ -432,6 +456,7 @@ export function parsePytestHtmlReport(html) {
|
|
|
432
456
|
const filePathFields = filePath ? { filePath } : {};
|
|
433
457
|
const durationMs = parseDurationLabelMs(record.duration);
|
|
434
458
|
const capturedLog = record.log ?? "";
|
|
459
|
+
const decodedCapturedLog = decodeHtmlEntitiesDeep(capturedLog);
|
|
435
460
|
// pytest-html collapses captured stdout/stderr into a single `log` field
|
|
436
461
|
// annotated with section markers. Preserve the whole log as stdout so the
|
|
437
462
|
// HTTP_REQUEST/HTTP_RESPONSE lines stay reachable for the per-case card.
|
|
@@ -462,7 +487,7 @@ export function parsePytestHtmlReport(html) {
|
|
|
462
487
|
durationMs,
|
|
463
488
|
status: "failure",
|
|
464
489
|
message: summary,
|
|
465
|
-
details:
|
|
490
|
+
details: decodedCapturedLog || summary,
|
|
466
491
|
...(stdout ? { stdout } : {}),
|
|
467
492
|
...(stderr ? { stderr } : {}),
|
|
468
493
|
});
|
|
@@ -479,7 +504,7 @@ export function parsePytestHtmlReport(html) {
|
|
|
479
504
|
durationMs,
|
|
480
505
|
status: "error",
|
|
481
506
|
message: summary,
|
|
482
|
-
details:
|
|
507
|
+
details: decodedCapturedLog || summary,
|
|
483
508
|
...(stdout ? { stdout } : {}),
|
|
484
509
|
...(stderr ? { stderr } : {}),
|
|
485
510
|
});
|
|
@@ -509,7 +534,7 @@ export function parsePytestHtmlReport(html) {
|
|
|
509
534
|
durationMs,
|
|
510
535
|
status: "error",
|
|
511
536
|
message: summary,
|
|
512
|
-
details:
|
|
537
|
+
details: decodedCapturedLog || summary,
|
|
513
538
|
...(stdout ? { stdout } : {}),
|
|
514
539
|
...(stderr ? { stderr } : {}),
|
|
515
540
|
});
|
|
@@ -572,7 +597,7 @@ function splitPytestHtmlLogSections(log) {
|
|
|
572
597
|
// pytest-html HTML-entity-escapes the captured log content (e.g. `"`
|
|
573
598
|
// for `"`). Decode entities first so downstream HTTP log parsers see the
|
|
574
599
|
// real JSON/kv payload rather than escaped markup.
|
|
575
|
-
const decoded =
|
|
600
|
+
const decoded = decodeHtmlEntitiesDeep(log);
|
|
576
601
|
// pytest-html interleaves captured stdout/stderr with section markers like
|
|
577
602
|
// "----------------------------- Captured stdout call -----------------------------".
|
|
578
603
|
// String.split includes capture-group matches in the result array, so the
|
|
@@ -601,16 +626,17 @@ function splitPytestHtmlLogSections(log) {
|
|
|
601
626
|
function extractPytestHtmlFailureMessage(log) {
|
|
602
627
|
if (!log)
|
|
603
628
|
return "";
|
|
629
|
+
const decoded = decodeHtmlEntitiesDeep(log);
|
|
604
630
|
// pytest-html failure logs contain assertion lines prefixed with "E " and a
|
|
605
631
|
// trailing location line like "test_x.py:N: AssertionError". Prefer the
|
|
606
632
|
// explicit AssertionError/Error line; fall back to the last "E " line.
|
|
607
|
-
const assertionLine =
|
|
633
|
+
const assertionLine = decoded
|
|
608
634
|
.split(/\r?\n/)
|
|
609
635
|
.map((line) => line.trim())
|
|
610
636
|
.find((line) => /:\s*AssertionError/.test(line));
|
|
611
637
|
if (assertionLine)
|
|
612
638
|
return assertionLine.replace(/^.*?:\s*/, "");
|
|
613
|
-
const eLines =
|
|
639
|
+
const eLines = decoded
|
|
614
640
|
.split(/\r?\n/)
|
|
615
641
|
.filter((line) => /^E\s+/.test(line))
|
|
616
642
|
.map((line) => line.replace(/^E\s+/, "").trim());
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
async function exists(file) {
|
|
4
|
+
try {
|
|
5
|
+
return (await stat(file)).isFile();
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export async function validateFrontendCaseChecklist(input) {
|
|
12
|
+
const root = path.join(input.workspaceRoot, "testcase/frontend/cases");
|
|
13
|
+
const draft = path.join(root, "manifest.draft.json");
|
|
14
|
+
const final = path.join(root, "manifest.json");
|
|
15
|
+
const manifestPath = await exists(draft) ? draft : await exists(final) ? final : "";
|
|
16
|
+
if (!manifestPath)
|
|
17
|
+
throw new Error("checklist: missing manifest.draft.json or manifest.json");
|
|
18
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
19
|
+
if (!Array.isArray(manifest.cases) || manifest.cases.length === 0)
|
|
20
|
+
throw new Error("checklist: empty cases");
|
|
21
|
+
const declaredAc = new Set(input.declaredAcIds ?? []);
|
|
22
|
+
const issues = [];
|
|
23
|
+
const caseIdRe = /^FE-[A-Za-z0-9][A-Za-z0-9-]*$/;
|
|
24
|
+
const acIdRe = /^AC(?:-[A-Z0-9]+)+$/i;
|
|
25
|
+
const openRe = /playwright-cli\s+open\s+--browser=chrome\s+--headed\s+https?:\/\/\S+/i;
|
|
26
|
+
const productionHostRe = /(^|[.-])(prod|production)([.-]|$)/i;
|
|
27
|
+
for (const raw of manifest.cases) {
|
|
28
|
+
const item = raw;
|
|
29
|
+
const id = typeof item?.caseId === "string" ? item.caseId : "?";
|
|
30
|
+
if (typeof item?.caseId !== "string" || !caseIdRe.test(item.caseId))
|
|
31
|
+
issues.push({ ruleId: "case-id-shape", caseId: id, detail: "caseId must be FE-<FEATURE>-<NNN>-<dimension>, never AC-FE-*" });
|
|
32
|
+
if (typeof item?.caseId === "string" && /^AC-/i.test(item.caseId))
|
|
33
|
+
issues.push({ ruleId: "case-id-is-ac", caseId: id, detail: "caseId must not be an acceptance id" });
|
|
34
|
+
const casePath = typeof item?.casePath === "string" ? item.casePath : "";
|
|
35
|
+
const expectedPath = typeof item?.caseId === "string" ? `testcase/frontend/cases/${item.caseId}.md` : "";
|
|
36
|
+
const absolute = path.resolve(input.workspaceRoot, casePath);
|
|
37
|
+
const relative = path.relative(input.workspaceRoot, absolute);
|
|
38
|
+
if (!casePath || relative.startsWith("..") || path.isAbsolute(relative) || !(await exists(absolute))) {
|
|
39
|
+
issues.push({ ruleId: "case-file-missing", caseId: id, detail: casePath || "missing casePath" });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (expectedPath && casePath.replaceAll("\\", "/") !== expectedPath)
|
|
43
|
+
issues.push({ ruleId: "case-path-mismatch", caseId: id, detail: `${casePath} must equal ${expectedPath}` });
|
|
44
|
+
const body = await readFile(absolute, "utf8");
|
|
45
|
+
if (!openRe.test(body))
|
|
46
|
+
issues.push({ ruleId: "open-prefix", caseId: id, detail: "missing playwright-cli open --browser=chrome --headed <absolute-url>" });
|
|
47
|
+
const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s+--headed\s+(https?:\/\/\S+)/i);
|
|
48
|
+
if (match) {
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(match[1].replace(/[)\]},.\"'`]+$/, ""));
|
|
51
|
+
if (productionHostRe.test(url.hostname))
|
|
52
|
+
issues.push({ ruleId: "production-url", caseId: id, detail: match[1] });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
issues.push({ ruleId: "production-url", caseId: id, detail: match[1] });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!Array.isArray(item?.acIds) || item.acIds.length === 0) {
|
|
59
|
+
issues.push({ ruleId: "ac-mapping", caseId: id, detail: "acIds required" });
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
for (const ac of item.acIds) {
|
|
63
|
+
if (typeof ac !== "string" || !acIdRe.test(ac))
|
|
64
|
+
issues.push({ ruleId: "ac-id-shape", caseId: id, detail: String(ac) });
|
|
65
|
+
else if (declaredAc.size > 0 && !declaredAc.has(ac))
|
|
66
|
+
issues.push({ ruleId: "unknown-ac", caseId: id, detail: `${ac} not in sourceBinding` });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { caseCount: manifest.cases.length, manifestPath, issues };
|
|
71
|
+
}
|