@tea-agent/loop-agent 0.44.0-next.10 → 0.44.0-next.11
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/dist/build-stamp.json +3 -3
- package/dist/executors/dag-pi/sessions/index.js +6 -0
- package/dist/executors/dag-pi/sessions/plan-batches.js +280 -0
- package/dist/executors/dag-pi/sessions/plan-prompts.js +367 -0
- package/dist/executors/dag-pi/sessions/scout-parallel.js +197 -0
- package/dist/executors/dag-pi/sessions/segmented-plan.js +1184 -0
- package/dist/executors/dag-pi/sessions/writer-evidence.js +60 -0
- package/dist/executors/dag-pi-executor.js +12 -2075
- package/dist/workflows/dag/checkpoint.js +686 -0
- package/dist/workflows/dag/hybrid/sources.js +1812 -0
- package/dist/workflows/dag/hybrid/templates/backend-test.js +1807 -0
- package/dist/workflows/dag/hybrid/templates/frontend-test.js +702 -0
- package/dist/workflows/dag/hybrid/templates/frontend.js +1529 -0
- package/dist/workflows/dag/hybrid/templates/index.js +8 -0
- package/dist/workflows/dag/hybrid/templates/kg-bootstrap.js +449 -0
- package/dist/workflows/dag/hybrid/templates/knowledge-sync.js +495 -0
- package/dist/workflows/dag/hybrid/templates/shared.js +101 -0
- package/dist/workflows/dag/hybrid/templates/standard.js +265 -0
- package/dist/workflows/dag/hybrid/types.js +32 -0
- package/dist/workflows/dag/init-hybrid.js +42 -7162
- package/dist/workflows/dag/runner.js +11 -1178
- package/dist/workflows/dag/terminal-status.js +502 -0
- package/docs/architecture/dag-execution.md +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1812 @@
|
|
|
1
|
+
/** DAG hybrid 输入准备:来源装载、Mock discovery、verification 规划与 source binding。禁止 import 模板分发入口。 */
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { access, readFile, readdir, realpath } from "node:fs/promises";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { DAG_AGENT_RUNTIME_PI_ONLY, DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1, DAG_RUNTIME_CONTRACT_SCHEMA_VERSION } from "../types.js";
|
|
7
|
+
import { planMavenVerification } from "../../../verification/maven/index.js";
|
|
8
|
+
import { pathMatchesPattern } from "../../../shared/git-progress.js";
|
|
9
|
+
import { BASELINE_FORBIDDEN_PATHS } from "../governance-constants.js";
|
|
10
|
+
import { resolveAdapter } from "../../../adapters/index.js";
|
|
11
|
+
import { loadHarnessManifest } from "../../../governance/harness.js";
|
|
12
|
+
import { mergeDocumentIndexCompanions } from "../../../governance/document-index-closure.js";
|
|
13
|
+
import { probeRepoLocalSddSkills } from "../sdd-embedded.js";
|
|
14
|
+
import { discoverProjectGovernancePresence } from "../project-governance-context.js";
|
|
15
|
+
import { CANONICAL_TASK_ID_PATTERN, getTaskPaths, loadTaskConfig } from "../../../task/runtime.js";
|
|
16
|
+
import { materializeTaskReferenceDocs } from "../../../task/source-references.js";
|
|
17
|
+
import { extractRequirementFactsFromMarkdown } from "../../../task/source-prepare/parse-intent.js";
|
|
18
|
+
import { computeLedgerInputDigest, parseLedgerJson, recoverLedgerInputContract, validateRequirementLedger } from "../../../task/source-prepare/ledger.js";
|
|
19
|
+
import { REQUIREMENT_LEDGER_FILE_NAME } from "../../../task/contract/constants.js";
|
|
20
|
+
import { resolveVerifyPreset } from "../../../executors/shell-verification.js";
|
|
21
|
+
import { resolveExecutorModelMatrices } from "../../../executors/model-routing.js";
|
|
22
|
+
import { normalizeTaskRequirementText } from "../task-demand-routing.js";
|
|
23
|
+
import { classifyFrontendRisk } from "../frontend-risk.js";
|
|
24
|
+
import { discoverFrontendProjectCapability } from "../frontend-project-capability.js";
|
|
25
|
+
import { classifyFrontendVerifyCommandText, isRecognizedFrontendVerifyCommandText } from "../frontend-implementation-contract.js";
|
|
26
|
+
import { computeFrontendShapeSourceDigest } from "../frontend-shape.js";
|
|
27
|
+
import { discoverLatestCommittedFrontendShapeCapsule } from "../frontend-shape-capsule-store.js";
|
|
28
|
+
import { serializeDagTaskSourcePath } from "../../../task/dag-source-paths.js";
|
|
29
|
+
import { resolveEnabledExecutors, } from "./types.js";
|
|
30
|
+
export const HYBRID_DEFAULTS = {
|
|
31
|
+
executor: "pi",
|
|
32
|
+
piBackend: "sdk-first",
|
|
33
|
+
contextProfile: "slim",
|
|
34
|
+
skills: ["ai-engineering-context"],
|
|
35
|
+
writePolicy: "read-only",
|
|
36
|
+
};
|
|
37
|
+
export const HYBRID_SKILLS_BY_ROLE = {
|
|
38
|
+
planner: ["loop-agent"],
|
|
39
|
+
scout: ["ai-engineering-context", "codebase-scout"],
|
|
40
|
+
implementer: ["test-driven-development", "verification-before-completion"],
|
|
41
|
+
reviewer: ["requesting-code-review", "code-review-core"],
|
|
42
|
+
verifier: ["verification-before-completion", "systematic-debugging"],
|
|
43
|
+
closeout: ["loop-agent", "verification-before-completion"],
|
|
44
|
+
};
|
|
45
|
+
export const FRONTEND_DEFAULTS = {
|
|
46
|
+
...HYBRID_DEFAULTS,
|
|
47
|
+
skills: [],
|
|
48
|
+
};
|
|
49
|
+
export const FRONTEND_SKILLS_BY_ROLE = {
|
|
50
|
+
planner: [],
|
|
51
|
+
scout: [],
|
|
52
|
+
implementer: [],
|
|
53
|
+
reviewer: [],
|
|
54
|
+
supervisor: [],
|
|
55
|
+
verifier: [],
|
|
56
|
+
closeout: [],
|
|
57
|
+
};
|
|
58
|
+
export const FRONTEND_CONTRACT_SKILLS = ["frontend-contract"];
|
|
59
|
+
export const FRONTEND_SCOUT_SKILLS = ["frontend-scout"];
|
|
60
|
+
export const FRONTEND_PLAN_SKILLS = ["frontend-plan"];
|
|
61
|
+
export const FRONTEND_BOUNDED_IMPLEMENT_SKILLS = ["frontend-bounded-implement"];
|
|
62
|
+
export const FRONTEND_DESIGN_REVIEW_SKILLS = ["frontend-design-review"];
|
|
63
|
+
export const FRONTEND_REVIEW_SKILLS = ["frontend-review"];
|
|
64
|
+
export const FRONTEND_VERIFICATION_SKILLS = ["frontend-verification"];
|
|
65
|
+
export const IMPLEMENT_WRITESET_PLACEHOLDER = "REPLACE/WITH/NARROW/IMPLEMENT/PATHS/**";
|
|
66
|
+
export const GENERATED_DAG_RUNTIME_CONTRACT = {
|
|
67
|
+
schemaVersion: DAG_RUNTIME_CONTRACT_SCHEMA_VERSION,
|
|
68
|
+
agentRuntime: DAG_AGENT_RUNTIME_PI_ONLY,
|
|
69
|
+
repairWriterProtocol: DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1,
|
|
70
|
+
};
|
|
71
|
+
export const STANDARD_GLOBAL_CONSTRAINTS = [
|
|
72
|
+
"Do not commit runtime traces under .harness/dag-runs/",
|
|
73
|
+
"Do not enable cross-node Pi runtime reuse by default",
|
|
74
|
+
"Preserve CODE_AGENT_PI_BACKEND=cli-only rollback for Pi nodes",
|
|
75
|
+
"Same-rank exclusive writeSet entries must be disjoint",
|
|
76
|
+
"Every task must explicitly declare executor; defaults.executor is schema-only and not a runtime fallback",
|
|
77
|
+
"Prefer same-rank parallel read-only scouts over serial chains when outputs are independent",
|
|
78
|
+
"If the DAG is a single linear chain, challenge whether read-only work can run in parallel ranks",
|
|
79
|
+
"Read-only DAG nodes must not write repository files, including root artifacts/**; return findings in node output only",
|
|
80
|
+
"Root artifacts/ is reserved for explicit exclusive write nodes, not read-only scout/reviewer output",
|
|
81
|
+
"exclusive implementer nodes must use narrow, concrete writeSet paths; never keep ** or repo root",
|
|
82
|
+
`Replace ${IMPLEMENT_WRITESET_PLACEHOLDER} with concrete paths before executing the implementation writer`,
|
|
83
|
+
];
|
|
84
|
+
export async function packageJsonHasMockScript(repoRoot) {
|
|
85
|
+
try {
|
|
86
|
+
const raw = await readFile(path.join(repoRoot, "package.json"), "utf-8");
|
|
87
|
+
const pkg = JSON.parse(raw);
|
|
88
|
+
const scripts = pkg.scripts ?? {};
|
|
89
|
+
const mockScripts = Object.keys(scripts).filter((name) => name === "mock" ||
|
|
90
|
+
name.startsWith("mock:") ||
|
|
91
|
+
name.startsWith("dev:mock") ||
|
|
92
|
+
/mock/i.test(name));
|
|
93
|
+
const verifyCommands = mockScripts
|
|
94
|
+
.filter((name) => /(?:test|check|verify|contract)/i.test(name))
|
|
95
|
+
.map((name) => ({
|
|
96
|
+
label: `npm run ${name}`,
|
|
97
|
+
args: ["npm", "run", name],
|
|
98
|
+
cwd: repoRoot,
|
|
99
|
+
}));
|
|
100
|
+
return {
|
|
101
|
+
hasScript: mockScripts.length > 0,
|
|
102
|
+
scriptNames: mockScripts,
|
|
103
|
+
verifyCommands,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { hasScript: false, scriptNames: [], verifyCommands: [] };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export async function hasDirectDependency(repoRoot, depName) {
|
|
111
|
+
try {
|
|
112
|
+
const raw = await readFile(path.join(repoRoot, "package.json"), "utf-8");
|
|
113
|
+
const pkg = JSON.parse(raw);
|
|
114
|
+
const deps = {
|
|
115
|
+
...(pkg.dependencies ?? {}),
|
|
116
|
+
...(pkg.devDependencies ?? {}),
|
|
117
|
+
};
|
|
118
|
+
return depName in deps;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export async function collectDeclaredDependencies(repoRoot) {
|
|
125
|
+
try {
|
|
126
|
+
const raw = await readFile(path.join(repoRoot, "package.json"), "utf-8");
|
|
127
|
+
const pkg = JSON.parse(raw);
|
|
128
|
+
return [
|
|
129
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
130
|
+
...Object.keys(pkg.devDependencies ?? {}),
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export async function discoverMockHandlerFiles(repoRoot, serviceRoot) {
|
|
138
|
+
const exactCandidates = [
|
|
139
|
+
...[
|
|
140
|
+
"src/mocks/handlers.ts",
|
|
141
|
+
"src/mocks/handlers.js",
|
|
142
|
+
"src/mocks/browser.ts",
|
|
143
|
+
"src/mocks/browser.js",
|
|
144
|
+
"src/mocks/server.ts",
|
|
145
|
+
"src/mocks/server.js",
|
|
146
|
+
"mocks/handlers.ts",
|
|
147
|
+
"mocks/handlers.js",
|
|
148
|
+
"mocks/browser.ts",
|
|
149
|
+
"mocks/browser.js",
|
|
150
|
+
].map((candidatePath) => ({ framework: "msw", path: candidatePath })),
|
|
151
|
+
...["db.json", "mock/db.json", "src/mock/db.json"].map((candidatePath) => ({
|
|
152
|
+
framework: "json-server",
|
|
153
|
+
path: candidatePath,
|
|
154
|
+
})),
|
|
155
|
+
];
|
|
156
|
+
const directoryCandidates = [
|
|
157
|
+
...(serviceRoot ? [{ path: serviceRoot }] : []),
|
|
158
|
+
{ framework: "mockjs", path: "src/mock" },
|
|
159
|
+
{ framework: "mockjs", path: "mock" },
|
|
160
|
+
{ framework: "msw", path: "src/mocks" },
|
|
161
|
+
{ framework: "msw", path: "mocks" },
|
|
162
|
+
{ framework: "mirage", path: "src/mirage" },
|
|
163
|
+
{ framework: "mirage", path: "mirage" },
|
|
164
|
+
];
|
|
165
|
+
const foundPaths = new Set();
|
|
166
|
+
let foundFramework;
|
|
167
|
+
for (const candidate of exactCandidates) {
|
|
168
|
+
try {
|
|
169
|
+
await access(path.join(repoRoot, candidate.path));
|
|
170
|
+
foundPaths.add(candidate.path);
|
|
171
|
+
foundFramework ??= candidate.framework;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Exact candidate does not exist.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function collectFiles(directory, relativeDirectory) {
|
|
178
|
+
let entries;
|
|
179
|
+
try {
|
|
180
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
if (foundPaths.size >= 24)
|
|
187
|
+
return;
|
|
188
|
+
const absoluteEntry = path.join(directory, entry.name);
|
|
189
|
+
const relativeEntry = path.posix.join(relativeDirectory.replace(/\\/g, "/"), entry.name);
|
|
190
|
+
if (entry.isDirectory()) {
|
|
191
|
+
await collectFiles(absoluteEntry, relativeEntry);
|
|
192
|
+
}
|
|
193
|
+
else if (entry.isFile()) {
|
|
194
|
+
foundPaths.add(relativeEntry);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const candidate of directoryCandidates) {
|
|
199
|
+
const before = foundPaths.size;
|
|
200
|
+
await collectFiles(path.join(repoRoot, candidate.path), candidate.path);
|
|
201
|
+
if (foundPaths.size > before && candidate.framework) {
|
|
202
|
+
foundFramework ??= candidate.framework;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { framework: foundFramework, paths: [...foundPaths].sort() };
|
|
206
|
+
}
|
|
207
|
+
export async function discoverMockBootstrapImports(repoRoot) {
|
|
208
|
+
const entryCandidates = [
|
|
209
|
+
"src/main.ts",
|
|
210
|
+
"src/main.tsx",
|
|
211
|
+
"src/main.js",
|
|
212
|
+
"src/main.jsx",
|
|
213
|
+
"src/index.ts",
|
|
214
|
+
"src/index.tsx",
|
|
215
|
+
"src/index.js",
|
|
216
|
+
"src/index.jsx",
|
|
217
|
+
"src/setupTests.ts",
|
|
218
|
+
"src/setupTests.js",
|
|
219
|
+
"test/setup.ts",
|
|
220
|
+
"test/setup.js",
|
|
221
|
+
];
|
|
222
|
+
const imports = [];
|
|
223
|
+
for (const candidate of entryCandidates) {
|
|
224
|
+
try {
|
|
225
|
+
const content = await readFile(path.join(repoRoot, candidate), "utf-8");
|
|
226
|
+
if (/(?:from\s*|import\s*)["'][^"']*(?:mock|msw|mirage)[^"']*["']/i.test(content)) {
|
|
227
|
+
imports.push(candidate);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Candidate entry does not exist or is unreadable.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return imports;
|
|
235
|
+
}
|
|
236
|
+
export async function discoverFrontendMockCapability(repoRoot, taskConfig) {
|
|
237
|
+
const safetyViolation = await frontendMockServiceRootSafetyViolation(repoRoot, taskConfig);
|
|
238
|
+
if (safetyViolation) {
|
|
239
|
+
return {
|
|
240
|
+
status: "ambiguous",
|
|
241
|
+
serviceRoot: taskConfig.frontendMock?.serviceRoot,
|
|
242
|
+
safetyViolation,
|
|
243
|
+
evidencePaths: [],
|
|
244
|
+
verifyCommands: [],
|
|
245
|
+
reasons: [safetyViolation],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const evidencePaths = [];
|
|
249
|
+
const reasons = [];
|
|
250
|
+
let framework;
|
|
251
|
+
let serviceRoot;
|
|
252
|
+
let strongEvidenceCount = 0;
|
|
253
|
+
let ambiguousSignals = 0;
|
|
254
|
+
// 1. Check package.json mock scripts
|
|
255
|
+
const scriptResult = await packageJsonHasMockScript(repoRoot);
|
|
256
|
+
if (scriptResult.hasScript) {
|
|
257
|
+
reasons.push(`package.json has mock scripts: ${scriptResult.scriptNames.join(", ")}`);
|
|
258
|
+
evidencePaths.push("package.json");
|
|
259
|
+
// A script alone is not strong evidence unless we also find config/entry
|
|
260
|
+
}
|
|
261
|
+
// 2. Check for direct mock framework dependencies
|
|
262
|
+
const mockDeps = [
|
|
263
|
+
"msw",
|
|
264
|
+
"mockjs",
|
|
265
|
+
"miragejs",
|
|
266
|
+
"json-server",
|
|
267
|
+
"vite-plugin-mock",
|
|
268
|
+
];
|
|
269
|
+
const foundDeps = [];
|
|
270
|
+
for (const dep of mockDeps) {
|
|
271
|
+
if (await hasDirectDependency(repoRoot, dep)) {
|
|
272
|
+
foundDeps.push(dep);
|
|
273
|
+
evidencePaths.push(`package.json (${dep})`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// 3. Check for handler/fixture/bootstrap files
|
|
277
|
+
const serviceRootHint = taskConfig.frontendMock?.serviceRoot;
|
|
278
|
+
const handlerResult = await discoverMockHandlerFiles(repoRoot, serviceRootHint);
|
|
279
|
+
const bootstrapImports = await discoverMockBootstrapImports(repoRoot);
|
|
280
|
+
if (handlerResult.framework) {
|
|
281
|
+
framework = handlerResult.framework;
|
|
282
|
+
}
|
|
283
|
+
if (handlerResult.paths.length > 0) {
|
|
284
|
+
evidencePaths.push(...handlerResult.paths);
|
|
285
|
+
reasons.push(`Mock handler/fixture paths found: ${handlerResult.paths.join(", ")}`);
|
|
286
|
+
}
|
|
287
|
+
if (bootstrapImports.length > 0) {
|
|
288
|
+
evidencePaths.push(...bootstrapImports);
|
|
289
|
+
reasons.push(`Application/test bootstrap imports Mock code: ${bootstrapImports.join(", ")}`);
|
|
290
|
+
}
|
|
291
|
+
// Evaluate strong evidence
|
|
292
|
+
// Case: direct dep + handler files
|
|
293
|
+
if (foundDeps.length > 0 && handlerResult.paths.length > 0) {
|
|
294
|
+
strongEvidenceCount++;
|
|
295
|
+
reasons.push(`Direct mock dependency (${foundDeps.join(", ")}) with handler files`);
|
|
296
|
+
}
|
|
297
|
+
// Case: mock script in package.json + corresponding config/entry
|
|
298
|
+
if (scriptResult.hasScript && handlerResult.paths.length > 0) {
|
|
299
|
+
strongEvidenceCount++;
|
|
300
|
+
reasons.push("Mock scripts and handler files both present");
|
|
301
|
+
}
|
|
302
|
+
if (bootstrapImports.length > 0 && handlerResult.paths.length > 0) {
|
|
303
|
+
strongEvidenceCount++;
|
|
304
|
+
reasons.push("Application/test bootstrap and project Mock files both present");
|
|
305
|
+
}
|
|
306
|
+
// Case: project specs define mock service root
|
|
307
|
+
if (taskConfig.frontendMock?.serviceRoot && handlerResult.paths.length > 0) {
|
|
308
|
+
strongEvidenceCount++;
|
|
309
|
+
serviceRoot = taskConfig.frontendMock.serviceRoot;
|
|
310
|
+
reasons.push(`task config specifies serviceRoot=${serviceRoot}`);
|
|
311
|
+
}
|
|
312
|
+
// Handle ambiguous: some signals but not enough for "present"
|
|
313
|
+
if (strongEvidenceCount === 0 &&
|
|
314
|
+
(foundDeps.length > 0 ||
|
|
315
|
+
scriptResult.hasScript ||
|
|
316
|
+
handlerResult.paths.length > 0 ||
|
|
317
|
+
bootstrapImports.length > 0)) {
|
|
318
|
+
ambiguousSignals++;
|
|
319
|
+
if (foundDeps.length > 0 && handlerResult.paths.length === 0) {
|
|
320
|
+
reasons.push(`Mock dependency found (${foundDeps.join(", ")}) but no handler/bootstrap files detected`);
|
|
321
|
+
}
|
|
322
|
+
if (scriptResult.hasScript &&
|
|
323
|
+
handlerResult.paths.length === 0 &&
|
|
324
|
+
foundDeps.length === 0) {
|
|
325
|
+
reasons.push("Mock scripts exist but no handler files or direct mock dependencies found");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// Build verify commands from task config
|
|
329
|
+
const configuredVerifyCommands = (taskConfig.frontendMock?.verifyCommands ?? []).map((cmd) => ({
|
|
330
|
+
label: cmd.label,
|
|
331
|
+
args: ["bash", "-lc", cmd.command],
|
|
332
|
+
cwd: repoRoot,
|
|
333
|
+
timeoutMs: cmd.timeoutMs,
|
|
334
|
+
}));
|
|
335
|
+
const verifyCommands = [
|
|
336
|
+
...configuredVerifyCommands,
|
|
337
|
+
...scriptResult.verifyCommands,
|
|
338
|
+
].filter((command, index, commands) => commands.findIndex((candidate) => candidate.label === command.label) ===
|
|
339
|
+
index);
|
|
340
|
+
if (strongEvidenceCount > 0) {
|
|
341
|
+
return {
|
|
342
|
+
status: "present",
|
|
343
|
+
framework,
|
|
344
|
+
serviceRoot: serviceRoot ?? taskConfig.frontendMock?.serviceRoot,
|
|
345
|
+
evidencePaths,
|
|
346
|
+
verifyCommands,
|
|
347
|
+
reasons,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (ambiguousSignals > 0) {
|
|
351
|
+
return {
|
|
352
|
+
status: "ambiguous",
|
|
353
|
+
framework,
|
|
354
|
+
serviceRoot: taskConfig.frontendMock?.serviceRoot,
|
|
355
|
+
evidencePaths,
|
|
356
|
+
verifyCommands,
|
|
357
|
+
reasons,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
status: "absent",
|
|
362
|
+
evidencePaths,
|
|
363
|
+
verifyCommands,
|
|
364
|
+
reasons: ["No mock service evidence found in project"],
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
export function frontendMockServiceRootAllowed(taskConfig) {
|
|
368
|
+
const serviceRoot = taskConfig.frontendMock?.serviceRoot;
|
|
369
|
+
if (!serviceRoot)
|
|
370
|
+
return true;
|
|
371
|
+
const normalized = serviceRoot.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
372
|
+
if (normalized === "." ||
|
|
373
|
+
normalized === ".." ||
|
|
374
|
+
normalized.startsWith("../") ||
|
|
375
|
+
path.isAbsolute(serviceRoot) ||
|
|
376
|
+
/[?*]/.test(normalized)) {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
const allowed = taskConfig.allowedPaths.some((allowedPath) => pathMatchesPattern(normalized, allowedPath) ||
|
|
380
|
+
pathMatchesPattern(`${normalized}/_probe_`, allowedPath));
|
|
381
|
+
if (!allowed)
|
|
382
|
+
return false;
|
|
383
|
+
return !mergeForbiddenPaths(taskConfig).some((forbiddenPath) => {
|
|
384
|
+
const forbiddenPrefix = patternStaticPrefix(forbiddenPath);
|
|
385
|
+
return (pathMatchesPattern(normalized, forbiddenPath) ||
|
|
386
|
+
(forbiddenPrefix.length > 0 &&
|
|
387
|
+
pathMatchesPattern(forbiddenPrefix, normalized)));
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
export async function frontendMockServiceRootSafetyViolation(repoRoot, taskConfig) {
|
|
391
|
+
const serviceRoot = taskConfig.frontendMock?.serviceRoot;
|
|
392
|
+
if (!serviceRoot)
|
|
393
|
+
return undefined;
|
|
394
|
+
if (!frontendMockServiceRootAllowed(taskConfig)) {
|
|
395
|
+
return `frontendMock.serviceRoot is outside allowedPaths or overlaps forbiddenPaths: ${serviceRoot}`;
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const [repoRealPath, serviceRealPath] = await Promise.all([
|
|
399
|
+
realpath(repoRoot),
|
|
400
|
+
realpath(path.resolve(repoRoot, serviceRoot)),
|
|
401
|
+
]);
|
|
402
|
+
const relativeRealPath = path.relative(repoRealPath, serviceRealPath);
|
|
403
|
+
if (relativeRealPath === ".." ||
|
|
404
|
+
relativeRealPath.startsWith(`..${path.sep}`) ||
|
|
405
|
+
path.isAbsolute(relativeRealPath)) {
|
|
406
|
+
return `frontendMock.serviceRoot resolves outside the repository: ${serviceRoot}`;
|
|
407
|
+
}
|
|
408
|
+
const normalizedRealPath = relativeRealPath.split(path.sep).join("/") || ".";
|
|
409
|
+
if (!frontendMockServiceRootAllowed({
|
|
410
|
+
...taskConfig,
|
|
411
|
+
frontendMock: {
|
|
412
|
+
...(taskConfig.frontendMock ?? {
|
|
413
|
+
policy: "auto",
|
|
414
|
+
verifyCommands: [],
|
|
415
|
+
}),
|
|
416
|
+
serviceRoot: normalizedRealPath,
|
|
417
|
+
},
|
|
418
|
+
})) {
|
|
419
|
+
return `frontendMock.serviceRoot resolves outside its allowed boundary: ${serviceRoot}`;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
// A missing configured root is capability absence, not a path escape. The
|
|
424
|
+
// discovery pass below will report it without traversing another location.
|
|
425
|
+
if (error.code !== "ENOENT") {
|
|
426
|
+
return `frontendMock.serviceRoot safety could not be verified: ${serviceRoot}`;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
export function hasApiDependency(sources) {
|
|
432
|
+
if (sources.taskConfig.frontendMock?.policy === "required") {
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
const requirement = normalizeTaskRequirementText(sources.requirementMarkdown).replace(/`[^`\n]*`/g, " ");
|
|
436
|
+
const dependencyPatterns = [
|
|
437
|
+
/(?:接口文档|接口定义|接口协议|后端接口|服务端接口|接口联调|请求|响应|远程数据|异步数据|数据获取|模拟接口|模拟数据)/,
|
|
438
|
+
/(?<![A-Za-z0-9_])API(?![A-Za-z0-9_])/i,
|
|
439
|
+
/\b(?:endpoint|request|response|fetch|axios|schema|mock|backend\s+api|server\s+api)\b/i,
|
|
440
|
+
];
|
|
441
|
+
const negationPatterns = [
|
|
442
|
+
/(?:不涉及|无需|不需要|不依赖|不调用|不请求|没有|禁止|不得).{0,16}(?:接口|后端|服务端|远程数据|异步数据|API)/i,
|
|
443
|
+
/\b(?:no|without|does\s+not|do\s+not|must\s+not)\b.{0,24}\b(?:api|endpoint|request|backend|server)\b/i,
|
|
444
|
+
];
|
|
445
|
+
return requirement
|
|
446
|
+
.split(/[。!?!?;;,,\r\n]+/)
|
|
447
|
+
.map((clause) => clause.trim())
|
|
448
|
+
.filter(Boolean)
|
|
449
|
+
.some((clause) => !negationPatterns.some((pattern) => pattern.test(clause)) &&
|
|
450
|
+
dependencyPatterns.some((pattern) => pattern.test(clause)));
|
|
451
|
+
}
|
|
452
|
+
export function frontendSourceMentionsMock(sources) {
|
|
453
|
+
const text = [sources.requirementMarkdown, sources.constraintMarkdown]
|
|
454
|
+
.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
455
|
+
.map((entry) => normalizeTaskRequirementText(entry).replace(/`[^`\n]*`/g, " "))
|
|
456
|
+
.join("\n");
|
|
457
|
+
const signalPatterns = [
|
|
458
|
+
/mock/i,
|
|
459
|
+
/模拟服务/,
|
|
460
|
+
/接口桩/,
|
|
461
|
+
/\bmsw\b/i,
|
|
462
|
+
/(?<![A-Za-z0-9_])API(?![A-Za-z0-9_])/i,
|
|
463
|
+
/接口/,
|
|
464
|
+
];
|
|
465
|
+
const negationPatterns = [
|
|
466
|
+
/(?:不涉及|无需|不需要|不依赖|不调用|不请求|不实现|没有|禁止|不得).{0,16}(?:接口|后端|服务端|远程数据|异步数据|API|mock|模拟|桩)/i,
|
|
467
|
+
/\b(?:no|without|does\s+not|do\s+not|must\s+not)\b.{0,24}\b(?:api|endpoint|request|backend|server|mock)\b/i,
|
|
468
|
+
];
|
|
469
|
+
return text
|
|
470
|
+
.split(/[。!?!?;;,,\r\n]+/)
|
|
471
|
+
.map((clause) => clause.trim())
|
|
472
|
+
.filter(Boolean)
|
|
473
|
+
.some((clause) => !negationPatterns.some((pattern) => pattern.test(clause)) &&
|
|
474
|
+
signalPatterns.some((pattern) => pattern.test(clause)));
|
|
475
|
+
}
|
|
476
|
+
export function resolveFrontendMockMode(capability, taskConfig, hasApiDep) {
|
|
477
|
+
const policy = taskConfig.frontendMock?.policy ?? "auto";
|
|
478
|
+
const hasDeterministicMockVerification = capability.verifyCommands.length > 0;
|
|
479
|
+
if (capability.safetyViolation ||
|
|
480
|
+
!frontendMockServiceRootAllowed(taskConfig)) {
|
|
481
|
+
return "blocked";
|
|
482
|
+
}
|
|
483
|
+
// disabled policy: must respect project mock rules (can't override spec)
|
|
484
|
+
if (policy === "disabled") {
|
|
485
|
+
// When disabled but the project spec mandates mock, it's blocked
|
|
486
|
+
if (capability.status === "present") {
|
|
487
|
+
// Project has mock service; disabled is an explicit override that still allows not-required
|
|
488
|
+
return "not-required";
|
|
489
|
+
}
|
|
490
|
+
return "not-required";
|
|
491
|
+
}
|
|
492
|
+
// required policy
|
|
493
|
+
if (policy === "required") {
|
|
494
|
+
if (capability.status === "present" && hasDeterministicMockVerification) {
|
|
495
|
+
return "required";
|
|
496
|
+
}
|
|
497
|
+
return "blocked";
|
|
498
|
+
}
|
|
499
|
+
// auto policy
|
|
500
|
+
if (!hasApiDep) {
|
|
501
|
+
return "not-required";
|
|
502
|
+
}
|
|
503
|
+
// In auto mode, frontend Mock is optional. Only enter Mock-backed "required"
|
|
504
|
+
// mode when the project has a confirmed native Mock capability AND
|
|
505
|
+
// deterministic Mock verification commands. Without executable verification
|
|
506
|
+
// commands a non-not-needed strategy could never be verified, so auto falls
|
|
507
|
+
// back to not-required; the prewrite gate then forces not-needed and the
|
|
508
|
+
// plan records the Real Integration Gap instead of inventing commands.
|
|
509
|
+
return capability.status === "present" && hasDeterministicMockVerification
|
|
510
|
+
? "required"
|
|
511
|
+
: "not-required";
|
|
512
|
+
}
|
|
513
|
+
export function excerptMarkdown(content, options = {}) {
|
|
514
|
+
const maxChars = options.maxChars ?? MAX_SOURCE_EXCERPT_CHARS;
|
|
515
|
+
const trimmed = content.trim();
|
|
516
|
+
if (trimmed.length <= maxChars) {
|
|
517
|
+
return {
|
|
518
|
+
text: trimmed,
|
|
519
|
+
truncated: false,
|
|
520
|
+
originalChars: trimmed.length,
|
|
521
|
+
maxChars,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const omitted = trimmed.length - maxChars;
|
|
525
|
+
const ref = options.sourceRef?.trim();
|
|
526
|
+
const pointer = ref
|
|
527
|
+
? [
|
|
528
|
+
`...[truncated for draft prompt: showing first ${maxChars} of ${trimmed.length} chars; omitted ${omitted} chars]`,
|
|
529
|
+
`Full source (authoritative, do not invent missing content): ${ref}`,
|
|
530
|
+
"When the omitted tail may affect acceptance/non-goals/constraints, re-read that file before deciding.",
|
|
531
|
+
].join("\n")
|
|
532
|
+
: `...[truncated for draft prompt: showing first ${maxChars} of ${trimmed.length} chars; omitted ${omitted} chars]`;
|
|
533
|
+
return {
|
|
534
|
+
text: `${trimmed.slice(0, maxChars)}\n\n${pointer}`,
|
|
535
|
+
truncated: true,
|
|
536
|
+
originalChars: trimmed.length,
|
|
537
|
+
maxChars,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
export function extractObjective(requirementMarkdown, title) {
|
|
541
|
+
const sectionMatchers = [
|
|
542
|
+
/##\s*(目标|Objective|Goals?)\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i,
|
|
543
|
+
/##\s*Description\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i,
|
|
544
|
+
];
|
|
545
|
+
for (const matcher of sectionMatchers) {
|
|
546
|
+
const match = requirementMarkdown.match(matcher);
|
|
547
|
+
if (!match)
|
|
548
|
+
continue;
|
|
549
|
+
const body = (match[2] ?? match[1] ?? "").trim();
|
|
550
|
+
for (const line of body.split("\n")) {
|
|
551
|
+
const trimmed = line
|
|
552
|
+
.replace(/^[-*]\s*\[[ xX]\]\s*/, "")
|
|
553
|
+
.replace(/^[-*]\s*/, "")
|
|
554
|
+
.trim();
|
|
555
|
+
if (!trimmed || isHeadingLine(trimmed) || isMetadataLine(trimmed))
|
|
556
|
+
continue;
|
|
557
|
+
return trimmed.slice(0, 500);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const lines = requirementMarkdown.split("\n");
|
|
561
|
+
for (const line of lines) {
|
|
562
|
+
const trimmed = line.trim();
|
|
563
|
+
if (!trimmed || isHeadingLine(trimmed) || isMetadataLine(trimmed))
|
|
564
|
+
continue;
|
|
565
|
+
return trimmed.slice(0, 500);
|
|
566
|
+
}
|
|
567
|
+
return `Implement task: ${title}`;
|
|
568
|
+
}
|
|
569
|
+
export function extractSuccessCriteria(requirementMarkdown, taskId) {
|
|
570
|
+
const criteria = [];
|
|
571
|
+
const sectionMatch = requirementMarkdown.match(SUCCESS_CRITERIA_SECTION);
|
|
572
|
+
if (sectionMatch) {
|
|
573
|
+
for (const line of (sectionMatch[2] ?? "").split("\n")) {
|
|
574
|
+
const trimmed = line.replace(/^[-*]\s*\[[ xX]\]\s*/, "").trim();
|
|
575
|
+
if (trimmed.startsWith("-") || trimmed.startsWith("*")) {
|
|
576
|
+
criteria.push(trimmed.replace(/^[-*]\s*/, "").trim());
|
|
577
|
+
}
|
|
578
|
+
else if (trimmed.length > 0 && !trimmed.startsWith("#")) {
|
|
579
|
+
criteria.push(trimmed);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (criteria.length > 0)
|
|
584
|
+
return criteria.slice(0, 8);
|
|
585
|
+
return [
|
|
586
|
+
`contract-pi produces an auditable read-only implementation contract for ${taskId}`,
|
|
587
|
+
"scout-src and scout-tests complete parallel read-only reconnaissance without file writes",
|
|
588
|
+
"plan-pi returns a read-only implementation plan aligned with constraints",
|
|
589
|
+
"the implementation writer changes only paths declared in writeSet",
|
|
590
|
+
"verify-pi returns read-only verification strategy and residual risks",
|
|
591
|
+
"closeout-pi returns read-only handoff summary without writing artifacts/docs",
|
|
592
|
+
];
|
|
593
|
+
}
|
|
594
|
+
export function resolveImplementPaths(taskConfig, options = {}) {
|
|
595
|
+
if (taskConfig.allowedPaths.length > 0) {
|
|
596
|
+
const allowed = [...taskConfig.allowedPaths];
|
|
597
|
+
const forbidden = mergeForbiddenPaths(taskConfig);
|
|
598
|
+
const repoRoot = options.repoRoot;
|
|
599
|
+
let companions = [];
|
|
600
|
+
// Only consult the catalog when writer paths can touch docs/* so non-docs
|
|
601
|
+
// temp fixtures without a catalog keep working; docs writers still fail closed.
|
|
602
|
+
const mayNeedDocIndex = allowed.some((entry) => {
|
|
603
|
+
const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
604
|
+
return normalized === "docs" || normalized.startsWith("docs/");
|
|
605
|
+
});
|
|
606
|
+
if (repoRoot && mayNeedDocIndex) {
|
|
607
|
+
const closure = mergeDocumentIndexCompanions({
|
|
608
|
+
repoRoot,
|
|
609
|
+
paths: allowed,
|
|
610
|
+
forbiddenPaths: forbidden,
|
|
611
|
+
});
|
|
612
|
+
companions = closure.companions;
|
|
613
|
+
const explicitAllowedPaths = new Set(allowed.map((entry) => entry.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "")));
|
|
614
|
+
const missingCompanions = closure.companions.filter((companion) => !explicitAllowedPaths.has(companion));
|
|
615
|
+
if (missingCompanions.length > 0) {
|
|
616
|
+
throw new Error(`document-index-closure: catalog companion paths must be explicitly authorized in task allowedPaths: ${missingCompanions.map((companion) => `"${companion}"`).join(", ")}. Add each missing path with task advance --allowed-path <path>; required command(s): ${missingCompanions.map((companion) => `task advance --allowed-path ${companion}`).join("; ")}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
allowedPaths: allowed,
|
|
621
|
+
writeSet: [...allowed],
|
|
622
|
+
docIndexCompanions: companions,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
allowedPaths: ["**"],
|
|
627
|
+
writeSet: [IMPLEMENT_WRITESET_PLACEHOLDER],
|
|
628
|
+
docIndexCompanions: [],
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
export function applyMavenVerificationPlanning(input) {
|
|
632
|
+
if (!input.repoRoot || !input.commands || input.commands.length === 0) {
|
|
633
|
+
return { commands: input.commands };
|
|
634
|
+
}
|
|
635
|
+
const implement = resolveImplementPaths(input.taskConfig, {
|
|
636
|
+
repoRoot: input.repoRoot,
|
|
637
|
+
});
|
|
638
|
+
const planned = planMavenVerification({
|
|
639
|
+
repoRoot: input.repoRoot,
|
|
640
|
+
commands: input.commands,
|
|
641
|
+
scope: {
|
|
642
|
+
allowedPaths: input.taskConfig.allowedPaths ?? [],
|
|
643
|
+
writeSet: implement.writeSet,
|
|
644
|
+
sourcePaths: input.taskConfig.sourceFiles ?? [],
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
if (!planned.plan) {
|
|
648
|
+
return { commands: planned.commands };
|
|
649
|
+
}
|
|
650
|
+
// Freeze shell command digests against the serialized shell form used at runtime.
|
|
651
|
+
const shellCommands = buildVerifyShellCommands({
|
|
652
|
+
repoRoot: input.repoRoot,
|
|
653
|
+
commands: planned.commands,
|
|
654
|
+
fallbackCommands: [],
|
|
655
|
+
});
|
|
656
|
+
return {
|
|
657
|
+
commands: planned.commands,
|
|
658
|
+
mavenVerificationPlan: toDagMavenVerificationPlan(planned.plan, shellCommands),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
export function extractFrontendVerifyCommandsFromMarkdown(input) {
|
|
662
|
+
if (!input.repoRoot)
|
|
663
|
+
return { staticCommands: [], behaviorCommands: [] };
|
|
664
|
+
const markdown = [
|
|
665
|
+
input.requirementMarkdown,
|
|
666
|
+
input.constraintMarkdown ?? "",
|
|
667
|
+
].join("\n");
|
|
668
|
+
const commands = new Set();
|
|
669
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
670
|
+
const bulletless = line
|
|
671
|
+
.trim()
|
|
672
|
+
.replace(/^[-*]\s*(?:\[[ xX]\]\s*)?/, "")
|
|
673
|
+
.trim();
|
|
674
|
+
const codeSpanCommands = Array.from(bulletless.matchAll(/`([^`]+)`/g), (match) => match[1]?.trim() ?? "").filter(Boolean);
|
|
675
|
+
const candidates = codeSpanCommands.length > 0 ? codeSpanCommands : [bulletless];
|
|
676
|
+
for (const candidate of candidates) {
|
|
677
|
+
if (isSupportedMarkdownVerifyCommand(candidate)) {
|
|
678
|
+
commands.add(candidate);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const staticCommands = [];
|
|
683
|
+
const behaviorCommands = [];
|
|
684
|
+
for (const command of commands) {
|
|
685
|
+
const verifyCommand = markdownVerifyCommand(input.repoRoot, command);
|
|
686
|
+
if (!verifyCommand)
|
|
687
|
+
continue;
|
|
688
|
+
// Markdown is untrusted prose: admit only commands with known verification
|
|
689
|
+
// semantics before using the shared classifier. Explicit task verifyCommands
|
|
690
|
+
// are operator-authorized separately and do not pass through this filter.
|
|
691
|
+
if (!isRecognizedFrontendVerifyCommandText(command))
|
|
692
|
+
continue;
|
|
693
|
+
// Single shared classifier: never fork lane regexes here. The old
|
|
694
|
+
// inline static-first regex misclassified behavior commands whose
|
|
695
|
+
// arguments mention build paths (e.g. `npx vitest run
|
|
696
|
+
// test/build.test.ts`).
|
|
697
|
+
if (classifyFrontendVerifyCommandText(command) === "static") {
|
|
698
|
+
staticCommands.push(verifyCommand);
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
behaviorCommands.push(verifyCommand);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return { staticCommands, behaviorCommands };
|
|
705
|
+
}
|
|
706
|
+
export function extractFrontendMockVerifyCommandsFromMarkdown(input) {
|
|
707
|
+
const commands = [];
|
|
708
|
+
const markdown = [
|
|
709
|
+
input.requirementMarkdown,
|
|
710
|
+
input.constraintMarkdown ?? "",
|
|
711
|
+
].join("\n");
|
|
712
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
713
|
+
if (!/(?:mock|模拟服务|接口桩)/i.test(line))
|
|
714
|
+
continue;
|
|
715
|
+
for (const match of line.matchAll(/`([^`]+)`/g)) {
|
|
716
|
+
const commandText = match[1]?.trim();
|
|
717
|
+
if (!commandText || !isSupportedMarkdownVerifyCommand(commandText))
|
|
718
|
+
continue;
|
|
719
|
+
const command = markdownVerifyCommand(input.repoRoot, commandText);
|
|
720
|
+
if (command)
|
|
721
|
+
commands.push(command);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return commands.filter((command, index, all) => all.findIndex((candidate) => candidate.args.join("\0") === command.args.join("\0")) === index);
|
|
725
|
+
}
|
|
726
|
+
export function buildExplicitFrontendVerifyCommands(taskConfig, repoRoot) {
|
|
727
|
+
const staticCommands = [];
|
|
728
|
+
const behaviorCommands = [];
|
|
729
|
+
if (!repoRoot)
|
|
730
|
+
return { staticCommands, behaviorCommands };
|
|
731
|
+
for (const command of taskConfig.verifyCommands) {
|
|
732
|
+
const verifyCommand = {
|
|
733
|
+
args: ["bash", "-lc", command.command],
|
|
734
|
+
cwd: repoRoot,
|
|
735
|
+
label: command.label,
|
|
736
|
+
timeoutMs: command.timeoutMs,
|
|
737
|
+
};
|
|
738
|
+
// An explicit `mode` declaration wins over automatic classification. The
|
|
739
|
+
// operator knows the command's nature; the classifier only guesses from
|
|
740
|
+
// text. This matters because the behavior lane requires test-observation
|
|
741
|
+
// capability at writer admission, so a governance/lint script that text
|
|
742
|
+
// classification calls "behavior" blocks admission deterministically and
|
|
743
|
+
// no model-side fix can clear it. A declared command still runs and its
|
|
744
|
+
// exit code is still checked in whichever lane it lands.
|
|
745
|
+
// An undeclared command is classified by text, and
|
|
746
|
+
// `classifyFrontendVerifyCommand` fails at generation when that text
|
|
747
|
+
// carries no recognizable verification semantics. Failing there keeps the
|
|
748
|
+
// decision with the operator, where it is cheap to fix, instead of
|
|
749
|
+
// silently entering the test-observed behavior lane and surfacing much
|
|
750
|
+
// later as an unactionable `FRONTEND_TEST_OBSERVATION_INVALID`.
|
|
751
|
+
const mode = command.mode ??
|
|
752
|
+
classifyFrontendVerifyCommand(command.command, command.label);
|
|
753
|
+
if (mode === "static") {
|
|
754
|
+
staticCommands.push(verifyCommand);
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
behaviorCommands.push(verifyCommand);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return { staticCommands, behaviorCommands };
|
|
761
|
+
}
|
|
762
|
+
export function tokenizeSimpleShellCommand(command) {
|
|
763
|
+
const tokens = [];
|
|
764
|
+
let token = "";
|
|
765
|
+
let quote = null;
|
|
766
|
+
let escaping = false;
|
|
767
|
+
let tokenStarted = false;
|
|
768
|
+
for (const char of command.trim()) {
|
|
769
|
+
if (escaping) {
|
|
770
|
+
token += char;
|
|
771
|
+
escaping = false;
|
|
772
|
+
tokenStarted = true;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (char === "\\" && quote !== "'") {
|
|
776
|
+
escaping = true;
|
|
777
|
+
tokenStarted = true;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (quote) {
|
|
781
|
+
if (quote === '"' && (char === "$" || char === "`"))
|
|
782
|
+
return undefined;
|
|
783
|
+
if (char === quote)
|
|
784
|
+
quote = null;
|
|
785
|
+
else
|
|
786
|
+
token += char;
|
|
787
|
+
tokenStarted = true;
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (char === "'" || char === '"') {
|
|
791
|
+
quote = char;
|
|
792
|
+
tokenStarted = true;
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (/\s/.test(char)) {
|
|
796
|
+
if (tokenStarted)
|
|
797
|
+
tokens.push(token);
|
|
798
|
+
token = "";
|
|
799
|
+
tokenStarted = false;
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (/[;&|<>`\r\n]/.test(char) || char === "$" || char === "(") {
|
|
803
|
+
return undefined;
|
|
804
|
+
}
|
|
805
|
+
token += char;
|
|
806
|
+
tokenStarted = true;
|
|
807
|
+
}
|
|
808
|
+
if (escaping || quote)
|
|
809
|
+
return undefined;
|
|
810
|
+
if (tokenStarted)
|
|
811
|
+
tokens.push(token);
|
|
812
|
+
return tokens.length > 0 ? tokens : undefined;
|
|
813
|
+
}
|
|
814
|
+
export function canonicalizeVerificationCommands(commands) {
|
|
815
|
+
const byKey = new Map();
|
|
816
|
+
const merged = new Map();
|
|
817
|
+
for (const command of commands) {
|
|
818
|
+
const canonicalKey = verifyCommandKey(command);
|
|
819
|
+
const retained = byKey.get(canonicalKey);
|
|
820
|
+
if (!retained) {
|
|
821
|
+
byKey.set(canonicalKey, {
|
|
822
|
+
...command,
|
|
823
|
+
env: command.env ? { ...command.env } : undefined,
|
|
824
|
+
});
|
|
825
|
+
merged.set(canonicalKey, {
|
|
826
|
+
canonicalKey,
|
|
827
|
+
keptLabel: command.label,
|
|
828
|
+
mergedLabels: [],
|
|
829
|
+
effectiveTimeoutMs: command.timeoutMs ?? null,
|
|
830
|
+
});
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
const retainedTimeout = retained.timeoutMs ?? 0;
|
|
834
|
+
const candidateTimeout = command.timeoutMs ?? 0;
|
|
835
|
+
if (candidateTimeout > retainedTimeout)
|
|
836
|
+
retained.timeoutMs = command.timeoutMs;
|
|
837
|
+
const mergedEntry = merged.get(canonicalKey);
|
|
838
|
+
mergedEntry.effectiveTimeoutMs = retained.timeoutMs ?? null;
|
|
839
|
+
mergedEntry.mergedLabels.push(command.label);
|
|
840
|
+
}
|
|
841
|
+
const retained = [...byKey.values()];
|
|
842
|
+
const aggregate = retained.find(isCheckRepoVerifyCommand);
|
|
843
|
+
const covered = [];
|
|
844
|
+
const commandsAfterCoverage = aggregate
|
|
845
|
+
? retained.filter((command) => {
|
|
846
|
+
if (!isCheckRepoCoveredCommand(command))
|
|
847
|
+
return true;
|
|
848
|
+
covered.push({
|
|
849
|
+
aggregateLabel: aggregate.label,
|
|
850
|
+
coveredLabel: command.label,
|
|
851
|
+
});
|
|
852
|
+
return false;
|
|
853
|
+
})
|
|
854
|
+
: retained;
|
|
855
|
+
return {
|
|
856
|
+
commands: commandsAfterCoverage,
|
|
857
|
+
merged: [...merged.values()].filter((entry) => entry.mergedLabels.length > 0),
|
|
858
|
+
covered,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
export function assertVerificationPlanPreflight(input) {
|
|
862
|
+
const root = path.resolve(input.repoRoot);
|
|
863
|
+
const statuses = [];
|
|
864
|
+
for (const command of input.commands) {
|
|
865
|
+
const cwd = path.resolve(command.cwd);
|
|
866
|
+
if (!isWithinRepo(root, cwd) || !existsSync(cwd)) {
|
|
867
|
+
throw new Error(`verification preflight rejected cwd for ${command.label}: ${command.cwd}`);
|
|
868
|
+
}
|
|
869
|
+
const vitestStatus = verifyVitestCommandPreflight({
|
|
870
|
+
command,
|
|
871
|
+
root,
|
|
872
|
+
taskConfig: input.taskConfig,
|
|
873
|
+
});
|
|
874
|
+
if (vitestStatus) {
|
|
875
|
+
statuses.push({ label: command.label, status: vitestStatus });
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
const script = localScriptTarget(command);
|
|
879
|
+
if (!script) {
|
|
880
|
+
statuses.push({ label: command.label, status: "ok" });
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
const scriptPath = path.resolve(cwd, script);
|
|
884
|
+
if (existsSync(scriptPath)) {
|
|
885
|
+
statuses.push({ label: command.label, status: "ok" });
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
const writerMayCreate = input.taskConfig.allowedPaths.some((allowed) => pathMatchesPattern(script, allowed.replace(/^\.\//, "")));
|
|
889
|
+
if (!writerMayCreate) {
|
|
890
|
+
throw new Error(`verification preflight missing local script for ${command.label}: ${script}`);
|
|
891
|
+
}
|
|
892
|
+
statuses.push({ label: command.label, status: "future-delivery-script" });
|
|
893
|
+
}
|
|
894
|
+
return statuses;
|
|
895
|
+
}
|
|
896
|
+
export function verifyVitestCommandPreflight(input) {
|
|
897
|
+
const args = normalizeVerifyCommandArgs(input.command.args);
|
|
898
|
+
const vitestIndex = args.findIndex((arg) => path.basename(arg).replace(/\.(?:exe|cmd)$/i, "") === "vitest");
|
|
899
|
+
if (vitestIndex < 0)
|
|
900
|
+
return undefined;
|
|
901
|
+
const configArgIndex = args.findIndex((arg, index) => index > vitestIndex &&
|
|
902
|
+
(arg === "--config" || arg === "-c" || arg.startsWith("--config=")));
|
|
903
|
+
let configPath;
|
|
904
|
+
if (configArgIndex >= 0) {
|
|
905
|
+
const arg = args[configArgIndex];
|
|
906
|
+
configPath = arg.startsWith("--config=")
|
|
907
|
+
? arg.slice("--config=".length)
|
|
908
|
+
: args[configArgIndex + 1];
|
|
909
|
+
}
|
|
910
|
+
const cwd = path.resolve(input.command.cwd);
|
|
911
|
+
const configAbsolute = configPath
|
|
912
|
+
? path.resolve(cwd, configPath)
|
|
913
|
+
: findRootVitestConfig(input.root);
|
|
914
|
+
const configRelative = configAbsolute
|
|
915
|
+
? path.relative(input.root, configAbsolute).replace(/\\/g, "/")
|
|
916
|
+
: undefined;
|
|
917
|
+
if (configAbsolute && !existsSync(configAbsolute)) {
|
|
918
|
+
const writerMayCreate = Boolean(configRelative &&
|
|
919
|
+
input.taskConfig.allowedPaths.some((allowed) => pathMatchesPattern(configRelative, allowed.replace(/^\.\//, ""))));
|
|
920
|
+
if (!writerMayCreate) {
|
|
921
|
+
throw new Error(`verification preflight missing Vitest config for ${input.command.label}: ${configRelative ?? configPath}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const targetPaths = vitestTargetPaths(args.slice(vitestIndex + 1));
|
|
925
|
+
if (targetPaths.length === 0)
|
|
926
|
+
return "ok";
|
|
927
|
+
const inheritedConfig = !configPath && configAbsolute && existsSync(configAbsolute);
|
|
928
|
+
if (inheritedConfig && vitestConfigExcludesDogfood(configAbsolute, targetPaths)) {
|
|
929
|
+
throw new Error(`verification preflight Vitest target ${targetPaths.join(", ")} is excluded by ${path.relative(input.root, configAbsolute).replace(/\\/g, "/")}`);
|
|
930
|
+
}
|
|
931
|
+
const futureTargets = targetPaths.filter((target) => {
|
|
932
|
+
const absolute = path.resolve(cwd, target);
|
|
933
|
+
if (existsSync(absolute))
|
|
934
|
+
return false;
|
|
935
|
+
return !input.taskConfig.allowedPaths.some((allowed) => pathMatchesPattern(target, allowed.replace(/^\.\//, "")));
|
|
936
|
+
});
|
|
937
|
+
if (futureTargets.length > 0) {
|
|
938
|
+
throw new Error(`verification preflight missing Vitest target for ${input.command.label}: ${futureTargets.join(", ")}`);
|
|
939
|
+
}
|
|
940
|
+
const missingAllowedTargets = targetPaths.filter((target) => !existsSync(path.resolve(cwd, target)));
|
|
941
|
+
return missingAllowedTargets.length > 0 ? "future-test-target" : "ok";
|
|
942
|
+
}
|
|
943
|
+
export function isFullSuiteVerifyCommand(command) {
|
|
944
|
+
const args = normalizeVerifyCommandArgs(command.args);
|
|
945
|
+
let index = 0;
|
|
946
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(args[index] ?? ""))
|
|
947
|
+
index += 1;
|
|
948
|
+
const executable = path
|
|
949
|
+
.basename(args[index] ?? "")
|
|
950
|
+
.toLowerCase()
|
|
951
|
+
.replace(/\.(?:exe|cmd)$/, "");
|
|
952
|
+
const rest = args.slice(index + 1);
|
|
953
|
+
if (["npm", "pnpm", "yarn", "bun"].includes(executable)) {
|
|
954
|
+
return ((rest.length === 1 && rest[0] === "test") ||
|
|
955
|
+
(rest.length === 2 && rest[0] === "run" && rest[1] === "test"));
|
|
956
|
+
}
|
|
957
|
+
if (["bash", "sh"].includes(executable) && rest.length === 1) {
|
|
958
|
+
return /(?:^|\/)scripts\/(?:ci|check-repo)\.sh$/i.test((rest[0] ?? "").replace(/\\/g, "/"));
|
|
959
|
+
}
|
|
960
|
+
return (rest.length === 0 &&
|
|
961
|
+
/(?:^|\/)scripts\/(?:ci|check-repo)\.sh$/i.test((args[index] ?? "").replace(/\\/g, "/")));
|
|
962
|
+
}
|
|
963
|
+
export function buildVerifyEvidence(input) {
|
|
964
|
+
const selectedCommands = input.commands && input.commands.length > 0 ? input.commands : undefined;
|
|
965
|
+
const commandCount = selectedCommands?.length ?? input.fallbackCommands.length;
|
|
966
|
+
return {
|
|
967
|
+
phase: input.phase,
|
|
968
|
+
quota: input.quota,
|
|
969
|
+
commandSource: input.commandSource,
|
|
970
|
+
commandCount,
|
|
971
|
+
commandLabels: selectedCommands?.map((command) => command.label) ??
|
|
972
|
+
input.fallbackCommands,
|
|
973
|
+
commandTexts: input.commandTexts ?? [],
|
|
974
|
+
canonicalKeys: selectedCommands?.map(verifyCommandKey) ?? [],
|
|
975
|
+
mergedCommands: input.plan?.merged ?? [],
|
|
976
|
+
coveredCommands: input.plan?.covered ?? [],
|
|
977
|
+
selectionReasons: selectedCommands?.map((command) => input.phase === "intermediate" && isFullSuiteVerifyCommand(command)
|
|
978
|
+
? `${command.label}: deferred-heavy`
|
|
979
|
+
: `${command.label}: kept`) ?? [],
|
|
980
|
+
preflight: input.preflight ??
|
|
981
|
+
(selectedCommands?.map((command) => ({
|
|
982
|
+
label: command.label,
|
|
983
|
+
status: "ok",
|
|
984
|
+
})) ?? []),
|
|
985
|
+
commandTimeoutMs: input.commandTimeoutMs,
|
|
986
|
+
totalTimeoutBudgetMs: commandCount * input.commandTimeoutMs,
|
|
987
|
+
finalFullRequired: input.finalFullRequired,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
export function partitionFrontendStaticVerifyCommands(input) {
|
|
991
|
+
const commands = input.commands ?? [];
|
|
992
|
+
// Frontend DAGs deliberately never generate lint verification. Existing
|
|
993
|
+
// project lint debt is allowed to remain outside this workflow, and an
|
|
994
|
+
// explicitly mentioned lint command must not reintroduce the lint gate.
|
|
995
|
+
const staticCommands = commands.filter((command) => !isFrontendLintVerifyCommand(command));
|
|
996
|
+
return {
|
|
997
|
+
lint: {
|
|
998
|
+
commandSource: input.commandSource,
|
|
999
|
+
},
|
|
1000
|
+
static: {
|
|
1001
|
+
...(staticCommands.length > 0 ? { commands: staticCommands } : {}),
|
|
1002
|
+
commandSource: input.commandSource,
|
|
1003
|
+
},
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
export async function discoverFrontendFallbackVerifyCommands(repoRoot) {
|
|
1007
|
+
const genericFallback = {
|
|
1008
|
+
staticCommands: ["npm run typecheck", "npm run build"],
|
|
1009
|
+
behaviorCommands: ["npm test"],
|
|
1010
|
+
};
|
|
1011
|
+
if (!repoRoot)
|
|
1012
|
+
return genericFallback;
|
|
1013
|
+
let scripts;
|
|
1014
|
+
try {
|
|
1015
|
+
const packageJson = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8"));
|
|
1016
|
+
if (packageJson.scripts && typeof packageJson.scripts === "object") {
|
|
1017
|
+
scripts = packageJson.scripts;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
catch {
|
|
1021
|
+
// No readable manifest: leave scripts unset and keep probing.
|
|
1022
|
+
}
|
|
1023
|
+
let packageManager = "npm";
|
|
1024
|
+
for (const [lockfile, manager] of [
|
|
1025
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
1026
|
+
["yarn.lock", "yarn"],
|
|
1027
|
+
["bun.lockb", "bun"],
|
|
1028
|
+
["bun.lock", "bun"],
|
|
1029
|
+
]) {
|
|
1030
|
+
try {
|
|
1031
|
+
await access(path.join(repoRoot, lockfile));
|
|
1032
|
+
packageManager = manager;
|
|
1033
|
+
break;
|
|
1034
|
+
}
|
|
1035
|
+
catch {
|
|
1036
|
+
// Try the next package-manager marker.
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
const commandForScript = (script) => script === "test"
|
|
1040
|
+
? `${packageManager} test`
|
|
1041
|
+
: `${packageManager} run ${script}`;
|
|
1042
|
+
const hasScript = (script) => typeof scripts?.[script] === "string" &&
|
|
1043
|
+
String(scripts[script]).trim().length > 0;
|
|
1044
|
+
const firstExisting = (names) => {
|
|
1045
|
+
const selected = [];
|
|
1046
|
+
for (const name of names) {
|
|
1047
|
+
if (hasScript(name) && !selected.includes(name))
|
|
1048
|
+
selected.push(name);
|
|
1049
|
+
}
|
|
1050
|
+
return selected.map(commandForScript);
|
|
1051
|
+
};
|
|
1052
|
+
const staticCommands = firstExisting([
|
|
1053
|
+
"typecheck",
|
|
1054
|
+
"check-types",
|
|
1055
|
+
"check",
|
|
1056
|
+
"build",
|
|
1057
|
+
]);
|
|
1058
|
+
const behaviorCommands = firstExisting([
|
|
1059
|
+
"test:unit",
|
|
1060
|
+
"test:frontend",
|
|
1061
|
+
"test:component",
|
|
1062
|
+
"test",
|
|
1063
|
+
"test:e2e",
|
|
1064
|
+
"e2e",
|
|
1065
|
+
]);
|
|
1066
|
+
if (staticCommands.length === 0 && behaviorCommands.length === 0) {
|
|
1067
|
+
try {
|
|
1068
|
+
await access(path.join(repoRoot, "node_modules", ".bin", "tsc"));
|
|
1069
|
+
staticCommands.push("npx --no-install tsc --noEmit");
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
// No local TypeScript binary: leave verification unavailable rather
|
|
1073
|
+
// than invoking a missing script or downloading a package.
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return {
|
|
1077
|
+
staticCommands: staticCommands.length > 0 ? staticCommands : [],
|
|
1078
|
+
behaviorCommands: behaviorCommands.length > 0 ? behaviorCommands : [],
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
export function extractExplicitRequirementIds(requirementMarkdown, ...fallbackMarkdown) {
|
|
1082
|
+
const ids = [];
|
|
1083
|
+
const seen = new Set();
|
|
1084
|
+
const collect = (markdown, declarationsOnly) => {
|
|
1085
|
+
if (!markdown)
|
|
1086
|
+
return;
|
|
1087
|
+
const source = declarationsOnly
|
|
1088
|
+
? markdown
|
|
1089
|
+
.split(/\r?\n/)
|
|
1090
|
+
.filter((line) => isRequirementIdDeclarationLine(line))
|
|
1091
|
+
.join("\n")
|
|
1092
|
+
: markdown;
|
|
1093
|
+
for (const match of source.matchAll(/\b(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi)) {
|
|
1094
|
+
const id = match[0].toUpperCase();
|
|
1095
|
+
if (isPlaceholderRequirementId(id))
|
|
1096
|
+
continue;
|
|
1097
|
+
if (!seen.has(id)) {
|
|
1098
|
+
seen.add(id);
|
|
1099
|
+
ids.push(id);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
// The requirement document itself is human-authored acceptance prose:
|
|
1104
|
+
// only declaration lines declare requirements there. If the document has
|
|
1105
|
+
// no declaration-shaped lines at all (minimal free-form tasks like
|
|
1106
|
+
// "covers AC-001"), fall back to its full text so bindings never go
|
|
1107
|
+
// missing for unstructured input. References and constraints are bound
|
|
1108
|
+
// documents (acceptance yaml, analysis docs) whose ids are authoritative
|
|
1109
|
+
// wherever they appear — keep full scan for them.
|
|
1110
|
+
const declarationLines = requirementMarkdown
|
|
1111
|
+
.split(/\r?\n/)
|
|
1112
|
+
.filter((line) => isRequirementIdDeclarationLine(line));
|
|
1113
|
+
collect(requirementMarkdown, declarationLines.length > 0);
|
|
1114
|
+
for (const markdown of fallbackMarkdown) {
|
|
1115
|
+
if (markdown)
|
|
1116
|
+
collect(markdown, false);
|
|
1117
|
+
}
|
|
1118
|
+
return ids;
|
|
1119
|
+
}
|
|
1120
|
+
export function extractTaskScopedRequirementIds(requirementMarkdown, ...fallbackMarkdown) {
|
|
1121
|
+
// Only the explicit TaskSpec-derived section is authoritative for scope.
|
|
1122
|
+
// Without it, preserve legacy full-scan behavior (requirement + references).
|
|
1123
|
+
const sectionMatch = requirementMarkdown.match(/(?:^|\n)##\s*Acceptance References\s*\n([\s\S]*?)(?=\n##\s+|\n#\s+|$)/i);
|
|
1124
|
+
if (sectionMatch?.[1]) {
|
|
1125
|
+
const fromSection = extractSectionRequirementIds(sectionMatch[1]);
|
|
1126
|
+
if (fromSection.length > 0)
|
|
1127
|
+
return fromSection;
|
|
1128
|
+
}
|
|
1129
|
+
const explicit = extractExplicitRequirementIds(requirementMarkdown, ...fallbackMarkdown);
|
|
1130
|
+
if (explicit.length > 0)
|
|
1131
|
+
return explicit;
|
|
1132
|
+
// Requirements frequently use a numbered acceptance list instead of
|
|
1133
|
+
// writing AC-* identifiers. Give that list a deterministic canonical
|
|
1134
|
+
// namespace so model-generated AC-1 references bind to the same source
|
|
1135
|
+
// facts instead of failing as unknown requirements at the prewrite gate.
|
|
1136
|
+
const acceptanceSection = requirementMarkdown.match(/(?:^|\n)##\s*(?:验收标准|Acceptance Criteria)\s*\n([\s\S]*?)(?=\n##\s+|\n#\s+|$)/i)?.[1] ?? "";
|
|
1137
|
+
const numbered = [...acceptanceSection.matchAll(/(?:^|\n)\s*(\d+)[.、)]\s+/g)]
|
|
1138
|
+
.map((match) => Number(match[1]))
|
|
1139
|
+
.filter((value, index, values) => Number.isFinite(value) && values.indexOf(value) === index)
|
|
1140
|
+
.sort((a, b) => a - b);
|
|
1141
|
+
return numbered.map((value) => `AC-${value}`);
|
|
1142
|
+
}
|
|
1143
|
+
export function probePathsForWriteSetEntry(entry) {
|
|
1144
|
+
const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1145
|
+
if (!normalized.includes("*")) {
|
|
1146
|
+
return [normalized];
|
|
1147
|
+
}
|
|
1148
|
+
const concrete = normalized
|
|
1149
|
+
.replace(/\/\*\*$/, "/_probe_")
|
|
1150
|
+
.replace(/\*\*$/, "_probe_")
|
|
1151
|
+
.replace(/\*/g, "_");
|
|
1152
|
+
return Array.from(new Set([normalized, concrete]));
|
|
1153
|
+
}
|
|
1154
|
+
export function buildDagSourceBindingBase(sources) {
|
|
1155
|
+
const sourceEntries = [
|
|
1156
|
+
{
|
|
1157
|
+
kind: "requirement",
|
|
1158
|
+
path: sources.requirementPath,
|
|
1159
|
+
markdown: sources.requirementMarkdown,
|
|
1160
|
+
},
|
|
1161
|
+
...(sources.constraintMarkdown
|
|
1162
|
+
? [
|
|
1163
|
+
{
|
|
1164
|
+
kind: "constraint",
|
|
1165
|
+
path: sources.constraintPath,
|
|
1166
|
+
markdown: sources.constraintMarkdown,
|
|
1167
|
+
},
|
|
1168
|
+
]
|
|
1169
|
+
: []),
|
|
1170
|
+
...(sources.referenceDocuments ?? []).map((reference) => ({
|
|
1171
|
+
kind: "reference",
|
|
1172
|
+
path: reference.path,
|
|
1173
|
+
markdown: reference.markdown,
|
|
1174
|
+
})),
|
|
1175
|
+
];
|
|
1176
|
+
return {
|
|
1177
|
+
schemaVersion: 1,
|
|
1178
|
+
taskId: sources.taskId,
|
|
1179
|
+
sources: sourceEntries.map((source) => ({
|
|
1180
|
+
kind: source.kind,
|
|
1181
|
+
path: toDagSourcePath(sources, source.path),
|
|
1182
|
+
sha256: createHash("sha256")
|
|
1183
|
+
.update(source.markdown, "utf8")
|
|
1184
|
+
.digest("hex"),
|
|
1185
|
+
})),
|
|
1186
|
+
requirementIds: extractTaskScopedRequirementIds(sources.requirementMarkdown, sources.constraintMarkdown, ...(sources.referenceDocuments ?? []).map((reference) => reference.markdown)),
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
export function loadPersistedFrontendSourceBindingLedger(sources) {
|
|
1190
|
+
const referenceDocs = (sources.referenceDocuments ?? []).filter((doc) => doc.markdown.trim().length > 0);
|
|
1191
|
+
const ledgerAbsolutePath = path.join(sources.taskDir, "source", REQUIREMENT_LEDGER_FILE_NAME);
|
|
1192
|
+
// Text-created frontend tasks have no imported reference manifest. Their
|
|
1193
|
+
// task-owned projected requirement is nevertheless a valid canonical source
|
|
1194
|
+
// document and is persisted in the v2 ledger by source preparation. Keep it
|
|
1195
|
+
// in the current-input map below instead of silently downgrading to v1.
|
|
1196
|
+
// 存在性守卫(保持既有 v1 回退行为):派生视图无可抽取 requirement/acceptance
|
|
1197
|
+
// 引用时不要求持久化 ledger,直接回退 base binding。此守卫只用于"是否 ledger
|
|
1198
|
+
// 任务"判定,绝不参与 digest 键计算——键输入完全来自持久化 ledger 恢复。
|
|
1199
|
+
const guardFacts = extractRequirementFactsFromMarkdown(sources.requirementMarkdown);
|
|
1200
|
+
if (guardFacts.acceptanceCriteria.length === 0 && referenceDocs.length > 0)
|
|
1201
|
+
return null;
|
|
1202
|
+
if (referenceDocs.length === 0 && !existsSync(ledgerAbsolutePath))
|
|
1203
|
+
return null;
|
|
1204
|
+
let raw;
|
|
1205
|
+
try {
|
|
1206
|
+
raw = readFileSync(ledgerAbsolutePath, "utf8");
|
|
1207
|
+
}
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
throw new Error(`SOURCE_FIDELITY_LEDGER_MISSING: frontend task ${sources.taskId} has resolvable requirement/acceptance references but no persisted ${REQUIREMENT_LEDGER_FILE_NAME} at ${ledgerAbsolutePath}; run task advance (intake) to persist the ledger before dag init-hybrid (${error instanceof Error ? error.message : String(error)})`);
|
|
1210
|
+
}
|
|
1211
|
+
const ledgerSha256 = createHash("sha256").update(raw).digest("hex");
|
|
1212
|
+
let ledger;
|
|
1213
|
+
try {
|
|
1214
|
+
ledger = parseLedgerJson(raw);
|
|
1215
|
+
}
|
|
1216
|
+
catch (error) {
|
|
1217
|
+
throw new Error(`SOURCE_FIDELITY_LEDGER_MISSING: persisted ${REQUIREMENT_LEDGER_FILE_NAME} is not a valid v1 ledger: ${error instanceof Error ? error.message : String(error)}`);
|
|
1218
|
+
}
|
|
1219
|
+
const ledgerErrors = validateRequirementLedger(ledger);
|
|
1220
|
+
if (ledgerErrors.length > 0) {
|
|
1221
|
+
const first = ledgerErrors[0];
|
|
1222
|
+
throw new Error(`${first.code}: persisted ${REQUIREMENT_LEDGER_FILE_NAME} is invalid; refusing to start a frontend DAG with incomplete source bindings (${ledgerErrors
|
|
1223
|
+
.map((error) => error.message)
|
|
1224
|
+
.join("; ")})`);
|
|
1225
|
+
}
|
|
1226
|
+
// canonical 侧:ledger.canonicalRequirements 按 build 顺序原样保留输入 canonical
|
|
1227
|
+
// requirements(id/text 逐字节),过滤物化 REQ-SRC-*(AC-HARD-002 "物化不入键")
|
|
1228
|
+
// 即精确恢复 digest 输入集;派生导航视图 需求.md 投影不参与键计算。
|
|
1229
|
+
const contract = recoverLedgerInputContract(ledger);
|
|
1230
|
+
if (contract.canonicalRequirements.length === 0) {
|
|
1231
|
+
// AC-HARD-003:存在 v2 ledger 但 canonical requirements 为空时 fail closed,
|
|
1232
|
+
// 绝不回退 v1 base 绑定(buildDagSourceBinding 不降级)。空 canonical 的 ledger
|
|
1233
|
+
// 无法提供 requirement→fragment 映射证据,任何 v1 回退都会静默放行 fail-open。
|
|
1234
|
+
throw new Error(`SOURCE_FIDELITY_LEDGER_STALE: persisted ${REQUIREMENT_LEDGER_FILE_NAME} has an empty canonical requirement set; refusing to fall back to a v1 base binding; re-run task advance (intake) to rebuild the ledger`);
|
|
1235
|
+
}
|
|
1236
|
+
// source 侧:ledger.sourcePaths(build 时权威 sourceDocuments 的 repo-root
|
|
1237
|
+
// relative POSIX 路径集合)作为权威身份集合,对当前 materialized reference 文件
|
|
1238
|
+
// 做交集过滤;内容取当前字节(漂移检测)。权威路径缺失(删除/清空/重命名)→
|
|
1239
|
+
// fail-closed STALE;新增的非权威 role 文件被正确排除(不入键)。
|
|
1240
|
+
const currentByPath = new Map();
|
|
1241
|
+
for (const doc of referenceDocs) {
|
|
1242
|
+
currentByPath.set(toDagSourcePath(sources, doc.path), doc.markdown);
|
|
1243
|
+
}
|
|
1244
|
+
if (referenceDocs.length === 0) {
|
|
1245
|
+
currentByPath.set(toDagSourcePath(sources, sources.requirementPath),
|
|
1246
|
+
// applyTaskContract appends a derived `## Source ledger` navigation
|
|
1247
|
+
// block after the ledger input has been hashed. Remove that block when
|
|
1248
|
+
// reconstructing the canonical text digest.
|
|
1249
|
+
sources.requirementMarkdown.replace(/\n## Source ledger\b[\s\S]*$/u, ""));
|
|
1250
|
+
}
|
|
1251
|
+
const missingAuthoritativePaths = contract.sourcePaths.filter((sourcePath) => !currentByPath.has(sourcePath));
|
|
1252
|
+
if (missingAuthoritativePaths.length > 0) {
|
|
1253
|
+
throw new Error(`SOURCE_FIDELITY_LEDGER_STALE: persisted ${REQUIREMENT_LEDGER_FILE_NAME} was built from authoritative source documents no longer present (${missingAuthoritativePaths.join(", ")}); re-run task advance (intake) to rebuild the ledger`);
|
|
1254
|
+
}
|
|
1255
|
+
// inputDigest 新鲜度:与当前源输入(canonical repo-root relative identity)一致。
|
|
1256
|
+
// digest 只绑定语义权威输入(规范化源文档 + canonical requirements +
|
|
1257
|
+
// schema/reconciler version);派生导航视图 需求.md 投影不参与键计算。
|
|
1258
|
+
const expectedDigest = computeLedgerInputDigest({
|
|
1259
|
+
sourceDocuments: contract.sourcePaths.map((sourcePath) => ({
|
|
1260
|
+
path: sourcePath,
|
|
1261
|
+
content: currentByPath.get(sourcePath),
|
|
1262
|
+
})),
|
|
1263
|
+
canonicalRequirements: contract.canonicalRequirements,
|
|
1264
|
+
});
|
|
1265
|
+
if (ledger.inputDigest !== expectedDigest) {
|
|
1266
|
+
throw new Error(`SOURCE_FIDELITY_LEDGER_STALE: persisted ${REQUIREMENT_LEDGER_FILE_NAME} inputDigest ${ledger.inputDigest} does not match current source input ${expectedDigest}; re-run task advance (intake) to rebuild the ledger`);
|
|
1267
|
+
}
|
|
1268
|
+
const requirementToFragments = {};
|
|
1269
|
+
for (const requirement of ledger.canonicalRequirements) {
|
|
1270
|
+
if (requirement.sourceFragmentIds.length > 0) {
|
|
1271
|
+
requirementToFragments[requirement.id] = requirement.sourceFragmentIds;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return {
|
|
1275
|
+
ledgerPath: toDagSourcePath(sources, ledgerAbsolutePath),
|
|
1276
|
+
ledgerSha256,
|
|
1277
|
+
inputDigest: ledger.inputDigest,
|
|
1278
|
+
requirementIds: ledger.canonicalRequirements.map((requirement) => requirement.id),
|
|
1279
|
+
requirementToFragments,
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
export function buildBackendTestAnalysisSourceBindingContract(sources) {
|
|
1283
|
+
const binding = buildDagSourceBinding(sources);
|
|
1284
|
+
const requirement = binding.sources.find((source) => source.kind === "requirement");
|
|
1285
|
+
if (!requirement) {
|
|
1286
|
+
throw new Error("backend-test analysis requires a requirement source binding");
|
|
1287
|
+
}
|
|
1288
|
+
return {
|
|
1289
|
+
taskId: binding.taskId,
|
|
1290
|
+
requirementPath: requirement.path,
|
|
1291
|
+
requirementSha256: requirement.sha256,
|
|
1292
|
+
referencePaths: binding.sources
|
|
1293
|
+
.filter((source) => source.kind === "reference")
|
|
1294
|
+
.map((source) => source.path),
|
|
1295
|
+
requirementIds: binding.requirementIds,
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
export function buildSourceContextBlock(sources, options = {}) {
|
|
1299
|
+
const includeRequirementExcerpt = options.includeRequirementExcerpt ?? true;
|
|
1300
|
+
const includeConstraintExcerpt = options.includeConstraintExcerpt ?? true;
|
|
1301
|
+
const includeReferenceDocuments = options.includeReferenceDocuments ?? true;
|
|
1302
|
+
const requirementRef = toDagSourcePath(sources, sources.requirementPath);
|
|
1303
|
+
const requirementExcerpt = excerptMarkdown(sources.requirementMarkdown, {
|
|
1304
|
+
sourceRef: requirementRef,
|
|
1305
|
+
});
|
|
1306
|
+
const boundReadPaths = [`- requirement: ${requirementRef}`];
|
|
1307
|
+
const parts = [
|
|
1308
|
+
`## Task source: 需求.md`,
|
|
1309
|
+
`Bound readPath (use for Pi read-tool calls): ${requirementRef}`,
|
|
1310
|
+
...(includeRequirementExcerpt ? [requirementExcerpt.text] : []),
|
|
1311
|
+
];
|
|
1312
|
+
if (sources.constraintMarkdown) {
|
|
1313
|
+
const constraintRef = toDagSourcePath(sources, sources.constraintPath);
|
|
1314
|
+
const constraintExcerpt = excerptMarkdown(sources.constraintMarkdown, {
|
|
1315
|
+
sourceRef: constraintRef,
|
|
1316
|
+
});
|
|
1317
|
+
boundReadPaths.push(`- constraints: ${constraintRef}`);
|
|
1318
|
+
parts.push("## Task source: 执行约束.md", `Bound readPath (use for Pi read-tool calls): ${constraintRef}`, ...(includeConstraintExcerpt ? [constraintExcerpt.text] : []));
|
|
1319
|
+
}
|
|
1320
|
+
for (const reference of (includeReferenceDocuments
|
|
1321
|
+
? sources.referenceDocuments ?? []
|
|
1322
|
+
: []).slice(0, MAX_INLINE_SOURCE_REFERENCE_DOCUMENTS)) {
|
|
1323
|
+
const relativePath = path
|
|
1324
|
+
.relative(path.join(sources.taskDir, "source"), reference.path)
|
|
1325
|
+
.replaceAll(path.sep, "/");
|
|
1326
|
+
const referenceRef = toDagSourcePath(sources, reference.path);
|
|
1327
|
+
const referenceExcerpt = excerptMarkdown(reference.markdown, {
|
|
1328
|
+
sourceRef: referenceRef,
|
|
1329
|
+
});
|
|
1330
|
+
boundReadPaths.push(`- reference ${relativePath}: ${referenceRef}`);
|
|
1331
|
+
parts.push(`## Task source reference: ${relativePath}`, `Bound readPath (use for Pi read-tool calls): ${referenceRef}`, referenceExcerpt.text);
|
|
1332
|
+
}
|
|
1333
|
+
parts.push("## Bound source read paths", ...boundReadPaths, "Use these repository-readable paths for any Pi read-tool calls. Bound files under `.harness/tasks/<taskId>/source/**` are read-only inputs: reading them is allowed even though writing `.harness/**` is forbidden.", "Never resolve task-relative citations such as `source/需求.md` or `source/references/*` against the repository root, invent `source/<taskId>/...`, search for substitutes, or fall back to `docs/**` when a bound read fails.", "## Task config summary", `- taskId: ${sources.taskConfig.taskId}`, `- flow: ${sources.taskConfig.flow}`, `- complexity: ${sources.taskConfig.complexity}`, `- contextProfile: ${sources.taskConfig.contextProfile}`, `- allowedPaths: ${sources.taskConfig.allowedPaths.join(", ") || "(none — review before execute)"}`, `- forbiddenPaths: ${sources.taskConfig.forbiddenPaths.join(", ") || "(none)"}`, '- Pi DAG nodes are read-only unless toolProfile="write" is explicitly selected for a bounded writer node.', "- Agent DAG read-only nodes must not write root artifacts/**; root artifacts/ is not a per-node scratchpad.", `- Derived execution contract and immutable references live under the Bound source read paths above (not as repo-root \`source/...\`).`);
|
|
1334
|
+
if (sources.taskConfig.hardConstraints.length > 0) {
|
|
1335
|
+
parts.push("- hardConstraints:", ...sources.taskConfig.hardConstraints.map((c) => ` - ${c}`));
|
|
1336
|
+
}
|
|
1337
|
+
return parts.join("\n\n");
|
|
1338
|
+
}
|
|
1339
|
+
export async function loadMaterializedSourceReferences(sourceDir) {
|
|
1340
|
+
const referenceDir = path.join(sourceDir, REFERENCE_DIRECTORY);
|
|
1341
|
+
const referencePaths = [];
|
|
1342
|
+
async function collect(dir) {
|
|
1343
|
+
let entries;
|
|
1344
|
+
try {
|
|
1345
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1346
|
+
}
|
|
1347
|
+
catch (error) {
|
|
1348
|
+
if (error.code === "ENOENT")
|
|
1349
|
+
return;
|
|
1350
|
+
throw error;
|
|
1351
|
+
}
|
|
1352
|
+
for (const entry of entries) {
|
|
1353
|
+
const entryPath = path.join(dir, entry.name);
|
|
1354
|
+
if (entry.isDirectory()) {
|
|
1355
|
+
await collect(entryPath);
|
|
1356
|
+
}
|
|
1357
|
+
else if (entry.isFile()) {
|
|
1358
|
+
// Skip index/manifest sidecars; keep only user/source reference content.
|
|
1359
|
+
if (entry.name === "index.json" ||
|
|
1360
|
+
entry.name === "source-manifest.json") {
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
referencePaths.push(entryPath);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
await collect(referenceDir);
|
|
1368
|
+
referencePaths.sort((left, right) => left.localeCompare(right));
|
|
1369
|
+
return Promise.all(referencePaths.map(async (filePath) => ({
|
|
1370
|
+
path: filePath,
|
|
1371
|
+
markdown: await readFile(filePath, "utf-8"),
|
|
1372
|
+
})));
|
|
1373
|
+
}
|
|
1374
|
+
export async function loadTaskHybridSources(repoRoot, taskId) {
|
|
1375
|
+
const paths = getTaskPaths(repoRoot, taskId);
|
|
1376
|
+
const requirementPath = path.join(paths.sourceDir, REQUIREMENT_FILE);
|
|
1377
|
+
const constraintPath = path.join(paths.sourceDir, CONSTRAINT_FILE);
|
|
1378
|
+
try {
|
|
1379
|
+
await access(paths.taskConfigPath);
|
|
1380
|
+
}
|
|
1381
|
+
catch {
|
|
1382
|
+
throw new Error(`task.json not found for task "${taskId}": expected ${paths.taskConfigPath}`);
|
|
1383
|
+
}
|
|
1384
|
+
const taskConfig = await loadTaskConfig(repoRoot, taskId);
|
|
1385
|
+
// Baseline boundaries are independently knowable from task.json and must
|
|
1386
|
+
// not be masked by source or adapter verification loading failures.
|
|
1387
|
+
assertTaskAllowedPathsPreflight(taskConfig);
|
|
1388
|
+
let requirementMarkdown;
|
|
1389
|
+
try {
|
|
1390
|
+
requirementMarkdown = await readFile(requirementPath, "utf-8");
|
|
1391
|
+
}
|
|
1392
|
+
catch {
|
|
1393
|
+
throw new Error(`required source file missing for task "${taskId}": ${requirementPath}`);
|
|
1394
|
+
}
|
|
1395
|
+
let constraintMarkdown = null;
|
|
1396
|
+
try {
|
|
1397
|
+
constraintMarkdown = await readFile(constraintPath, "utf-8");
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
// optional
|
|
1401
|
+
}
|
|
1402
|
+
await materializeTaskReferenceDocs({
|
|
1403
|
+
repoRoot,
|
|
1404
|
+
taskId,
|
|
1405
|
+
taskConfig,
|
|
1406
|
+
});
|
|
1407
|
+
const referenceDocuments = await loadMaterializedSourceReferences(paths.sourceDir);
|
|
1408
|
+
const manifest = await loadHarnessManifest(repoRoot);
|
|
1409
|
+
const strategy = resolveDagVerifyStrategy(taskConfig);
|
|
1410
|
+
let verifyCommands;
|
|
1411
|
+
let verificationPreflight = [];
|
|
1412
|
+
try {
|
|
1413
|
+
const adapter = await resolveAdapter(repoRoot);
|
|
1414
|
+
const preset = resolveVerifyPreset(taskConfig.verifyPreset, taskConfig);
|
|
1415
|
+
const adapterIntermediate = adapter.getVerifyCommands(repoRoot, {
|
|
1416
|
+
preset,
|
|
1417
|
+
quota: strategy.intermediateQuota,
|
|
1418
|
+
phase: "intermediate",
|
|
1419
|
+
taskConfig,
|
|
1420
|
+
});
|
|
1421
|
+
const adapterFinal = adapter.getVerifyCommands(repoRoot, {
|
|
1422
|
+
preset,
|
|
1423
|
+
quota: "full",
|
|
1424
|
+
phase: "final",
|
|
1425
|
+
taskConfig,
|
|
1426
|
+
});
|
|
1427
|
+
const intermediatePlan = canonicalizeVerificationCommands([
|
|
1428
|
+
...taskVerifyCommands(repoRoot, taskConfig),
|
|
1429
|
+
...adapterIntermediate,
|
|
1430
|
+
]);
|
|
1431
|
+
const finalPlan = canonicalizeVerificationCommands([
|
|
1432
|
+
...taskVerifyCommands(repoRoot, taskConfig),
|
|
1433
|
+
...adapterFinal,
|
|
1434
|
+
]);
|
|
1435
|
+
verificationPreflight = assertVerificationPlanPreflight({
|
|
1436
|
+
repoRoot,
|
|
1437
|
+
commands: [...intermediatePlan.commands, ...finalPlan.commands],
|
|
1438
|
+
taskConfig,
|
|
1439
|
+
});
|
|
1440
|
+
verifyCommands = {
|
|
1441
|
+
intermediate: [
|
|
1442
|
+
...taskVerifyCommands(repoRoot, taskConfig),
|
|
1443
|
+
...adapterIntermediate,
|
|
1444
|
+
],
|
|
1445
|
+
final: [...taskVerifyCommands(repoRoot, taskConfig), ...adapterFinal],
|
|
1446
|
+
};
|
|
1447
|
+
if (verifyCommands.final.length === 0) {
|
|
1448
|
+
throw new Error("adapter returned no final verification commands");
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
catch (error) {
|
|
1452
|
+
throw new Error(`failed to load verification commands for task "${taskId}": ${error instanceof Error ? error.message : String(error)}`);
|
|
1453
|
+
}
|
|
1454
|
+
const frontendShapeTransitionCapsule = taskConfig.taskKind === "frontend-implementation" && CANONICAL_TASK_ID_PATTERN.test(taskId)
|
|
1455
|
+
? await discoverLatestCommittedFrontendShapeCapsule({
|
|
1456
|
+
repoRoot,
|
|
1457
|
+
taskId,
|
|
1458
|
+
sourceDigest: computeFrontendShapeSourceDigest({
|
|
1459
|
+
requirementMarkdown,
|
|
1460
|
+
constraintMarkdown: constraintMarkdown ?? "",
|
|
1461
|
+
}),
|
|
1462
|
+
})
|
|
1463
|
+
: undefined;
|
|
1464
|
+
const sources = {
|
|
1465
|
+
taskId,
|
|
1466
|
+
repoRoot,
|
|
1467
|
+
taskDir: paths.taskDir,
|
|
1468
|
+
requirementPath,
|
|
1469
|
+
constraintPath,
|
|
1470
|
+
requirementMarkdown,
|
|
1471
|
+
constraintMarkdown,
|
|
1472
|
+
outputLanguage: manifest.workflowPolicy.dag.outputLanguage,
|
|
1473
|
+
referenceDocuments,
|
|
1474
|
+
taskConfig,
|
|
1475
|
+
enabledExecutors: resolveEnabledExecutors(manifest.executors),
|
|
1476
|
+
executorModelMatrix: resolveExecutorModelMatrices(manifest),
|
|
1477
|
+
verifyCommands,
|
|
1478
|
+
verificationPreflight,
|
|
1479
|
+
sddEmbeddedSkills: await probeRepoLocalSddSkills(repoRoot),
|
|
1480
|
+
projectGovernancePresent: await discoverProjectGovernancePresence(repoRoot),
|
|
1481
|
+
...(frontendShapeTransitionCapsule ? { autoloadedFrontendShapeTransitionCapsule: frontendShapeTransitionCapsule } : {}),
|
|
1482
|
+
};
|
|
1483
|
+
return sources;
|
|
1484
|
+
}
|
|
1485
|
+
export async function prepareFrontendMockSources(sources, discoveredProjectCapability) {
|
|
1486
|
+
const repoRoot = sources.repoRoot ?? process.cwd();
|
|
1487
|
+
const capability = await discoverFrontendMockCapability(repoRoot, sources.taskConfig);
|
|
1488
|
+
const sourceMockVerifyCommands = extractFrontendMockVerifyCommandsFromMarkdown({
|
|
1489
|
+
repoRoot,
|
|
1490
|
+
requirementMarkdown: sources.requirementMarkdown,
|
|
1491
|
+
constraintMarkdown: sources.constraintMarkdown,
|
|
1492
|
+
});
|
|
1493
|
+
for (const command of sourceMockVerifyCommands) {
|
|
1494
|
+
if (!capability.verifyCommands.some((existing) => existing.args.join("\0") === command.args.join("\0"))) {
|
|
1495
|
+
capability.verifyCommands.push(command);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
const projectCapability = discoveredProjectCapability ??
|
|
1499
|
+
(await discoverFrontendProjectCapability(repoRoot, {
|
|
1500
|
+
specRoots: sources.taskConfig.frontendOpenspec?.specRoots,
|
|
1501
|
+
}));
|
|
1502
|
+
const frontendRisk = classifyFrontendRisk({
|
|
1503
|
+
title: sources.taskConfig.title,
|
|
1504
|
+
requirementMarkdown: sources.requirementMarkdown,
|
|
1505
|
+
constraintMarkdown: sources.constraintMarkdown ?? undefined,
|
|
1506
|
+
allowedPaths: sources.taskConfig.allowedPaths,
|
|
1507
|
+
forbiddenPaths: sources.taskConfig.forbiddenPaths,
|
|
1508
|
+
complexity: sources.taskConfig.complexity,
|
|
1509
|
+
manifestEvidence: [
|
|
1510
|
+
projectCapability.framework,
|
|
1511
|
+
projectCapability.frameworkVersion ?? "",
|
|
1512
|
+
projectCapability.adapterGuidance,
|
|
1513
|
+
].join("\n"),
|
|
1514
|
+
});
|
|
1515
|
+
return {
|
|
1516
|
+
...sources,
|
|
1517
|
+
frontendMockCapability: capability,
|
|
1518
|
+
frontendMockMode: resolveFrontendMockMode(capability, sources.taskConfig, hasApiDependency(sources)),
|
|
1519
|
+
frontendProjectCapability: projectCapability,
|
|
1520
|
+
frontendRisk,
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
export function mergeForbiddenPaths(taskConfig) {
|
|
1524
|
+
const merged = new Set([
|
|
1525
|
+
".harness/**",
|
|
1526
|
+
".harness/dag-runs/**",
|
|
1527
|
+
"artifacts/**",
|
|
1528
|
+
...taskConfig.forbiddenPaths,
|
|
1529
|
+
]);
|
|
1530
|
+
return [...merged];
|
|
1531
|
+
}
|
|
1532
|
+
export function patternStaticPrefix(pattern) {
|
|
1533
|
+
const normalized = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1534
|
+
const wildcard = normalized.search(/[?*]/);
|
|
1535
|
+
return (wildcard >= 0 ? normalized.slice(0, wildcard) : normalized).replace(/\/+$/, "");
|
|
1536
|
+
}
|
|
1537
|
+
export function allowedPathHitsBaselineForbidden(allowedPath) {
|
|
1538
|
+
const probes = probePathsForWriteSetEntry(allowedPath);
|
|
1539
|
+
for (const forbidden of BASELINE_FORBIDDEN_PATHS) {
|
|
1540
|
+
if (probes.some((probe) => pathMatchesPattern(probe, forbidden)) ||
|
|
1541
|
+
pathMatchesPattern(allowedPath, forbidden) ||
|
|
1542
|
+
pathMatchesPattern(forbidden, allowedPath)) {
|
|
1543
|
+
return forbidden;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return undefined;
|
|
1547
|
+
}
|
|
1548
|
+
export function assertTaskAllowedPathsPreflight(taskConfig) {
|
|
1549
|
+
for (const allowedPath of taskConfig.allowedPaths) {
|
|
1550
|
+
const forbidden = allowedPathHitsBaselineForbidden(allowedPath);
|
|
1551
|
+
if (forbidden) {
|
|
1552
|
+
throw new Error(`task allowedPaths entry "${allowedPath}" overlaps baseline forbidden path "${forbidden}"; task source paths are main-session owned and must not be executor writeSet`);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
export function buildDagSourceBinding(sources, taskKind) {
|
|
1557
|
+
const base = buildDagSourceBindingBase(sources);
|
|
1558
|
+
if (taskKind !== "frontend-implementation")
|
|
1559
|
+
return base;
|
|
1560
|
+
const ledgerBinding = loadPersistedFrontendSourceBindingLedger(sources);
|
|
1561
|
+
if (!ledgerBinding)
|
|
1562
|
+
return base;
|
|
1563
|
+
// v2 is ledger-owned: the persisted canonical set is the only requirement
|
|
1564
|
+
// identity source. In particular, explanatory AC-XXX mentions from a
|
|
1565
|
+
// materialized reference document must not leak in beside ledger ids.
|
|
1566
|
+
return {
|
|
1567
|
+
schemaVersion: 2,
|
|
1568
|
+
taskId: base.taskId,
|
|
1569
|
+
sources: base.sources,
|
|
1570
|
+
requirementIds: ledgerBinding.requirementIds,
|
|
1571
|
+
ledgerPath: ledgerBinding.ledgerPath,
|
|
1572
|
+
ledgerSha256: ledgerBinding.ledgerSha256,
|
|
1573
|
+
inputDigest: ledgerBinding.inputDigest,
|
|
1574
|
+
requirementToFragments: ledgerBinding.requirementToFragments,
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
export function buildVerifyShellCommands(input) {
|
|
1578
|
+
if (!input.repoRoot || !input.commands || input.commands.length === 0) {
|
|
1579
|
+
return input.fallbackCommands;
|
|
1580
|
+
}
|
|
1581
|
+
const repoRoot = input.repoRoot;
|
|
1582
|
+
return input.commands.map((command) => verifyCommandToShell(repoRoot, command));
|
|
1583
|
+
}
|
|
1584
|
+
export function classifyFrontendVerifyCommand(command, label) {
|
|
1585
|
+
const mode = classifyFrontendVerifyCommandText(command);
|
|
1586
|
+
if (!mode) {
|
|
1587
|
+
throw new Error(`verifyCommands[${label}]: unrecognized verification command ${JSON.stringify(command)}; declare its lane explicitly with mode: "static" or mode: "behavior", or use a recognized test runner such as \`npx vitest run\``);
|
|
1588
|
+
}
|
|
1589
|
+
return mode;
|
|
1590
|
+
}
|
|
1591
|
+
export const CONSTRAINT_FILE = "执行约束.md";
|
|
1592
|
+
export function extractSectionRequirementIds(sectionMarkdown) {
|
|
1593
|
+
const ids = [];
|
|
1594
|
+
const seen = new Set();
|
|
1595
|
+
for (const match of sectionMarkdown.matchAll(/\b(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi)) {
|
|
1596
|
+
const id = match[0].toUpperCase();
|
|
1597
|
+
if (isPlaceholderRequirementId(id))
|
|
1598
|
+
continue;
|
|
1599
|
+
if (!seen.has(id)) {
|
|
1600
|
+
seen.add(id);
|
|
1601
|
+
ids.push(id);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
return ids;
|
|
1605
|
+
}
|
|
1606
|
+
export function findRootVitestConfig(root) {
|
|
1607
|
+
for (const name of ["vitest.config.ts", "vitest.config.js", "vitest.config.mjs", "vitest.config.cjs"]) {
|
|
1608
|
+
const candidate = path.join(root, name);
|
|
1609
|
+
if (existsSync(candidate))
|
|
1610
|
+
return candidate;
|
|
1611
|
+
}
|
|
1612
|
+
return undefined;
|
|
1613
|
+
}
|
|
1614
|
+
export { INTERACTIVE_UI_DELIVERY_CONTRACT } from "./types.js";
|
|
1615
|
+
export function isCheckRepoCoveredCommand(command) {
|
|
1616
|
+
const target = localScriptTarget(command);
|
|
1617
|
+
return target ? CHECK_REPO_COVERED_SCRIPTS.has(target) : false;
|
|
1618
|
+
}
|
|
1619
|
+
export function isCheckRepoVerifyCommand(command) {
|
|
1620
|
+
return normalizeVerifyCommandArgs(command.args)
|
|
1621
|
+
.join(" ")
|
|
1622
|
+
.replaceAll("\\", "/")
|
|
1623
|
+
.includes("scripts/check-repo.sh");
|
|
1624
|
+
}
|
|
1625
|
+
export function isFrontendLintVerifyCommand(command) {
|
|
1626
|
+
const text = `${command.label}\n${command.args.join(" ")}`;
|
|
1627
|
+
return /\b(?:lint|eslint)\b/i.test(text);
|
|
1628
|
+
}
|
|
1629
|
+
export function isHeadingLine(line) {
|
|
1630
|
+
return /^#{1,6}\s+/.test(line.trim());
|
|
1631
|
+
}
|
|
1632
|
+
export function isMetadataLine(line) {
|
|
1633
|
+
const trimmed = line.trim();
|
|
1634
|
+
if (!trimmed)
|
|
1635
|
+
return true;
|
|
1636
|
+
if (/^(TaskSpec business id|Feature id|Task type|Risk level)\s*:/i.test(trimmed)) {
|
|
1637
|
+
return true;
|
|
1638
|
+
}
|
|
1639
|
+
if (/^(权威来源|SHA-256|冲突时以)/.test(trimmed))
|
|
1640
|
+
return true;
|
|
1641
|
+
if (/^>/.test(trimmed) &&
|
|
1642
|
+
/(权威来源|SHA-256|原始 PRD|reference)/i.test(trimmed)) {
|
|
1643
|
+
return true;
|
|
1644
|
+
}
|
|
1645
|
+
return false;
|
|
1646
|
+
}
|
|
1647
|
+
export function isPlaceholderRequirementId(id) {
|
|
1648
|
+
return /(?:^|-)X{2,}$/.test(id);
|
|
1649
|
+
}
|
|
1650
|
+
export function isRequirementIdDeclarationLine(line) {
|
|
1651
|
+
return REQUIREMENT_ID_DECLARATION_LINE.test(line);
|
|
1652
|
+
}
|
|
1653
|
+
export function isSupportedMarkdownVerifyCommand(command) {
|
|
1654
|
+
return (/^(npm|pnpm|yarn|bun)\s+(run\s+)?[a-z0-9:_-]+(?:\s.*)?$/i.test(command) ||
|
|
1655
|
+
/^(npx|pnpm\s+exec|yarn\s+exec|bunx)\s+(vitest|jest|playwright|cypress|tsc|eslint)(?:\s.*)?$/i.test(command));
|
|
1656
|
+
}
|
|
1657
|
+
export function isWithinRepo(root, candidate) {
|
|
1658
|
+
const relative = path.relative(root, candidate);
|
|
1659
|
+
return (relative === "" ||
|
|
1660
|
+
(!relative.startsWith("..") && !path.isAbsolute(relative)));
|
|
1661
|
+
}
|
|
1662
|
+
export function localScriptTarget(command) {
|
|
1663
|
+
const args = normalizeVerifyCommandArgs(command.args);
|
|
1664
|
+
const candidate = args.find((arg) => arg.replaceAll("\\", "/").startsWith("scripts/"));
|
|
1665
|
+
return candidate?.replaceAll("\\", "/");
|
|
1666
|
+
}
|
|
1667
|
+
export function markdownVerifyCommand(repoRoot, command) {
|
|
1668
|
+
const args = command.split(/\s+/).filter(Boolean);
|
|
1669
|
+
if (args.length === 0 ||
|
|
1670
|
+
args.some((arg) => !/^[a-z0-9@._/:=,+-]+$/i.test(arg))) {
|
|
1671
|
+
return null;
|
|
1672
|
+
}
|
|
1673
|
+
return {
|
|
1674
|
+
args,
|
|
1675
|
+
cwd: repoRoot,
|
|
1676
|
+
label: command,
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
export const MAX_INLINE_SOURCE_REFERENCE_DOCUMENTS = 8;
|
|
1680
|
+
export const MAX_SOURCE_EXCERPT_CHARS = 2000;
|
|
1681
|
+
export const REFERENCE_DIRECTORY = "references";
|
|
1682
|
+
export const REQUIREMENT_FILE = "需求.md";
|
|
1683
|
+
export function resolveDagVerifyStrategy(taskConfig, defaultIntermediateQuotaWhenFull = "full") {
|
|
1684
|
+
const explicitIntermediateQuota = taskConfig.dagVerifyStrategy?.intermediateQuota;
|
|
1685
|
+
return {
|
|
1686
|
+
intermediateQuota: explicitIntermediateQuota ??
|
|
1687
|
+
(taskConfig.verifyQuota === "full"
|
|
1688
|
+
? defaultIntermediateQuotaWhenFull
|
|
1689
|
+
: taskConfig.verifyQuota),
|
|
1690
|
+
finalQuota: "full",
|
|
1691
|
+
focusedCommandSource: taskConfig.dagVerifyStrategy?.focusedCommandSource ?? "adapter",
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
export const SUCCESS_CRITERIA_SECTION = /##\s*(验收标准|完成标准|Acceptance(?:\s+References)?|Success Criteria)\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i;
|
|
1695
|
+
export function taskVerifyCommands(repoRoot, taskConfig) {
|
|
1696
|
+
if (!repoRoot)
|
|
1697
|
+
return [];
|
|
1698
|
+
return taskConfig.verifyCommands.map((command) => ({
|
|
1699
|
+
args: ["bash", "-lc", command.command],
|
|
1700
|
+
cwd: repoRoot,
|
|
1701
|
+
label: command.label,
|
|
1702
|
+
timeoutMs: command.timeoutMs,
|
|
1703
|
+
}));
|
|
1704
|
+
}
|
|
1705
|
+
export function toDagMavenVerificationPlan(plan, shellCommands) {
|
|
1706
|
+
const shellCommandDigests = shellCommands?.map((command) => createHash("sha256").update(command).digest("hex")) ?? plan.shellCommandDigests;
|
|
1707
|
+
return {
|
|
1708
|
+
schemaVersion: plan.schemaVersion,
|
|
1709
|
+
plannerVersion: plan.plannerVersion,
|
|
1710
|
+
fingerprint: plan.fingerprint,
|
|
1711
|
+
inputDigest: plan.inputDigest,
|
|
1712
|
+
targets: plan.targets,
|
|
1713
|
+
commandDigests: plan.commandDigests,
|
|
1714
|
+
plannedCommands: plan.plannedCommands,
|
|
1715
|
+
...(plan.workspaceManifest
|
|
1716
|
+
? { workspaceManifest: plan.workspaceManifest }
|
|
1717
|
+
: {}),
|
|
1718
|
+
...(shellCommandDigests && shellCommandDigests.length > 0
|
|
1719
|
+
? { shellCommandDigests }
|
|
1720
|
+
: {}),
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
export function toDagSourcePath(sources, absolutePath) {
|
|
1724
|
+
return serializeDagTaskSourcePath({
|
|
1725
|
+
repoRoot: sources.repoRoot,
|
|
1726
|
+
taskDir: sources.taskDir,
|
|
1727
|
+
absolutePath,
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
export function verifyCommandKey(command) {
|
|
1731
|
+
const normalizedArgs = normalizeVerifyCommandArgs(command.args);
|
|
1732
|
+
const normalizedCwd = path.resolve(command.cwd).replace(/\\/g, "/");
|
|
1733
|
+
const cwdKey = process.platform === "win32" ? normalizedCwd.toLowerCase() : normalizedCwd;
|
|
1734
|
+
const envKey = Object.entries(command.env ?? {})
|
|
1735
|
+
.filter((entry) => entry[1] !== undefined)
|
|
1736
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
1737
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
1738
|
+
.join("\0");
|
|
1739
|
+
return [cwdKey, normalizedArgs.join("\0"), envKey].join("\u0001");
|
|
1740
|
+
}
|
|
1741
|
+
export function vitestConfigExcludesDogfood(configPath, targets) {
|
|
1742
|
+
if (!targets.some((target) => target.replace(/\\/g, "/").startsWith("dogfood/")))
|
|
1743
|
+
return false;
|
|
1744
|
+
try {
|
|
1745
|
+
const text = readFileSync(configPath, "utf8");
|
|
1746
|
+
return /exclude\s*:[\s\S]{0,500}dogfood\/\*\*/.test(text);
|
|
1747
|
+
}
|
|
1748
|
+
catch {
|
|
1749
|
+
return false;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
export function vitestTargetPaths(args) {
|
|
1753
|
+
const targets = [];
|
|
1754
|
+
let afterRun = false;
|
|
1755
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1756
|
+
const arg = args[index];
|
|
1757
|
+
if (arg === "run" || arg === "related") {
|
|
1758
|
+
afterRun = true;
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1761
|
+
if (!afterRun || arg.startsWith("-")) {
|
|
1762
|
+
if ((arg === "--config" || arg === "-c" || arg === "--reporter") && !arg.includes("="))
|
|
1763
|
+
index += 1;
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
targets.push(arg);
|
|
1767
|
+
}
|
|
1768
|
+
return targets.filter((target) => target.includes("/") || /\.(?:test|spec)\.[^/]+$/.test(target));
|
|
1769
|
+
}
|
|
1770
|
+
export function normalizeVerifyCommandArgs(args) {
|
|
1771
|
+
const [shell, flag, payload] = args;
|
|
1772
|
+
if (args.length === 3 &&
|
|
1773
|
+
shell !== undefined &&
|
|
1774
|
+
payload !== undefined &&
|
|
1775
|
+
/^(?:bash|sh)(?:\.exe)?$/i.test(path.basename(shell)) &&
|
|
1776
|
+
flag === "-lc") {
|
|
1777
|
+
const parsed = tokenizeSimpleShellCommand(payload);
|
|
1778
|
+
if (parsed)
|
|
1779
|
+
return normalizeVerifyCommandArgs(parsed);
|
|
1780
|
+
}
|
|
1781
|
+
return args.map((arg, index) => {
|
|
1782
|
+
const normalized = arg.replace(/\\/g, "/");
|
|
1783
|
+
return index === 0 && process.platform === "win32"
|
|
1784
|
+
? normalized.toLowerCase()
|
|
1785
|
+
: normalized;
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
export function verifyCommandToShell(repoRoot, command) {
|
|
1789
|
+
const relativeCwd = path.relative(repoRoot, command.cwd);
|
|
1790
|
+
const cwdPrefix = relativeCwd &&
|
|
1791
|
+
!relativeCwd.startsWith("..") &&
|
|
1792
|
+
!path.isAbsolute(relativeCwd)
|
|
1793
|
+
? `cd ${shellQuote(relativeCwd)} && `
|
|
1794
|
+
: command.cwd !== repoRoot
|
|
1795
|
+
? `cd ${shellQuote(command.cwd)} && `
|
|
1796
|
+
: "";
|
|
1797
|
+
const envPrefix = [
|
|
1798
|
+
"HARNESS_ALLOW_ACTIVE_DAG_RUNS=1",
|
|
1799
|
+
...Object.entries(command.env ?? {}).map(([key, value]) => `${key}=${shellQuote(String(value))}`),
|
|
1800
|
+
].join(" ");
|
|
1801
|
+
const commandText = command.args.map(shellQuote).join(" ");
|
|
1802
|
+
return `${cwdPrefix}${envPrefix ? `${envPrefix} ` : ""}${commandText}`;
|
|
1803
|
+
}
|
|
1804
|
+
export function shellQuote(value) {
|
|
1805
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1806
|
+
}
|
|
1807
|
+
export const CHECK_REPO_COVERED_SCRIPTS = new Set([
|
|
1808
|
+
"scripts/check-doc-links.sh",
|
|
1809
|
+
"scripts/check-skill-entry.sh",
|
|
1810
|
+
"scripts/check-init-surface.sh",
|
|
1811
|
+
]);
|
|
1812
|
+
export const REQUIREMENT_ID_DECLARATION_LINE = /^\s*(?:[-+*]\s+|\d+[.、))]\s*|\|\s*)?(?:[-A-Z0-9]+\s*[::]\s*)?(?:\*\*)?(?:REQ|BR|AC)-/i;
|