@guanlin4924/devflow-pi 0.1.95 → 0.1.97

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.
Files changed (49) hide show
  1. package/README.md +42 -2
  2. package/package.json +2 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +679 -91
  4. package/plugins/devflow-core/runtime/hooks/hook_common.py +245 -0
  5. package/plugins/devflow-core/runtime/hooks/index_gate.py +18 -0
  6. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +98 -2
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_draft.py +53 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
  24. package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
  25. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
  26. package/plugins/devflow-intel/extensions/devflow-intel.ts +1 -1
  27. package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
  28. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +879 -0
  29. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
  30. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
  31. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
  32. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +10 -2
  33. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
  34. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
  35. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
  36. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
  37. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
  38. package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +3 -1
  39. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +69 -0
  40. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
  41. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +4 -2
  42. package/src/cli.ts +10 -0
  43. package/src/constants.ts +1 -1
  44. package/src/doctor.ts +9 -0
  45. package/src/mcp/auth.ts +103 -0
  46. package/src/mcp/core.ts +102 -0
  47. package/src/mcp/server.ts +193 -0
  48. package/src/mcp/types.ts +5 -0
  49. package/src/startup-briefing.ts +109 -0
@@ -51,6 +51,8 @@ from dsl_coverage import check as coverage_check # noqa: E402
51
51
  from dsl_v1 import SCHEMA_VERSION, validate as validate_dsl # noqa: E402
52
52
  from execution_surface import derive as derive_surface # noqa: E402
53
53
  from tc_hash import tc_sha256 # noqa: E402
54
+ from locator_map import LocatorMapError, expand_anchor_reference # noqa: E402
55
+ from locator_report import write_report # noqa: E402
54
56
 
55
57
 
56
58
  FEATURE_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]+$")
@@ -58,7 +60,7 @@ TC_ID_RE = re.compile(r"^TC-[0-9]{4,}$")
58
60
  GENERATABLE_SURFACES = frozenset({"ui", "api_flow"})
59
61
 
60
62
  UI_ACTIONS = frozenset({"goto", "click", "fill", "select", "check", "uncheck", "wait_for",
61
- "assert_visible", "assert_text", "assert_url", "screenshot"})
63
+ "assert_visible", "assert_text", "assert_each_text", "assert_value", "assert_url", "screenshot"})
62
64
  TARGET_ACTIONS = UI_ACTIONS - {"goto", "assert_url", "screenshot"}
63
65
  HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
64
66
  API_SHORTHAND = {method.lower(): method for method in HTTP_METHODS}
@@ -74,9 +76,13 @@ UI_KEYS = {
74
76
  "wait_for": {"locator", "exact", "state"},
75
77
  "assert_visible": {"locator", "exact"},
76
78
  "assert_text": {"locator", "exact", "expected", "match", "normalize_whitespace", "case_sensitive"},
79
+ "assert_each_text": {"locator", "exact", "expected", "match", "min_count", "normalize_whitespace", "case_sensitive"},
80
+ "assert_value": {"locator", "exact", "expected", "match", "normalize_whitespace", "case_sensitive"},
77
81
  "assert_url": {"expected", "match"},
78
82
  "screenshot": {"full_page"},
79
83
  }
84
+ for _ui_action in UI_KEYS:
85
+ UI_KEYS[_ui_action] = UI_KEYS[_ui_action] | {"source_anchor"}
80
86
  API_KEYS = {"method", "url", "headers", "query", "json", "expect_status", "assertions", "extract", "retry", "idempotent"}
81
87
  CASE_KEYS = {"tc_id", "name", "description", "variables", "data", "steps", "source_evidence", "tags"}
82
88
  DATA_KEYS = {"namespace", "owner", "accounts", "repeatable", "setup", "cleanup"}
@@ -199,12 +205,73 @@ def _css_errors(css: str) -> list[str]:
199
205
  return errors
200
206
 
201
207
 
202
- def compile_target(raw: Any, exact: Any, label: str, warnings: list[str]) -> dict[str, Any]:
208
+ def _validate_object_target(target: Any, label: str) -> dict[str, Any]:
209
+ """Validate new structured targets without duplicating Core semantics."""
210
+ if not isinstance(target, dict) or not target:
211
+ raise CompileError(f"{label}.locator target must be a non-empty object")
212
+ allowed = {"testid", "role", "name", "label", "placeholder", "text", "css", "exact",
213
+ "within", "table", "column", "table_kind", "row"}
214
+ unknown = set(target) - allowed
215
+ if unknown:
216
+ raise CompileError(f"{label}.locator has unsupported target keys {sorted(unknown)}")
217
+ forms = [key for key in ("testid", "role", "label", "placeholder", "text", "css", "table", "row") if key in target]
218
+ if "within" in target:
219
+ if len(forms) != 1:
220
+ raise CompileError(f"{label}.locator.within must wrap exactly one locator form")
221
+ _validate_object_target(target["within"], f"{label}.locator.within")
222
+ elif len(forms) != 1:
223
+ raise CompileError(f"{label}.locator must contain exactly one locator form")
224
+ if "role" in target and (not isinstance(target["role"], str) or not isinstance(target.get("name"), str)):
225
+ raise CompileError(f"{label}.locator role target requires role and name")
226
+ if "role" not in target and "name" in target:
227
+ raise CompileError(f"{label}.locator.name requires role")
228
+ if "exact" in target and not isinstance(target["exact"], bool):
229
+ raise CompileError(f"{label}.locator.exact must be a boolean")
230
+ if "table" in target:
231
+ table = target["table"]
232
+ if not isinstance(table, dict) or set(table) not in ({"near_heading"}, {"testid"}, {"only"}, {"ref"}):
233
+ raise CompileError(f"{label}.locator.table must use near_heading, testid, only, or ref")
234
+ if "column" not in target or not isinstance(target["column"], str) or not target["column"].strip():
235
+ raise CompileError(f"{label}.locator.table requires a non-empty column")
236
+ if target.get("table_kind", "html") not in {"html", "element-plus"}:
237
+ raise CompileError(f"{label}.locator.table_kind must be html or element-plus")
238
+ if "row" in target:
239
+ row = target["row"]
240
+ if not isinstance(row, dict) or set(row) not in ({"column", "equals"}, {"column", "contains"}):
241
+ raise CompileError(f"{label}.locator.row must be {{column, equals}} or {{column, contains}}")
242
+ match_key = "equals" if "equals" in row else "contains"
243
+ if not isinstance(row.get("column"), str) or not row["column"].strip() or not isinstance(row.get(match_key), str):
244
+ raise CompileError(f"{label}.locator.row column and match value must be strings")
245
+ return json.loads(json.dumps(target, ensure_ascii=False))
246
+
247
+
248
+ def _compile_target(raw: Any, exact: Any, label: str, warnings: list[str], locator_dir: Path | None = None) -> tuple[dict[str, Any], str | None]:
203
249
  """'type:value' → one v1 target. testid first; role/label/placeholder/text default to exact."""
