@guanlin4924/devflow-pi 0.1.98 → 0.1.99

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 (23) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +560 -262
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +6 -1
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +3 -1
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +3 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_traceability.py +31 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +6 -3
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +11 -2
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +14 -5
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +12 -5
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +8 -0
  13. package/plugins/devflow-intel/extensions/devflow-intel.ts +25 -3
  14. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +55 -28
  15. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +41 -14
  16. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +2 -2
  17. package/src/constants.ts +1 -1
  18. package/src/mcp/core.ts +23 -24
  19. package/src/mcp/server.ts +11 -12
  20. package/src/project-context.ts +9 -1
  21. package/src/project-initializer.ts +4 -4
  22. package/src/session-rollover.ts +10 -0
  23. package/src/workflow-diagnosis.ts +407 -0
@@ -766,10 +766,15 @@ def validate_results(root: Path, feature: str, today: date | None = None) -> dic
766
766
  errors.append("acceptance-test results are pinned to a stale TC suite")
767
767
  expected = set(validation.get("tc_ids", []))
768
768
  result_entries = results.get("results") if isinstance(results.get("results"), list) else []
769
+ if any(not isinstance(item, dict) or not isinstance(item.get("tc_id"), str) for item in result_entries):
770
+ errors.append("acceptance-test result entries must be objects with a string tc_id")
769
771
  actual = {item.get("tc_id") for item in result_entries if isinstance(item, dict) and isinstance(item.get("tc_id"), str)}
770
772
  if actual != expected:
771
773
  errors.append("acceptance-test results do not cover exactly the frozen TC suite")
772
- failed = sorted(item.get("tc_id", "<unknown>") for item in result_entries if not isinstance(item, dict) or item.get("status") != "passed")
774
+ failed = sorted(
775
+ item["tc_id"] if isinstance(item, dict) and isinstance(item.get("tc_id"), str) else "<unknown>"
776
+ for item in result_entries if not isinstance(item, dict) or item.get("status") != "passed"
777
+ )
773
778
  if failed:
774
779
  errors.append(f"frozen TC cases not passed: {', '.join(failed)}")
775
780
  if suite is not None and isinstance(bindings, dict):
@@ -8,6 +8,8 @@ import subprocess
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
+ from business_traceability import confirmed_requirement_ids
12
+
11
13
 
12
14
  def load_yaml(path: Path) -> Any:
13
15
  try:
@@ -36,7 +38,7 @@ def main() -> int:
36
38
  errors = []
37
39
  warnings = []
38
40
  actions = {item.get("id") for item in business.get("actions", []) if isinstance(item, dict)}
39
- requirements = {item.get("id") for item in traceability.get("requirements", []) if isinstance(item, dict) and item.get("status") in {"confirmed", "approved"}} if isinstance(traceability, dict) else set()
41
+ requirements = confirmed_requirement_ids(traceability)
40
42
  values = claims.get("claims") if isinstance(claims, dict) else claims
41
43
  if not isinstance(values, list):
42
44
  errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": "claims", "message": "claims must be an array"})
@@ -8,6 +8,8 @@ import subprocess
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
+ from business_traceability import confirmed_requirement_ids
12
+
11
13
 
12
14
  def read_json(path: Path) -> Any:
13
15
  return json.loads(path.read_text(encoding="utf-8"))
@@ -41,7 +43,7 @@ def main() -> int:
41
43
  requirements = set()
42
44
  if args.traceability:
43
45
  traceability = read_json(args.traceability)
44
- requirements = {item.get("id") for item in traceability.get("requirements", []) if isinstance(item, dict) and item.get("status") in {"confirmed", "approved"}}
46
+ requirements = confirmed_requirement_ids(traceability)
45
47
  if args.wireframe:
46
48
  wireframe = read_json(args.wireframe)
47
49
  for screen in wireframe.get("screens", []):
@@ -0,0 +1,31 @@
1
+ """Read confirmed requirement references from Core graph views and legacy lists."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+
7
+ def confirmed_requirement_ids(traceability: Any) -> set[str]:
8
+ if not isinstance(traceability, dict):
9
+ return set()
10
+ meta = traceability.get("view_meta", {})
11
+ if isinstance(meta, dict) and (meta.get("state") == "stale" or meta.get("needs_recheck") is True):
12
+ return set()
13
+ stale_ids = {
14
+ entry.get("node_id") if isinstance(entry, dict) else entry
15
+ for entry in traceability.get("stale", [])
16
+ if isinstance(entry, (dict, str))
17
+ }
18
+ # The graph's typed nodes are authoritative when present; a legacy list
19
+ # must not override their confirmation state or introduce other node kinds.
20
+ graph_view = "nodes" in traceability
21
+ items = traceability.get("nodes" if graph_view else "requirements", [])
22
+ if not isinstance(items, list):
23
+ return set()
24
+ return {
25
+ item["id"] for item in items
26
+ if isinstance(item, dict)
27
+ and isinstance(item.get("id"), str)
28
+ and (not graph_view or item.get("kind") == "requirement")
29
+ and item.get("status") in {"confirmed", "approved"}
30
+ and item["id"] not in stale_ids
31
+ }
@@ -255,11 +255,14 @@ def semantic_errors(dsl: dict[str, Any]) -> list[str]:
255
255
  for name, spec in variables.items():
256
256
  if name in BUILTIN_VARIABLES:
257
257
  errors.append(f"$.variables.{name} redefines a built-in variable")
258
+ if isinstance(spec.get("value"), str) and VARIABLE_REF_RE.search(spec["value"]):
259
+ errors.append(f"$.variables.{name} variable values must be literals")
258
260
  if "secret" in spec or spec.get("sensitive") is True:
259
261
  sensitive.add(name)
260
- known = set(variables) | {"execution_id"}
261
- _references(dsl["data"]["namespace"], "$.data.namespace", known, errors)
262
- known.add("namespace")
262
+ # Runtime initializes namespace with execution_id only; declared values are
263
+ # literals, not expressions evaluated against other variables.
264
+ _references(dsl["data"]["namespace"], "$.data.namespace", {"execution_id"}, errors)
265
+ known = set(variables) | BUILTIN_VARIABLES
263
266
  seen_ids: dict[str, str] = {}
264
267
  for _phase, path, step in all_steps(dsl):
265
268
  step_id = step["id"]
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import argparse
10
10
  import hashlib
11
+ import ipaddress
11
12
  import json
12
13
  import re
13
14
  from pathlib import Path
@@ -35,7 +36,15 @@ def profile_path(root: Path, name: str) -> Path:
35
36
 
36
37
 
37
38
  def origin_of(url: str) -> str | None:
38
- parts = urlsplit(url)
39
+ try:
40
+ parts = urlsplit(url)
41
+ # urlsplit validates ports lazily; older Python versions also accept
42
+ # malformed bracketed IPv6 hosts that the browser rejects.
43
+ _ = parts.port
44
+ if parts.hostname and ":" in parts.hostname:
45
+ ipaddress.IPv6Address(parts.hostname)
46
+ except ValueError:
47
+ return None
39
48
  if parts.scheme not in {"http", "https"} or not parts.hostname or parts.username or parts.password:
40
49
  return None
41
50
  origin = f"{parts.scheme}://{parts.netloc}"
@@ -48,7 +57,7 @@ def _origins(value: Any, label: str, errors: list[str], required: bool) -> list[
48
57
  return []
49
58
  origins: list[str] = []
50
59
  for index, item in enumerate(value):
51
- if not isinstance(item, str) or not ORIGIN_RE.fullmatch(item):
60
+ if not isinstance(item, str) or not ORIGIN_RE.fullmatch(item) or origin_of(item) is None:
52
61
  errors.append(f"{label}[{index}] must be an exact http(s) origin without path, wildcard, or credentials")
53
62
  elif item in origins:
54
63
  errors.append(f"{label}[{index}] duplicates {item}")
@@ -75,7 +75,7 @@ module ParallelRun
75
75
  match && match[1]
76
76
  end
77
77
 
78
- def attach_state_ref(root, state_path, run_id, expected_revision)
78
+ def prepare_state_ref(root, state_path, run_id, expected_revision)
79
79
  absolute, = DevflowControlIO.resolve_control_path(root, state_path, :state)
80
80
  original = File.read(absolute)
81
81
  front = state_front_matter(original)
@@ -90,6 +90,11 @@ module ParallelRun
90
90
  state["parallel_run_ref"] = run_id
91
91
  state["revision"] = actual + 1
92
92
  body = original.sub(/\A---\n.*?\n---\n/m, "---\n#{state.to_yaml.sub(/\A---\n/, "")}---\n")
93
+ [absolute, body, state]
94
+ end
95
+
96
+ def attach_state_ref(root, state_path, run_id, expected_revision)
97
+ absolute, body, state = prepare_state_ref(root, state_path, run_id, expected_revision)
93
98
  DevflowControlIO.atomic_write(absolute, body)
94
99
  state
95
100
  end
@@ -131,11 +136,13 @@ module ParallelRun
131
136
  "created_at" => Time.now.utc.iso8601,
132
137
  "branches" => branch_records,
133
138
  }
134
- write(root, feature, record)
135
- updated_state = attach_state_ref(root, state_path, run_id, state_revision)
139
+ # Validate the STATE CAS and reference before persisting scheduler state.
140
+ # A rejected prepare must not leave a run that blocks a corrected retry.
141
+ absolute, body, updated_state = prepare_state_ref(root, state_path, run_id, state_revision)
136
142
  record["bound_state_revision"] = Integer(updated_state.fetch("revision"))
137
143
  record["branches"].each { |branch| branch["expected_state_revision"] = record["bound_state_revision"] }
138
144
  write(root, feature, record)
145
+ DevflowControlIO.atomic_write(absolute, body)
139
146
  { "ok" => true, "run" => record }
140
147
  ensure
141
148
  DevflowControlIO.release_lock(lock)
@@ -185,12 +192,14 @@ module ParallelRun
185
192
 
186
193
  previous_id = branch["branch_id"]
187
194
  branch["history"] ||= []
188
- branch["history"] << branch.slice("branch_id", "attempt", "branch_revision", "status", "task_id", "evidence_path")
195
+ branch["history"] << branch.slice("branch_id", "attempt", "branch_revision", "status", "task_id", "evidence_path", "output_fingerprint", "error")
189
196
  branch["attempt"] = Integer(branch.fetch("attempt", 0)) + 1
190
197
  branch["branch_revision"] = 0
191
198
  branch["previous_branch_id"] = previous_id
192
199
  branch["status"] = "pending"
193
- branch.delete("error")
200
+ # Task ownership and evidence belong to the previous attempt. Keeping
201
+ # them on a pending retry makes inspect expose stale execution results.
202
+ %w[task_id evidence_path output_fingerprint error].each { |key| branch.delete(key) }
194
203
  record["status"] = "running"
195
204
  record["run_revision"] = Integer(record.fetch("run_revision", 0)) + 1
196
205
  record["updated_at"] = Time.now.utc.iso8601
@@ -107,9 +107,14 @@ def previous_step_payload(root, state)
107
107
  outputs = paths.each_with_object([]) do |raw_path, result|
108
108
  relative = render.call(raw_path)
109
109
  absolute = File.expand_path(relative, root)
110
- next unless absolute.start_with?("#{File.realpath(root)}#{File::SEPARATOR}") && File.exist?(absolute) && File.lstat(absolute).file?
111
-
112
- result << { "path" => relative, "sha256" => Digest::SHA256.hexdigest(File.binread(absolute)) }
110
+ # Keep required paths even when their files disappeared before prepare.
111
+ # Otherwise startup_preflight cannot distinguish missing evidence from a
112
+ # previous step that had no output obligations at all.
113
+ output = { "path" => relative, "sha256" => nil }
114
+ if absolute.start_with?("#{File.realpath(root)}#{File::SEPARATOR}") && File.exist?(absolute) && File.lstat(absolute).file? && File.realpath(absolute).start_with?("#{File.realpath(root)}#{File::SEPARATOR}")
115
+ output["sha256"] = Digest::SHA256.hexdigest(File.binread(absolute))
116
+ end
117
+ result << output
113
118
  end
114
119
  { "id" => step_id, "label" => step["label"].to_s, "outputs" => outputs }
115
120
  rescue StandardError
@@ -156,7 +161,8 @@ result = catch(:transition_done) do
156
161
  # is safe only when it belongs to this exact STATE revision and its
157
162
  # content hash still verifies.
158
163
  expected_state_revision = rollover["expected_state_revision"]
159
- if expected_state_revision == revision || (expected_state_revision.is_a?(Integer) && expected_state_revision + 1 == revision)
164
+ prepared_revision = rollover.fetch("prepared_state_revision", expected_state_revision.is_a?(Integer) ? expected_state_revision + 1 : nil)
165
+ if expected_state_revision == revision || prepared_revision == revision
160
166
  existing_path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
161
167
  if File.exist?(existing_path) && Digest::SHA256.hexdigest(File.binread(existing_path)) == rollover["handoff_sha256"]
162
168
  result = { "ok" => true, "written" => false, "reused" => true, "rollover" => rollover, "revision" => revision }
@@ -216,7 +222,7 @@ result = catch(:transition_done) do
216
222
  state["rollover"] = {
217
223
  "id" => rollover_id, "status" => "prepared", "handoff_path" => relative_path,
218
224
  "handoff_sha256" => digest, "source_session_id" => options[:source_session_id],
219
- "target_session_id" => nil, "expected_state_revision" => revision,
225
+ "target_session_id" => nil, "expected_state_revision" => revision, "prepared_state_revision" => revision + 1,
220
226
  "prepared_at" => iso_now, "claimed_at" => nil
221
227
  }
222
228
  state["revision"] = revision + 1
@@ -284,6 +290,7 @@ result = catch(:transition_done) do
284
290
  throw :transition_done, result
285
291
  end
286
292
  if options[:write]
293
+ rollover["prepared_state_revision"] = revision + 1
287
294
  rollover["status"] = "prepared"; rollover["target_session_id"] = nil; rollover["claimed_at"] = nil; rollover.delete("lease_expires_at"); rollover.delete("last_renewed_at")
288
295
  state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw); rebase_parallel_run(root, state, lock)
289
296
  end
@@ -125,6 +125,14 @@ def startup_preflight(root: Path, feature: str, step: str,
125
125
  except ValueError:
126
126
  exists = False
127
127
  input_facts.append({"path": rendered, "required": required, "exists": exists})
128
+ # Input readiness is independent of whether the caller supplied
129
+ # a frozen contract. Keep the derived contract view exempt, as
130
+ # review_preflight does, and avoid duplicating its diagnostics.
131
+ if (required and not exists
132
+ and rendered != ".devflow/planning/execution-contract.md"
133
+ and not any(issue.get("code") == "MISSING_INPUT_FACT"
134
+ and issue.get("path") == rendered for issue in issues)):
135
+ issues.append({"code": "MISSING_INPUT_FACT", "path": rendered})
128
136
  facts["inputs"] = input_facts
129
137
  facts["outputs"] = [
130
138
  render_path(item["path"], feature, phase)
@@ -9,6 +9,14 @@ type ProcessResult = { ok: boolean; exitCode: number; stdout: string; stderr: st
9
9
 
10
10
  function resolveSkillRoot(cwd: string): string {
11
11
  const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
12
+ if (process.env.DEVFLOW_RUNTIME_MODE === "embedded") {
13
+ const pluginRoot = path.resolve(process.env.DEVFLOW_PLUGIN_ROOT || path.resolve(moduleDirectory, "../.."));
14
+ const packaged = path.join(pluginRoot, "devflow-intel", "skills");
15
+ if (!existsSync(path.join(packaged, "workflow-code-index/scripts/code_index.py"))) {
16
+ throw new Error("Embedded Devflow Pi intel skills are missing");
17
+ }
18
+ return packaged;
19
+ }
12
20
  const candidates = [
13
21
  process.env.DEVFLOW_KIT_ROOT,
14
22
  path.join(cwd, ".skills"),
@@ -57,12 +65,24 @@ async function run(command: string[], cwd: string): Promise<ProcessResult> {
57
65
  return { ok: exitCode === 0, exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
58
66
  }
59
67
 
68
+ const SYMBOL_ACTIONS = new Set(["query", "relations", "impact", "query-plan"]);
69
+
70
+ /** A successful query with no matches prints nothing; say so instead of "Process exited with 0". */
71
+ function emptyOutputMessage(result: ProcessResult, action: unknown): string {
72
+ if (!result.ok) return `命令执行失败,没有输出任何信息(退出码 ${result.exitCode})。`;
73
+ if (SYMBOL_ACTIONS.has(String(action))) {
74
+ return "代码索引中没有匹配的结果。符号名多为英文类名、函数名或文件路径:请换用英文标识符再查,或用 action=search-content 按源码文本检索。";
75
+ }
76
+ if (action === "search-content") return "源码中没有找到匹配的文本。";
77
+ return "命令执行成功,但没有返回任何结果。";
78
+ }
79
+
60
80
  function render(result: ProcessResult, details: Record<string, unknown>) {
61
81
  return {
62
82
  content: [
63
83
  {
64
84
  type: "text" as const,
65
- text: result.stdout || result.stderr || `Process exited with ${result.exitCode}`,
85
+ text: result.stdout || result.stderr || emptyOutputMessage(result, details.action),
66
86
  },
67
87
  ],
68
88
  details: { ...details, exitCode: result.exitCode, stderr: result.stderr || undefined },
@@ -186,13 +206,15 @@ export default function devflowIntel(pi: ExtensionAPI) {
186
206
  parameters: z.object({ args: z.array(z.string()).default([]) }),
187
207
  approval: "exec",
188
208
  async execute(_id, params, _signal, _onUpdate, ctx) {
189
- const skillRoot = resolveSkillRoot(ctx.cwd);
209
+ const projectRoot = resolveProjectRoot(ctx.cwd);
210
+ const skillRoot = resolveSkillRoot(projectRoot);
190
211
  const script = path.join(
191
212
  skillRoot,
192
213
  "workflow-context-sync/scripts/context_sync_cursor.py",
193
214
  );
194
- return render(await run(["python3", script, ...params.args], ctx.cwd), {
215
+ return render(await run(["python3", script, ...params.args], projectRoot), {
195
216
  action: "context-cursor",
217
+ projectRoot,
196
218
  });
197
219
  },
198
220
  });
@@ -1,3 +1,32 @@
1
+ // plugins/devflow-quality/runtime/playwright/src/text-match.ts
2
+ function normalizeText(value, normalizeWhitespace, caseSensitive) {
3
+ let out = normalizeWhitespace ? value.replace(/\s+/g, " ").trim() : value;
4
+ if (!caseSensitive)
5
+ out = out.toLowerCase();
6
+ return out;
7
+ }
8
+ function textMatches(actual, expected, match) {
9
+ return match === "exact" ? actual === expected : actual.includes(expected);
10
+ }
11
+
12
+ // plugins/devflow-quality/runtime/playwright/src/explore/request-tracker.ts
13
+ class RequestTracker {
14
+ pending = new Set;
15
+ started = (request) => {
16
+ if (["fetch", "xhr", "document"].includes(request.resourceType()))
17
+ this.pending.add(request);
18
+ };
19
+ finished = (request) => {
20
+ this.pending.delete(request);
21
+ };
22
+ get size() {
23
+ return this.pending.size;
24
+ }
25
+ clear() {
26
+ this.pending.clear();
27
+ }
28
+ }
29
+
1
30
  // plugins/devflow-quality/runtime/playwright/src/explore/host.ts
2
31
  import readline from "node:readline";
3
32
  import fs2 from "node:fs";
@@ -565,7 +594,7 @@ class Recorder {
565
594
  this.feature = feature;
566
595
  this.environmentProfile = environmentProfile;
567
596
  this.startUrl = startUrl;
568
- const initial = new URL(startUrl);
597
+ const initial = new URL(redactor.url(startUrl));
569
598
  this.steps.push({ action: "goto", url: `${initial.pathname}${initial.search}${initial.hash}` });
570
599
  }
571
600
  async unique(locator) {
@@ -706,7 +735,17 @@ class Recorder {
706
735
  exploration: { environment_profile: this.environmentProfile, start_url: this.redactor.url(this.startUrl), recorded_steps: steps.length, needs_manual: manual, review_items, steps: metadata }
707
736
  };
708
737
  const output = path.join(root, fileName);
709
- const serialized = this.redactor.text(JSON.stringify(draft, null, 2));
738
+ const redactLiterals = (value) => {
739
+ if (typeof value === "string")
740
+ return this.redactor.text(value);
741
+ if (Array.isArray(value))
742
+ return value.map(redactLiterals);
743
+ if (value && typeof value === "object") {
744
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactLiterals(item)]));
745
+ }
746
+ return value;
747
+ };
748
+ const serialized = JSON.stringify(redactLiterals(draft), null, 2);
710
749
  await fs.writeFile(output, `${serialized}
711
750
  `, { mode: 384 });
712
751
  return { path: output, manual, review_items };
@@ -723,31 +762,18 @@ var redactor = null;
723
762
  var profileMemory = null;
724
763
  var idleTimer = null;
725
764
  var totalTimer = null;
726
- var inFlightRequests = 0;
727
- var requestStarted = null;
728
- var requestFinished = null;
765
+ var requests = new RequestTracker;
729
766
  function installRequestCounter(currentContext) {
730
- inFlightRequests = 0;
731
- requestStarted = (request) => {
732
- if (["fetch", "xhr", "document"].includes(request.resourceType()))
733
- inFlightRequests++;
734
- };
735
- requestFinished = () => {
736
- inFlightRequests = Math.max(0, inFlightRequests - 1);
737
- };
738
- currentContext.on("request", requestStarted);
739
- currentContext.on("requestfinished", requestFinished);
740
- currentContext.on("requestfailed", requestFinished);
767
+ requests.clear();
768
+ currentContext.on("request", requests.started);
769
+ currentContext.on("requestfinished", requests.finished);
770
+ currentContext.on("requestfailed", requests.finished);
741
771
  }
742
772
  function removeRequestCounter() {
743
- if (context && requestStarted && requestFinished) {
744
- context.off("request", requestStarted);
745
- context.off("requestfinished", requestFinished);
746
- context.off("requestfailed", requestFinished);
747
- }
748
- requestStarted = null;
749
- requestFinished = null;
750
- inFlightRequests = 0;
773
+ context?.off("request", requests.started);
774
+ context?.off("requestfinished", requests.finished);
775
+ context?.off("requestfailed", requests.finished);
776
+ requests.clear();
751
777
  }
752
778
  function reply(id, result) {
753
779
  process.stdout.write(`${JSON.stringify({ id, ok: true, result })}
@@ -805,7 +831,7 @@ async function settle(currentPage) {
805
831
  let quietSince = 0;
806
832
  let previousSnapshot = null;
807
833
  while (Date.now() < deadline) {
808
- if (inFlightRequests === 0) {
834
+ if (requests.size === 0) {
809
835
  if (!quietSince)
810
836
  quietSince = Date.now();
811
837
  if (Date.now() - quietSince >= 500) {
@@ -922,24 +948,25 @@ async function doAssert(request) {
922
948
  const match = request.params?.match === "contains" ? "contains" : "exact";
923
949
  const normalizeWhitespace = request.params?.normalize_whitespace !== false;
924
950
  const caseSensitive = request.params?.case_sensitive !== false;
951
+ const matchesText = (actual, expected2) => textMatches(normalizeText(actual, normalizeWhitespace, caseSensitive), normalizeText(expected2, normalizeWhitespace, caseSensitive), match);
925
952
  if (kind === "visible") {
926
953
  if (!await locator.isVisible())
927
954
  throw Object.assign(new Error("element is not visible"), { code: "assertion_failed" });
928
955
  } else if (kind === "value") {
929
956
  const actual = await locator.inputValue();
930
957
  expected = String(requested ?? actual);
931
- if (actual !== expected)
958
+ if (!matchesText(actual, expected))
932
959
  throw Object.assign(new Error(`value assertion failed: expected ${current.redactor.text(expected)}`), { code: "assertion_failed" });
933
960
  } else if (kind === "text") {
934
961
  const actual = await locator.innerText();
935
962
  expected = String(requested ?? actual.trim());
936
- const matches = match === "contains" ? actual.includes(expected) : actual.trim() === expected;
963
+ const matches = matchesText(actual, expected);
937
964
  if (!matches)
938
965
  throw Object.assign(new Error(`text assertion failed: expected ${current.redactor.text(expected)}`), { code: "assertion_failed" });
939
966
  } else if (kind === "each_text") {
940
967
  const actual = await locator.allInnerTexts();
941
968
  expected = typeof requested === "string" ? requested : actual[0]?.trim() ?? "";
942
- const matches = actual.every((item) => match === "contains" ? item.includes(expected) : item.trim() === expected);
969
+ const matches = actual.length > 0 && actual.every((item) => matchesText(item, expected));
943
970
  if (!matches)
944
971
  throw Object.assign(new Error("each_text assertion failed"), { code: "assertion_failed" });
945
972
  } else {
@@ -227,6 +227,7 @@ var API_ACTIONS = new Set(["request"]);
227
227
  var BUILTIN_VARIABLES = new Set(["execution_id", "namespace"]);
228
228
  var VAR_REF = /\$\{([^}]*)\}/g;
229
229
  var VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
230
+ var SECRET_HEADERS = new Set(["authorization", "proxy-authorization", "cookie", "x-api-key", "x-auth-token"]);
230
231
  var FRAGILE_CSS = [
231
232
  /el-table_\d+(?:_column_\d+)?/,
232
233
  /:nth-(?:last-)?(?:child|of-type)\(/,
@@ -245,6 +246,15 @@ function collectRefs(value, out = new Set) {
245
246
  }
246
247
  return out;
247
248
  }
249
+ function unterminatedRefs(value, at, issues) {
250
+ if (typeof value === "string") {
251
+ if (value.replace(VAR_REF, "").includes("${"))
252
+ issues.push({ path: at, message: "unterminated variable reference" });
253
+ } else if (value && typeof value === "object") {
254
+ for (const [key, child] of Object.entries(value))
255
+ unterminatedRefs(child, `${at}/${key}`, issues);
256
+ }
257
+ }
248
258
  function validateDsl(dsl, schema) {
249
259
  const issues = new SchemaValidator(schema).validate(dsl);
250
260
  if (issues.length)
@@ -261,6 +271,7 @@ function semanticIssues(dsl) {
261
271
  const ids = new Set;
262
272
  const screenshotNames = new Set;
263
273
  const defined = new Set([...BUILTIN_VARIABLES, ...Object.keys(dsl.variables)]);
274
+ const sensitive = new Set(Object.entries(dsl.variables).filter(([, variable]) => variable.secret !== undefined || variable.sensitive === true).map(([name]) => name));
264
275
  for (const name of Object.keys(dsl.variables)) {
265
276
  if (BUILTIN_VARIABLES.has(name))
266
277
  issues.push({ path: `/variables/${name}`, message: "shadows a built-in variable" });
@@ -270,6 +281,7 @@ function semanticIssues(dsl) {
270
281
  if (refs.size)
271
282
  issues.push({ path: `/variables/${name}`, message: "variable values must be literals" });
272
283
  }
284
+ unterminatedRefs(dsl.data.namespace, "/data/namespace", issues);
273
285
  for (const ref of collectRefs(dsl.data.namespace)) {
274
286
  if (ref !== "execution_id")
275
287
  issues.push({ path: "/data/namespace", message: `namespace may only reference \${execution_id}, got \${${ref}}` });
@@ -283,6 +295,8 @@ function semanticIssues(dsl) {
283
295
  const allowed = step.driver === "api" ? API_ACTIONS : UI_ACTIONS;
284
296
  if (!allowed.has(step.action))
285
297
  issues.push({ path: `${at}/action`, message: `unknown action ${step.action}` });
298
+ const payload = Object.fromEntries(Object.entries(step).filter(([key]) => !["id", "driver", "action", "covers", "extract", "assertions", "retry"].includes(key)));
299
+ unterminatedRefs(payload, at, issues);
286
300
  for (const ref of collectRefs(step)) {
287
301
  if (!VAR_NAME.test(ref))
288
302
  issues.push({ path: at, message: `malformed variable reference \${${ref}}` });
@@ -298,6 +312,7 @@ function semanticIssues(dsl) {
298
312
  if (step.driver === "api") {
299
313
  const assertions = step.assertions ?? [];
300
314
  assertions.forEach((a, i) => {
315
+ unterminatedRefs(a.value, `${at}/assertions/${i}/value`, issues);
301
316
  const needsValue = a.op !== "exists" && a.op !== "not_exists";
302
317
  if (needsValue && !("value" in a))
303
318
  issues.push({ path: `${at}/assertions/${i}`, message: `op ${a.op} requires value` });
@@ -308,11 +323,21 @@ function semanticIssues(dsl) {
308
323
  if (step.retry && (method === "POST" || method === "PATCH") && step.idempotent !== true) {
309
324
  issues.push({ path: `${at}/retry`, message: `${method} retry requires idempotent=true` });
310
325
  }
311
- for (const name of Object.keys(step.extract ?? {})) {
312
- if (BUILTIN_VARIABLES.has(name) || name in dsl.variables) {
313
- issues.push({ path: `${at}/extract/${name}`, message: "extract may not overwrite a declared or built-in variable" });
326
+ for (const [header, value] of Object.entries(step.headers ?? {})) {
327
+ if (!SECRET_HEADERS.has(header.toLowerCase()))
328
+ continue;
329
+ const refs = collectRefs(value);
330
+ if (!refs.size || [...refs].some((name) => !sensitive.has(name))) {
331
+ issues.push({ path: `${at}/headers/${header}`, message: "must be built only from secret or sensitive variables" });
332
+ }
333
+ }
334
+ for (const [name, spec] of Object.entries(step.extract ?? {})) {
335
+ if (defined.has(name)) {
336
+ issues.push({ path: `${at}/extract/${name}`, message: "extract may not overwrite an existing variable" });
314
337
  }
315
338
  defined.add(name);
339
+ if (spec.sensitive === true || spec.header !== undefined && spec.sensitive !== false)
340
+ sensitive.add(name);
316
341
  }
317
342
  } else if (step.target && typeof step.target === "object") {
318
343
  semanticTargetIssues(step.target, `${at}/target`, issues, step.action);
@@ -769,6 +794,17 @@ async function executeApi(step, ctx) {
769
794
  return details;
770
795
  }
771
796
 
797
+ // plugins/devflow-quality/runtime/playwright/src/text-match.ts
798
+ function normalizeText(value, normalizeWhitespace, caseSensitive) {
799
+ let out = normalizeWhitespace ? value.replace(/\s+/g, " ").trim() : value;
800
+ if (!caseSensitive)
801
+ out = out.toLowerCase();
802
+ return out;
803
+ }
804
+ function textMatches(actual, expected, match) {
805
+ return match === "exact" ? actual === expected : actual.includes(expected);
806
+ }
807
+
772
808
  // plugins/devflow-quality/runtime/playwright/src/ui.ts
773
809
  import path from "node:path";
774
810
  import fs from "node:fs";
@@ -995,15 +1031,6 @@ function resolveNavigationUrl(raw, ctx) {
995
1031
  throw new StepFailure("blocked", "invalid_url", `goto url must be absolute or start with '/': ${ctx.redactor.text(url)}`);
996
1032
  }
997
1033
  }
998
- function normalizeText(value, normalizeWhitespace, caseSensitive) {
999
- let out = normalizeWhitespace ? value.replace(/\s+/g, " ").trim() : value;
1000
- if (!caseSensitive)
1001
- out = out.toLowerCase();
1002
- return out;
1003
- }
1004
- function textMatches(actual, expected, match) {
1005
- return match === "exact" ? actual === expected : actual.includes(expected);
1006
- }
1007
1034
  async function executeUi(step, ctx) {
1008
1035
  const page = ctx.page;
1009
1036
  const defaultTimeout = step.action === "goto" ? DEFAULT_NAVIGATION_TIMEOUT_MS : DEFAULT_UI_TIMEOUT_MS;
@@ -1477,8 +1504,8 @@ async function executeRun(page, runtimeDir) {
1477
1504
  } catch {
1478
1505
  return outcome({ status: "invalid", error_code: "dsl_not_json", dsl_sha256: dslSha, started_at: startedAt }, redactor);
1479
1506
  }
1480
- const allSteps = [...dsl?.data?.setup ?? [], ...dsl?.steps ?? [], ...dsl?.data?.cleanup ?? []];
1481
- const unknown = Array.isArray(allSteps) && allSteps.some((s) => {
1507
+ const allSteps = [dsl?.data?.setup, dsl?.steps, dsl?.data?.cleanup].flatMap((steps) => Array.isArray(steps) ? steps : []);
1508
+ const unknown = allSteps.some((s) => {
1482
1509
  const set = s?.driver === "api" ? API_ACTIONS : s?.driver === "ui" ? UI_ACTIONS : null;
1483
1510
  return !set || !set.has(s?.action);
1484
1511
  });
@@ -3,10 +3,10 @@
3
3
  "runtime_version": "0.1.0",
4
4
  "playwright_version": "1.63.0",
5
5
  "files": {
6
- "runner.spec.mjs": "abf0c5c9aff43b4f233de4c44db0946001c970758ef63ab861fad1d125c1a6fc",
6
+ "runner.spec.mjs": "25910031e9d9b413843a19c2ac5496b2109546e40283bb68dd9aa2ac9b97b743",
7
7
  "playwright.config.mjs": "301c0b97e229f83e6934994718b11b93a1da5d46ec606cfd11a8292e9d622b02",
8
8
  "reporter.mjs": "5c91d68c50a93603f583cf82345d427bcd9ce9a7cfb78fd6d0989abddbd55275",
9
- "explore-host.mjs": "035ea37f40cef7399e7038537f3f03d42baffe9d2db32d9bf8add25b0d81118e",
9
+ "explore-host.mjs": "686474bcbe9af1151849e4e25266d60e01e8a49162511c49e071c5ec60a65979",
10
10
  "devflow-test-dsl-v1.schema.json": "df07e55507697a70df214ae820403b530b04aeb04a12d0aa85715bf5e528e194"
11
11
  }
12
12
  }
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.98";
2
+ export const PRODUCT_VERSION = "0.1.99";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5