@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +560 -262
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +6 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +3 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +3 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_traceability.py +31 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +6 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +11 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +14 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +12 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +8 -0
- package/plugins/devflow-intel/extensions/devflow-intel.ts +25 -3
- package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +55 -28
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +41 -14
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +2 -2
- package/src/constants.ts +1 -1
- package/src/mcp/core.ts +23 -24
- package/src/mcp/server.ts +11 -12
- package/src/project-context.ts +9 -1
- package/src/project-initializer.ts +4 -4
- package/src/session-rollover.ts +10 -0
- 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(
|
|
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):
|
package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py
CHANGED
|
@@ -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 =
|
|
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"})
|
package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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],
|
|
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
|
|
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
|
|
727
|
-
var requestStarted = null;
|
|
728
|
-
var requestFinished = null;
|
|
765
|
+
var requests = new RequestTracker;
|
|
729
766
|
function installRequestCounter(currentContext) {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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 (
|
|
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
|
|
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 =
|
|
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) =>
|
|
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
|
|
312
|
-
if (
|
|
313
|
-
|
|
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 = [
|
|
1481
|
-
const unknown =
|
|
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": "
|
|
6
|
+
"runner.spec.mjs": "25910031e9d9b413843a19c2ac5496b2109546e40283bb68dd9aa2ac9b97b743",
|
|
7
7
|
"playwright.config.mjs": "301c0b97e229f83e6934994718b11b93a1da5d46ec606cfd11a8292e9d622b02",
|
|
8
8
|
"reporter.mjs": "5c91d68c50a93603f583cf82345d427bcd9ce9a7cfb78fd6d0989abddbd55275",
|
|
9
|
-
"explore-host.mjs": "
|
|
9
|
+
"explore-host.mjs": "686474bcbe9af1151849e4e25266d60e01e8a49162511c49e071c5ec60a65979",
|
|
10
10
|
"devflow-test-dsl-v1.schema.json": "df07e55507697a70df214ae820403b530b04aeb04a12d0aa85715bf5e528e194"
|
|
11
11
|
}
|
|
12
12
|
}
|
package/src/constants.ts
CHANGED