agent-project-sdlc 0.1.21 → 0.1.23
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/README.md +12 -3
- package/assets/docs/README.md +13 -4
- package/assets/policies/allowed_paths.yaml +1 -0
- package/assets/policies/phase_contracts.yaml +131 -12
- package/assets/skills/pjsdlc_architect_design/SKILL.md +3 -0
- package/assets/skills/pjsdlc_dev_sprint/SKILL.md +10 -5
- package/assets/skills/pjsdlc_implementation_doc/SKILL.md +7 -3
- package/assets/skills/pjsdlc_reviewer/SKILL.md +2 -2
- package/assets/skills/pjsdlc_rfc_recalibrate/SKILL.md +3 -3
- package/assets/skills/pjsdlc_tester/SKILL.md +4 -3
- package/assets/templates/EVIDENCE_INDEX_TEMPLATE.md +18 -0
- package/assets/templates/EXPLORATION_APPENDIX_TEMPLATE.md +24 -0
- package/assets/templates/IMPLEMENTATION_DOC_TEMPLATE.md +63 -10
- package/assets/templates/PLAN_TEMPLATE.yaml +48 -1
- package/assets/templates/RUNBOOK_TEMPLATE.md +52 -0
- package/assets/tools/harness_utils.py +470 -17
- package/assets/tools/transition.py +24 -31
- package/assets/tools/validate_design.py +5 -0
- package/assets/tools/validate_harness.py +15 -1
- package/assets/tools/validate_prompt_language.py +1 -1
- package/assets/tools/validate_rfc.py +5 -0
- package/dist/lib/init.js +2 -1
- package/dist/lib/validators.js +811 -21
- package/package.json +1 -1
package/dist/lib/validators.js
CHANGED
|
@@ -9,6 +9,8 @@ const PARALLEL_MODES = new Set(["runtime_managed", "user_orchestrated"]);
|
|
|
9
9
|
const PARALLEL_TRIGGERS = new Set(["user_requested", "workflow_default"]);
|
|
10
10
|
const PARALLEL_RUNTIME_PROVIDERS = new Set(["codex_native_subagents", "user_orchestrated", "codex_exec_worktree"]);
|
|
11
11
|
const TASK_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
12
|
+
const RESERVED_SUSPENDED_PHASE_TARGET = "<suspended_phase>";
|
|
13
|
+
const TRANSITION_KINDS = new Set(["normal", "return", "interrupt", "resume"]);
|
|
12
14
|
const PARALLEL_ALLOWED_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
13
15
|
const PARALLEL_READ_ONLY_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "REVIEWING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
14
16
|
const PARALLEL_PROTECTED_WRITE_PATTERNS = [
|
|
@@ -89,6 +91,7 @@ const TEST_REPORT_PLACEHOLDER_TERMS = ["pending", "tbd", "todo", "待填", "待
|
|
|
89
91
|
const TEST_FACT_SOURCE_PHASES = new Set(["TESTING", "RFC_RECALIBRATION"]);
|
|
90
92
|
const TEST_FACT_SOURCE_PATTERNS = [".docs/07_test/**", ".docs/07_test/"];
|
|
91
93
|
const TEST_FACT_SOURCE_REF = /\.docs\/07_test\/[^\s`,)]+/g;
|
|
94
|
+
const RUNBOOK_DOC_PREFIX = ".docs/09_runbooks/";
|
|
92
95
|
const RUNNABLE_ENTRY_EXIT_TERMS = [
|
|
93
96
|
"runnable entry/exit",
|
|
94
97
|
"entry/exit",
|
|
@@ -103,6 +106,9 @@ const DEVELOPMENT_SELF_TEST_CONTRACT_TERMS = ["development self-test contract",
|
|
|
103
106
|
const DEVELOPMENT_SELF_TEST_REPORT_TERMS = ["development self-test report", "开发自测报告"];
|
|
104
107
|
const DEVELOPMENT_SELF_TEST_IMPACT_TERMS = ["development self-test impact", "开发自测影响"];
|
|
105
108
|
const MODULE_KEY_TEST_PATH_TERMS = ["module key test path", "模块关键测试路径"];
|
|
109
|
+
const MODULE_KEY_TEST_GRAPH_TERMS = ["module key test graph", "module_key_test_graph", "模块关键测试图"];
|
|
110
|
+
const GATE_BREAKDOWN_TERMS = ["gate breakdown", "gate 分层", "gate breakdown(gate 分层)"];
|
|
111
|
+
const CURRENT_OPERATOR_PATH_TERMS = ["current operator path", "operator path", "当前操作路径", "当前 operator path"];
|
|
106
112
|
const TESTING_HANDOFF_TERMS = ["testing handoff contract", "测试交接合同"];
|
|
107
113
|
const EVIDENCE_PLACEHOLDER_TERMS = [
|
|
108
114
|
"pending",
|
|
@@ -122,7 +128,132 @@ const SELF_TEST_REPORT_PLACEHOLDER_TERMS = [
|
|
|
122
128
|
"all self-test scenarios",
|
|
123
129
|
"all task/module promised runnable entries",
|
|
124
130
|
"actual internal key paths",
|
|
125
|
-
"observable completion evidence"
|
|
131
|
+
"observable completion evidence",
|
|
132
|
+
"required only when",
|
|
133
|
+
"compact dag pointer",
|
|
134
|
+
"复杂 / high-risk",
|
|
135
|
+
"只记录实际 handoff"
|
|
136
|
+
];
|
|
137
|
+
const SELF_TEST_REPORT_STATUSES = new Set(["PASS", "BLOCKED", "IN_PROGRESS", "STALE"]);
|
|
138
|
+
const SELF_TEST_REPORT_REQUIRED_FIELDS = [
|
|
139
|
+
"Contract Source",
|
|
140
|
+
"Module Application Entry",
|
|
141
|
+
"Module Key Test Path",
|
|
142
|
+
"Scenario Results",
|
|
143
|
+
"Executed Gates",
|
|
144
|
+
"Observable Exit",
|
|
145
|
+
"Current Blocker",
|
|
146
|
+
"Testing Handoff Readiness",
|
|
147
|
+
"Evidence Index Refs"
|
|
148
|
+
];
|
|
149
|
+
const SELF_TEST_REPORT_NONE_ALLOWED_FIELDS = new Set(["Current Blocker"]);
|
|
150
|
+
const MAX_SELF_TEST_REPORT_LINES = 80;
|
|
151
|
+
const MAX_HIGH_RISK_SELF_TEST_REPORT_LINES = 120;
|
|
152
|
+
const SELF_TEST_REPORT_DISALLOWED_SECTION_TERMS = [
|
|
153
|
+
"debug log",
|
|
154
|
+
"operator log",
|
|
155
|
+
"operation log",
|
|
156
|
+
"runbook",
|
|
157
|
+
"exploration",
|
|
158
|
+
"diagnostic attempts",
|
|
159
|
+
"fallback attempts",
|
|
160
|
+
"history log",
|
|
161
|
+
"remote operation log",
|
|
162
|
+
"调试日志",
|
|
163
|
+
"操作日志",
|
|
164
|
+
"远端操作日志",
|
|
165
|
+
"探索流水",
|
|
166
|
+
"失败探索",
|
|
167
|
+
"诊断尝试",
|
|
168
|
+
"历史流水"
|
|
169
|
+
];
|
|
170
|
+
const SELF_TEST_REPORT_DISALLOWED_FIELD_TERMS = [
|
|
171
|
+
"Actual Evidence"
|
|
172
|
+
];
|
|
173
|
+
const IMPLEMENTATION_MAINLINE_DISALLOWED_SECTION_TERMS = [
|
|
174
|
+
"debug log",
|
|
175
|
+
"operator log",
|
|
176
|
+
"operation log",
|
|
177
|
+
"evidence dump",
|
|
178
|
+
"screenshot index",
|
|
179
|
+
"full screenshot",
|
|
180
|
+
"runbook body",
|
|
181
|
+
"failed attempts",
|
|
182
|
+
"failure exploration",
|
|
183
|
+
"diagnostic attempts",
|
|
184
|
+
"diagnostic path",
|
|
185
|
+
"remote operation log",
|
|
186
|
+
"recovery notes",
|
|
187
|
+
"task changelog",
|
|
188
|
+
"old task",
|
|
189
|
+
"historical task",
|
|
190
|
+
"调试日志",
|
|
191
|
+
"操作日志",
|
|
192
|
+
"证据正文",
|
|
193
|
+
"截图索引",
|
|
194
|
+
"失败探索",
|
|
195
|
+
"诊断尝试",
|
|
196
|
+
"诊断路径",
|
|
197
|
+
"恢复备注",
|
|
198
|
+
"旧 task",
|
|
199
|
+
"历史 task"
|
|
200
|
+
];
|
|
201
|
+
const HARD_CONSTRAINT_TERMS = [
|
|
202
|
+
"hard constraint",
|
|
203
|
+
"hard constraints",
|
|
204
|
+
"strategy constraint",
|
|
205
|
+
"strategy constraints",
|
|
206
|
+
"恢复硬约束",
|
|
207
|
+
"硬约束",
|
|
208
|
+
"策略约束"
|
|
209
|
+
];
|
|
210
|
+
const STRATEGY_CHANGING_DECISION_GROUPS = [
|
|
211
|
+
[
|
|
212
|
+
"session persistence/reset",
|
|
213
|
+
[
|
|
214
|
+
"session persistence",
|
|
215
|
+
"session reset",
|
|
216
|
+
"session_reset",
|
|
217
|
+
"session 异常",
|
|
218
|
+
"rule_assumption_gap",
|
|
219
|
+
"operator_induced_logout_or_session_reset",
|
|
220
|
+
"operator induced logout",
|
|
221
|
+
"logout",
|
|
222
|
+
"logged out",
|
|
223
|
+
"退登",
|
|
224
|
+
"qr",
|
|
225
|
+
"二维码",
|
|
226
|
+
"扫码",
|
|
227
|
+
"重新扫码"
|
|
228
|
+
]
|
|
229
|
+
],
|
|
230
|
+
[
|
|
231
|
+
"canonical path change",
|
|
232
|
+
[
|
|
233
|
+
"canonical path changed",
|
|
234
|
+
"canonical path change",
|
|
235
|
+
"canonical path switched",
|
|
236
|
+
"canonical path reset",
|
|
237
|
+
"主路径变化",
|
|
238
|
+
"主路径切换",
|
|
239
|
+
"恢复路径变化"
|
|
240
|
+
]
|
|
241
|
+
],
|
|
242
|
+
[
|
|
243
|
+
"do-not-retry decision",
|
|
244
|
+
[
|
|
245
|
+
"do not retry",
|
|
246
|
+
"do-not-retry",
|
|
247
|
+
"don't retry",
|
|
248
|
+
"not retry",
|
|
249
|
+
"must not retry",
|
|
250
|
+
"不得重试",
|
|
251
|
+
"不要重试",
|
|
252
|
+
"不要再试",
|
|
253
|
+
"不要重复",
|
|
254
|
+
"不得直接"
|
|
255
|
+
]
|
|
256
|
+
]
|
|
126
257
|
];
|
|
127
258
|
const SELF_TEST_OBSERVABLE_EVIDENCE_TERMS = [
|
|
128
259
|
"pass output",
|
|
@@ -143,6 +274,25 @@ const SELF_TEST_OBSERVABLE_EVIDENCE_TERMS = [
|
|
|
143
274
|
"queue",
|
|
144
275
|
"file"
|
|
145
276
|
];
|
|
277
|
+
const RESUME_CAPSULE_REQUIRED_EVIDENCE_LEVELS = new Set(["external_provider_live", "deployed_runtime", "business_handoff_ready"]);
|
|
278
|
+
const RESUME_CAPSULE_REQUIRED_TARGET_KINDS = new Set(["cloud_vm", "managed_service", "browser", "worker"]);
|
|
279
|
+
const RESUME_CAPSULE_FIELDS = [
|
|
280
|
+
"task_id",
|
|
281
|
+
"state",
|
|
282
|
+
"canonical_path",
|
|
283
|
+
"next_step",
|
|
284
|
+
"blocker",
|
|
285
|
+
"last_passed_gate",
|
|
286
|
+
"do_not_retry",
|
|
287
|
+
"recovery_refs"
|
|
288
|
+
];
|
|
289
|
+
const MAX_WORKING_NOTES = 8;
|
|
290
|
+
const GATE_BREAKDOWN_LAYER_GROUPS = [
|
|
291
|
+
["local gate", ["local", "unit", "lint", "test", "本地"]],
|
|
292
|
+
["cloud/service gate", ["cloud", "service", "runtime", "server", "managed_service", "cloud_vm", "服务", "云端"]],
|
|
293
|
+
["executor/operator readiness", ["executor", "operator", "worker", "browser", "provider", "adapter", "readiness", "执行器", "操控", "就绪"]],
|
|
294
|
+
["live smoke or handoff", ["live", "smoke", "handoff", "external_provider_live", "deployed_runtime", "business_handoff_ready", "冒烟", "交接"]]
|
|
295
|
+
];
|
|
146
296
|
const PAGE_TASK_TERMS = ["frontend", "front-end", "browser", "page", "页面", "前端", "按钮", "表单", "跳转"];
|
|
147
297
|
const PAGE_ENTRY_TERMS = ["http://", "https://", "localhost", "127.0.0.1", "page url", "页面 url", "dev server"];
|
|
148
298
|
const PAGE_BROWSER_CHECK_TERMS = ["browser check", "playwright", "screenshot", "click", "button", "form", "页面可加载", "浏览器验证"];
|
|
@@ -264,6 +414,27 @@ const REVIEW_READINESS_FIELDS = [
|
|
|
264
414
|
"Testing Handoff Readiness"
|
|
265
415
|
];
|
|
266
416
|
const SELF_TEST_CONTRACT_STATUSES = new Set(["required", "not_applicable"]);
|
|
417
|
+
const SELF_TEST_GRAPH_NODE_KINDS = new Set(["entry", "checkpoint", "branch", "scenario", "observable_exit"]);
|
|
418
|
+
const SELF_TEST_GRAPH_ORDINARY_NODE_LIMIT = 12;
|
|
419
|
+
const SELF_TEST_GRAPH_HIGH_RISK_NODE_LIMIT = 25;
|
|
420
|
+
const SELF_TEST_GRAPH_EVIDENCE_BODY_TERMS = [
|
|
421
|
+
"```",
|
|
422
|
+
"|---",
|
|
423
|
+
"actual evidence",
|
|
424
|
+
"command transcript",
|
|
425
|
+
"full command output",
|
|
426
|
+
"stdout",
|
|
427
|
+
"stderr",
|
|
428
|
+
"traceback",
|
|
429
|
+
"debug log",
|
|
430
|
+
"operator log",
|
|
431
|
+
"evidence dump",
|
|
432
|
+
"screenshot process",
|
|
433
|
+
"截图过程",
|
|
434
|
+
"调试日志",
|
|
435
|
+
"操作日志",
|
|
436
|
+
"证据正文"
|
|
437
|
+
];
|
|
267
438
|
const RFC_SELF_TEST_TRIGGER_TERMS = [
|
|
268
439
|
"entry/exit",
|
|
269
440
|
"runnable entry",
|
|
@@ -278,6 +449,11 @@ const RFC_SELF_TEST_TRIGGER_TERMS = [
|
|
|
278
449
|
"handoff",
|
|
279
450
|
"blocker",
|
|
280
451
|
"module key test path",
|
|
452
|
+
"module key test graph",
|
|
453
|
+
"module_key_test_graph",
|
|
454
|
+
"checkpoint",
|
|
455
|
+
"observable exit",
|
|
456
|
+
"evidence refs",
|
|
281
457
|
"test route",
|
|
282
458
|
"test path",
|
|
283
459
|
"debug path",
|
|
@@ -319,6 +495,7 @@ async function validateHarness(projectRoot) {
|
|
|
319
495
|
for (const required of [
|
|
320
496
|
"AGENTS.md",
|
|
321
497
|
".docs/INDEX.md",
|
|
498
|
+
".docs/09_runbooks",
|
|
322
499
|
harnessPath(root, "config.yaml"),
|
|
323
500
|
harnessPath(root, "state", "lifecycle.yaml"),
|
|
324
501
|
harnessPath(root, "state", "plan.yaml"),
|
|
@@ -330,6 +507,9 @@ async function validateHarness(projectRoot) {
|
|
|
330
507
|
errors.push(`missing ${required}`);
|
|
331
508
|
}
|
|
332
509
|
}
|
|
510
|
+
const phaseContractPath = path.join(projectRoot, harnessPath(root, "pjsdlc_managed", "policies", "phase_contracts.yaml"));
|
|
511
|
+
const phaseContracts = await readYamlObject(phaseContractPath);
|
|
512
|
+
errors.push(...validatePhaseTransitionContract(phaseContracts));
|
|
333
513
|
return { info: [`validate-harness checked ${projectRoot} (${root})`], errors };
|
|
334
514
|
}
|
|
335
515
|
async function validateCurrent(projectRoot) {
|
|
@@ -786,6 +966,7 @@ async function validatePlanState(projectRoot, allowOpen) {
|
|
|
786
966
|
if (!Array.isArray(task.acceptance_criteria) || task.acceptance_criteria.length === 0) {
|
|
787
967
|
errors.push(`Open task ${task.id} must define acceptance_criteria`);
|
|
788
968
|
}
|
|
969
|
+
errors.push(...validateWorkingNotesLimit(task));
|
|
789
970
|
errors.push(...validateRuntimeEvidenceContract(task));
|
|
790
971
|
errors.push(...testingBoundaryErrorsForAllowedPaths(task));
|
|
791
972
|
}
|
|
@@ -804,8 +985,90 @@ async function validatePlanState(projectRoot, allowOpen) {
|
|
|
804
985
|
if (currentTaskId && !tasks.some((task) => isRecord(task) && task.id === currentTaskId)) {
|
|
805
986
|
errors.push(`current_task_id does not match a task: ${currentTaskId}`);
|
|
806
987
|
}
|
|
988
|
+
errors.push(...(await validateResumeCapsule(projectRoot, tasksData)));
|
|
807
989
|
return { taskCount: tasks.length, errors, plan: tasksData };
|
|
808
990
|
}
|
|
991
|
+
function validateWorkingNotesLimit(task) {
|
|
992
|
+
if (!("working_notes" in task))
|
|
993
|
+
return [];
|
|
994
|
+
const taskId = String(task.id ?? "Open task");
|
|
995
|
+
const notes = task.working_notes;
|
|
996
|
+
if (typeof notes !== "string" && !Array.isArray(notes)) {
|
|
997
|
+
return [`Open task ${taskId} working_notes must be a short string or list with at most ${MAX_WORKING_NOTES} items`];
|
|
998
|
+
}
|
|
999
|
+
const count = Array.isArray(notes) ? notes.length : notes.trim() ? 1 : 0;
|
|
1000
|
+
if (count > MAX_WORKING_NOTES) {
|
|
1001
|
+
return [`Open task ${taskId} working_notes must stay resume-first and contain at most ${MAX_WORKING_NOTES} items; found ${count}`];
|
|
1002
|
+
}
|
|
1003
|
+
return [];
|
|
1004
|
+
}
|
|
1005
|
+
async function validateResumeCapsule(projectRoot, plan) {
|
|
1006
|
+
const errors = [];
|
|
1007
|
+
const currentTask = currentOpenSprintTask(plan);
|
|
1008
|
+
const capsule = plan.resume_capsule;
|
|
1009
|
+
if (!currentTask) {
|
|
1010
|
+
if (capsule !== undefined) {
|
|
1011
|
+
errors.push("plan.yaml resume_capsule must only be present for the current open SPRINTING task");
|
|
1012
|
+
}
|
|
1013
|
+
return errors;
|
|
1014
|
+
}
|
|
1015
|
+
const taskId = String(currentTask.id ?? "current task");
|
|
1016
|
+
const required = requiresResumeCapsule(currentTask);
|
|
1017
|
+
if (!required && capsule === undefined)
|
|
1018
|
+
return errors;
|
|
1019
|
+
if (!isRecord(capsule)) {
|
|
1020
|
+
errors.push(`${taskId} high-risk runtime task must define top-level resume_capsule`);
|
|
1021
|
+
return errors;
|
|
1022
|
+
}
|
|
1023
|
+
for (const field of RESUME_CAPSULE_FIELDS) {
|
|
1024
|
+
if (!(field in capsule)) {
|
|
1025
|
+
errors.push(`${taskId} resume_capsule missing field: ${field}`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
const capsuleTaskId = String(capsule.task_id ?? "").trim();
|
|
1029
|
+
if (capsuleTaskId !== taskId) {
|
|
1030
|
+
errors.push(`${taskId} resume_capsule.task_id must match current_task_id`);
|
|
1031
|
+
}
|
|
1032
|
+
for (const field of ["state", "canonical_path", "next_step", "blocker", "last_passed_gate"]) {
|
|
1033
|
+
const value = String(capsule[field] ?? "").trim();
|
|
1034
|
+
if (!value || isPlaceholderEvidence(value)) {
|
|
1035
|
+
errors.push(`${taskId} resume_capsule.${field} must contain concrete recovery information`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
const doNotRetry = asStringList(capsule.do_not_retry);
|
|
1039
|
+
if (doNotRetry.length === 0 || doNotRetry.some((item) => isPlaceholderEvidence(item))) {
|
|
1040
|
+
errors.push(`${taskId} resume_capsule.do_not_retry must list concrete paths or attempts not to repeat`);
|
|
1041
|
+
}
|
|
1042
|
+
const refs = asStringList(capsule.recovery_refs);
|
|
1043
|
+
if (refs.length === 0) {
|
|
1044
|
+
errors.push(`${taskId} resume_capsule.recovery_refs must link implementation doc and runbook/evidence documents`);
|
|
1045
|
+
return errors;
|
|
1046
|
+
}
|
|
1047
|
+
const implementationDoc = String(currentTask.implementation_doc ?? "").trim();
|
|
1048
|
+
if (implementationDoc && !refs.includes(implementationDoc)) {
|
|
1049
|
+
errors.push(`${taskId} resume_capsule.recovery_refs must include current implementation_doc ${implementationDoc}`);
|
|
1050
|
+
}
|
|
1051
|
+
if (!refs.some((ref) => ref.startsWith(RUNBOOK_DOC_PREFIX))) {
|
|
1052
|
+
errors.push(`${taskId} resume_capsule.recovery_refs must include a runbook/evidence document under ${RUNBOOK_DOC_PREFIX}`);
|
|
1053
|
+
}
|
|
1054
|
+
for (const ref of refs) {
|
|
1055
|
+
if (!ref.startsWith(".docs/04_implementation/") && !ref.startsWith(RUNBOOK_DOC_PREFIX)) {
|
|
1056
|
+
errors.push(`${taskId} resume_capsule.recovery_refs may only point to implementation docs or runbook/evidence docs: ${ref}`);
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
if (!(await pathExists(path.join(projectRoot, ref)))) {
|
|
1060
|
+
errors.push(`${taskId} resume_capsule recovery_ref does not exist: ${ref}`);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return errors;
|
|
1064
|
+
}
|
|
1065
|
+
function requiresResumeCapsule(task) {
|
|
1066
|
+
if (String(task.phase ?? "") !== "SPRINTING")
|
|
1067
|
+
return false;
|
|
1068
|
+
const evidenceLevel = isRecord(task.evidence_level) ? String(task.evidence_level.required ?? "") : "";
|
|
1069
|
+
const targetKind = isRecord(task.target_runtime_environment) ? String(task.target_runtime_environment.kind ?? "") : "";
|
|
1070
|
+
return RESUME_CAPSULE_REQUIRED_EVIDENCE_LEVELS.has(evidenceLevel) || RESUME_CAPSULE_REQUIRED_TARGET_KINDS.has(targetKind);
|
|
1071
|
+
}
|
|
809
1072
|
function validateRuntimeEvidenceContract(task) {
|
|
810
1073
|
const errors = [];
|
|
811
1074
|
const taskId = String(task.id ?? "Task");
|
|
@@ -877,6 +1140,205 @@ function isNotApplicableRuntimeTask(task) {
|
|
|
877
1140
|
const targetRuntime = isRecord(task.target_runtime_environment) ? task.target_runtime_environment : undefined;
|
|
878
1141
|
return String(evidenceLevel?.required ?? "") === "unit" && String(targetRuntime?.kind ?? "") === "not_applicable";
|
|
879
1142
|
}
|
|
1143
|
+
function selfTestGraphEvidenceRefIsBody(value) {
|
|
1144
|
+
const trimmed = value.trim();
|
|
1145
|
+
const lowered = trimmed.toLowerCase();
|
|
1146
|
+
return trimmed.includes("\n") || trimmed.length > 180 || SELF_TEST_GRAPH_EVIDENCE_BODY_TERMS.some((term) => lowered.includes(term));
|
|
1147
|
+
}
|
|
1148
|
+
function graphHasCycle(nodeIds, adjacency) {
|
|
1149
|
+
const visiting = new Set();
|
|
1150
|
+
const visited = new Set();
|
|
1151
|
+
const visit = (nodeId) => {
|
|
1152
|
+
if (visiting.has(nodeId))
|
|
1153
|
+
return true;
|
|
1154
|
+
if (visited.has(nodeId))
|
|
1155
|
+
return false;
|
|
1156
|
+
visiting.add(nodeId);
|
|
1157
|
+
for (const target of adjacency.get(nodeId) ?? []) {
|
|
1158
|
+
if (visit(target))
|
|
1159
|
+
return true;
|
|
1160
|
+
}
|
|
1161
|
+
visiting.delete(nodeId);
|
|
1162
|
+
visited.add(nodeId);
|
|
1163
|
+
return false;
|
|
1164
|
+
};
|
|
1165
|
+
for (const nodeId of nodeIds) {
|
|
1166
|
+
if (!visited.has(nodeId) && visit(nodeId))
|
|
1167
|
+
return true;
|
|
1168
|
+
}
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
function reachableFrom(entryId, adjacency) {
|
|
1172
|
+
const reached = new Set();
|
|
1173
|
+
const stack = [entryId];
|
|
1174
|
+
while (stack.length > 0) {
|
|
1175
|
+
const nodeId = stack.pop();
|
|
1176
|
+
if (reached.has(nodeId))
|
|
1177
|
+
continue;
|
|
1178
|
+
reached.add(nodeId);
|
|
1179
|
+
stack.push(...(adjacency.get(nodeId) ?? []));
|
|
1180
|
+
}
|
|
1181
|
+
return reached;
|
|
1182
|
+
}
|
|
1183
|
+
function nodesThatCanReachExits(nodeIds, adjacency, exits) {
|
|
1184
|
+
const reverse = new Map();
|
|
1185
|
+
for (const nodeId of nodeIds)
|
|
1186
|
+
reverse.set(nodeId, []);
|
|
1187
|
+
for (const [source, targets] of adjacency.entries()) {
|
|
1188
|
+
for (const target of targets) {
|
|
1189
|
+
const incoming = reverse.get(target) ?? [];
|
|
1190
|
+
incoming.push(source);
|
|
1191
|
+
reverse.set(target, incoming);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
const reached = new Set();
|
|
1195
|
+
const stack = [...exits];
|
|
1196
|
+
while (stack.length > 0) {
|
|
1197
|
+
const nodeId = stack.pop();
|
|
1198
|
+
if (reached.has(nodeId))
|
|
1199
|
+
continue;
|
|
1200
|
+
reached.add(nodeId);
|
|
1201
|
+
stack.push(...(reverse.get(nodeId) ?? []));
|
|
1202
|
+
}
|
|
1203
|
+
return reached;
|
|
1204
|
+
}
|
|
1205
|
+
function validateSelfTestGraph(taskId, contract, scenarioIds, highRiskRuntime) {
|
|
1206
|
+
const errors = [];
|
|
1207
|
+
const graphRequired = contract.graph_required;
|
|
1208
|
+
if (graphRequired !== undefined && typeof graphRequired !== "boolean") {
|
|
1209
|
+
errors.push(`${taskId} self_test_contract.graph_required must be a boolean when set`);
|
|
1210
|
+
}
|
|
1211
|
+
const graph = contract.module_key_test_graph;
|
|
1212
|
+
if (graphRequired === true && !isRecord(graph)) {
|
|
1213
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph is required when graph_required is true`);
|
|
1214
|
+
}
|
|
1215
|
+
if (graph === undefined)
|
|
1216
|
+
return errors;
|
|
1217
|
+
if (!isRecord(graph)) {
|
|
1218
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph must be a mapping`);
|
|
1219
|
+
return errors;
|
|
1220
|
+
}
|
|
1221
|
+
const nodes = graph.nodes;
|
|
1222
|
+
const edges = graph.edges;
|
|
1223
|
+
if (!Array.isArray(nodes) || nodes.length === 0) {
|
|
1224
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph.nodes must be a non-empty list`);
|
|
1225
|
+
return errors;
|
|
1226
|
+
}
|
|
1227
|
+
if (!Array.isArray(edges) || edges.length === 0) {
|
|
1228
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph.edges must be a non-empty list`);
|
|
1229
|
+
return errors;
|
|
1230
|
+
}
|
|
1231
|
+
const nodeLimit = highRiskRuntime ? SELF_TEST_GRAPH_HIGH_RISK_NODE_LIMIT : SELF_TEST_GRAPH_ORDINARY_NODE_LIMIT;
|
|
1232
|
+
if (nodes.length > nodeLimit) {
|
|
1233
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph has ${nodes.length} nodes; keep ordinary graphs <= ${SELF_TEST_GRAPH_ORDINARY_NODE_LIMIT} nodes and high-risk graphs <= ${SELF_TEST_GRAPH_HIGH_RISK_NODE_LIMIT} nodes`);
|
|
1234
|
+
}
|
|
1235
|
+
const nodeIds = new Set();
|
|
1236
|
+
const entryIds = [];
|
|
1237
|
+
const observableExitIds = new Set();
|
|
1238
|
+
const scenarioNodesByRef = new Map();
|
|
1239
|
+
const adjacency = new Map();
|
|
1240
|
+
nodes.forEach((node, index) => {
|
|
1241
|
+
if (!isRecord(node)) {
|
|
1242
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph.nodes[${index}] must be a mapping`);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
const nodeId = String(node.id ?? "").trim();
|
|
1246
|
+
const kind = String(node.kind ?? "").trim();
|
|
1247
|
+
const label = String(node.label ?? "").trim();
|
|
1248
|
+
if (!nodeId) {
|
|
1249
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph.nodes[${index}].id must be set`);
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
if (nodeIds.has(nodeId)) {
|
|
1253
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph node id must be unique: ${nodeId}`);
|
|
1254
|
+
}
|
|
1255
|
+
nodeIds.add(nodeId);
|
|
1256
|
+
adjacency.set(nodeId, adjacency.get(nodeId) ?? []);
|
|
1257
|
+
if (!SELF_TEST_GRAPH_NODE_KINDS.has(kind)) {
|
|
1258
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph.nodes[${nodeId}].kind must be one of ${[...SELF_TEST_GRAPH_NODE_KINDS].join(", ")}`);
|
|
1259
|
+
}
|
|
1260
|
+
if (!label || isPlaceholderEvidence(label)) {
|
|
1261
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph.nodes[${nodeId}].label must be concrete`);
|
|
1262
|
+
}
|
|
1263
|
+
if (kind === "entry")
|
|
1264
|
+
entryIds.push(nodeId);
|
|
1265
|
+
if (kind === "observable_exit")
|
|
1266
|
+
observableExitIds.add(nodeId);
|
|
1267
|
+
if (kind === "scenario") {
|
|
1268
|
+
const scenarioRef = String(node.scenario_ref ?? "").trim();
|
|
1269
|
+
if (!scenarioRef) {
|
|
1270
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph scenario node ${nodeId} must set scenario_ref`);
|
|
1271
|
+
}
|
|
1272
|
+
else if (!scenarioIds.has(scenarioRef)) {
|
|
1273
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph scenario node ${nodeId} references unknown scenario: ${scenarioRef}`);
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
const current = scenarioNodesByRef.get(scenarioRef) ?? [];
|
|
1277
|
+
current.push(nodeId);
|
|
1278
|
+
scenarioNodesByRef.set(scenarioRef, current);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
if (node.evidence_ref !== undefined) {
|
|
1282
|
+
const evidenceRef = String(node.evidence_ref).trim();
|
|
1283
|
+
if (!evidenceRef || isPlaceholderEvidence(evidenceRef) || selfTestGraphEvidenceRefIsBody(evidenceRef)) {
|
|
1284
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph.nodes[${nodeId}].evidence_ref must be a short evidence pointer, not evidence body`);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
if (entryIds.length !== 1) {
|
|
1289
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph must have exactly one entry node`);
|
|
1290
|
+
}
|
|
1291
|
+
if (observableExitIds.size === 0) {
|
|
1292
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph must have at least one observable_exit node`);
|
|
1293
|
+
}
|
|
1294
|
+
edges.forEach((edge, index) => {
|
|
1295
|
+
if (!isRecord(edge)) {
|
|
1296
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph.edges[${index}] must be a mapping`);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const source = String(edge.from ?? "").trim();
|
|
1300
|
+
const target = String(edge.to ?? "").trim();
|
|
1301
|
+
if (!nodeIds.has(source)) {
|
|
1302
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph edge references unknown from node: ${source || "<empty>"}`);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
if (!nodeIds.has(target)) {
|
|
1306
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph edge references unknown to node: ${target || "<empty>"}`);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
const current = adjacency.get(source) ?? [];
|
|
1310
|
+
current.push(target);
|
|
1311
|
+
adjacency.set(source, current);
|
|
1312
|
+
});
|
|
1313
|
+
if (graphHasCycle(nodeIds, adjacency)) {
|
|
1314
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph must be a DAG; cycles are not allowed`);
|
|
1315
|
+
}
|
|
1316
|
+
let reachedFromEntry = new Set();
|
|
1317
|
+
if (entryIds.length === 1) {
|
|
1318
|
+
reachedFromEntry = reachableFrom(entryIds[0], adjacency);
|
|
1319
|
+
const unreachable = [...nodeIds].filter((nodeId) => !reachedFromEntry.has(nodeId)).sort();
|
|
1320
|
+
if (unreachable.length > 0) {
|
|
1321
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph nodes must be reachable from entry: ${unreachable.join(", ")}`);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
const canReachExit = nodesThatCanReachExits(nodeIds, adjacency, observableExitIds);
|
|
1325
|
+
for (const scenarioId of [...scenarioIds].sort()) {
|
|
1326
|
+
const scenarioNodeIds = scenarioNodesByRef.get(scenarioId) ?? [];
|
|
1327
|
+
if (scenarioNodeIds.length === 0) {
|
|
1328
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph must include a scenario node for ${scenarioId}`);
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
for (const nodeId of scenarioNodeIds) {
|
|
1332
|
+
if (reachedFromEntry.size > 0 && !reachedFromEntry.has(nodeId)) {
|
|
1333
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph scenario ${scenarioId} must be reachable from entry`);
|
|
1334
|
+
}
|
|
1335
|
+
if (!canReachExit.has(nodeId)) {
|
|
1336
|
+
errors.push(`${taskId} self_test_contract.module_key_test_graph scenario ${scenarioId} must reach an observable_exit`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
return errors;
|
|
1341
|
+
}
|
|
880
1342
|
function validateSelfTestContract(task, requiredForRunnableBoundary) {
|
|
881
1343
|
const errors = [];
|
|
882
1344
|
const taskId = String(task.id ?? "Task");
|
|
@@ -930,6 +1392,7 @@ function validateSelfTestContract(task, requiredForRunnableBoundary) {
|
|
|
930
1392
|
errors.push(`${taskId} self_test_contract.scenarios must be a non-empty list`);
|
|
931
1393
|
}
|
|
932
1394
|
const seen = new Set();
|
|
1395
|
+
const scenarioIds = new Set();
|
|
933
1396
|
scenarios.forEach((scenario, index) => {
|
|
934
1397
|
if (!isRecord(scenario)) {
|
|
935
1398
|
errors.push(`${taskId} self_test_contract.scenarios[${index}] must be a mapping`);
|
|
@@ -942,6 +1405,9 @@ function validateSelfTestContract(task, requiredForRunnableBoundary) {
|
|
|
942
1405
|
else if (seen.has(scenarioId)) {
|
|
943
1406
|
errors.push(`${taskId} self_test_contract scenario id must be unique: ${scenarioId}`);
|
|
944
1407
|
}
|
|
1408
|
+
else {
|
|
1409
|
+
scenarioIds.add(scenarioId);
|
|
1410
|
+
}
|
|
945
1411
|
seen.add(scenarioId);
|
|
946
1412
|
for (const field of ["entry", "expected_exit", "evidence"]) {
|
|
947
1413
|
if (typeof scenario[field] !== "string" || !String(scenario[field]).trim() || isPlaceholderEvidence(String(scenario[field]))) {
|
|
@@ -949,6 +1415,7 @@ function validateSelfTestContract(task, requiredForRunnableBoundary) {
|
|
|
949
1415
|
}
|
|
950
1416
|
}
|
|
951
1417
|
});
|
|
1418
|
+
errors.push(...validateSelfTestGraph(taskId, contract, scenarioIds, requiresResumeCapsule(task)));
|
|
952
1419
|
return errors;
|
|
953
1420
|
}
|
|
954
1421
|
async function validateSelfTestContractTechPlanBinding(projectRoot, task, normalizedTechRefs) {
|
|
@@ -976,6 +1443,9 @@ async function validateSelfTestContractTechPlanBinding(projectRoot, task, normal
|
|
|
976
1443
|
if (!containsAny(section, MODULE_KEY_TEST_PATH_TERMS)) {
|
|
977
1444
|
errors.push(`${taskId} tech plan Development Self-Test Contract must include Module key test path: ${source}`);
|
|
978
1445
|
}
|
|
1446
|
+
if (contract.graph_required === true && !containsAny(section, MODULE_KEY_TEST_GRAPH_TERMS)) {
|
|
1447
|
+
errors.push(`${taskId} tech plan Development Self-Test Contract must include Module Key Test Graph when graph_required is true: ${source}`);
|
|
1448
|
+
}
|
|
979
1449
|
for (const scenario of Array.isArray(contract.scenarios) ? contract.scenarios.filter(isRecord) : []) {
|
|
980
1450
|
const scenarioId = String(scenario.id ?? "").trim();
|
|
981
1451
|
if (scenarioId && !section.includes(scenarioId)) {
|
|
@@ -1192,6 +1662,95 @@ function matchesGlob(file, pattern) {
|
|
|
1192
1662
|
function isRecord(value) {
|
|
1193
1663
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1194
1664
|
}
|
|
1665
|
+
function validatePhaseTransitionContract(contract) {
|
|
1666
|
+
const errors = [];
|
|
1667
|
+
const phases = contract.phases;
|
|
1668
|
+
if (!isRecord(phases)) {
|
|
1669
|
+
return ["phase_contracts.yaml must contain phases"];
|
|
1670
|
+
}
|
|
1671
|
+
for (const [phaseName, phaseContract] of Object.entries(phases)) {
|
|
1672
|
+
if (!isRecord(phaseContract)) {
|
|
1673
|
+
errors.push(`${phaseName} phase contract must be a mapping`);
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
for (const legacyKey of ["next", "returns"]) {
|
|
1677
|
+
if (legacyKey in phaseContract) {
|
|
1678
|
+
errors.push(`${phaseName} must not define legacy ${legacyKey}; use top-level transitions`);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
const transitions = contract.transitions;
|
|
1683
|
+
if (!Array.isArray(transitions)) {
|
|
1684
|
+
errors.push("phase_contracts.yaml must contain top-level transitions");
|
|
1685
|
+
return errors;
|
|
1686
|
+
}
|
|
1687
|
+
const phaseNames = new Set(Object.keys(phases));
|
|
1688
|
+
const seen = new Set();
|
|
1689
|
+
const outgoing = new Set();
|
|
1690
|
+
transitions.forEach((transition, index) => {
|
|
1691
|
+
const prefix = `transition #${index + 1}`;
|
|
1692
|
+
if (!isRecord(transition)) {
|
|
1693
|
+
errors.push(`${prefix} must be a mapping`);
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
const fromPhase = String(transition.from ?? "");
|
|
1697
|
+
const toPhase = String(transition.to ?? "");
|
|
1698
|
+
const trigger = String(transition.trigger ?? "");
|
|
1699
|
+
const kind = String(transition.kind ?? "");
|
|
1700
|
+
for (const [field, value] of [
|
|
1701
|
+
["from", fromPhase],
|
|
1702
|
+
["to", toPhase],
|
|
1703
|
+
["trigger", trigger],
|
|
1704
|
+
["kind", kind]
|
|
1705
|
+
]) {
|
|
1706
|
+
if (!value.trim())
|
|
1707
|
+
errors.push(`${prefix} missing ${field}`);
|
|
1708
|
+
}
|
|
1709
|
+
if (!fromPhase || !toPhase || !trigger || !kind)
|
|
1710
|
+
return;
|
|
1711
|
+
if (!phaseNames.has(fromPhase)) {
|
|
1712
|
+
errors.push(`${prefix} from references unknown phase: ${fromPhase}`);
|
|
1713
|
+
}
|
|
1714
|
+
if (toPhase === RESERVED_SUSPENDED_PHASE_TARGET) {
|
|
1715
|
+
if (fromPhase !== "BLOCKED" || kind !== "resume") {
|
|
1716
|
+
errors.push(`${prefix} may use ${RESERVED_SUSPENDED_PHASE_TARGET} only for BLOCKED resume`);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
else if (!phaseNames.has(toPhase)) {
|
|
1720
|
+
errors.push(`${prefix} to references unknown phase: ${toPhase}`);
|
|
1721
|
+
}
|
|
1722
|
+
if (!TRANSITION_KINDS.has(kind)) {
|
|
1723
|
+
errors.push(`${prefix} has invalid kind: ${kind}`);
|
|
1724
|
+
}
|
|
1725
|
+
const duplicateKey = `${fromPhase}\0${toPhase}\0${trigger}`;
|
|
1726
|
+
if (seen.has(duplicateKey)) {
|
|
1727
|
+
errors.push(`${prefix} duplicates transition ${fromPhase} -> ${toPhase} (${trigger})`);
|
|
1728
|
+
}
|
|
1729
|
+
seen.add(duplicateKey);
|
|
1730
|
+
outgoing.add(fromPhase);
|
|
1731
|
+
if ("effects" in transition && transition.effects !== undefined) {
|
|
1732
|
+
if (!isRecord(transition.effects)) {
|
|
1733
|
+
errors.push(`${prefix} effects must be a mapping`);
|
|
1734
|
+
}
|
|
1735
|
+
else {
|
|
1736
|
+
for (const [effectName, effectValue] of Object.entries(transition.effects)) {
|
|
1737
|
+
if (!["set_suspended_phase", "clear_suspended_phase"].includes(effectName)) {
|
|
1738
|
+
errors.push(`${prefix} has unknown effect: ${effectName}`);
|
|
1739
|
+
}
|
|
1740
|
+
if (typeof effectValue !== "boolean") {
|
|
1741
|
+
errors.push(`${prefix} effect ${effectName} must be boolean`);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
for (const phaseName of phaseNames) {
|
|
1748
|
+
if (!outgoing.has(phaseName)) {
|
|
1749
|
+
errors.push(`${phaseName} must have at least one outgoing transition`);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return errors;
|
|
1753
|
+
}
|
|
1195
1754
|
async function readYamlObject(filePath) {
|
|
1196
1755
|
if (!(await pathExists(filePath)))
|
|
1197
1756
|
return {};
|
|
@@ -1250,7 +1809,10 @@ async function validateCurrentTaskDevelopmentEvidence(projectRoot, plan) {
|
|
|
1250
1809
|
return [`${taskId} implementation_doc is missing: ${implementationDoc}`];
|
|
1251
1810
|
}
|
|
1252
1811
|
const text = await readText(docPath);
|
|
1253
|
-
return
|
|
1812
|
+
return [
|
|
1813
|
+
...validateDevelopmentEvidenceText(text, currentTask, implementationDoc),
|
|
1814
|
+
...(await validateHighRiskRecoveryBoundaries(projectRoot, text, plan, currentTask, implementationDoc))
|
|
1815
|
+
];
|
|
1254
1816
|
}
|
|
1255
1817
|
function currentOpenSprintTask(plan) {
|
|
1256
1818
|
const currentTaskId = String(plan.current_task_id ?? "");
|
|
@@ -1327,17 +1889,31 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
|
|
|
1327
1889
|
if (!report) {
|
|
1328
1890
|
return [`${taskId} implementation_doc must include Development Self-Test Report for self_test_contract: ${implementationDoc}`];
|
|
1329
1891
|
}
|
|
1892
|
+
const reportStatus = normalizeSelfTestReportStatus(evidenceFieldValue(report, "Report Status"));
|
|
1893
|
+
if (!reportStatus) {
|
|
1894
|
+
errors.push(`${taskId} Development Self-Test Report must include Report Status: PASS | BLOCKED | IN_PROGRESS | STALE in ${implementationDoc}`);
|
|
1895
|
+
}
|
|
1896
|
+
else if (reportStatus !== "PASS") {
|
|
1897
|
+
errors.push(`${taskId} Development Self-Test Report Report Status is ${reportStatus}; validate-dev cannot handoff until the report status is PASS`);
|
|
1898
|
+
}
|
|
1899
|
+
const highRiskRuntime = requiresResumeCapsule(task);
|
|
1900
|
+
errors.push(...validateSelfTestReportBoundary(report, taskId, implementationDoc));
|
|
1901
|
+
errors.push(...validateSelfTestReportLength(report, taskId, implementationDoc, highRiskRuntime));
|
|
1330
1902
|
const basicSelfTest = evidenceFieldValue(developmentEvidenceSection, "Basic Self-test Evidence") ?? "";
|
|
1331
1903
|
if (!containsAny(basicSelfTest, ["Development Self-Test Report", "开发自测报告", "self-test report"])) {
|
|
1332
1904
|
errors.push(`${taskId} Basic Self-test Evidence must reference the Development Self-Test Report in ${implementationDoc}`);
|
|
1333
1905
|
}
|
|
1334
|
-
for (const field of
|
|
1906
|
+
for (const field of SELF_TEST_REPORT_REQUIRED_FIELDS) {
|
|
1335
1907
|
const value = evidenceFieldValue(report, field);
|
|
1336
|
-
const allowsNone = field
|
|
1908
|
+
const allowsNone = SELF_TEST_REPORT_NONE_ALLOWED_FIELDS.has(field);
|
|
1337
1909
|
if (!value || (!allowsNone && isPlaceholderEvidence(value))) {
|
|
1338
1910
|
errors.push(`${taskId} Development Self-Test Report ${field} must contain executed evidence in ${implementationDoc}`);
|
|
1339
1911
|
}
|
|
1340
1912
|
}
|
|
1913
|
+
const evidenceIndexRefs = evidenceFieldValue(report, "Evidence Index Refs") ?? "";
|
|
1914
|
+
if (highRiskRuntime && !evidenceIndexRefs.includes(RUNBOOK_DOC_PREFIX)) {
|
|
1915
|
+
errors.push(`${taskId} high-risk Development Self-Test Report Evidence Index Refs must link evidence under ${RUNBOOK_DOC_PREFIX} in ${implementationDoc}`);
|
|
1916
|
+
}
|
|
1341
1917
|
const source = String(contract.source ?? "").trim();
|
|
1342
1918
|
if (source && !report.includes(source)) {
|
|
1343
1919
|
errors.push(`${taskId} Development Self-Test Report must reference contract source ${source} in ${implementationDoc}`);
|
|
@@ -1351,10 +1927,18 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
|
|
|
1351
1927
|
if (isPlaceholderSelfTestReportValue(moduleKeyTestPath) || isTemplateModuleKeyTestPath(moduleKeyTestPath)) {
|
|
1352
1928
|
errors.push(`${taskId} Development Self-Test Report Module Key Test Path must replace template placeholders with actual executed path evidence in ${implementationDoc}`);
|
|
1353
1929
|
}
|
|
1930
|
+
const graphRequired = contract.graph_required === true || isRecord(contract.module_key_test_graph);
|
|
1931
|
+
const moduleKeyTestGraph = (markdownSection(report, MODULE_KEY_TEST_GRAPH_TERMS) ?? evidenceFieldValue(report, "Module Key Test Graph") ?? "").trim();
|
|
1932
|
+
if (graphRequired && (!moduleKeyTestGraph || isPlaceholderSelfTestReportValue(moduleKeyTestGraph))) {
|
|
1933
|
+
errors.push(`${taskId} Development Self-Test Report must include Module Key Test Graph because self_test_contract graph is required or present in ${implementationDoc}`);
|
|
1934
|
+
}
|
|
1354
1935
|
const runnableEntry = String(contract.runnable_entry ?? "").trim();
|
|
1355
1936
|
if (runnableEntry && !moduleKeyTestPath.includes(runnableEntry)) {
|
|
1356
1937
|
errors.push(`${taskId} Development Self-Test Report Module Key Test Path must include runnable entry ${runnableEntry} in ${implementationDoc}`);
|
|
1357
1938
|
}
|
|
1939
|
+
if (graphRequired && moduleKeyTestGraph && runnableEntry && !moduleKeyTestGraph.includes(runnableEntry)) {
|
|
1940
|
+
errors.push(`${taskId} Development Self-Test Report Module Key Test Graph must include runnable entry ${runnableEntry} in ${implementationDoc}`);
|
|
1941
|
+
}
|
|
1358
1942
|
const scenarios = Array.isArray(contract.scenarios) ? contract.scenarios.filter(isRecord) : [];
|
|
1359
1943
|
const exitEvidenceTerms = [
|
|
1360
1944
|
String(contract.observable_exit ?? "").trim(),
|
|
@@ -1368,6 +1952,13 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
|
|
|
1368
1952
|
&& !containsAny(moduleKeyTestPath, SELF_TEST_OBSERVABLE_EVIDENCE_TERMS)) {
|
|
1369
1953
|
errors.push(`${taskId} Development Self-Test Report Module Key Test Path must include observable exit or evidence from self_test_contract in ${implementationDoc}`);
|
|
1370
1954
|
}
|
|
1955
|
+
if (graphRequired
|
|
1956
|
+
&& moduleKeyTestGraph
|
|
1957
|
+
&& exitEvidenceTerms.length > 0
|
|
1958
|
+
&& !exitEvidenceTerms.some((term) => normalizedIncludes(moduleKeyTestGraph, term))
|
|
1959
|
+
&& !containsAny(moduleKeyTestGraph, ["observable_exit", "observable exit", "exit", "出口"])) {
|
|
1960
|
+
errors.push(`${taskId} Development Self-Test Report Module Key Test Graph must include observable exit or evidence from self_test_contract in ${implementationDoc}`);
|
|
1961
|
+
}
|
|
1371
1962
|
for (const scenario of scenarios) {
|
|
1372
1963
|
const scenarioId = String(scenario.id ?? "").trim();
|
|
1373
1964
|
if (!scenarioId)
|
|
@@ -1375,15 +1966,18 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
|
|
|
1375
1966
|
if (!moduleKeyTestPath.includes(scenarioId)) {
|
|
1376
1967
|
errors.push(`${taskId} Development Self-Test Report Module Key Test Path must include scenario ${scenarioId} in ${implementationDoc}`);
|
|
1377
1968
|
}
|
|
1969
|
+
if (graphRequired && moduleKeyTestGraph && !moduleKeyTestGraph.includes(scenarioId)) {
|
|
1970
|
+
errors.push(`${taskId} Development Self-Test Report Module Key Test Graph must include scenario ${scenarioId} in ${implementationDoc}`);
|
|
1971
|
+
}
|
|
1378
1972
|
const status = scenarioStatus(report, scenarioId);
|
|
1379
1973
|
if (!status) {
|
|
1380
|
-
errors.push(`${taskId} Development Self-Test Report must record scenario ${scenarioId} as PASS or
|
|
1974
|
+
errors.push(`${taskId} Development Self-Test Report must record scenario ${scenarioId} as PASS, BLOCKED, IN_PROGRESS, or STALE in ${implementationDoc}`);
|
|
1381
1975
|
}
|
|
1382
1976
|
else if (status === "AMBIGUOUS") {
|
|
1383
|
-
errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} must choose exactly one
|
|
1977
|
+
errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} must choose exactly one status in ${implementationDoc}`);
|
|
1384
1978
|
}
|
|
1385
|
-
else if (status
|
|
1386
|
-
errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} is
|
|
1979
|
+
else if (status !== "PASS") {
|
|
1980
|
+
errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} is ${status}; validate-dev cannot handoff until every scenario is PASS`);
|
|
1387
1981
|
}
|
|
1388
1982
|
errors.push(...validateScenarioTableEvidence(report, scenarioId, taskId, implementationDoc));
|
|
1389
1983
|
}
|
|
@@ -1398,23 +1992,215 @@ function validateDevelopmentSelfTestReport(fullText, developmentEvidenceSection,
|
|
|
1398
1992
|
errors.push(`${taskId} page Development Self-Test Report must include browser, Playwright, screenshot, or equivalent interaction evidence in ${implementationDoc}`);
|
|
1399
1993
|
}
|
|
1400
1994
|
}
|
|
1995
|
+
if (highRiskRuntime) {
|
|
1996
|
+
errors.push(...validateCurrentOperatorPath(fullText, taskId, implementationDoc));
|
|
1997
|
+
errors.push(...validateGateBreakdown(fullText, taskId, implementationDoc));
|
|
1998
|
+
}
|
|
1999
|
+
return errors;
|
|
2000
|
+
}
|
|
2001
|
+
function normalizeSelfTestReportStatus(value) {
|
|
2002
|
+
if (!value)
|
|
2003
|
+
return undefined;
|
|
2004
|
+
const normalized = value.replace(/`/g, "").trim().toUpperCase().replace(/[\s-]+/g, "_");
|
|
2005
|
+
return SELF_TEST_REPORT_STATUSES.has(normalized) ? normalized : undefined;
|
|
2006
|
+
}
|
|
2007
|
+
function validateSelfTestReportBoundary(report, taskId, implementationDoc) {
|
|
2008
|
+
const errors = [];
|
|
2009
|
+
for (const line of report.split(/\r?\n/)) {
|
|
2010
|
+
const fieldMatch = line.match(/^\s*[-*]\s*([^:]+)\s*:/);
|
|
2011
|
+
if (fieldMatch) {
|
|
2012
|
+
const field = fieldMatch[1].trim().toLowerCase();
|
|
2013
|
+
const blockedField = SELF_TEST_REPORT_DISALLOWED_FIELD_TERMS.find((term) => field === term.toLowerCase());
|
|
2014
|
+
if (blockedField) {
|
|
2015
|
+
errors.push(`${taskId} Development Self-Test Report must not use ${blockedField}; put evidence bodies in an Evidence Index and reference them with Evidence Index Refs in ${implementationDoc}`);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
|
2019
|
+
if (!match)
|
|
2020
|
+
continue;
|
|
2021
|
+
const title = match[2].trim().toLowerCase();
|
|
2022
|
+
const blockedTerm = SELF_TEST_REPORT_DISALLOWED_SECTION_TERMS.find((term) => title.includes(term));
|
|
2023
|
+
if (blockedTerm) {
|
|
2024
|
+
errors.push(`${taskId} Development Self-Test Report must not include debug/operator/runbook/exploration log section "${match[2].trim()}" in ${implementationDoc}; link a runbook or exploration appendix instead`);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
return errors;
|
|
2028
|
+
}
|
|
2029
|
+
function validateSelfTestReportLength(report, taskId, implementationDoc, highRiskRuntime) {
|
|
2030
|
+
const limit = highRiskRuntime ? MAX_HIGH_RISK_SELF_TEST_REPORT_LINES : MAX_SELF_TEST_REPORT_LINES;
|
|
2031
|
+
const lineCount = report.split(/\r?\n/).filter((line) => line.trim()).length;
|
|
2032
|
+
if (lineCount <= limit)
|
|
2033
|
+
return [];
|
|
2034
|
+
return [
|
|
2035
|
+
`${taskId} Development Self-Test Report must stay as a short handoff card (${limit} non-empty lines max); move logs, evidence bodies, runbook steps, and exploration history out of ${implementationDoc}`
|
|
2036
|
+
];
|
|
2037
|
+
}
|
|
2038
|
+
async function validateHighRiskRecoveryBoundaries(projectRoot, implementationText, plan, task, implementationDoc) {
|
|
2039
|
+
if (!requiresResumeCapsule(task))
|
|
2040
|
+
return [];
|
|
2041
|
+
const taskId = String(task.id ?? "current task");
|
|
2042
|
+
const errors = validateImplementationDocMainlineBoundary(implementationText, taskId, implementationDoc);
|
|
2043
|
+
errors.push(...(await validateStrategyChangingDecisionPromotion(projectRoot, implementationText, plan, task, implementationDoc)));
|
|
2044
|
+
return errors;
|
|
2045
|
+
}
|
|
2046
|
+
function validateImplementationDocMainlineBoundary(fullText, taskId, implementationDoc) {
|
|
2047
|
+
const errors = [];
|
|
2048
|
+
for (const line of fullText.split(/\r?\n/)) {
|
|
2049
|
+
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
|
2050
|
+
if (!match)
|
|
2051
|
+
continue;
|
|
2052
|
+
const title = match[2].trim().toLowerCase();
|
|
2053
|
+
const blockedTerm = IMPLEMENTATION_MAINLINE_DISALLOWED_SECTION_TERMS.find((term) => title.includes(term));
|
|
2054
|
+
if (blockedTerm) {
|
|
2055
|
+
errors.push(`${taskId} implementation_doc must not keep ${blockedTerm} as a mainline section in ${implementationDoc}; move it to a runbook, evidence index, exploration appendix, or external artifact`);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
return errors;
|
|
2059
|
+
}
|
|
2060
|
+
async function validateStrategyChangingDecisionPromotion(projectRoot, implementationText, plan, task, implementationDoc) {
|
|
2061
|
+
const taskId = String(task.id ?? "current task");
|
|
2062
|
+
const capsule = isRecord(plan.resume_capsule) ? plan.resume_capsule : {};
|
|
2063
|
+
const recoveryRefs = asStringList(capsule.recovery_refs);
|
|
2064
|
+
const runbookTexts = await readExistingRunbookRefs(projectRoot, recoveryRefs);
|
|
2065
|
+
const promotionText = [
|
|
2066
|
+
asStringList(capsule.do_not_retry).join("\n"),
|
|
2067
|
+
markdownSection(implementationText, CURRENT_OPERATOR_PATH_TERMS) ?? "",
|
|
2068
|
+
...runbookTexts.map((text) => markdownSection(text, HARD_CONSTRAINT_TERMS) ?? "")
|
|
2069
|
+
].join("\n");
|
|
2070
|
+
const sourceText = [
|
|
2071
|
+
asStringList(task.working_notes).join("\n"),
|
|
2072
|
+
removeMarkdownSections(implementationText, CURRENT_OPERATOR_PATH_TERMS),
|
|
2073
|
+
...runbookTexts.map((text) => removeMarkdownSections(text, HARD_CONSTRAINT_TERMS))
|
|
2074
|
+
].join("\n");
|
|
2075
|
+
const errors = [];
|
|
2076
|
+
for (const [label, terms] of STRATEGY_CHANGING_DECISION_GROUPS) {
|
|
2077
|
+
if (containsAny(sourceText, terms) && !containsAny(promotionText, terms)) {
|
|
2078
|
+
errors.push(`${taskId} strategy-changing ${label} judgment appears outside promoted hard constraints; promote it to resume_capsule.do_not_retry, runbook Hard Constraints, or Current Operator Path in ${implementationDoc}`);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
return errors;
|
|
2082
|
+
}
|
|
2083
|
+
async function readExistingRunbookRefs(projectRoot, refs) {
|
|
2084
|
+
const texts = [];
|
|
2085
|
+
for (const ref of refs) {
|
|
2086
|
+
if (!ref.startsWith(RUNBOOK_DOC_PREFIX) || !ref.endsWith(".md"))
|
|
2087
|
+
continue;
|
|
2088
|
+
const fullPath = path.join(projectRoot, ref);
|
|
2089
|
+
if (await pathExists(fullPath)) {
|
|
2090
|
+
texts.push(await readText(fullPath));
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
return texts;
|
|
2094
|
+
}
|
|
2095
|
+
function removeMarkdownSections(text, headerTerms) {
|
|
2096
|
+
const lines = text.split(/\r?\n/);
|
|
2097
|
+
const kept = [];
|
|
2098
|
+
let skippingLevel = 0;
|
|
2099
|
+
for (const line of lines) {
|
|
2100
|
+
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
|
2101
|
+
if (match) {
|
|
2102
|
+
const level = match[1].length;
|
|
2103
|
+
if (skippingLevel > 0 && level <= skippingLevel) {
|
|
2104
|
+
skippingLevel = 0;
|
|
2105
|
+
}
|
|
2106
|
+
const title = match[2].toLowerCase();
|
|
2107
|
+
if (skippingLevel === 0 && headerTerms.some((term) => title.includes(term.toLowerCase()))) {
|
|
2108
|
+
skippingLevel = level;
|
|
2109
|
+
continue;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
if (skippingLevel === 0)
|
|
2113
|
+
kept.push(line);
|
|
2114
|
+
}
|
|
2115
|
+
return kept.join("\n");
|
|
2116
|
+
}
|
|
2117
|
+
function validateCurrentOperatorPath(fullText, taskId, implementationDoc) {
|
|
2118
|
+
const section = markdownSection(fullText, CURRENT_OPERATOR_PATH_TERMS);
|
|
2119
|
+
if (!section) {
|
|
2120
|
+
return [`${taskId} high-risk runtime task must include a short Current Operator Path section in ${implementationDoc}`];
|
|
2121
|
+
}
|
|
2122
|
+
const errors = [];
|
|
2123
|
+
const requiredFields = [
|
|
2124
|
+
["canonical operator path", ["Canonical operator path", "Canonical path"]],
|
|
2125
|
+
["runbook link", ["Operator runbook", "Runbook"]],
|
|
2126
|
+
["credential reference name", ["Credential reference", "Credential reference name"]],
|
|
2127
|
+
["command/UI channel", ["Command/UI channel", "Command channel", "UI channel"]],
|
|
2128
|
+
["do-not-retry summary", ["Do-not-retry summary", "Do not retry summary"]],
|
|
2129
|
+
["hard constraints", ["Hard Constraints", "Hard Constraint", "Strategy Constraints", "Strategy Constraint"]]
|
|
2130
|
+
];
|
|
2131
|
+
for (const [label, fields] of requiredFields) {
|
|
2132
|
+
const value = fields.map((field) => evidenceFieldValue(section, field)).find((candidate) => candidate && candidate.trim());
|
|
2133
|
+
if (!value) {
|
|
2134
|
+
errors.push(`${taskId} Current Operator Path must record ${label} in ${implementationDoc}`);
|
|
2135
|
+
}
|
|
2136
|
+
else if (label !== "credential reference name" && isPlaceholderEvidence(value)) {
|
|
2137
|
+
errors.push(`${taskId} Current Operator Path ${label} must be concrete in ${implementationDoc}`);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
const runbookValue = evidenceFieldValue(section, "Operator runbook") ?? evidenceFieldValue(section, "Runbook") ?? section;
|
|
2141
|
+
if (!runbookValue.includes(RUNBOOK_DOC_PREFIX)) {
|
|
2142
|
+
errors.push(`${taskId} Current Operator Path must link a runbook/evidence document under ${RUNBOOK_DOC_PREFIX} in ${implementationDoc}`);
|
|
2143
|
+
}
|
|
2144
|
+
if (!containsAny(section, HARD_CONSTRAINT_TERMS)) {
|
|
2145
|
+
errors.push(`${taskId} Current Operator Path must promote strategy-changing recovery decisions as Hard Constraints in ${implementationDoc}`);
|
|
2146
|
+
}
|
|
2147
|
+
return errors;
|
|
2148
|
+
}
|
|
2149
|
+
function validateGateBreakdown(fullText, taskId, implementationDoc) {
|
|
2150
|
+
const section = markdownSection(fullText, GATE_BREAKDOWN_TERMS);
|
|
2151
|
+
if (!section) {
|
|
2152
|
+
return [`${taskId} high-risk runtime task Development Self-Test Report must include Gate Breakdown in ${implementationDoc}`];
|
|
2153
|
+
}
|
|
2154
|
+
const errors = [];
|
|
2155
|
+
const lowered = section.toLowerCase();
|
|
2156
|
+
for (const [label, terms] of GATE_BREAKDOWN_LAYER_GROUPS) {
|
|
2157
|
+
if (!containsAny(lowered, terms)) {
|
|
2158
|
+
errors.push(`${taskId} Gate Breakdown must include ${label} status/evidence in ${implementationDoc}`);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
const rows = markdownTableRows(section).filter((cells) => !cells.some((cell) => /gate layer|layer|层级/i.test(cell)));
|
|
2162
|
+
const concreteRows = rows.filter((cells) => cells.some((cell) => !isPlaceholderSelfTestReportValue(cell)));
|
|
2163
|
+
if (concreteRows.length < 2) {
|
|
2164
|
+
errors.push(`${taskId} Gate Breakdown must split evidence into multiple concrete gate layers in ${implementationDoc}`);
|
|
2165
|
+
}
|
|
2166
|
+
if (concreteRows.length <= 1 && lowered.includes("validate-dev")) {
|
|
2167
|
+
errors.push(`${taskId} Gate Breakdown cannot collapse high-risk runtime progress into only validate-dev in ${implementationDoc}`);
|
|
2168
|
+
}
|
|
1401
2169
|
return errors;
|
|
1402
2170
|
}
|
|
1403
2171
|
function scenarioStatus(text, scenarioId) {
|
|
1404
2172
|
const escaped = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1405
2173
|
const pattern = new RegExp("^.*" + escaped + ".*$", "gim");
|
|
2174
|
+
const seen = new Set();
|
|
1406
2175
|
for (const match of text.matchAll(pattern)) {
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
if (
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
2176
|
+
const status = selfTestLineStatus(match[0]);
|
|
2177
|
+
if (status === "AMBIGUOUS")
|
|
2178
|
+
return status;
|
|
2179
|
+
if (status)
|
|
2180
|
+
seen.add(status);
|
|
2181
|
+
}
|
|
2182
|
+
if (seen.size > 1)
|
|
2183
|
+
return "AMBIGUOUS";
|
|
2184
|
+
return [...seen][0];
|
|
2185
|
+
}
|
|
2186
|
+
function selfTestLineStatus(line) {
|
|
2187
|
+
const normalized = line.toUpperCase().replace(/\bIN[\s-]+PROGRESS\b/g, "IN_PROGRESS");
|
|
2188
|
+
const matches = [];
|
|
2189
|
+
if (hasStatusToken(normalized, "PASS"))
|
|
2190
|
+
matches.push("PASS");
|
|
2191
|
+
if (hasStatusToken(normalized, "BLOCKED"))
|
|
2192
|
+
matches.push("BLOCKED");
|
|
2193
|
+
if (hasStatusToken(normalized, "IN_PROGRESS"))
|
|
2194
|
+
matches.push("IN_PROGRESS");
|
|
2195
|
+
if (hasStatusToken(normalized, "STALE"))
|
|
2196
|
+
matches.push("STALE");
|
|
2197
|
+
if (matches.length > 1)
|
|
2198
|
+
return "AMBIGUOUS";
|
|
2199
|
+
return matches[0];
|
|
2200
|
+
}
|
|
2201
|
+
function hasStatusToken(line, status) {
|
|
2202
|
+
const escaped = status.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2203
|
+
return new RegExp(`(^|[^A-Z0-9_])${escaped}([^A-Z0-9_]|$)`).test(line);
|
|
1418
2204
|
}
|
|
1419
2205
|
function validateScenarioTableEvidence(report, scenarioId, taskId, implementationDoc) {
|
|
1420
2206
|
const errors = [];
|
|
@@ -1434,8 +2220,12 @@ function validateScenarioTableEvidence(report, scenarioId, taskId, implementatio
|
|
|
1434
2220
|
errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} table ${label} must contain concrete evidence in ${implementationDoc}`);
|
|
1435
2221
|
}
|
|
1436
2222
|
}
|
|
1437
|
-
|
|
1438
|
-
|
|
2223
|
+
const tableStatus = result ? scenarioStatus(`| ${cells.join(" | ")} |`, scenarioId) : undefined;
|
|
2224
|
+
if (tableStatus === "AMBIGUOUS") {
|
|
2225
|
+
errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} table Result must choose exactly one status in ${implementationDoc}`);
|
|
2226
|
+
}
|
|
2227
|
+
else if (tableStatus && tableStatus !== "PASS") {
|
|
2228
|
+
errors.push(`${taskId} Development Self-Test Report scenario ${scenarioId} table Result is ${tableStatus}; validate-dev cannot handoff until every scenario is PASS`);
|
|
1439
2229
|
}
|
|
1440
2230
|
}
|
|
1441
2231
|
return errors;
|