agent-project-sdlc 0.1.23 → 0.1.25
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 +16 -7
- package/assets/agents/AGENTS_CORE.md +18 -7
- package/assets/docs/README.md +23 -7
- package/assets/make/sdlc-harness.mk +5 -1
- package/assets/policies/allowed_paths.yaml +9 -0
- package/assets/policies/gates.yaml +6 -0
- package/assets/policies/phase_contracts.yaml +57 -0
- package/assets/skills/pjsdlc_architect_design/SKILL.md +17 -7
- package/assets/skills/pjsdlc_dev_sprint/SKILL.md +10 -2
- package/assets/skills/pjsdlc_implementation_doc/SKILL.md +9 -4
- package/assets/skills/pjsdlc_manager/SKILL.md +18 -14
- package/assets/skills/pjsdlc_reviewer/SKILL.md +6 -1
- package/assets/skills/pjsdlc_rfc_recalibrate/SKILL.md +8 -5
- package/assets/skills/pjsdlc_tester/SKILL.md +16 -5
- package/assets/skills/pjsdlc_uiux_design/SKILL.md +76 -0
- package/assets/templates/PLAN_TEMPLATE.yaml +4 -0
- package/assets/templates/REVIEW_TEMPLATE.md +14 -4
- package/assets/templates/RFC_TEMPLATE.md +13 -5
- package/assets/templates/TECH_DESIGN_TEMPLATE.md +18 -10
- package/assets/templates/TEST_CASES_TEMPLATE.md +5 -3
- package/assets/templates/TEST_REPORT_TEMPLATE.md +1 -0
- package/assets/templates/TEST_STRATEGY_TEMPLATE.md +4 -0
- package/assets/templates/UI_UX_DESIGN_TEMPLATE.md +67 -0
- package/assets/tools/harness_utils.py +4 -2
- package/assets/tools/validate_design.py +55 -2
- package/assets/tools/validate_harness.py +1 -0
- package/assets/tools/validate_rfc.py +31 -0
- package/assets/tools/validate_test_plan.py +118 -1
- package/assets/tools/validate_uiux_design.py +101 -0
- package/dist/commands/index.js +2 -1
- package/dist/lib/init.js +1 -0
- package/dist/lib/validators.js +319 -6
- package/package.json +2 -1
package/dist/lib/validators.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { promisify } from "node:util";
|
|
4
5
|
import { harnessPath, harnessRoot } from "./harness-root.js";
|
|
5
6
|
import { listFiles, pathExists, readText } from "./fs.js";
|
|
@@ -8,11 +9,11 @@ const execFileAsync = promisify(execFile);
|
|
|
8
9
|
const PARALLEL_MODES = new Set(["runtime_managed", "user_orchestrated"]);
|
|
9
10
|
const PARALLEL_TRIGGERS = new Set(["user_requested", "workflow_default"]);
|
|
10
11
|
const PARALLEL_RUNTIME_PROVIDERS = new Set(["codex_native_subagents", "user_orchestrated", "codex_exec_worktree"]);
|
|
11
|
-
const TASK_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
12
|
+
const TASK_PHASES = new Set(["REQUIREMENT_GATHERING", "UI_UX_DESIGNING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
12
13
|
const RESERVED_SUSPENDED_PHASE_TARGET = "<suspended_phase>";
|
|
13
14
|
const TRANSITION_KINDS = new Set(["normal", "return", "interrupt", "resume"]);
|
|
14
|
-
const PARALLEL_ALLOWED_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
15
|
-
const PARALLEL_READ_ONLY_PHASES = new Set(["REQUIREMENT_GATHERING", "ARCHITECTING", "REVIEWING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
15
|
+
const PARALLEL_ALLOWED_PHASES = new Set(["REQUIREMENT_GATHERING", "UI_UX_DESIGNING", "ARCHITECTING", "SPRINTING", "REVIEWING", "TESTING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
16
|
+
const PARALLEL_READ_ONLY_PHASES = new Set(["REQUIREMENT_GATHERING", "UI_UX_DESIGNING", "ARCHITECTING", "REVIEWING", "RELEASING", "RFC_RECALIBRATION"]);
|
|
16
17
|
const PARALLEL_PROTECTED_WRITE_PATTERNS = [
|
|
17
18
|
".codex/state/**",
|
|
18
19
|
"<harnessRoot>/state/**",
|
|
@@ -86,8 +87,12 @@ const TESTING_DISALLOWED_CHANGED_PATHS = [...TESTING_DISALLOWED_ALLOWED_PATHS, "
|
|
|
86
87
|
const TESTING_RUNTIME_FILE_TERMS = ["bootstrap", "cloud", "daemon", "poller", "provider", "runtime", "service", "systemd"];
|
|
87
88
|
const TESTING_ALLOWED_TEST_FILE_TERMS = ["assertion", "fixture", "mock", "smoke"];
|
|
88
89
|
const TEST_REPORT_PATH = ".docs/07_test/TEST_REPORT.md";
|
|
90
|
+
const TEST_CASES_PATH = ".docs/07_test/TEST_CASES.md";
|
|
91
|
+
const EXPERIENCE_DOC_PREFIX = ".docs/02_experience/";
|
|
92
|
+
const DESIGN_SYSTEM_PATH = "DESIGN.md";
|
|
89
93
|
const CURRENT_RELEASE_REPORT_PATH = ".docs/08_release/CURRENT_RELEASE.md";
|
|
90
94
|
const TEST_REPORT_PLACEHOLDER_TERMS = ["pending", "tbd", "todo", "待填", "待补", "placeholder"];
|
|
95
|
+
const TEST_CASE_ID_PATTERN = /\bTC-\d{3,}\b/g;
|
|
91
96
|
const TEST_FACT_SOURCE_PHASES = new Set(["TESTING", "RFC_RECALIBRATION"]);
|
|
92
97
|
const TEST_FACT_SOURCE_PATTERNS = [".docs/07_test/**", ".docs/07_test/"];
|
|
93
98
|
const TEST_FACT_SOURCE_REF = /\.docs\/07_test\/[^\s`,)]+/g;
|
|
@@ -466,11 +471,47 @@ const RFC_SELF_TEST_TRIGGER_TERMS = [
|
|
|
466
471
|
"运行环境",
|
|
467
472
|
"阻塞"
|
|
468
473
|
];
|
|
474
|
+
const RFC_UIUX_TRIGGER_TERMS = [
|
|
475
|
+
"ui/ux",
|
|
476
|
+
"ux",
|
|
477
|
+
"screen",
|
|
478
|
+
"interaction",
|
|
479
|
+
"design.md",
|
|
480
|
+
"frontend",
|
|
481
|
+
"browser",
|
|
482
|
+
"page",
|
|
483
|
+
"visual",
|
|
484
|
+
"体验",
|
|
485
|
+
"屏幕",
|
|
486
|
+
"交互",
|
|
487
|
+
"视觉",
|
|
488
|
+
"前端"
|
|
489
|
+
];
|
|
490
|
+
const RFC_UIUX_IMPACT_TERMS = ["ui/ux impact", "体验影响"];
|
|
491
|
+
const UI_DRAFT_TASK_TERMS = [
|
|
492
|
+
"frontend",
|
|
493
|
+
"front-end",
|
|
494
|
+
"browser",
|
|
495
|
+
"page",
|
|
496
|
+
"screen",
|
|
497
|
+
"route",
|
|
498
|
+
"component",
|
|
499
|
+
"visual_ui",
|
|
500
|
+
"design.md",
|
|
501
|
+
".docs/02_experience/",
|
|
502
|
+
"页面",
|
|
503
|
+
"前端",
|
|
504
|
+
"屏幕",
|
|
505
|
+
"交互",
|
|
506
|
+
"组件",
|
|
507
|
+
"视觉"
|
|
508
|
+
];
|
|
469
509
|
const validators = {
|
|
470
510
|
"validate-harness": validateHarness,
|
|
471
511
|
"validate-current": validateCurrent,
|
|
472
512
|
"validate-plan": validatePlan,
|
|
473
513
|
"validate-pm": validatePm,
|
|
514
|
+
"validate-uiux": validateUiux,
|
|
474
515
|
"validate-design": validateDesign,
|
|
475
516
|
"validate-dev": validateDev,
|
|
476
517
|
"validate-review": validateReview,
|
|
@@ -495,6 +536,7 @@ async function validateHarness(projectRoot) {
|
|
|
495
536
|
for (const required of [
|
|
496
537
|
"AGENTS.md",
|
|
497
538
|
".docs/INDEX.md",
|
|
539
|
+
".docs/02_experience",
|
|
498
540
|
".docs/09_runbooks",
|
|
499
541
|
harnessPath(root, "config.yaml"),
|
|
500
542
|
harnessPath(root, "state", "lifecycle.yaml"),
|
|
@@ -521,6 +563,7 @@ async function validateCurrent(projectRoot) {
|
|
|
521
563
|
}
|
|
522
564
|
const gateByPhase = {
|
|
523
565
|
REQUIREMENT_GATHERING: "validate-pm",
|
|
566
|
+
UI_UX_DESIGNING: "validate-uiux",
|
|
524
567
|
ARCHITECTING: "validate-design",
|
|
525
568
|
SPRINTING: "validate-dev",
|
|
526
569
|
REVIEWING: "validate-review",
|
|
@@ -545,6 +588,48 @@ async function validatePm(projectRoot) {
|
|
|
545
588
|
errors.push("PRD must include open questions");
|
|
546
589
|
return { info: [`validate-pm checked ${docs.length} file(s)`], errors };
|
|
547
590
|
}
|
|
591
|
+
async function validateUiux(projectRoot) {
|
|
592
|
+
const root = await harnessRoot(projectRoot);
|
|
593
|
+
const lifecycle = await readYamlObject(path.join(projectRoot, root, "state", "lifecycle.yaml"));
|
|
594
|
+
const plan = await validatePlanState(projectRoot, String(lifecycle.current_phase ?? "") !== "UI_UX_DESIGNING");
|
|
595
|
+
const docs = await markdownFiles(path.join(projectRoot, ".docs/02_experience"));
|
|
596
|
+
const errors = [...plan.errors];
|
|
597
|
+
if (docs.length === 0)
|
|
598
|
+
errors.push("No UI/UX deliverables found in .docs/02_experience/");
|
|
599
|
+
let visualUi = false;
|
|
600
|
+
for (const doc of docs) {
|
|
601
|
+
const relative = repoRelative(projectRoot, doc);
|
|
602
|
+
const text = await readText(doc);
|
|
603
|
+
const notApplicable = containsAny(text, ["applicability: not_applicable", "applicability: `not_applicable`"]);
|
|
604
|
+
visualUi = visualUi || containsAny(text, ["applicability: visual_ui", "applicability: `visual_ui`"]);
|
|
605
|
+
if (notApplicable)
|
|
606
|
+
continue;
|
|
607
|
+
if (!containsAny(text, ["prd", "requirement", "需求"]))
|
|
608
|
+
errors.push(`${relative} must cite PRD and requirement IDs`);
|
|
609
|
+
if (!containsAny(text, ["user journey", "user journeys", "用户旅程"]))
|
|
610
|
+
errors.push(`${relative} must include user journeys`);
|
|
611
|
+
if (!containsAny(text, ["handoff matrix", "交接矩阵"]))
|
|
612
|
+
errors.push(`${relative} must include a handoff matrix`);
|
|
613
|
+
if (!containsAny(text, ["loading", "empty", "error", "success", "permission", "加载", "空状态", "错误", "成功", "权限"])) {
|
|
614
|
+
errors.push(`${relative} screen contracts must cover applicable loading/empty/error/success/permission states`);
|
|
615
|
+
}
|
|
616
|
+
if (!containsAny(text, ["responsive", "breakpoint", "响应式", "断点"]))
|
|
617
|
+
errors.push(`${relative} must include responsive acceptance`);
|
|
618
|
+
if (!containsAny(text, ["accessibility", "a11y", "focus", "keyboard", "touch", "无障碍", "焦点", "键盘", "触控"])) {
|
|
619
|
+
errors.push(`${relative} must include accessibility/focus/keyboard/touch expectations`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (visualUi) {
|
|
623
|
+
const designPath = path.join(projectRoot, DESIGN_SYSTEM_PATH);
|
|
624
|
+
if (!(await pathExists(designPath))) {
|
|
625
|
+
errors.push("visual UI experience requires root DESIGN.md");
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
errors.push(...(await validateDesignMd(projectRoot)));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return { info: [`validate-uiux checked ${docs.length} file(s)`], errors };
|
|
632
|
+
}
|
|
548
633
|
async function validateDesign(projectRoot) {
|
|
549
634
|
const root = await harnessRoot(projectRoot);
|
|
550
635
|
const lifecycle = await readYamlObject(path.join(projectRoot, root, "state", "lifecycle.yaml"));
|
|
@@ -552,6 +637,7 @@ async function validateDesign(projectRoot) {
|
|
|
552
637
|
const architecture = await markdownFiles(path.join(projectRoot, ".docs/02_architecture"));
|
|
553
638
|
const techPlan = await markdownFiles(path.join(projectRoot, ".docs/03_tech_plan"));
|
|
554
639
|
const product = await markdownFiles(path.join(projectRoot, ".docs/01_product"));
|
|
640
|
+
const experience = await markdownFiles(path.join(projectRoot, ".docs/02_experience"));
|
|
555
641
|
const text = await combinedText([...architecture, ...techPlan]);
|
|
556
642
|
const errors = [...plan.errors];
|
|
557
643
|
if (architecture.length === 0)
|
|
@@ -564,7 +650,7 @@ async function validateDesign(projectRoot) {
|
|
|
564
650
|
errors.push("Design must describe interfaces or contracts");
|
|
565
651
|
if (!containsAny(text, ["task", "任务", "breakdown"]))
|
|
566
652
|
errors.push("Design must include task breakdown");
|
|
567
|
-
const draft = await validateDesignDraft(projectRoot, root, techPlan);
|
|
653
|
+
const draft = await validateDesignDraft(projectRoot, root, techPlan, experience);
|
|
568
654
|
errors.push(...draft.errors);
|
|
569
655
|
errors.push(...(await validateCrossCuttingArchitecture(projectRoot, product, techPlan, architecture, draft.tasks)));
|
|
570
656
|
return { info: [`validate-design checked ${architecture.length + techPlan.length} file(s)`], errors };
|
|
@@ -574,7 +660,7 @@ async function validatePlan(projectRoot) {
|
|
|
574
660
|
const pathErrors = await validateChangedPaths(projectRoot, plan.plan, true);
|
|
575
661
|
return { info: [`validate-plan checked ${plan.taskCount} task(s)`], errors: [...plan.errors, ...pathErrors] };
|
|
576
662
|
}
|
|
577
|
-
async function validateDesignDraft(projectRoot, root, techPlanFiles) {
|
|
663
|
+
async function validateDesignDraft(projectRoot, root, techPlanFiles, experienceFiles) {
|
|
578
664
|
const errors = [];
|
|
579
665
|
const draft = await readYamlObject(path.join(projectRoot, root, "state", "plan.draft.yaml"));
|
|
580
666
|
if ("current_phase" in draft) {
|
|
@@ -590,6 +676,7 @@ async function validateDesignDraft(projectRoot, root, techPlanFiles) {
|
|
|
590
676
|
}
|
|
591
677
|
const tasks = rawTasks.filter(isRecord);
|
|
592
678
|
const availableTechPlans = new Set(techPlanFiles.map((file) => repoRelative(projectRoot, file)));
|
|
679
|
+
const availableExperienceDocs = new Set(experienceFiles.map((file) => repoRelative(projectRoot, file)));
|
|
593
680
|
const developmentTasks = [];
|
|
594
681
|
const primaryRefs = [];
|
|
595
682
|
for (const [index, rawTask] of rawTasks.entries()) {
|
|
@@ -622,6 +709,7 @@ async function validateDesignDraft(projectRoot, root, techPlanFiles) {
|
|
|
622
709
|
errors.push(`Draft task ${String(rawTask.id ?? "")} references missing or generated tech plan slice: ${ref}`);
|
|
623
710
|
}
|
|
624
711
|
}
|
|
712
|
+
errors.push(...(await validateUiuxDesignRefsForDraftTask(projectRoot, rawTask, availableExperienceDocs)));
|
|
625
713
|
errors.push(...(await validateSelfTestContractTechPlanBinding(projectRoot, rawTask, normalizedRefs)));
|
|
626
714
|
primaryRefs.push(normalizedRefs[0]);
|
|
627
715
|
}
|
|
@@ -633,6 +721,51 @@ async function validateDesignDraft(projectRoot, root, techPlanFiles) {
|
|
|
633
721
|
}
|
|
634
722
|
return { errors, tasks };
|
|
635
723
|
}
|
|
724
|
+
async function validateUiuxDesignRefsForDraftTask(projectRoot, task, availableExperienceDocs) {
|
|
725
|
+
const errors = [];
|
|
726
|
+
if (!isRecord(task.docs))
|
|
727
|
+
return errors;
|
|
728
|
+
const taskId = String(task.id ?? "");
|
|
729
|
+
const uiuxRefs = asStringList(task.docs.uiux).map(normalizeDocRef);
|
|
730
|
+
const designRefs = asStringList(task.docs.design_system).map(normalizeDocRef);
|
|
731
|
+
const uiTask = isUiDraftTask(task);
|
|
732
|
+
if (uiTask && uiuxRefs.length === 0) {
|
|
733
|
+
errors.push(`UI/frontend draft task ${taskId} must reference a UI/UX slice in docs.uiux`);
|
|
734
|
+
}
|
|
735
|
+
if (uiTask && designRefs.length === 0) {
|
|
736
|
+
errors.push(`UI/frontend draft task ${taskId} must reference DESIGN.md in docs.design_system`);
|
|
737
|
+
}
|
|
738
|
+
for (const ref of uiuxRefs) {
|
|
739
|
+
if (!ref.startsWith(EXPERIENCE_DOC_PREFIX)) {
|
|
740
|
+
errors.push(`Draft task ${taskId} docs.uiux must point into .docs/02_experience/: ${ref}`);
|
|
741
|
+
}
|
|
742
|
+
else if (!availableExperienceDocs.has(ref)) {
|
|
743
|
+
errors.push(`Draft task ${taskId} references missing or generated UI/UX slice: ${ref}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
for (const ref of designRefs) {
|
|
747
|
+
if (ref !== DESIGN_SYSTEM_PATH) {
|
|
748
|
+
errors.push(`Draft task ${taskId} docs.design_system must point to DESIGN.md: ${ref}`);
|
|
749
|
+
}
|
|
750
|
+
else if (!(await pathExists(path.join(projectRoot, DESIGN_SYSTEM_PATH)))) {
|
|
751
|
+
errors.push(`Draft task ${taskId} references missing design system: ${DESIGN_SYSTEM_PATH}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return errors;
|
|
755
|
+
}
|
|
756
|
+
function isUiDraftTask(task) {
|
|
757
|
+
const docsText = isRecord(task.docs)
|
|
758
|
+
? Object.values(task.docs)
|
|
759
|
+
.flatMap((value) => asStringList(value))
|
|
760
|
+
.join("\n")
|
|
761
|
+
: "";
|
|
762
|
+
const runtimeText = [task.target_runtime_environment, task.self_test_contract]
|
|
763
|
+
.filter(Boolean)
|
|
764
|
+
.map((value) => JSON.stringify(value))
|
|
765
|
+
.join("\n");
|
|
766
|
+
const text = [task.id, task.title, task.summary, task.phase, docsText, runtimeText].map((value) => String(value ?? "")).join("\n");
|
|
767
|
+
return containsAny(text, UI_DRAFT_TASK_TERMS);
|
|
768
|
+
}
|
|
636
769
|
function validateDraftTaskShape(task, index, errors) {
|
|
637
770
|
const prefix = `Task #${index + 1}`;
|
|
638
771
|
for (const field of ["id", "title", "status", "summary"]) {
|
|
@@ -815,6 +948,7 @@ async function validateTest(projectRoot) {
|
|
|
815
948
|
errors.push("Test report must include PASS/BLOCKED decision");
|
|
816
949
|
errors.push(...validateTestReadinessDecision(text));
|
|
817
950
|
errors.push(...validateRuntimeHandoffReport(report?.text ?? "", "Test report"));
|
|
951
|
+
errors.push(...(await validateTestCasesIfNeeded(projectRoot, plan.plan, report?.text ?? "")));
|
|
818
952
|
if (lifecycle.current_phase === "TESTING") {
|
|
819
953
|
errors.push(...testingBoundaryErrorsForChangedFiles(await changedFiles(projectRoot)));
|
|
820
954
|
}
|
|
@@ -872,6 +1006,7 @@ async function validateRfc(projectRoot) {
|
|
|
872
1006
|
if (invalidStatuses.length > 0)
|
|
873
1007
|
errors.push(`Invalid RFC status: ${invalidStatuses.join(", ")}`);
|
|
874
1008
|
errors.push(...(await validateRfcSelfTestImpact(projectRoot, docs)));
|
|
1009
|
+
errors.push(...(await validateRfcUiuxImpact(projectRoot, docs)));
|
|
875
1010
|
return { info: [`validate-rfc checked ${docs.length} file(s)`], errors };
|
|
876
1011
|
}
|
|
877
1012
|
async function validateRfcSelfTestImpact(projectRoot, docs) {
|
|
@@ -891,6 +1026,23 @@ async function validateRfcSelfTestImpact(projectRoot, docs) {
|
|
|
891
1026
|
}
|
|
892
1027
|
return errors;
|
|
893
1028
|
}
|
|
1029
|
+
async function validateRfcUiuxImpact(projectRoot, docs) {
|
|
1030
|
+
const errors = [];
|
|
1031
|
+
for (const doc of docs) {
|
|
1032
|
+
const relative = repoRelative(projectRoot, doc);
|
|
1033
|
+
const basename = path.basename(doc);
|
|
1034
|
+
const number = rfcNumber(basename);
|
|
1035
|
+
if (number !== undefined && number < 27)
|
|
1036
|
+
continue;
|
|
1037
|
+
const text = await readText(doc);
|
|
1038
|
+
if (!containsAny(text, RFC_UIUX_TRIGGER_TERMS))
|
|
1039
|
+
continue;
|
|
1040
|
+
if (!containsAny(text, RFC_UIUX_IMPACT_TERMS)) {
|
|
1041
|
+
errors.push(`${relative} must include UI/UX Impact when RFC changes experience docs, screen contracts, DESIGN.md, frontend, or browser behavior`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return errors;
|
|
1045
|
+
}
|
|
894
1046
|
function rfcNumber(fileName) {
|
|
895
1047
|
const match = fileName.match(/^RFC[_-](\d+)/i);
|
|
896
1048
|
return match ? Number(match[1]) : undefined;
|
|
@@ -1484,7 +1636,7 @@ function validateParallelExecutionContract(plan, currentPhase, errors) {
|
|
|
1484
1636
|
errors.push("parallel_execution must not define linked_task_id; use plan.yaml current_task_id");
|
|
1485
1637
|
}
|
|
1486
1638
|
if (!PARALLEL_ALLOWED_PHASES.has(currentPhase)) {
|
|
1487
|
-
errors.push("parallel_execution is only supported during REQUIREMENT_GATHERING, ARCHITECTING, SPRINTING, REVIEWING, TESTING, RELEASING, or RFC_RECALIBRATION");
|
|
1639
|
+
errors.push("parallel_execution is only supported during REQUIREMENT_GATHERING, UI_UX_DESIGNING, ARCHITECTING, SPRINTING, REVIEWING, TESTING, RELEASING, or RFC_RECALIBRATION");
|
|
1488
1640
|
}
|
|
1489
1641
|
if (contract.coordinator !== "main_agent")
|
|
1490
1642
|
errors.push('parallel_execution.coordinator must be "main_agent"');
|
|
@@ -2375,6 +2527,167 @@ function validateTestReadinessDecision(text) {
|
|
|
2375
2527
|
}
|
|
2376
2528
|
return [];
|
|
2377
2529
|
}
|
|
2530
|
+
async function validateDesignMd(projectRoot) {
|
|
2531
|
+
const errors = [];
|
|
2532
|
+
try {
|
|
2533
|
+
const cliPath = await findDesignMdCliPath(projectRoot);
|
|
2534
|
+
if (!cliPath) {
|
|
2535
|
+
errors.push("DESIGN.md linter not found; install package dependencies for @google/design.md");
|
|
2536
|
+
return errors;
|
|
2537
|
+
}
|
|
2538
|
+
const { stdout } = await execFileAsync(process.execPath, [cliPath, "lint", DESIGN_SYSTEM_PATH], { cwd: projectRoot });
|
|
2539
|
+
errors.push(...designMdLintErrors(stdout));
|
|
2540
|
+
}
|
|
2541
|
+
catch (caught) {
|
|
2542
|
+
const error = caught;
|
|
2543
|
+
const parsedErrors = designMdLintErrors(String(error.stdout ?? ""));
|
|
2544
|
+
if (parsedErrors.length > 0) {
|
|
2545
|
+
errors.push(...parsedErrors);
|
|
2546
|
+
}
|
|
2547
|
+
else {
|
|
2548
|
+
errors.push(`DESIGN.md linter failed: ${String(error.stderr ?? error.message ?? "unknown error").trim()}`);
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
return errors;
|
|
2552
|
+
}
|
|
2553
|
+
async function findDesignMdCliPath(projectRoot) {
|
|
2554
|
+
const candidates = [];
|
|
2555
|
+
const addSearchRoots = (start) => {
|
|
2556
|
+
let current = path.resolve(start);
|
|
2557
|
+
while (true) {
|
|
2558
|
+
candidates.push(path.join(current, "node_modules", "@google", "design.md", "dist", "index.js"));
|
|
2559
|
+
const parent = path.dirname(current);
|
|
2560
|
+
if (parent === current)
|
|
2561
|
+
break;
|
|
2562
|
+
current = parent;
|
|
2563
|
+
}
|
|
2564
|
+
};
|
|
2565
|
+
addSearchRoots(projectRoot);
|
|
2566
|
+
addSearchRoots(path.dirname(fileURLToPath(import.meta.url)));
|
|
2567
|
+
for (const candidate of candidates) {
|
|
2568
|
+
if (await pathExists(candidate))
|
|
2569
|
+
return candidate;
|
|
2570
|
+
}
|
|
2571
|
+
return undefined;
|
|
2572
|
+
}
|
|
2573
|
+
function designMdLintErrors(stdout) {
|
|
2574
|
+
const text = stdout.trim();
|
|
2575
|
+
if (!text)
|
|
2576
|
+
return [];
|
|
2577
|
+
try {
|
|
2578
|
+
const diagnostics = JSON.parse(text);
|
|
2579
|
+
const errors = [];
|
|
2580
|
+
const summary = isRecord(diagnostics.summary) ? diagnostics.summary : {};
|
|
2581
|
+
if (Number(summary.errors ?? 0) > 0) {
|
|
2582
|
+
errors.push("DESIGN.md linter reported errors");
|
|
2583
|
+
}
|
|
2584
|
+
const findings = Array.isArray(diagnostics.findings) ? diagnostics.findings : [];
|
|
2585
|
+
for (const finding of findings) {
|
|
2586
|
+
if (!isRecord(finding) || String(finding.severity ?? "").toLowerCase() !== "error")
|
|
2587
|
+
continue;
|
|
2588
|
+
errors.push(String(finding.message ?? "DESIGN.md linter error"));
|
|
2589
|
+
}
|
|
2590
|
+
return errors;
|
|
2591
|
+
}
|
|
2592
|
+
catch {
|
|
2593
|
+
return [];
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
async function validateTestCasesIfNeeded(projectRoot, plan, reportText) {
|
|
2597
|
+
const casesPath = path.join(projectRoot, TEST_CASES_PATH);
|
|
2598
|
+
const shouldValidate = testCaseRefs(reportText).length > 0 || planReferencesTestCases(plan) || (await pathExists(casesPath));
|
|
2599
|
+
if (!shouldValidate)
|
|
2600
|
+
return [];
|
|
2601
|
+
if (!(await pathExists(casesPath))) {
|
|
2602
|
+
return [`Missing test cases: expected ${TEST_CASES_PATH} because TEST_REPORT.md or current TESTING task references test cases`];
|
|
2603
|
+
}
|
|
2604
|
+
return validateTestCases(await readText(casesPath), reportText);
|
|
2605
|
+
}
|
|
2606
|
+
function testCaseRefs(text) {
|
|
2607
|
+
return [...new Set(text.match(TEST_CASE_ID_PATTERN) ?? [])].sort();
|
|
2608
|
+
}
|
|
2609
|
+
function planReferencesTestCases(plan) {
|
|
2610
|
+
const tasks = Array.isArray(plan.tasks) ? plan.tasks : [];
|
|
2611
|
+
return tasks.some((task) => {
|
|
2612
|
+
if (!isRecord(task) || String(task.phase ?? "") !== "TESTING")
|
|
2613
|
+
return false;
|
|
2614
|
+
return asStringList(task.result_docs).includes(TEST_CASES_PATH);
|
|
2615
|
+
});
|
|
2616
|
+
}
|
|
2617
|
+
function validateTestCases(text, reportText) {
|
|
2618
|
+
const errors = [];
|
|
2619
|
+
if (containsAny(text, TEST_REPORT_PLACEHOLDER_TERMS)) {
|
|
2620
|
+
errors.push("TEST_CASES.md must not contain pending/TBD/TODO/placeholder content");
|
|
2621
|
+
}
|
|
2622
|
+
const lines = text.split(/\r?\n/);
|
|
2623
|
+
const rows = [];
|
|
2624
|
+
lines.forEach((line, index) => {
|
|
2625
|
+
if (!line.trim().startsWith("|") || !/\bTC-\d{3,}\b/.test(line))
|
|
2626
|
+
return;
|
|
2627
|
+
const headers = findMarkdownTableHeader(lines, index);
|
|
2628
|
+
if (!headers) {
|
|
2629
|
+
errors.push("TEST_CASES.md cases must be listed in a Markdown table with headers");
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
rows.push({ lineNumber: index + 1, headers, cells: splitMarkdownRow(line) });
|
|
2633
|
+
});
|
|
2634
|
+
TEST_CASE_ID_PATTERN.lastIndex = 0;
|
|
2635
|
+
const ids = rows.flatMap((row) => testCaseRefs(row.cells.join("|")));
|
|
2636
|
+
if (ids.length === 0) {
|
|
2637
|
+
errors.push("TEST_CASES.md must include at least one TC-* case");
|
|
2638
|
+
}
|
|
2639
|
+
const duplicates = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))].sort();
|
|
2640
|
+
if (duplicates.length > 0) {
|
|
2641
|
+
errors.push(`TEST_CASES.md Case ID must be unique: ${duplicates.join(", ")}`);
|
|
2642
|
+
}
|
|
2643
|
+
for (const row of rows) {
|
|
2644
|
+
const requirement = headerIndex(row.headers, ["requirement", "risk", "需求", "风险"]);
|
|
2645
|
+
const runnableEntry = headerIndex(row.headers, ["runnable entry", "runnable", "entry", "入口"]);
|
|
2646
|
+
const steps = headerIndex(row.headers, ["steps", "步骤"]);
|
|
2647
|
+
const expectedExit = headerIndex(row.headers, ["expected exit", "expected result", "expected", "预期", "出口"]);
|
|
2648
|
+
const missing = [];
|
|
2649
|
+
if (!requiredCell(row.cells, requirement))
|
|
2650
|
+
missing.push("Requirement / Risk Ref");
|
|
2651
|
+
if (!requiredCell(row.cells, runnableEntry))
|
|
2652
|
+
missing.push("Runnable Entry");
|
|
2653
|
+
if (!requiredCell(row.cells, steps))
|
|
2654
|
+
missing.push("Steps");
|
|
2655
|
+
if (!requiredCell(row.cells, expectedExit))
|
|
2656
|
+
missing.push("Expected Exit");
|
|
2657
|
+
if (missing.length > 0) {
|
|
2658
|
+
errors.push(`TEST_CASES.md row ${row.lineNumber} missing required case fields: ${missing.join(", ")}`);
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
const missingRefs = testCaseRefs(reportText).filter((id) => !ids.includes(id));
|
|
2662
|
+
if (missingRefs.length > 0) {
|
|
2663
|
+
errors.push(`TEST_REPORT.md references case IDs not found in TEST_CASES.md: ${[...new Set(missingRefs)].join(", ")}`);
|
|
2664
|
+
}
|
|
2665
|
+
return errors;
|
|
2666
|
+
}
|
|
2667
|
+
function splitMarkdownRow(line) {
|
|
2668
|
+
return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
|
|
2669
|
+
}
|
|
2670
|
+
function isMarkdownSeparatorRow(line) {
|
|
2671
|
+
const stripped = line.trim();
|
|
2672
|
+
return stripped.startsWith("|") && /^[|:\-\s]+$/.test(stripped);
|
|
2673
|
+
}
|
|
2674
|
+
function findMarkdownTableHeader(lines, rowIndex) {
|
|
2675
|
+
for (let index = rowIndex - 1; index > 0; index -= 1) {
|
|
2676
|
+
if (!isMarkdownSeparatorRow(lines[index]))
|
|
2677
|
+
continue;
|
|
2678
|
+
const header = lines[index - 1];
|
|
2679
|
+
return header.trim().startsWith("|") ? splitMarkdownRow(header) : undefined;
|
|
2680
|
+
}
|
|
2681
|
+
return undefined;
|
|
2682
|
+
}
|
|
2683
|
+
function headerIndex(headers, terms) {
|
|
2684
|
+
return headers.findIndex((header) => terms.some((term) => header.toLowerCase().includes(term.toLowerCase())));
|
|
2685
|
+
}
|
|
2686
|
+
function requiredCell(cells, index) {
|
|
2687
|
+
if (index === undefined || index < 0 || index >= cells.length)
|
|
2688
|
+
return "";
|
|
2689
|
+
return cells[index].trim();
|
|
2690
|
+
}
|
|
2378
2691
|
function validateRuntimeHandoffReport(text, label) {
|
|
2379
2692
|
const decision = finalDecision(text);
|
|
2380
2693
|
if (decision !== "PASS")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-project-sdlc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
4
4
|
"description": "CLI and canonical assets for the AI SDLC Harness workflow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"node": ">=20"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
+
"@google/design.md": "^0.2.0",
|
|
26
27
|
"yaml": "^2.9.0"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|