250
+ if isinstance(raw, dict):
251
+ if exact is not None:
252
+ raise CompileError(f"{label}.exact must be inside a structured locator object")
253
+ target = _validate_object_target(raw, label)
254
+ if "table" in target:
255
+ warnings.append(f"{label} uses table+column locator; verify the table adapter and column uniqueness")
256
+ elif "row" in target:
257
+ warnings.append(f"{label} uses a data-dependent row locator")
258
+ elif "within" in target:
259
+ warnings.append(f"{label} uses a within-scoped locator")
260
+ return target, None
204
261
  if not isinstance(raw, str) or ":" not in raw:
205
262
  raise CompileError(f"{label}.locator must be 'testid:<id>' | 'role:<role>:<name>' | 'label:<text>' | "
206
263
  "'placeholder:<text>' | 'text:<text>' | 'css:<selector>'")
207
264
  kind, _, value = raw.strip().partition(":")
265
+ if kind.strip().lower() == "anchor":
266
+ if locator_dir is None:
267
+ raise CompileError(f"{label}.locator anchor requires the test-locators directory")
268
+ try:
269
+ target, source_anchor = expand_anchor_reference(raw, locator_dir)
270
+ except LocatorMapError as error:
271
+ raise CompileError(f"{label}.locator: {error}") from error
272
+ if exact is not None:
273
+ raise CompileError(f"{label}.exact cannot override an anchor target")
274
+ return _compile_target(target, None, label, warnings, locator_dir)[0], source_anchor
208
275
  kind = kind.strip().lower()
209
276
  if kind in REJECTED_LOCATOR_TYPES:
210
277
  raise CompileError(f"{label}.locator: {REJECTED_LOCATOR_TYPES[kind]}")
@@ -217,7 +284,7 @@ def compile_target(raw: Any, exact: Any, label: str, warnings: list[str]) -> dic
217
284
  raise CompileError(f"{label}.exact does not apply to testid locators")
218
285
  if not value.strip():
219
286
  raise CompileError(f"{label}.locator has an empty value")
220
- return {"testid": value.strip()}
287
+ return {"testid": value.strip()}, None
221
288
  if kind == "css":
222
289
  if exact is not None:
223
290
  raise CompileError(f"{label}.exact does not apply to css locators")
@@ -225,7 +292,7 @@ def compile_target(raw: Any, exact: Any, label: str, warnings: list[str]) -> dic
225
292
  if errors:
226
293
  raise CompileError(f"{label}: " + "; ".join(errors))
227
294
  warnings.append(f"{label} uses a css locator; prefer a data-testid and cite the selector source in source-map.md")
228
- return {"css": value.strip()}
295
+ return {"css": value.strip()}, None
229
296
  if kind == "role":
230
297
  role, _, name = value.partition(":")
231
298
  if not re.fullmatch(r"[a-z]+", role.strip()) or not name.strip():
@@ -242,7 +309,12 @@ def compile_target(raw: Any, exact: Any, label: str, warnings: list[str]) -> dic
242
309
  warnings.append(f"{label} locates '{text}' by {kind}; this text usually repeats per row/dialog and fails strict mode — use a data-testid")
243
310
  else:
244
311
  warnings.append(f"{label} uses {kind} instead of a data-testid")
245
- return target
312
+ return target, None
313
+
314
+
315
+ def compile_target(raw: Any, exact: Any, label: str, warnings: list[str], locator_dir: Path | None = None) -> dict[str, Any]:
316
+ """Backward-compatible public target compiler; anchor provenance is step-level."""
317
+ return _compile_target(raw, exact, label, warnings, locator_dir)[0]
246
318
 
247
319
 
248
320
  # ---------------------------------------------------------------------------
@@ -360,7 +432,7 @@ def compile_api_step(step: dict[str, Any], method: str | None, default_id: str,
360
432
  return compiled
361
433
 
362
434
 
363
- def compile_step(step: Any, default_id: str, label: str, warnings: list[str], api_only: bool = False) -> dict[str, Any]:
435
+ def compile_step(step: Any, default_id: str, label: str, warnings: list[str], api_only: bool = False, locator_dir: Path | None = None) -> dict[str, Any]:
364
436
  if not isinstance(step, dict):
365
437
  raise CompileError(f"{label} must be an object")
366
438
  _check_forbidden(step, label)
@@ -384,16 +456,20 @@ def compile_step(step: Any, default_id: str, label: str, warnings: list[str], ap
384
456
  if lowered in TARGET_ACTIONS:
385
457
  if "locator" not in step:
386
458
  raise CompileError(f"{label}.locator is required for {lowered}")
387
- compiled["target"] = compile_target(step["locator"], step.get("exact"), label, warnings)
459
+ compiled["target"], source_anchor = _compile_target(step["locator"], step.get("exact"), label, warnings, locator_dir)
460
+ if source_anchor:
461
+ compiled["source_anchor"] = source_anchor
388
462
  if lowered == "goto":
389
463
  compiled["url"] = step.get("url")
390
464
  elif lowered in {"fill", "select"}:
391
465
  compiled["value"] = step.get("value")
392
466
  elif lowered == "wait_for":
393
467
  compiled["state"] = step.get("state", "visible")
394
- elif lowered == "assert_text":
468
+ elif lowered in {"assert_text", "assert_each_text", "assert_value"}:
395
469
  compiled["match"] = step.get("match", "exact")
396
470
  compiled["expected"] = step.get("expected")
471
+ if lowered == "assert_each_text" and "min_count" in step:
472
+ compiled["min_count"] = step["min_count"]
397
473
  for key in ("normalize_whitespace", "case_sensitive"):
398
474
  if key in step:
399
475
  compiled[key] = step[key]
@@ -428,7 +504,7 @@ def compile_variables(raw: Any, label: str) -> dict[str, Any]:
428
504
  return compiled
429
505
 
430
506
 
431
- def compile_case(desc: Any, index: int, eligible: dict[str, dict[str, Any]], rejected: dict[str, str]) -> tuple[str, dict[str, Any], list[str]]:
507
+ def compile_case(desc: Any, index: int, eligible: dict[str, dict[str, Any]], rejected: dict[str, str], locator_dir: Path | None = None) -> tuple[str, dict[str, Any], list[str]]:
432
508
  label = f"cases[{index}]"
433
509
  if not isinstance(desc, dict):
434
510
  raise CompileError(f"{label} must be an object")
@@ -457,12 +533,12 @@ def compile_case(desc: Any, index: int, eligible: dict[str, dict[str, Any]], rej
457
533
  steps = data_raw.get(phase, [])
458
534
  if not isinstance(steps, list):
459
535
  raise CompileError(f"{label}.data.{phase} must be an array")
460
- data[phase] = [compile_step(step, f"{phase}-{i:02d}", f"{label}.data.{phase}[{i - 1}]", warnings, api_only=True)
536
+ data[phase] = [compile_step(step, f"{phase}-{i:02d}", f"{label}.data.{phase}[{i - 1}]", warnings, api_only=True, locator_dir=locator_dir)
461
537
  for i, step in enumerate(steps, start=1)]
462
538
  raw_steps = desc.get("steps")
463
539
  if not isinstance(raw_steps, list) or not raw_steps:
464
540
  raise CompileError(f"{label}.steps must be a non-empty array")
465
- steps = [compile_step(step, f"s{i:02d}", f"{label}.steps[{i - 1}]", warnings) for i, step in enumerate(raw_steps, start=1)]
541
+ steps = [compile_step(step, f"s{i:02d}", f"{label}.steps[{i - 1}]", warnings, locator_dir=locator_dir) for i, step in enumerate(raw_steps, start=1)]
466
542
  source = eligible[tc_id]
467
543
  name = desc.get("name")
468
544
  dsl = {
@@ -482,7 +558,7 @@ def compile_case(desc: Any, index: int, eligible: dict[str, dict[str, Any]], rej
482
558
  return tc_id, dsl, [f"{label}: {warning}" for warning in warnings]
483
559
 
484
560
 
485
- def compile_all(payload: Any, suite: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], list[str], list[str]]:
561
+ def compile_all(payload: Any, suite: dict[str, Any], locator_dir: Path | None = None) -> tuple[dict[str, dict[str, Any]], list[str], list[str]]:
486
562
  """Return ({tc_id: dsl}, warnings, errors). Errors are collected per case."""
487
563
  cases = payload.get("cases") if isinstance(payload, dict) else payload
488
564
  if not isinstance(cases, list):
@@ -493,7 +569,7 @@ def compile_all(payload: Any, suite: dict[str, Any]) -> tuple[dict[str, dict[str
493
569
  errors: list[str] = []
494
570
  for index, desc in enumerate(cases):
495
571
  try:
496
- tc_id, dsl, case_warnings = compile_case(desc, index, eligible, rejected)
572
+ tc_id, dsl, case_warnings = compile_case(desc, index, eligible, rejected, locator_dir=locator_dir)
497
573
  except CompileError as error:
498
574
  errors.append(str(error))
499
575
  continue
@@ -540,7 +616,8 @@ def main() -> int:
540
616
  payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
541
617
  except (OSError, json.JSONDecodeError) as error:
542
618
  raise CompileError(f"cannot read case descriptions: {error}") from error
543
- compiled, warnings, errors = compile_all(payload, suite)
619
+ locator_dir = root / ".devflow/planning/test-locators"
620
+ compiled, warnings, errors = compile_all(payload, suite, locator_dir=locator_dir)
544
621
  except CompileError as error:
545
622
  print(f"COMPILE ERROR: {error}", file=sys.stderr)
546
623
  return 1
@@ -561,8 +638,14 @@ def main() -> int:
561
638
  write_atomic(path, content)
562
639
  files.append({"tc_id": tc_id, "path": path.relative_to(root).as_posix() if path.is_relative_to(root) else str(path),
563
640
  "changed": not unchanged})
641
+ report_path = root / ".devflow/planning/features" / feature / "locator-stability.md"
642
+ try:
643
+ write_report(report_path, compiled.values(), feature)
644
+ except (OSError, ValueError) as error:
645
+ print(f"COMPILE ERROR: cannot write locator stability report: {error}", file=sys.stderr)
646
+ return 1
564
647
  print(json.dumps({"status": "compiled", "feature": feature, "case_count": len(files), "files": files,
565
- "warnings": len(warnings)}, ensure_ascii=False, indent=2))
648
+ "warnings": len(warnings), "locator_report": str(report_path.relative_to(root))}, ensure_ascii=False, indent=2))
566
649
  return 0
567
650
 
568
651
 
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """Render the internal locator stability report for generated DSL."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Iterable
8
+
9
+
10
+ def _strategy(target: dict[str, Any]) -> tuple[str, str, str]:
11
+ if "within" in target:
12
+ return "within", "中", "区域限定目标;区域与内层目标都必须唯一匹配。"
13
+ if "table" in target:
14
+ return "table_column", "中", f"按表格选择器和表头‘{target.get('column', '')}’解析,运行时计算列位置。"
15
+ if "row" in target:
16
+ return "row", "高", "依赖测试数据;行值变化或重复会导致失败。"
17
+ for key, risk, reason in (
18
+ ("testid", "低", "使用稳定的 data-testid。"),
19
+ ("role", "中", "依赖可访问角色和名称。"),
20
+ ("label", "中", "依赖表单 label 文本。"),
21
+ ("placeholder", "中", "依赖 placeholder 文本。"),
22
+ ("text", "高", "可见文本可能重复或随文案变化。"),
23
+ ("css", "高", "CSS 选择器容易受结构或构建变化影响。"),
24
+ ):
25
+ if key in target:
26
+ return key, risk, reason
27
+ return "unknown", "高", "无法识别的定位形式。"
28
+
29
+
30
+ def _steps(dsl: dict[str, Any]) -> Iterable[tuple[str, dict[str, Any]]]:
31
+ for phase, values in (("setup", dsl.get("data", {}).get("setup", [])), ("main", dsl.get("steps", [])),
32
+ ("cleanup", dsl.get("data", {}).get("cleanup", []))):
33
+ for step in values if isinstance(values, list) else []:
34
+ if isinstance(step, dict) and step.get("driver") == "ui" and "target" in step:
35
+ yield phase, step
36
+
37
+
38
+ def report_entries(dsl: dict[str, Any]) -> list[dict[str, str]]:
39
+ entries: list[dict[str, str]] = []
40
+ for phase, step in _steps(dsl):
41
+ strategy, risk, reason = _strategy(step["target"])
42
+ entry = {"step": str(step.get("id", "")), "phase": phase, "strategy": strategy,
43
+ "risk": risk, "reason": reason}
44
+ if step.get("source_anchor"):
45
+ entry["source_anchor"] = str(step["source_anchor"])
46
+ entries.append(entry)
47
+ return entries
48
+
49
+
50
+ def render_report(dsls: Iterable[dict[str, Any]], feature: str | None = None) -> str:
51
+ cases = list(dsls)
52
+ title = f"# 定位稳定性报告{f':{feature}' if feature else ''}\n\n"
53
+ lines = [title, "本报告由编译后的 DSL 自动生成,仅用于内部留档;运行时只执行展开后的 DSL。", ""]
54
+ for dsl in sorted(cases, key=lambda item: str(item.get("tc_id", ""))):
55
+ lines.extend([f"## {dsl.get('tc_id', 'unknown')}:{dsl.get('name', '')}", "", "| 步骤 | 阶段 | 定位方式 | 风险 | 原因 | 来源锚点 |", "|---|---|---|---|---|---|"])
56
+ entries = report_entries(dsl)
57
+ if not entries:
58
+ lines.append("| — | — | — | — | UI 步骤无定位目标 | — |")
59
+ else:
60
+ for item in entries:
61
+ row = dict(item)
62
+ row["source_anchor"] = "`" + item["source_anchor"] + "`" if item.get("source_anchor") else "—"
63
+ lines.append("| {step} | {phase} | {strategy} | {risk} | {reason} | {source_anchor} |".format(**row))
64
+ lines.extend(["", "可选前端改进:优先补充已有业务语义的 `data-testid`;本报告不要求修改被测前端。", ""])
65
+ return "\n".join(lines)
66
+
67
+
68
+ def write_report(path: Path, dsls: Iterable[dict[str, Any]], feature: str | None = None) -> None:
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ path.write_text(render_report(dsls, feature), encoding="utf-8")
71
+
72
+
73
+ __all__ = ["render_report", "report_entries", "write_report"]
@@ -17,7 +17,7 @@ description: "Use after code review to execute frozen manual TC cases, capture h
17
17
 
18
18
  1. 确认 `code-review` 已完成,测试套件已由 CP-3 冻结。
19
19
  2. 只筛选 `automation_mode: manual` 的 TC;`automated` 由后续 Core runner 执行,`blocked` 保持阻塞。
20
- 3. 人工按冻结步骤执行,逐条收集实际结果和项目内证据文件;Agent 不得代填 `passed|failed|blocked` 结论。
20
+ 3. 人工按冻结步骤执行,逐条收集实际结果和项目内证据文件;Agent 不得代填 `passed|failed|blocked` 结论。失败分析只依据 `result.json` 与 `acceptance-results.json`;`.devflow/runs/**` 下的 trace、截图、网络工件和 `runtime.log` 由用户在本地查看,Agent 不得读取(hook 会拒绝)。
21
21
  4. 本步骤不生成自动化用例,也不改变自动化 TC 的模式;需要降级为 `manual` 的自动化 TC 必须先有有效的 `mode_override` approval,随后才按人工 TC 处理。
22
22
  5. 对每个 manual TC 调用 `devflow_approval(action="propose", feature="...", kind="human_result", items=[{"subject":"TC-xxxx","evidence_files":[...]}])`。Agent 只能提交证据,不得提交 status、actual_result 或 pins。
23
23
  6. 用户在 `/devflow` → “待审批”中逐 TC 查看 oracle、步骤和证据,自行选择 `passed|failed|blocked` 并填写实际结果;只有该 UI 确认产生的 `human_result` approval 才是最终人工结果。
@@ -49,6 +49,8 @@ description: "Use after code review to execute frozen manual TC cases, capture h
49
49
 
50
50
  草稿不得作为版本通过证据。每个 manual TC 的最终 verdict 和 actual_result 只从 control.db 中仍有效的 `human_result` approval 读取;`passed` 必须固定至少一个项目内证据文件及其哈希。
51
51
 
52
+ 人工 TC 的项目内证据文件不属于 `.devflow/runs/**` Runtime 运行工件;Agent 只登记其路径并提交审批,不据其内容替用户下结论。
53
+
52
54
  ## 开发者交接
53
55
 
54
56
  写入 `.devflow/planning/test-runs/{feature}/developer-handoff.md`,至少包含:
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: workflow-regression-test-design
3
+ description: "Use from an idle project to derive a user-confirmed, frozen regression acceptance suite for an existing feature without requiring a requirement baseline."
4
+ ---
5
+
6
+ # 存量功能回归验收测试设计
7
+
8
+ 本步骤为已经上线或已有实现的功能建立可复现的回归验收套件。它不补造需求,也不把当前实现自动宣布为正确;它把代码、代码索引、外部契约和用户确认的现有行为整理为候选 `TC-*`,然后交给 Core 在 CP-3 冻结。
9
+
10
+ ## 执行合同
11
+
12
+ 图谱写入受 ExecutionContract v2 约束,active contract 的 step 必须是 `regression-test-design`。开始时先调用 `devflow_contract(action="status")`;没有 active contract 时调用 `devflow_contract(action="activate", feature="<feature>", step="regression-test-design")`。不得用普通对话、Skill 自己写 `tests.json` 或逐条确认替代 Core 的冻结操作。
13
+
14
+ ## 输入顺序与范围
15
+
16
+ 需要补充当前页面行为时,可在已批准的测试环境中调用 `devflow_explore`(start/observe/act/assert/draft)。探索只用于发现和录制草稿,不是验收证据;密码、密钥和凭据值不得写入描述、日志或草稿。草稿必须由后续 `workflow-generate-mainflow-cases` 编译、校验并经用户批准后才能执行。
17
+
18
+ 1. 读取 `devflow_requirement_graph(action="inspect")` 和 `.devflow/planning/code-index/`,确认当前功能、代码索引 revision、入口、接口、数据流和调用关系。
19
+ 2. 阅读实际前端与后端源码、路由/控制器、服务、权限规则、数据访问和已有测试;代码只能用于发现真实功能点与可执行入口,不能把实现细节直接当作预期。
20
+ 3. 阅读 `.devflow/planning/test-environments/*.json` 中的环境声明;需要登录或测试 token 时,只记录 `secret_env` 的变量名和所需前置,不读取或输出值。
21
+ 4. 阅读项目已有文档、接口定义和公开约定,区分可引用的 `contract` 与仅能说明当前行为的 `characterization`。
22
+
23
+ 如果代码索引缺失、源码与索引明显不一致、或无法写出外部可观察的判定标准,先记录阻塞,不用猜测补全。
24
+
25
+ ## 生成流程
26
+
27
+ 先列出功能点和证据来源,再为每个功能点提出候选 TC。优先顺序是 `invariant`、`contract`,其次是 `characterization`;只有明确的需求/验收文档才使用 `requirement`。每条回归 TC 必须显式填写 `oracle_source`。非 `requirement` 必须填写 `oracle_evidence`,包括文件:行、接口定义、不变量说明,或带环境和时间的观察记录。
28
+
29
+ 候选结构至少包含:
30
+
31
+ ```json
32
+ {
33
+ "acceptance_refs": [],
34
+ "requirement_refs": [],
35
+ "test_type": "page|api|permission|boundary|resilience|migration|performance|analytics|freshness|reconciliation",
36
+ "execution_surface": "http|ui|api_flow|project_test",
37
+ "target": "外部可观察功能点",
38
+ "preconditions": ["身份、环境和数据前置"],
39
+ "steps": ["原子化操作"],
40
+ "oracle": "不依赖易变业务数据值的可观察结果",
41
+ "oracle_source": "invariant",
42
+ "oracle_evidence": "不变量说明及其来源",
43
+ "automation_mode": "automated|manual|blocked",
44
+ "evidence_strategy": "replay|causal|observation"
45
+ }
46
+ ```
47
+
48
+ - `requirement` 必须有非空 `requirement_refs` 与 `acceptance_refs`,并通过已有 `verified_by`/确认校验;`contract`、`invariant`、`characterization` 在回归工作流中可以两者均为空,因为存量功能可能没有 REQ/AC 图谱节点,但仍必须有 `oracle_evidence`。如果实际存在 REQ/AC 引用,则必须按普通边和确认规则校验。
49
+ - 禁止把依赖环境数据的值写死为预期,例如“公海 = 394”。优先断言结构、关系、权限边界、稳定状态和每行一致性;`characterization` 只证明行为未变化,不证明行为正确。
50
+ - `execution_surface` 必须显式填写,且遵守已有 DSL/adapter 兼容矩阵。具体 selector、endpoint、测试数据和命令属于后续绑定,不得改变 TC 意图。
51
+ - `automated` 的 `ui`/`api_flow` 用例必须能由 `workflow-generate-mainflow-cases` 生成确定性 DSL;没有稳定 locator、无法隔离数据或需要纯视觉判断时标为 `manual` 或 `blocked`。
52
+ - 回归用例需要绑定定位时,优先复用已有 `data-testid`,其次在 `.devflow/planning/test-locators/<page-key>.json` 建立可审计锚点;用例描述使用 `anchor:<page-key>/<anchor-name>`,不要把映射文件交给 Runtime。
53
+ - `observation` 只能配合 `automation_mode: blocked`,并填写 `observation_plan`;不能把人工看过当作通过证据。
54
+ - 需要当前行为观察时,可以在已批准测试环境中执行只读观察。记录环境、时间、身份类别、操作摘要、观察到的结构/关系和复现条件,不记录凭据、响应中的秘密或脱敏前的个人数据。
55
+
56
+ ## 用户检查点与冻结
57
+
58
+ 写入 `.devflow/planning/features/{feature}/回归验收测试设计.md`,包含功能点清单、每条 TC 的来源和证据、执行面、自动化可行性、环境前置、风险与未决问题;同时维护 `tests.meta.json` 作为该候选套件的元数据视图。检查点前必须有这两个回归设计产出,Core 才能生成并冻结 `tests.json`。回归没有实现任务,因此不生成 `test-bindings.json`;后续 DSL 和验收步骤的绑定/结果文件不属于本步骤输出。
59
+
60
+ 在 CP-3 展示所有候选,明确提示:`characterization` 只保证行为不变;`contract`/`invariant`/`characterization` 的证据是否足够;哪些用例会因无环境或无稳定执行面而 blocked。只有用户确认后,Core 才能一次性冻结完整套件。冻结失败时严格拒绝,修正候选后重新走检查点。
61
+
62
+ 冻结后移交 `workflow-generate-mainflow-cases` 的 `regression-dsl` 步骤。该步骤只绑定已冻结意图;它不能新增、删减或改写 oracle、步骤或 `oracle_source`。
63
+
64
+ ## 输出与禁止事项
65
+
66
+ - 输出:回归设计文档及其 meta 视图;结构化 `TC-*` 只由 Core 图谱冻结产生。
67
+ - 不读取或使用 agent 交付自述、`SUMMARY.md` 或 git diff 来决定测试范围。
68
+ - 不写入 `acceptance-results.json`,不生成人工通过结果,不确认执行计划或环境批准。
69
+ - 不向模型上下文、文档或日志输出测试凭据值。
@@ -31,7 +31,7 @@ description: "在冻结需求验收测试全部通过后,执行 Devflow 自有
31
31
 
32
32
  ## 收口动作
33
33
 
34
- 1. 调用 `devflow_run_acceptance_tests(action="validate_results", feature=...)`,让 Core 重新校验 `acceptance-results.json`(suite pins、`execution_surface` 兼容、当前仍有效的 `execution_plan`/`environment_profile`/`human_result`/`mode_override` 批准、运行目录与工件哈希);运行后 DSL 或 profile 被修改、批准被替换,都会使已记录结果失效,需要回到 `run-acceptance-tests` 重跑;不通过则停止收口。通过后读取该文件(读取即可,不得写入),计算通过/失败/阻塞数量,并展示 `legacy_unclassified`、有效降级记录和逐 TC `dsl_approval`。任何 `dsl_approval: none` 必须对应 blocked;若其被标为 passed、结果未全 passed 或存在未批准降级,立即拒绝收口。人工 TC 的 passed 只来自已确认的 `human_result` approval,`human-acceptance-results.json` 草稿不算证据。
34
+ 1. 调用 `devflow_run_acceptance_tests(action="validate_results", feature=...)`,让 Core 重新校验 `acceptance-results.json`(suite pins、`execution_surface` 兼容、当前仍有效的 `execution_plan`/`environment_profile`/`human_result`/`mode_override` 批准、运行目录与工件哈希);运行后 DSL 或 profile 被修改、批准被替换,都会使已记录结果失效,需要回到 `run-acceptance-tests` 重跑;不通过则停止收口。通过后读取该文件(读取即可,不得写入),计算通过/失败/阻塞数量,展示逐 TC 的 `oracle_source`、按来源计数、`legacy_unclassified`、有效降级记录和逐 TC `dsl_approval`。`characterization` 用例只证明行为未变化,不证明正确性。任何 `dsl_approval: none` 必须对应 blocked;若其被标为 passed、结果未全 passed 或存在未批准降级,立即拒绝收口。人工 TC 的 passed 只来自已确认的 `human_result` approval,`human-acceptance-results.json` 草稿不算证据。失败分析和证据读取只依据 `result.json` 与 `acceptance-results.json`;Agent 不得读取 trace、截图、网络工件或 `runtime.log`,这些由用户本地查看(hook 会拒绝 Agent 读取)。
35
35
  2. 更新 `.devflow/planning/navigation/INDEX.md`,记录本次 code-index revision 和验收结果 fingerprint。
36
36
  3. 在 `.devflow/planning/milestones/` 写入本版本的 ROADMAP、需求覆盖、测试结果、变更清单和已接受风险摘要。
37
37
  4. 更新 `.devflow/planning/PROJECT.md` 的当前版本与下一步目标。
@@ -28,7 +28,7 @@ description: "Execute the frozen TC acceptance suite after code review. Use the
28
28
  ## 过程
29
29
 
30
30
  1. 用 `devflow_acceptance_tests(feature)` 检查测试集已由 CP-3 冻结。若有 stale 或候选 TC,停止并回到需求/测试设计。
31
- 2. 读取冻结 TC 与其 oracle、前置数据、身份、自动化模式和 `execution_surface`。
31
+ 2. 读取冻结 TC 与其 oracle、`oracle_source`/`oracle_evidence`、前置数据、身份、自动化模式和 `execution_surface`。结果与交接报告必须逐 TC 展示 `oracle_source`,并按来源汇总;`characterization` 用例只证明行为未变化。
32
32
  对 `api_refs`、`workload_refs` 和 `risk_refs` 指向的专项用例,保留其契约、性能/迁移或韧性义务;没有适配器或环境时必须写为 blocked,不能用普通主流程替代。
33
33
  3. 读取代码审查报告、代码索引和实际源码,仅为每个 TC 绑定运行适配器;不得改变 TC 的断言、范围或预期。对每个自动化 `ui|api_flow` TC,若 `generated-cases/{feature}/TC-xxxx.json` 不存在或已过期(TC 重冻结、代码变更),先调用 `workflow-generate-mainflow-cases`;它无法生成的正向 UI/API TC(无稳定 locator、无法准备或隔离数据、纯视觉判断)按下一步降级,异常/边界/权限/性能专项 TC 也不由它生成。
34
34
  4. 写入 `.devflow/planning/features/{feature}/acceptance-test-bindings.json`:
@@ -48,9 +48,11 @@ description: "Execute the frozen TC acceptance suite after code review. Use the
48
48
  7. 调用 `devflow_run_acceptance_tests(action="run", feature=...)`。该 Core 工具执行 automated TC(`devflow_playwright` 在净化环境和独立运行目录中执行,并校验 Runtime 完整性与结果 nonce),合并已校验的 manual TC 结果,并记录命令/证据哈希、退出码、`dsl_approval`、`execution_surface`、`legacy_unclassified` 和降级记录。
49
49
  8. 失败、未执行或 blocked 的 TC 只能报告为 `blocked`/`failed`,不得进入版本收口。验收未全 passed 是硬门禁:即使默认 soft 模式,`run-acceptance-tests` 完成和 `release-closeout` 确认时 Core 也会重新校验结果并以 `acceptance_results_not_passed` 拒绝。可用 `devflow_run_acceptance_tests(action="validate_results", feature=...)` 预先检查。该校验会对照当前批准、DSL/profile 哈希与运行目录重新验证每条记录:运行后修改 DSL 或 profile、或批准被新的批准替换,都会使已记录的 passed 失效,必须重跑。
50
50
 
51
+ 失败分析和证据读取只依据 Runtime 的 `result.json` 与 Core 的 `acceptance-results.json`。Agent 不得读取 trace、截图、网络工件或 `runtime.log`;这些工件由用户在本地查看(hook 会拒绝 Agent 读取)。
52
+
51
53
  ## 输出
52
54
 
53
55
  - `.devflow/planning/features/{feature}/acceptance-test-bindings.json`
54
56
  - `.devflow/planning/test-runs/{feature}/acceptance-results.json`
55
57
 
56
- `acceptance-results.json` 是 Core 指定写入的本地执行缓存(Agent 不得写入,可读取用于展示);Markdown 结论不能替代它,收口以 Core 对它的重新校验(`validate_results`)为准。Hook 会拦截常规 Write/Edit/Bash 误写,但不是对同一系统用户下任意进程的安全隔离;未配置外部受保护信任根时,不得宣称结果在密码学意义上不可伪造。
58
+ `acceptance-results.json` 是 Core 指定写入的本地执行缓存(Agent 不得写入,可读取用于展示);Markdown 结论不能替代它,收口以 Core 对它的重新校验(`validate_results`)为准。失败分析和证据读取仍只使用 `result.json` 与 `acceptance-results.json`;trace、截图、网络工件和 `runtime.log` 由用户本地查看,Agent 不得读取(hook 会拒绝)。Hook 会拦截常规 Write/Edit/Bash 误写,但不是对同一系统用户下任意进程的安全隔离;未配置外部受保护信任根时,不得宣称结果在密码学意义上不可伪造。
package/src/cli.ts CHANGED
@@ -16,6 +16,7 @@ function usage(): string {
16
16
  " devflow-pi init [project] [--scan]",
17
17
  " devflow-pi migrate [project] --confirm-active",
18
18
  " devflow-pi doctor [project]",
19
+ " devflow-pi mcp --client <name> [--run-timeout-seconds <seconds>]",
19
20
  " devflow-pi usage [project]",
20
21
  " devflow-pi [project] [--resume [session-id]] [--prompt <text>] [--mcp] [--yolo]",
21
22
  ].join("\n");
@@ -80,6 +81,15 @@ async function main(): Promise<void> {
80
81
  console.log(JSON.stringify({ status: "migrated", root, migrated }, null, 2));
81
82
  return;
82
83
  }
84
+ if (command === "mcp") {
85
+ const client = option(args, "--client");
86
+ if (!client) throw new Error("devflow-pi mcp requires --client <name>");
87
+ const timeout = option(args, "--run-timeout-seconds");
88
+ if (timeout !== undefined && (!Number.isFinite(Number(timeout)) || Number(timeout) <= 0 || Number(timeout) > 7200)) throw new Error("--run-timeout-seconds must be between 1 and 7200");
89
+ const { startMcpServer } = await import("./mcp/server.ts");
90
+ await startMcpServer({ client, runTimeoutSeconds: timeout === undefined ? undefined : Number(timeout) });
91
+ return;
92
+ }
83
93
  const positionalProject = command && !["doctor", "usage"].includes(command) && !command.startsWith("-") ? command : undefined;
84
94
  const requestedProject = ["doctor", "usage"].includes(command || "") ? args[1] : positionalProject;
85
95
  const root = await resolveProjectRoot(process.cwd(), { explicit: requestedProject });
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.95";
2
+ export const PRODUCT_VERSION = "0.1.97";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5
 
package/src/doctor.ts CHANGED
@@ -316,6 +316,15 @@ export async function runHostDoctor(project: ProjectContext, options: DoctorOpti
316
316
  add("agent-dir", true, devflowAgentDir());
317
317
  add("session-isolation", true, projectSessionDir(project.manifest.projectId));
318
318
  add("devflow-home", true, devflowHome());
319
+ const mcpConfig = path.join(devflowHome(), "mcp.json");
320
+ if (!(await exists(mcpConfig))) add("mcp", true, "MCP 未配置");
321
+ else {
322
+ try {
323
+ const config = JSON.parse(await readFile(mcpConfig, "utf8")) as Record<string, unknown>;
324
+ const valid = config.schema_version === 1 && config.clients !== null && typeof config.clients === "object" && !Array.isArray(config.clients);
325
+ add("mcp", valid, valid ? `${mcpConfig} (schema_version 1)` : `${mcpConfig}: schema_version must be 1 and clients must be an object`);
326
+ } catch (error) { add("mcp", false, `${mcpConfig}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`); }
327
+ }
319
328
  return { ok: checks.every(check => check.ok), checks };
320
329
  }
321
330
 
@@ -0,0 +1,103 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { normalizeDevflowHome } from "../paths.ts";
6
+ import type { McpToolName } from "./types.ts";
7
+
8
+ export const FEATURE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
9
+ export const TOOLS = ["validate_feature", "run_feature", "get_feature_report", "dsl_draft_validate"] as const;
10
+ export const MAX_DSL_BYTES = 2 * 1024 * 1024;
11
+ const PERMISSIONS = new Set(["validate", "report", "run", "dsl_draft"]);
12
+
13
+ export type ProjectGrant = { features: string[] | "*"; permissions: string[]; environment_profiles: string[] };
14
+ export type McpConfig = { schema_version: 1; clients: Record<string, { projects: Record<string, ProjectGrant> }> };
15
+
16
+ export class McpError extends Error {
17
+ constructor(public readonly code: string, message: string) { super(message); }
18
+ }
19
+
20
+ function invalidConfig(message: string): never {
21
+ throw new McpError("mcp_permission_denied", `invalid mcp.json: ${message}`);
22
+ }
23
+
24
+ function stringArray(value: unknown, label: string): string[] {
25
+ if (!Array.isArray(value) || value.some(item => typeof item !== "string")) invalidConfig(`${label} must be an array of strings`);
26
+ return value as string[];
27
+ }
28
+
29
+ function validateGrant(value: unknown, label: string): ProjectGrant {
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalidConfig(`${label} must be an object`);
31
+ const grant = value as Record<string, unknown>;
32
+ const features = grant.features === "*" ? "*" : stringArray(grant.features, `${label}.features`);
33
+ const permissions = stringArray(grant.permissions, `${label}.permissions`);
34
+ if (permissions.some(permission => !PERMISSIONS.has(permission))) invalidConfig(`${label}.permissions contains an unsupported permission`);
35
+ const environmentProfiles = grant.environment_profiles === undefined ? [] : stringArray(grant.environment_profiles, `${label}.environment_profiles`);
36
+ return { features, permissions, environment_profiles: environmentProfiles };
37
+ }
38
+
39
+ function validateConfig(value: unknown): McpConfig {
40
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalidConfig("root must be an object");
41
+ const root = value as Record<string, unknown>;
42
+ if (root.schema_version !== 1) invalidConfig("schema_version must be 1");
43
+ if (!root.clients || typeof root.clients !== "object" || Array.isArray(root.clients)) invalidConfig("clients must be an object");
44
+ const clients: McpConfig["clients"] = {};
45
+ for (const [clientName, clientValue] of Object.entries(root.clients as Record<string, unknown>)) {
46
+ if (!clientValue || typeof clientValue !== "object" || Array.isArray(clientValue)) invalidConfig(`clients.${clientName} must be an object`);
47
+ const projectsValue = (clientValue as Record<string, unknown>).projects;
48
+ if (!projectsValue || typeof projectsValue !== "object" || Array.isArray(projectsValue)) invalidConfig(`clients.${clientName}.projects must be an object`);
49
+ const projects: Record<string, ProjectGrant> = {};
50
+ for (const [project, grant] of Object.entries(projectsValue as Record<string, unknown>)) {
51
+ if (!path.isAbsolute(project)) invalidConfig(`project key must be absolute: ${project}`);
52
+ projects[project] = validateGrant(grant, `clients.${clientName}.projects.${project}`);
53
+ }
54
+ clients[clientName] = { projects };
55
+ }
56
+ return { schema_version: 1, clients };
57
+ }
58
+
59
+ export async function loadAuthorization(client: string, home = normalizeDevflowHome(process.env.DEVFLOW_HOME)): Promise<McpConfig> {
60
+ const file = path.join(home, "mcp.json");
61
+ let value: unknown;
62
+ try {
63
+ value = JSON.parse(await readFile(file, "utf8"));
64
+ } catch (error) {
65
+ throw new McpError("mcp_permission_denied", `MCP authorization is unavailable: ${error instanceof Error ? error.message : String(error)}`);
66
+ }
67
+ const config = validateConfig(value);
68
+ if (!Object.prototype.hasOwnProperty.call(config.clients, client)) throw new McpError("mcp_permission_denied", `unknown MCP client: ${client}`);
69
+ return config;
70
+ }
71
+
72
+ function permissionFor(tool: McpToolName): string {
73
+ if (tool === "dsl_draft_validate") return "dsl_draft";
74
+ if (tool === "get_feature_report") return "report";
75
+ if (tool === "validate_feature") return "validate";
76
+ return "run";
77
+ }
78
+
79
+ export async function authorize(config: McpConfig, client: string, tool: McpToolName, project: unknown, feature?: unknown): Promise<{ project: string; grant: ProjectGrant }> {
80
+ if (typeof project !== "string" || !path.isAbsolute(project)) throw new McpError("mcp_input_invalid", "project must be an absolute path");
81
+ let resolved: string;
82
+ try { resolved = await realpath(project); } catch { throw new McpError("mcp_project_not_allowed", "project does not exist"); }
83
+ const grants = config.clients[client]?.projects ?? {};
84
+ let grant: ProjectGrant | undefined;
85
+ for (const [key, candidate] of Object.entries(grants)) {
86
+ try {
87
+ if (await realpath(key) === resolved) { grant = candidate; break; }
88
+ } catch {
89
+ // A missing configured project cannot authorize a request.
90
+ }
91
+ }
92
+ if (!grant) throw new McpError("mcp_project_not_allowed", "project is not authorized");
93
+ if (tool !== "dsl_draft_validate") {
94
+ if (typeof feature !== "string" || !FEATURE_RE.test(feature)) throw new McpError("mcp_input_invalid", "feature is invalid");
95
+ if (grant.features !== "*" && !grant.features.includes(feature)) throw new McpError("mcp_feature_not_allowed", "feature is not authorized");
96
+ }
97
+ if (!grant.permissions.includes(permissionFor(tool))) throw new McpError("mcp_permission_denied", "permission is not authorized");
98
+ return { project: resolved, grant };
99
+ }
100
+
101
+ export function requestHash(value: unknown): string {
102
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
103
+ }