agent-project-sdlc 0.1.12 → 0.1.14

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.
@@ -27,6 +27,62 @@ const DESIGN_CATEGORIES = [
27
27
  architectureTerms: ["compliance", "permission", "authorization", "audit", "合规", "权限", "审计", "授权", "客户确认", "回执归档"]
28
28
  }
29
29
  ];
30
+ const TESTING_DISALLOWED_ALLOWED_PATHS = [
31
+ "package.json",
32
+ "**/package.json",
33
+ "package-lock.json",
34
+ "**/package-lock.json",
35
+ "npm-shrinkwrap.json",
36
+ "**/npm-shrinkwrap.json",
37
+ "pnpm-lock.yaml",
38
+ "**/pnpm-lock.yaml",
39
+ "yarn.lock",
40
+ "**/yarn.lock",
41
+ "bun.lock",
42
+ "**/bun.lock",
43
+ "bun.lockb",
44
+ "**/bun.lockb",
45
+ "src/**",
46
+ "app/**",
47
+ "lib/**",
48
+ "server/**",
49
+ "bin/**",
50
+ "cli/**",
51
+ "runtime/**",
52
+ "scripts/**",
53
+ "tools/**",
54
+ "deploy/**",
55
+ "deployment/**",
56
+ "infra/**",
57
+ "ops/**",
58
+ "systemd/**",
59
+ ".github/workflows/**",
60
+ "dockerfile",
61
+ "dockerfile.*",
62
+ "docker-compose*.yml",
63
+ "docker-compose*.yaml",
64
+ "*.service",
65
+ "tests/runtime/**",
66
+ "tests/**/runtime/**"
67
+ ];
68
+ const TESTING_DISALLOWED_CHANGED_PATHS = [...TESTING_DISALLOWED_ALLOWED_PATHS, "scripts/**", "tools/**"];
69
+ const TESTING_RUNTIME_FILE_TERMS = ["bootstrap", "cloud", "daemon", "poller", "provider", "runtime", "service", "systemd"];
70
+ const TESTING_ALLOWED_TEST_FILE_TERMS = ["assertion", "fixture", "mock", "smoke"];
71
+ const TEST_REPORT_PATH = ".docs/07_test/TEST_REPORT.md";
72
+ const CURRENT_RELEASE_REPORT_PATH = ".docs/08_release/CURRENT_RELEASE.md";
73
+ const TEST_REPORT_PLACEHOLDER_TERMS = ["pending", "tbd", "todo", "待填", "待补", "placeholder"];
74
+ const TEST_FACT_SOURCE_PHASES = new Set(["TESTING", "RFC_RECALIBRATION"]);
75
+ const TEST_FACT_SOURCE_PATTERNS = [".docs/07_test/**", ".docs/07_test/"];
76
+ const TEST_FACT_SOURCE_REF = /\.docs\/07_test\/[^\s`,)]+/g;
77
+ const RUNNABLE_ENTRY_EXIT_TERMS = [
78
+ "runnable entry/exit",
79
+ "entry/exit",
80
+ "entry points",
81
+ "entry point",
82
+ "可运行入口/出口",
83
+ "入口/出口",
84
+ "not applicable"
85
+ ];
30
86
  const validators = {
31
87
  "validate-harness": validateHarness,
32
88
  "validate-current": validateCurrent,
@@ -73,6 +129,9 @@ async function validateCurrent(projectRoot) {
73
129
  const root = await harnessRoot(projectRoot);
74
130
  const lifecycle = await readYamlObject(path.join(projectRoot, root, "state", "lifecycle.yaml"));
75
131
  const current = String(lifecycle.current_phase ?? "");
132
+ if (current === "SPRINTING") {
133
+ return validateDevInternal(projectRoot, { phaseExit: true });
134
+ }
76
135
  const gateByPhase = {
77
136
  REQUIREMENT_GATHERING: "validate-pm",
78
137
  ARCHITECTING: "validate-design",
@@ -213,6 +272,7 @@ function validateDraftTaskShape(task, index, errors) {
213
272
  if (!hasImplementationDoc && !hasResultDocs) {
214
273
  errors.push(`${String(task.id ?? prefix)} must define implementation_doc or result_docs`);
215
274
  }
275
+ errors.push(...testFactSourceErrorsForTask(task));
216
276
  if (OPEN_TASK_STATUSES.has(String(task.status))) {
217
277
  if ("gate_result" in task)
218
278
  errors.push(`${String(task.id ?? prefix)} open task must not define gate_result`);
@@ -262,10 +322,49 @@ async function validateCrossCuttingArchitecture(projectRoot, productFiles, techP
262
322
  return errors;
263
323
  }
264
324
  async function validateDev(projectRoot) {
325
+ return validateDevInternal(projectRoot, { phaseExit: false });
326
+ }
327
+ async function validateDevInternal(projectRoot, options) {
265
328
  const root = await harnessRoot(projectRoot);
266
- const plan = await validatePlanState(projectRoot, false);
329
+ const lifecycle = await readYamlObject(path.join(projectRoot, root, "state", "lifecycle.yaml"));
330
+ const plan = await validatePlanState(projectRoot, !options.phaseExit);
331
+ const phaseErrors = String(lifecycle.current_phase ?? "") === "SPRINTING" ? [] : ["validate-dev requires lifecycle current_phase SPRINTING"];
332
+ const openTaskErrors = options.phaseExit ? [] : validateDevOpenTaskState(plan.plan);
333
+ const pathErrors = options.phaseExit ? [] : await validateChangedPaths(projectRoot, plan.plan, true);
267
334
  const draftErrors = await validateDevDraftConsumed(projectRoot, root);
268
- return { info: [`validate-dev checked ${plan.taskCount} task(s)`], errors: [...plan.errors, ...draftErrors] };
335
+ const implementationDocErrors = await validateImplementationDocRunnableEntryExit(projectRoot);
336
+ return {
337
+ info: [`validate-dev checked ${plan.taskCount} task(s)${options.phaseExit ? " for phase exit" : ""}`],
338
+ errors: [...phaseErrors, ...plan.errors, ...openTaskErrors, ...pathErrors, ...draftErrors, ...implementationDocErrors]
339
+ };
340
+ }
341
+ function validateDevOpenTaskState(plan) {
342
+ const errors = [];
343
+ const tasks = Array.isArray(plan.tasks) ? plan.tasks.filter(isRecord) : [];
344
+ const open = tasks.filter((task) => OPEN_TASK_STATUSES.has(String(task.status)));
345
+ if (open.length === 0)
346
+ return errors;
347
+ const currentTaskId = String(plan.current_task_id ?? "");
348
+ if (!currentTaskId) {
349
+ errors.push("validate-dev requires current_task_id when SPRINTING has an open task");
350
+ return errors;
351
+ }
352
+ const currentTask = open.find((task) => String(task.id ?? "") === currentTaskId);
353
+ if (!currentTask) {
354
+ errors.push(`current_task_id does not match an open SPRINTING task: ${currentTaskId}`);
355
+ return errors;
356
+ }
357
+ const otherOpen = open.filter((task) => String(task.id ?? "") !== currentTaskId);
358
+ if (otherOpen.length > 0) {
359
+ errors.push(`validate-dev supports only the current open task during SPRINTING: ${open.map((task) => task.id).join(", ")}`);
360
+ }
361
+ if (String(currentTask.phase ?? "") !== "SPRINTING") {
362
+ errors.push(`${currentTaskId} must have phase SPRINTING for validate-dev`);
363
+ }
364
+ if (typeof currentTask.implementation_doc !== "string" || !currentTask.implementation_doc.trim()) {
365
+ errors.push(`${currentTaskId} must define implementation_doc for validate-dev`);
366
+ }
367
+ return errors;
269
368
  }
270
369
  async function validateDevDraftConsumed(projectRoot, root) {
271
370
  const errors = [];
@@ -291,38 +390,55 @@ async function validateReview(projectRoot) {
291
390
  errors.push("Review report must include findings or risks");
292
391
  if (!containsAny(text, ["test gap", "测试缺口", "coverage"]))
293
392
  errors.push("Review report must include test gaps or coverage notes");
393
+ if (!containsAny(text, ["entry/exit", "entrypoint", "入口", "出口", "runnable", "可运行"])) {
394
+ errors.push("Review report must assess runnable entry/exit readiness before TESTING");
395
+ }
294
396
  if (!containsAny(text, ["pass", "blocked", "通过", "阻塞"]))
295
397
  errors.push("Review report must include PASS/BLOCKED decision");
296
398
  return { info: ["validate-review checked review report"], errors };
297
399
  }
298
400
  async function validateTest(projectRoot) {
401
+ const root = await harnessRoot(projectRoot);
402
+ const lifecycle = await readYamlObject(path.join(projectRoot, root, "state", "lifecycle.yaml"));
299
403
  const plan = await validatePlanState(projectRoot, false);
300
- const text = (await readText(path.join(projectRoot, ".docs/07_test/TEST_PLAN.md"))).toLowerCase();
301
404
  const errors = [...plan.errors];
405
+ const report = await readTestReport(projectRoot);
406
+ const text = report ? report.text.toLowerCase() : "";
407
+ if (!report)
408
+ errors.push(`Missing test report: expected executed evidence at ${TEST_REPORT_PATH}`);
409
+ if (containsAny(text, TEST_REPORT_PLACEHOLDER_TERMS)) {
410
+ errors.push("Test report must contain executed evidence, not pending/TBD/TODO/placeholder content");
411
+ }
302
412
  if (!containsAny(text, ["matrix", "矩阵"]))
303
- errors.push("Test plan must include a test matrix");
413
+ errors.push("Test report must include a test matrix");
304
414
  if (!containsAny(text, ["regression", "回归"]))
305
- errors.push("Test plan must include regression coverage");
415
+ errors.push("Test report must include regression evidence");
306
416
  if (!containsAny(text, ["coverage gap", "覆盖缺口", "gap"]))
307
- errors.push("Test plan must include coverage gaps");
417
+ errors.push("Test report must include coverage gaps");
418
+ if (!containsAny(text, ["entry/exit", "entrypoint", "入口", "出口", "runnable", "可运行"])) {
419
+ errors.push("Test report must state existing runnable entry/exit coverage or blocker status");
420
+ }
308
421
  if (!containsAny(text, ["pass", "blocked", "通过", "阻塞"]))
309
- errors.push("Test plan must include PASS/BLOCKED decision");
310
- return { info: ["validate-test checked test plan"], errors };
422
+ errors.push("Test report must include PASS/BLOCKED decision");
423
+ if (lifecycle.current_phase === "TESTING") {
424
+ errors.push(...testingBoundaryErrorsForChangedFiles(await changedFiles(projectRoot)));
425
+ }
426
+ return { info: [`validate-test checked ${report?.source ?? "missing test report"}`], errors };
311
427
  }
312
428
  async function validateRelease(projectRoot) {
313
429
  const plan = await validatePlanState(projectRoot, false);
314
- const docs = await markdownFiles(path.join(projectRoot, ".docs/08_release"));
315
- const text = await combinedText(docs);
430
+ const report = await readReleaseReport(projectRoot);
431
+ const text = report?.text ?? "";
316
432
  const errors = [...plan.errors];
317
- if (docs.length === 0)
318
- errors.push("No release deliverables found");
433
+ if (!report)
434
+ errors.push(`Missing current release report: expected ${CURRENT_RELEASE_REPORT_PATH} or legacy .docs/08_release/*.md`);
319
435
  if (!containsAny(text, ["release", "发布"]))
320
- errors.push("Release docs must include release notes");
436
+ errors.push("Current release report must include release notes");
321
437
  if (!containsAny(text, ["smoke", "冒烟"]))
322
- errors.push("Release docs must include smoke test evidence");
438
+ errors.push("Current release report must include smoke test evidence");
323
439
  if (!containsAny(text, ["rollback", "回滚"]))
324
- errors.push("Release docs must include rollback plan");
325
- return { info: [`validate-release checked ${docs.length} file(s)`], errors };
440
+ errors.push("Current release report must include rollback plan");
441
+ return { info: [`validate-release checked ${report?.source ?? "missing current release report"}`], errors };
326
442
  }
327
443
  async function validateRfc(projectRoot) {
328
444
  const plan = await validatePlanState(projectRoot, false);
@@ -339,6 +455,21 @@ async function validateRfc(projectRoot) {
339
455
  errors.push("RFC must include technical impact candidates");
340
456
  if (!containsAny(text, ["regression", "回归"]))
341
457
  errors.push("RFC must include regression requirements");
458
+ if (!containsAny(text, ["test fact source impact", "测试事实源影响"])) {
459
+ errors.push("RFC must include Test Fact Source Impact");
460
+ }
461
+ const indexPath = path.join(projectRoot, ".docs/INDEX.md");
462
+ const indexText = (await pathExists(indexPath)) ? await readText(indexPath) : "";
463
+ if (!indexText)
464
+ errors.push("Missing .docs/INDEX.md for RFC test fact source validation");
465
+ for (const superseded of await supersededTestDocs(docs)) {
466
+ if (await pathExists(path.join(projectRoot, superseded))) {
467
+ errors.push(`Superseded test doc still exists in current facts: ${superseded}`);
468
+ }
469
+ if (indexText.includes(superseded)) {
470
+ errors.push(`Superseded test doc still linked from .docs/INDEX.md: ${superseded}`);
471
+ }
472
+ }
342
473
  const statuses = [...text.matchAll(/status:\s*([a-z_]+)/g)].map((match) => match[1].toUpperCase());
343
474
  if (statuses.length === 0)
344
475
  errors.push("RFC must include a Status line");
@@ -397,6 +528,7 @@ async function validatePlanState(projectRoot, allowOpen) {
397
528
  if (match) {
398
529
  maxTaskSequence = Math.max(maxTaskSequence, Number(match[1]));
399
530
  }
531
+ errors.push(...testFactSourceErrorsForTask(task));
400
532
  if (["pending", "in_progress", "blocked", "pending_revision"].includes(String(task.status))) {
401
533
  if ("gate_result" in task) {
402
534
  errors.push(`Open task ${task.id} must not define gate_result`);
@@ -417,6 +549,7 @@ async function validatePlanState(projectRoot, allowOpen) {
417
549
  if (!Array.isArray(task.acceptance_criteria) || task.acceptance_criteria.length === 0) {
418
550
  errors.push(`Open task ${task.id} must define acceptance_criteria`);
419
551
  }
552
+ errors.push(...testingBoundaryErrorsForAllowedPaths(task));
420
553
  }
421
554
  else {
422
555
  errors.push(`Completed task ${task.id} must not remain in plan.yaml`);
@@ -564,6 +697,46 @@ async function readYamlObject(filePath) {
564
697
  return {};
565
698
  return (parseYaml(await readText(filePath)) ?? {});
566
699
  }
700
+ async function readTestReport(projectRoot) {
701
+ const canonical = path.join(projectRoot, TEST_REPORT_PATH);
702
+ if (await pathExists(canonical)) {
703
+ return { text: await readText(canonical), source: TEST_REPORT_PATH };
704
+ }
705
+ return undefined;
706
+ }
707
+ async function readReleaseReport(projectRoot) {
708
+ const canonical = path.join(projectRoot, CURRENT_RELEASE_REPORT_PATH);
709
+ if (await pathExists(canonical)) {
710
+ return { text: await readText(canonical), source: CURRENT_RELEASE_REPORT_PATH };
711
+ }
712
+ const legacyDocs = await markdownFiles(path.join(projectRoot, ".docs/08_release"));
713
+ if (legacyDocs.length > 0) {
714
+ return { text: await combinedText(legacyDocs), source: `legacy .docs/08_release/*.md (${legacyDocs.length} file(s))` };
715
+ }
716
+ return undefined;
717
+ }
718
+ async function validateImplementationDocRunnableEntryExit(projectRoot) {
719
+ const docs = await markdownFiles(path.join(projectRoot, ".docs/04_implementation"));
720
+ const errors = [];
721
+ const indexPath = path.join(projectRoot, ".docs/INDEX.md");
722
+ const indexText = (await pathExists(indexPath)) ? await readText(indexPath) : "";
723
+ if (docs.length > 0 && !indexText) {
724
+ errors.push("Missing .docs/INDEX.md for implementation doc validation");
725
+ }
726
+ for (const doc of docs) {
727
+ const relative = repoRelative(projectRoot, doc);
728
+ const dotted = relative.startsWith(".") ? relative : `.${relative}`;
729
+ const withoutDocsPrefix = dotted.replace(/^\.docs\//, "");
730
+ if (indexText && !indexText.includes(dotted) && !indexText.includes(withoutDocsPrefix)) {
731
+ errors.push(`.docs/INDEX.md does not link implementation doc: ${dotted}`);
732
+ }
733
+ const text = await readText(doc);
734
+ if (!containsAny(text, RUNNABLE_ENTRY_EXIT_TERMS)) {
735
+ errors.push(`Implementation doc must include Runnable Entry/Exit facts or explicit Not applicable: ${dotted}`);
736
+ }
737
+ }
738
+ return errors;
739
+ }
567
740
  async function markdownFiles(root) {
568
741
  const files = await listFiles(root);
569
742
  return files.filter((file) => {
@@ -579,6 +752,78 @@ function containsAny(text, needles) {
579
752
  const lowered = text.toLowerCase();
580
753
  return needles.some((needle) => lowered.includes(needle.toLowerCase()));
581
754
  }
755
+ function testingBoundaryErrorsForAllowedPaths(task) {
756
+ if (task.phase !== "TESTING")
757
+ return [];
758
+ const allowed = Array.isArray(task.allowed_paths) ? task.allowed_paths.map((item) => String(item)) : [];
759
+ const blocked = allowed.filter((item) => isTestingBoundaryAllowedPath(item));
760
+ if (blocked.length === 0)
761
+ return [];
762
+ return [
763
+ `TESTING task allowed_paths must not include product runtime, package/deploy config, or long-running runtime paths: ${blocked.join(", ")}`
764
+ ];
765
+ }
766
+ function testingBoundaryErrorsForChangedFiles(files) {
767
+ const blocked = files.filter((file) => isTestingRuntimeBoundaryChange(file));
768
+ if (blocked.length === 0)
769
+ return [];
770
+ return [
771
+ `TESTING changes must use existing product entrypoints only; move runtime, bootstrap, provider, deploy, or package script changes to SPRINTING/RFC: ${blocked.join(", ")}`
772
+ ];
773
+ }
774
+ function testFactSourceErrorsForTask(task) {
775
+ const phase = String(task.phase ?? "");
776
+ if (TEST_FACT_SOURCE_PHASES.has(phase))
777
+ return [];
778
+ const candidates = [...asStringList(task.allowed_paths), ...asStringList(task.result_docs)];
779
+ const blocked = candidates.filter((candidate) => {
780
+ const normalized = candidate.replace(/\\/g, "/");
781
+ return normalized.startsWith(".docs/07_test/") || matchesAny(normalized, TEST_FACT_SOURCE_PATTERNS);
782
+ });
783
+ if (blocked.length === 0)
784
+ return [];
785
+ return [
786
+ `Only TESTING or RFC_RECALIBRATION tasks may target current test fact sources under .docs/07_test/**: ${blocked.join(", ")}`
787
+ ];
788
+ }
789
+ async function supersededTestDocs(docs) {
790
+ const refs = new Set();
791
+ for (const doc of docs) {
792
+ const text = await readText(doc);
793
+ for (const line of text.split("\n")) {
794
+ const lowered = line.toLowerCase();
795
+ if (!lowered.includes("superseded") && !lowered.includes("被替代") && !lowered.includes("失效")) {
796
+ continue;
797
+ }
798
+ for (const match of line.matchAll(TEST_FACT_SOURCE_REF)) {
799
+ refs.add(normalizeDocRef(match[0]).replace(/[.,;:]$/, ""));
800
+ }
801
+ }
802
+ }
803
+ return [...refs].filter((ref) => ref.startsWith(".docs/07_test/"));
804
+ }
805
+ function isTestingBoundaryAllowedPath(file) {
806
+ const lowered = file.replace(/\\/g, "/").toLowerCase();
807
+ if (["package.json", "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"].includes(lowered)) {
808
+ return true;
809
+ }
810
+ return matchesAny(lowered, TESTING_DISALLOWED_ALLOWED_PATHS);
811
+ }
812
+ function isTestingRuntimeBoundaryChange(file) {
813
+ const normalized = file.replace(/\\/g, "/");
814
+ const lowered = normalized.toLowerCase();
815
+ if (isTestingBoundaryAllowedPath(lowered) || matchesAny(lowered, TESTING_DISALLOWED_CHANGED_PATHS)) {
816
+ return true;
817
+ }
818
+ if (lowered.startsWith("tests/")) {
819
+ const name = path.basename(lowered);
820
+ if (TESTING_ALLOWED_TEST_FILE_TERMS.some((term) => name.includes(term))) {
821
+ return false;
822
+ }
823
+ return TESTING_RUNTIME_FILE_TERMS.some((term) => name.includes(term));
824
+ }
825
+ return false;
826
+ }
582
827
  function isDevelopmentDraft(task) {
583
828
  const taskId = String(task.id ?? "");
584
829
  return Boolean(task.implementation_doc) || task.phase === "SPRINTING" || taskId.startsWith("DEV-");
@@ -609,7 +854,7 @@ function taskText(task) {
609
854
  }
610
855
  export async function changedFiles(projectRoot) {
611
856
  try {
612
- const { stdout } = await execFileAsync("git", ["status", "--porcelain"], { cwd: projectRoot });
857
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: projectRoot });
613
858
  return stdout
614
859
  .split("\n")
615
860
  .map((line) => line.slice(3).trim())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-project-sdlc",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "CLI and canonical assets for the AI SDLC Harness workflow.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,29 +0,0 @@
1
- # Test Plan(测试计划)
2
-
3
- ## 1. Scope(范围)
4
-
5
- - PRD:
6
- - Technical design:
7
- - Implementation docs:
8
- - Review report:
9
-
10
- ## 2. Test Matrix(测试矩阵)
11
-
12
- | 需求(Requirement) | 场景(Scenario) | 测试类型(Test Type) | 测试用例(Test Case) | 结果(Result) |
13
- |---|---|---|---|---|
14
- | | | unit/integration/e2e/regression | | pending |
15
-
16
- ## 3. Regression Checklist(回归检查清单)
17
-
18
- - [ ]
19
-
20
- ## 4. Coverage Gaps(覆盖缺口)
21
-
22
- | 缺口(Gap) | 风险(Risk) | 后续动作(Follow-up) |
23
- |---|---|---|
24
- | | | |
25
-
26
- ## 5. Final Result(最终结论)
27
-
28
- - Decision: `PASS` / `BLOCKED`
29
- - Evidence: