@capagents/forgeqe 1.2.0 → 1.3.0
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/HOW_TO_USE.md +54 -3
- package/README.md +1 -1
- package/docs/COST_ARCHITECTURE.md +280 -0
- package/docs/feature-test-prompts.md +95 -0
- package/docs/user-guide.html +66 -8
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/brain.md +36 -4
- package/prompts/browser-api.md +13 -0
- package/prompts/browser-capture.md +2 -2
- package/prompts/browser-checks.md +2 -0
- package/py/forgeqe_engine/api_step.py +130 -0
- package/py/forgeqe_engine/capture.py +53 -2
- package/py/forgeqe_engine/checks.py +8 -2
- package/py/forgeqe_engine/llm.py +33 -5
- package/py/forgeqe_engine/memory.py +70 -2
- package/py/forgeqe_engine/observe.py +123 -0
- package/py/forgeqe_engine/replay.py +55 -2
- package/py/forgeqe_engine/service.py +177 -5
- package/src/api/fromSteps.ts +111 -0
- package/src/capabilities/registry.ts +19 -5
- package/src/cases/ado.ts +1 -1
- package/src/cases/journey.ts +61 -0
- package/src/cases/quality.ts +3 -1
- package/src/cases/readiness.ts +35 -6
- package/src/cases/save.ts +241 -48
- package/src/cases/types.ts +11 -0
- package/src/cli/help.ts +8 -0
- package/src/cli/index.ts +64 -1
- package/src/code/coverage.ts +22 -3
- package/src/code/decisions.ts +73 -0
- package/src/code/find.ts +3 -1
- package/src/code/graph/graph.ts +33 -2
- package/src/code/handoff.ts +20 -0
- package/src/code/loop.ts +27 -1
- package/src/code/notes.ts +48 -1
- package/src/code/prompt.ts +31 -9
- package/src/code/protect.ts +103 -0
- package/src/code/replay.ts +33 -0
- package/src/code/review.ts +2 -0
- package/src/code/runtime.ts +35 -3
- package/src/code/shortlist.ts +309 -0
- package/src/code/testrun.ts +3 -1
- package/src/code/traceability.ts +2 -0
- package/src/commands.ts +6 -1
- package/src/config.ts +46 -0
- package/src/engine/bridge.ts +47 -5
- package/src/mcp/casetools.ts +107 -11
- package/src/mcp/commands.ts +74 -20
- package/src/mcp/packtools.ts +187 -0
- package/src/mcp/tools.ts +238 -29
- package/src/pack/collect.ts +251 -0
- package/src/pack/index.ts +134 -0
- package/src/pack/init.ts +45 -0
- package/src/pack/pack.ts +327 -0
- package/src/pack/pipeline.ts +373 -0
- package/src/pack/run.ts +155 -0
- package/src/registry/db.ts +249 -0
- package/src/registry/index.ts +72 -0
- package/src/registry/resolve.ts +57 -0
- package/src/registry/tier0.ts +81 -0
- package/src/report/data.ts +7 -0
- package/src/report/html.ts +14 -0
- package/src/report/template.html +52 -48
- package/src/report/usage.ts +113 -0
- package/src/run.ts +32 -2
- package/src/suite/report.ts +22 -4
- package/src/suite/run.ts +13 -5
- package/src/summary.ts +6 -0
- package/src/tokenomics/budget.ts +172 -0
- package/src/tokenomics/ledger.ts +287 -0
- package/src/tui/App.tsx +90 -2
- package/src/tui/inspector.tsx +40 -35
- package/src/tui/transcript.tsx +5 -4
- package/src/types.ts +11 -2
package/src/code/runtime.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { entraGateway } from "../entra.ts"
|
|
|
9
9
|
import { missingKey, type LlmProfile } from "../llm.ts"
|
|
10
10
|
import type { CodeEvent, FileAction, PlanItem, Usage } from "../types.ts"
|
|
11
11
|
import { envRefs, withoutNames } from "../childEnv.ts"
|
|
12
|
-
import {
|
|
12
|
+
import { recordCall } from "../tokenomics/ledger.ts"
|
|
13
|
+
import { addUsage, type Role } from "../usage.ts"
|
|
13
14
|
import { linkSkills, type Skill, type SkillAgent } from "../skills/registry.ts"
|
|
14
15
|
import { EXE, githubDigest, installTool, isMusl, toolPath, type Log } from "../tools.ts"
|
|
15
16
|
import { RESULT_SCHEMA, testAgentPrompt, type AgentReport } from "./prompt.ts"
|
|
@@ -648,6 +649,14 @@ export async function openSession<R = AgentReport>(opts: {
|
|
|
648
649
|
const said = new Set<string>()
|
|
649
650
|
/** Tool calls already said to have started, by part id. */
|
|
650
651
|
const startedCalls = new Set<string>()
|
|
652
|
+
/** Per model call (assistant message): when it started and the tools it asked for, for the usage ledger. */
|
|
653
|
+
const steps = new Map<string, { started: number; tools: string[]; answered?: number }>()
|
|
654
|
+
const finished = new Set<string>()
|
|
655
|
+
const stepOf = (messageID: string) => {
|
|
656
|
+
let st = steps.get(messageID)
|
|
657
|
+
if (!st) steps.set(messageID, (st = { started: Date.now(), tools: [] }))
|
|
658
|
+
return st
|
|
659
|
+
}
|
|
651
660
|
let idle: (() => void) | null = null
|
|
652
661
|
let sessionError = ""
|
|
653
662
|
// The turn's result comes from events: in agent runtime 1.18 listing a session's messages fails once a prompt used `format`.
|
|
@@ -668,6 +677,13 @@ export async function openSession<R = AgentReport>(opts: {
|
|
|
668
677
|
break
|
|
669
678
|
case "message.part.updated": {
|
|
670
679
|
const part = p.part
|
|
680
|
+
if (part?.type === "step-start" && part.messageID) stepOf(part.messageID).started = Date.now()
|
|
681
|
+
if (part?.type === "tool" && part.messageID && part.tool) {
|
|
682
|
+
const st = stepOf(part.messageID)
|
|
683
|
+
if (!st.tools.includes(part.tool)) st.tools.push(part.tool)
|
|
684
|
+
// The model's part of the step ends when it hands over to a tool; the tool's own time is not the model's.
|
|
685
|
+
st.answered ??= Date.now()
|
|
686
|
+
}
|
|
671
687
|
// A call is said as soon as it starts, so a long one (an API query, a test run) is visible while it runs.
|
|
672
688
|
if (part?.type === "tool" && part.state?.status === "running" && part.id && !startedCalls.has(part.id) && !QUIET_TOOLS.has(part.tool) && !opts.hideTool?.(part.tool)) {
|
|
673
689
|
startedCalls.add(part.id)
|
|
@@ -705,7 +721,8 @@ export async function openSession<R = AgentReport>(opts: {
|
|
|
705
721
|
turn.text.push(text)
|
|
706
722
|
emit({ type: "code", kind: "say", text: clip(text, 400) })
|
|
707
723
|
}
|
|
708
|
-
} else if (part?.type === "step-finish" && part.tokens) {
|
|
724
|
+
} else if (part?.type === "step-finish" && part.tokens && !(part.id && finished.has(part.id))) {
|
|
725
|
+
if (part.id) finished.add(part.id)
|
|
709
726
|
const t = part.tokens
|
|
710
727
|
const step: Usage = {
|
|
711
728
|
input: (t.input || 0) + (t.cache?.read || 0) + (t.cache?.write || 0),
|
|
@@ -714,7 +731,22 @@ export async function openSession<R = AgentReport>(opts: {
|
|
|
714
731
|
...(t.cache?.write ? { cacheWrite: t.cache.write } : {}),
|
|
715
732
|
}
|
|
716
733
|
Object.assign(usage, addUsage(usage, step))
|
|
717
|
-
|
|
734
|
+
const st = part.messageID ? steps.get(part.messageID) : undefined
|
|
735
|
+
if (part.messageID) steps.delete(part.messageID)
|
|
736
|
+
recordCall({
|
|
737
|
+
agent: role,
|
|
738
|
+
usage: step,
|
|
739
|
+
price,
|
|
740
|
+
provider: model.providerID,
|
|
741
|
+
model: model.modelID,
|
|
742
|
+
session: session.id,
|
|
743
|
+
...(opts.title ? { task: opts.title } : {}),
|
|
744
|
+
...(st ? { ms: (st.answered ?? Date.now()) - st.started, tools: st.tools } : {}),
|
|
745
|
+
...(t.reasoning ? { reasoning: t.reasoning } : {}),
|
|
746
|
+
outcome: part.reason === "error" ? "error" : "ok",
|
|
747
|
+
// With code.model the runtime prices the call itself; the profile's price is unknown then.
|
|
748
|
+
...(typeof part.cost === "number" && part.cost > 0 ? { knownCost: { amount: part.cost, currency: "USD" } } : {}),
|
|
749
|
+
})
|
|
718
750
|
emit({ type: "code", kind: "usage", usage: { ...usage }, context: step.input })
|
|
719
751
|
}
|
|
720
752
|
break
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
|
|
3
|
+
import type { MemoryElement, MemoryPage } from "../memory.ts"
|
|
4
|
+
import { allMembers, elementRefs, membersLocating, qualified, testsReachingMembers, type CodeGraph, type Located } from "./graph/graph.ts"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which of a repository's tests a request is about, before any browser run and without a model: hundreds of tests
|
|
8
|
+
* cannot all be shown to ForgeQE's agent, so they are ranked first and the closest few are shown with what each does.
|
|
9
|
+
*
|
|
10
|
+
* Three signals, each weighted by how rare it is among the tests (a link every test clicks says little):
|
|
11
|
+
* - elements: the request names elements page memory knows ("Select Skills", "Email"); the page-object members that
|
|
12
|
+
* locate them, and the tests that reach those members, are found through the code graph, as coverage does after a run.
|
|
13
|
+
* - pages: the request names a page ("the Practice tab", a URL path); tests that reach that page's elements.
|
|
14
|
+
* - names: the request shares words with a test's class, method, case ids, or categories; a case id is decisive.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A test of the repository, as the shortlist shows it. */
|
|
18
|
+
export type TestEntry = {
|
|
19
|
+
/** Class.Method, or the test's title for a top-level test. */
|
|
20
|
+
test: string
|
|
21
|
+
file: string
|
|
22
|
+
line: number
|
|
23
|
+
lines: number
|
|
24
|
+
/** Other tests in the same class: a test can be added next to them. */
|
|
25
|
+
siblings: number
|
|
26
|
+
/** Case ids and categories from its attributes or decorators. */
|
|
27
|
+
tags: string[]
|
|
28
|
+
/** What it calls, in order, without assertions and framework plumbing. */
|
|
29
|
+
steps: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Shortlisted = TestEntry & {
|
|
33
|
+
score: number
|
|
34
|
+
/** Elements the request names that this test reaches, and the ones it does not. */
|
|
35
|
+
covers: string[]
|
|
36
|
+
lacks: string[]
|
|
37
|
+
why: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Shortlist = { candidates: Shortlisted[]; elements: string[]; unlocated: string[]; pages: string[]; tests: number; ms: number }
|
|
41
|
+
|
|
42
|
+
/** A test this close is worth showing. */
|
|
43
|
+
export const SHORTLIST_FROM = 0.2
|
|
44
|
+
const MAX_ELEMENTS = 40
|
|
45
|
+
/** How much the element signal counts when the request names a single known element. */
|
|
46
|
+
const ONE_ELEMENT = 0.4
|
|
47
|
+
const MAX_PAGE_ELEMENTS = 150
|
|
48
|
+
const MAX_STEPS = 14
|
|
49
|
+
|
|
50
|
+
const STOP = new Set(
|
|
51
|
+
"a an and the of in on to with within only each every any all then this that is are be as it its if for from by or not when also into page step steps test tests flow verify verifying check checks expected appears appear present remains accepted go open navigate click fill type enter select see should can must sure make use using value values field fields realistic data async com org net www http https html".split(
|
|
52
|
+
" ",
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
/** Words of an identifier or phrase, endings off: FillSkillsSearchAsync → fill, skill, search. */
|
|
57
|
+
function words(s: string): string[] {
|
|
58
|
+
return s
|
|
59
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
60
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.split(/[^a-z0-9]+/)
|
|
63
|
+
.filter((w) => w.length > 1 && !STOP.has(w))
|
|
64
|
+
.map((w) => w.replace(/(ing|ed|es|s)$/, ""))
|
|
65
|
+
.filter((w) => w.length > 1)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const norm = (s: string) => ` ${s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()} `
|
|
69
|
+
|
|
70
|
+
/** Assertions, waits, and plumbing: not steps of the journey. */
|
|
71
|
+
const NOISE = /^(ConfigureAwait|Expect|expect|That|AreEqual|IsTrue|IsFalse|IsNotNull|Equal|True|False|assert\w*|Assert\w*|to[A-Z]\w*|To[A-Z]\w*|Not\w*|not|should\w*|Should\w*|log|Log\w*|WriteLine|WaitFor\w*|waitFor\w*|Delay|sleep|Sleep|StepAsync|Step|step|Run|GetAwaiter|GetResult|Result|toString|ToString|Format|Contains|Trim|Lower\w*|Upper\w*)$/
|
|
72
|
+
const NOISE_RECEIVER = /^(Assertions|Assert|ClassicAssert|expect|console|Console|Task|string|String|Math|this\.Page|Page|page|TestContext|Logger|logger|log)$/
|
|
73
|
+
|
|
74
|
+
/** Case ids (WEB-014, ADO-2238) and categories from a test's declaration. */
|
|
75
|
+
function tagsOf(signature: string): string[] {
|
|
76
|
+
const ids = [...signature.matchAll(/\b([A-Z][A-Z0-9]+-\d+)\b/g)].map((m) => m[1]!)
|
|
77
|
+
const cats = [...signature.matchAll(/(?:Category|Tag|Trait|mark\.)\s*\(?\s*"?([A-Za-z][\w-]*)"?/g)].map((m) => m[1]!).filter((c) => !/^(Test|TestCase)$/.test(c))
|
|
78
|
+
const at = [...signature.matchAll(/@([a-z][\w-]+)/g)].map((m) => `@${m[1]}`)
|
|
79
|
+
return [...new Set([...ids, ...cats, ...at])]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function entryOf(at: Located, siblings: number): TestEntry {
|
|
83
|
+
const steps: string[] = []
|
|
84
|
+
// In source order: the parser gives calls in the order it meets them in the tree, not as the test runs them.
|
|
85
|
+
const calls = at.member.calls.map((c, i) => ({ c, i })).sort((a, b) => a.c.line - b.c.line || a.i - b.i).map(({ c }) => c)
|
|
86
|
+
for (const c of calls) {
|
|
87
|
+
if (NOISE.test(c.name) || NOISE_RECEIVER.test(c.receiver)) continue
|
|
88
|
+
const step = c.receiver && !/^(this|self|base|super)$/.test(c.receiver) ? `${c.receiver}.${c.name}` : c.name
|
|
89
|
+
if (steps[steps.length - 1] !== step) steps.push(step)
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
test: qualified(at),
|
|
93
|
+
file: at.file,
|
|
94
|
+
line: at.member.line,
|
|
95
|
+
lines: at.member.endLine - at.member.line + 1,
|
|
96
|
+
siblings,
|
|
97
|
+
tags: tagsOf(at.member.signature),
|
|
98
|
+
steps: steps.length > MAX_STEPS ? [...steps.slice(0, MAX_STEPS), `… ${steps.length - MAX_STEPS} more`] : steps,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Every test in the graph, with its entry. */
|
|
103
|
+
export function indexTests(g: CodeGraph): { at: Located; entry: TestEntry }[] {
|
|
104
|
+
const tests = allMembers(g).filter((l) => l.member.kind === "test")
|
|
105
|
+
const perClass = new Map<string, number>()
|
|
106
|
+
for (const t of tests) perClass.set(`${t.file}#${t.cls?.name || ""}`, (perClass.get(`${t.file}#${t.cls?.name || ""}`) || 0) + 1)
|
|
107
|
+
return tests.map((at) => ({ at, entry: entryOf(at, (perClass.get(`${at.file}#${at.cls?.name || ""}`) || 1) - 1) }))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The host a request names, if any. */
|
|
111
|
+
function hostOf(request: string, url?: string): string {
|
|
112
|
+
const m = /https?:\/\/(?:www\.)?([^/\s,)]+)/i.exec(url || request)
|
|
113
|
+
return (m?.[1] || "").toLowerCase().replace(/[.:;!?]+$/, "")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const hostMatches = (page: MemoryPage, host: string) => !host || page.host.replace(/^www\./, "") === host
|
|
117
|
+
|
|
118
|
+
/** Hosts page memory knows whose name the request says without a URL: "Open Wikipedia" is en.wikipedia.org. */
|
|
119
|
+
function hostsNamed(pages: MemoryPage[], request: string): Set<string> {
|
|
120
|
+
const text = norm(request)
|
|
121
|
+
const hosts = new Set<string>()
|
|
122
|
+
for (const p of pages) {
|
|
123
|
+
const labels = p.host.replace(/^www\./, "").split(".").slice(0, -1).filter((l) => l.length >= 4 && !/^(www|com|org|net|app|en|de|fr)$/.test(l))
|
|
124
|
+
if (labels.some((l) => text.includes(` ${l} `) || text.includes(` ${l.replace(/-/g, " ")} `))) hosts.add(p.host.replace(/^www\./, ""))
|
|
125
|
+
}
|
|
126
|
+
return hosts
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Only locators that name one element: a page's other elements located by CSS or text match code of any page. */
|
|
130
|
+
const STABLE = new Set(["testid", "id", "role", "label", "placeholder", "name"])
|
|
131
|
+
|
|
132
|
+
/** The label a person would use for an element: its accessible name, text, or placeholder. */
|
|
133
|
+
function labelOf(e: MemoryElement): string {
|
|
134
|
+
const a = e.attributes || {}
|
|
135
|
+
const label = (e.name || a["aria-label"] || a.placeholder || e.text || "").replace(/\s+/g, " ").trim()
|
|
136
|
+
return label.length >= 3 && label.length <= 60 ? label : ""
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The pages a request names: by its URL path, or a word of the page's path or title ("the Practice tab"). */
|
|
140
|
+
function pagesNamed(pages: MemoryPage[], request: string, url?: string): MemoryPage[] {
|
|
141
|
+
const text = norm(request)
|
|
142
|
+
// The site's own name is not a page's: "Wikipedia" names the site, not every page titled "… - Wikipedia".
|
|
143
|
+
const site = new Set(pages.flatMap((p) => p.host.split(".")))
|
|
144
|
+
const paths = [url, ...(request.match(/https?:\/\/\S+/g) || [])].filter(Boolean).map((u) => {
|
|
145
|
+
try {
|
|
146
|
+
return new URL(String(u).replace(/[.,;)]+$/, "")).pathname.replace(/\/+$/, "") || "/"
|
|
147
|
+
} catch {
|
|
148
|
+
return ""
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
return pages.filter((p) => {
|
|
152
|
+
if (p.path !== "/" && paths.includes(p.path.replace(/\/+$/, ""))) return true
|
|
153
|
+
const named = [...p.path.split(/[/._-]+/), ...(p.title || "").split(/[^A-Za-z0-9]+/)].map((w) => w.toLowerCase()).filter((w) => w.length >= 4 && !site.has(w) && !STOP.has(w) && !/^(html?|index|aspx|php|www|com|main|home)$/.test(w))
|
|
154
|
+
return named.some((w) => text.includes(` ${w} `))
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Tests reached through the members that locate any of these elements. */
|
|
159
|
+
function testsThrough(g: CodeGraph, elements: MemoryElement[], stableOnly = false): Set<string> {
|
|
160
|
+
return reachOf(g, elements, stableOnly).tests
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The page-object members that locate these elements, and the tests that reach them. */
|
|
164
|
+
function reachOf(g: CodeGraph, elements: MemoryElement[], stableOnly = false): { members: string[]; tests: Set<string> } {
|
|
165
|
+
const refsOf = (e: MemoryElement) => elementRefs(e).filter((r) => !stableOnly || (STABLE.has(r.kind) && Boolean(r.value)))
|
|
166
|
+
const members = elements.flatMap((e) => membersLocating(g, refsOf(e)).filter((m) => m.how === "exact" || (!stableOnly && m.how !== "param")).map((m) => m.at))
|
|
167
|
+
const seen = new Set<string>()
|
|
168
|
+
const unique = members.filter((m) => {
|
|
169
|
+
const k = `${m.file}|${qualified(m)}`
|
|
170
|
+
return seen.has(k) ? false : (seen.add(k), true)
|
|
171
|
+
})
|
|
172
|
+
return { members: unique.map(qualified), tests: new Set(testsReachingMembers(g, unique).map((t) => `${t.file}#${qualified(t)}`)) }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The repository's tests closest to a request, best first, with what each covers of the elements it names. */
|
|
176
|
+
export function shortlistTests(opts: { graph: CodeGraph; pages: MemoryPage[]; request: string; url?: string; max?: number }): Shortlist {
|
|
177
|
+
const started = performance.now()
|
|
178
|
+
const { graph, request } = opts
|
|
179
|
+
const index = indexTests(graph)
|
|
180
|
+
const n = index.length
|
|
181
|
+
const done = (candidates: Shortlisted[], extra: Partial<Shortlist> = {}): Shortlist => ({ candidates, elements: [], unlocated: [], pages: [], tests: n, ms: Math.round(performance.now() - started), ...extra })
|
|
182
|
+
if (!n) return done([])
|
|
183
|
+
const host = hostOf(request, opts.url)
|
|
184
|
+
const hosts = host ? null : hostsNamed(opts.pages, request)
|
|
185
|
+
const scope = opts.pages.filter((p) => (hosts?.size ? hosts.has(p.host.replace(/^www\./, "")) : hostMatches(p, host)))
|
|
186
|
+
const idf = (reach: number) => Math.log(1 + n / (1 + reach))
|
|
187
|
+
|
|
188
|
+
// Pages the request names, and the tests that reach their elements.
|
|
189
|
+
const pages = pagesNamed(scope, request, opts.url)
|
|
190
|
+
// Each element of those pages, weighted by how few tests reach it: a generic "Search" button other sites' page
|
|
191
|
+
// objects also locate says little; the elements only this page's tests use say the most.
|
|
192
|
+
const pageElements = pages.flatMap((p) => Object.values(p.elements || {}).filter((e) => elementRefs(e).some((r) => STABLE.has(r.kind))).slice(0, MAX_PAGE_ELEMENTS))
|
|
193
|
+
const pageWeight = new Map<string, number>()
|
|
194
|
+
for (const e of pageElements) {
|
|
195
|
+
const reach = testsThrough(graph, [e], true)
|
|
196
|
+
for (const t of reach) pageWeight.set(t, (pageWeight.get(t) || 0) + idf(reach.size))
|
|
197
|
+
}
|
|
198
|
+
const pageTop = Math.max(0, ...pageWeight.values())
|
|
199
|
+
|
|
200
|
+
// Elements the request names, longest labels first ("Select Skills" before "Skills"). On the pages it names when it
|
|
201
|
+
// names any ("Email" is on many pages), else anywhere on the site; every element with that label counts.
|
|
202
|
+
const text = norm(request)
|
|
203
|
+
const collect = (from: MemoryPage[]) => {
|
|
204
|
+
const named = new Map<string, MemoryElement[]>()
|
|
205
|
+
for (const page of from)
|
|
206
|
+
for (const e of Object.values(page.elements || {})) {
|
|
207
|
+
const key = norm(labelOf(e))
|
|
208
|
+
if (key.trim() && key.trim().split(" ").some((w) => !STOP.has(w)) && text.includes(key)) named.set(key, [...(named.get(key) || []), e])
|
|
209
|
+
}
|
|
210
|
+
return named
|
|
211
|
+
}
|
|
212
|
+
const onPages = pages.length ? collect(pages) : new Map<string, MemoryElement[]>()
|
|
213
|
+
const named = onPages.size ? onPages : collect(scope)
|
|
214
|
+
const labels = [...named.keys()].sort((a, b) => b.length - a.length).slice(0, MAX_ELEMENTS)
|
|
215
|
+
// A shorter label inside a longer one the request names ("skills" in "select skills") is the same mention.
|
|
216
|
+
const kept = labels.filter((l, i) => !labels.slice(0, i).some((longer) => longer.includes(l)))
|
|
217
|
+
const elementReach = kept.map((key) => {
|
|
218
|
+
const r = reachOf(graph, named.get(key)!)
|
|
219
|
+
return { label: labelOf(named.get(key)![0]!), reach: r.tests, members: r.members }
|
|
220
|
+
})
|
|
221
|
+
// Located: a page-object member finds it, whether or not a test uses it yet (then every test lacks it).
|
|
222
|
+
const located = elementReach.filter((e) => e.members.length)
|
|
223
|
+
const unlocated = elementReach.filter((e) => !e.members.length).map((e) => e.label)
|
|
224
|
+
const elementWeight = located.map((e) => idf(e.reach.size))
|
|
225
|
+
const elementTotal = elementWeight.reduce((a, b) => a + b, 0)
|
|
226
|
+
|
|
227
|
+
// Words: a request word in few test names says more than one in many.
|
|
228
|
+
const reqWords = new Set(words(request))
|
|
229
|
+
const nameWords = index.map(({ at, entry }) => new Set([...words(at.cls?.name || ""), ...words(at.member.name), ...entry.tags.flatMap(words)]))
|
|
230
|
+
const df = new Map<string, number>()
|
|
231
|
+
for (const ws of nameWords) for (const w of ws) df.set(w, (df.get(w) || 0) + 1)
|
|
232
|
+
const reqWeight = [...reqWords].reduce((sum, w) => sum + idf(df.get(w) || 0) ** 2, 0)
|
|
233
|
+
const reqIds = new Set((request.match(/\b[A-Z][A-Z0-9]+-\d+\b/g) || []).map((s) => s.toUpperCase()))
|
|
234
|
+
|
|
235
|
+
const scored: Shortlisted[] = index.map(({ at, entry }, i) => {
|
|
236
|
+
const key = `${at.file}#${qualified(at)}`
|
|
237
|
+
const why: string[] = []
|
|
238
|
+
const covers = located.filter((e) => e.reach.has(key)).map((e) => e.label)
|
|
239
|
+
const lacks = located.filter((e) => !e.reach.has(key)).map((e) => e.label)
|
|
240
|
+
const elem = elementTotal ? located.reduce((s, e, j) => s + (e.reach.has(key) ? elementWeight[j]! : 0), 0) / elementTotal : 0
|
|
241
|
+
if (covers.length) why.push(`reaches ${covers.length} of the ${located.length} elements the request names`)
|
|
242
|
+
const page = pageTop ? (pageWeight.get(key) || 0) / pageTop : 0
|
|
243
|
+
if (page >= 0.5) why.push(`works on ${pages.map((p) => p.path).join(", ")}`)
|
|
244
|
+
const ws = nameWords[i]!
|
|
245
|
+
// Cosine of the two word sets, each word weighted by how rare it is among test names.
|
|
246
|
+
let shared = 0
|
|
247
|
+
let own = 0
|
|
248
|
+
for (const w of ws) {
|
|
249
|
+
const weight = idf(df.get(w) || 0) ** 2
|
|
250
|
+
own += weight
|
|
251
|
+
if (reqWords.has(w)) shared += weight
|
|
252
|
+
}
|
|
253
|
+
// How much of the test's name the request says, with the cosine so a long name is not lost against a short one.
|
|
254
|
+
const name = own && reqWeight ? 0.6 * (shared / own) + 0.4 * (shared / Math.sqrt(own * reqWeight)) : 0
|
|
255
|
+
if (name >= 0.3) why.push(`its name shares "${[...ws].filter((w) => reqWords.has(w)).join(", ")}" with the request`)
|
|
256
|
+
const caseHit = entry.tags.some((t) => reqIds.has(t.toUpperCase()))
|
|
257
|
+
if (caseHit) why.push(`tagged ${entry.tags.filter((t) => reqIds.has(t.toUpperCase())).join(", ")}, which the request names`)
|
|
258
|
+
// Without elements named, pages and names carry the score.
|
|
259
|
+
// One element the request names may be a generic word ("use the language search"); two or more are its journey.
|
|
260
|
+
const strength = located.length >= 2 ? 1 : located.length === 1 ? ONE_ELEMENT : 0
|
|
261
|
+
const score = strength * (0.45 * elem + 0.2 * page + 0.35 * name) + (1 - strength) * (0.35 * page + 0.65 * name) + (caseHit ? 1 : 0)
|
|
262
|
+
return { ...entry, score: Number(score.toFixed(3)), covers, lacks, why }
|
|
263
|
+
})
|
|
264
|
+
const candidates = scored
|
|
265
|
+
.filter((c) => c.score >= SHORTLIST_FROM)
|
|
266
|
+
.sort((a, b) => b.score - a.score || b.covers.length - a.covers.length || a.lines - b.lines)
|
|
267
|
+
.slice(0, opts.max ?? 6)
|
|
268
|
+
return done(candidates, { elements: located.map((e) => e.label), unlocated, pages: pages.map((p) => `${p.host}${p.path}`) })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The command that runs one test, built the way the repository already runs one: from a command an earlier job used
|
|
273
|
+
* (its filter swapped for this test), else the framework's usual form. Null when neither fits.
|
|
274
|
+
*/
|
|
275
|
+
export function oneTestCommand(repo: string, t: Pick<TestEntry, "test" | "file">, known: string[], files: string[]): string | null {
|
|
276
|
+
const [cls, method] = t.test.includes(".") ? [t.test.slice(0, t.test.lastIndexOf(".")), t.test.slice(t.test.lastIndexOf(".") + 1)] : ["", t.test]
|
|
277
|
+
const ext = path.extname(t.file)
|
|
278
|
+
if (ext === ".cs") {
|
|
279
|
+
const filter = known.find((c) => /--filter\s+"?FullyQualifiedName~/.test(c))
|
|
280
|
+
if (filter) return filter.replace(/(FullyQualifiedName~)[^"\s]+/, `$1${cls}.${method}`)
|
|
281
|
+
// The test project nearest the file.
|
|
282
|
+
const projects = files.filter((f) => f.endsWith(".csproj")).filter((p) => t.file.startsWith(path.dirname(p) + "/")).sort((a, b) => b.length - a.length)
|
|
283
|
+
return projects[0] ? `dotnet test ${projects[0]} --filter "FullyQualifiedName~${cls}.${method}"` : null
|
|
284
|
+
}
|
|
285
|
+
if ([".ts", ".js", ".mjs", ".tsx"].includes(ext) && files.some((f) => /(^|\/)playwright\.config\.(ts|js|mjs|cjs)$/.test(f))) return `npx playwright test ${t.file} -g "${method.replace(/"/g, '\\"')}"`
|
|
286
|
+
if (ext === ".py") return `pytest ${t.file}::${cls ? `${cls}::` : ""}${method}`
|
|
287
|
+
if (ext === ".java") {
|
|
288
|
+
if (files.some((f) => /(^|\/)pom\.xml$/.test(f))) return `mvn test -Dtest="${cls}#${method}"`
|
|
289
|
+
if (files.some((f) => /(^|\/)build\.gradle(\.kts)?$/.test(f))) return `./gradlew test --tests "*${cls}.${method}"`
|
|
290
|
+
}
|
|
291
|
+
void repo
|
|
292
|
+
return null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The tests that work on the pages of these areas (/checkout, /api/cart/:id): they reach elements page memory knows on
|
|
297
|
+
* those pages, through stable locators. Keyed file#Class.Method, with the areas each one reaches.
|
|
298
|
+
*/
|
|
299
|
+
export function testsOnAreas(graph: CodeGraph, pages: MemoryPage[], areas: string[]): Map<string, string[]> {
|
|
300
|
+
const out = new Map<string, string[]>()
|
|
301
|
+
for (const area of areas) {
|
|
302
|
+
const pattern = new RegExp(`^${area.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\:[^/]+|:[^/]+/g, "[^/]+")}/?$`, "i")
|
|
303
|
+
const on = pages.filter((p) => pattern.test(p.path))
|
|
304
|
+
if (!on.length) continue
|
|
305
|
+
const reach = testsThrough(graph, on.flatMap((p) => Object.values(p.elements || {}).filter((e) => elementRefs(e).some((r) => STABLE.has(r.kind))).slice(0, MAX_PAGE_ELEMENTS)), true)
|
|
306
|
+
for (const t of reach) out.set(t, [...(out.get(t) || []), area])
|
|
307
|
+
}
|
|
308
|
+
return out
|
|
309
|
+
}
|
package/src/code/testrun.ts
CHANGED
|
@@ -142,6 +142,8 @@ export async function runTestCommand(opts: {
|
|
|
142
142
|
save?: string
|
|
143
143
|
/** Variables the test must not see: ForgeQE's own secrets (privateEnvNames). */
|
|
144
144
|
hide?: string[]
|
|
145
|
+
/** Variables the test gets on top of ForgeQE's own (a remote browser's endpoint): never put in the command line. */
|
|
146
|
+
env?: Record<string, string>
|
|
145
147
|
}): Promise<TestRun> {
|
|
146
148
|
const started = Date.now()
|
|
147
149
|
const windows = process.platform === "win32"
|
|
@@ -149,7 +151,7 @@ export async function runTestCommand(opts: {
|
|
|
149
151
|
const proc = Bun.spawn(argv, {
|
|
150
152
|
cwd: opts.cwd,
|
|
151
153
|
windowsVerbatimArguments: windows,
|
|
152
|
-
env: { ...withoutNames(process.env, opts.hide || []), ...sessionLoginEnv(), CI: process.env.CI || "1", PLAYWRIGHT_HTML_OPEN: "never", FORCE_COLOR: "0", NO_COLOR: "1" },
|
|
154
|
+
env: { ...withoutNames(process.env, opts.hide || []), ...sessionLoginEnv(), CI: process.env.CI || "1", PLAYWRIGHT_HTML_OPEN: "never", FORCE_COLOR: "0", NO_COLOR: "1", ...(opts.env || {}) },
|
|
153
155
|
stdin: "ignore",
|
|
154
156
|
stdout: "pipe",
|
|
155
157
|
stderr: "pipe",
|
package/src/code/traceability.ts
CHANGED
|
@@ -33,6 +33,8 @@ export type Mapping = {
|
|
|
33
33
|
bug?: { label: string; url?: string }
|
|
34
34
|
/** The needs-attention folder, for an unfinished test. */
|
|
35
35
|
attention?: string
|
|
36
|
+
/** The case's steps when the test was written (stepsHash): when they change, the next run updates the test. */
|
|
37
|
+
stepsHash?: string
|
|
36
38
|
updatedAt: string
|
|
37
39
|
}
|
|
38
40
|
|
package/src/commands.ts
CHANGED
|
@@ -14,6 +14,8 @@ export type ShellCommand =
|
|
|
14
14
|
| { type: "init" }
|
|
15
15
|
/** Sign in to a system (or save a secret by name), forget one, or show which are signed in. */
|
|
16
16
|
| { type: "auth"; action: "status" | "login" | "logout"; system?: string }
|
|
17
|
+
/** Run a passed run again as recorded, without ForgeQE's agent: the last one, a run folder, or a goal. */
|
|
18
|
+
| { type: "rerun"; target: string }
|
|
17
19
|
| { type: "text"; text: string }
|
|
18
20
|
| { type: "unknown"; raw: string }
|
|
19
21
|
|
|
@@ -30,6 +32,7 @@ export const HELP = [
|
|
|
30
32
|
" Add a GitHub Actions workflow that runs tests/cases.yaml on every PR, and make it green",
|
|
31
33
|
"",
|
|
32
34
|
" /stop stop what is running (or press esc)",
|
|
35
|
+
" /rerun [last|run] run a passed run again as recorded, without ForgeQE's agent (last, a run folder, or its goal)",
|
|
33
36
|
" /headed on|off show the browser (live profiles)",
|
|
34
37
|
" /details [on|off] show everything the agents do (reasoning, every file read, engine logs); off: just progress",
|
|
35
38
|
" /profile <name> switch LLM profile (or press tab)",
|
|
@@ -46,7 +49,7 @@ export const HELP = [
|
|
|
46
49
|
"Select text with the mouse to copy it.",
|
|
47
50
|
].join("\n")
|
|
48
51
|
|
|
49
|
-
const COMMANDS = new Set(["help", "?", "exit", "quit", "q", "clear", "stop", "headed", "details", "verbose", "profile", "p", "logs", "skills", "features", "auth", "signin", "init"])
|
|
52
|
+
const COMMANDS = new Set(["help", "?", "exit", "quit", "q", "clear", "stop", "headed", "details", "verbose", "profile", "p", "logs", "skills", "features", "auth", "signin", "init", "rerun"])
|
|
50
53
|
|
|
51
54
|
/** Session controls; every other line, including one that starts with a path, goes to the agent as text. */
|
|
52
55
|
export function parseCommand(raw: string): ShellCommand {
|
|
@@ -80,6 +83,8 @@ export function parseCommand(raw: string): ShellCommand {
|
|
|
80
83
|
return { type: "features" }
|
|
81
84
|
case "init":
|
|
82
85
|
return { type: "init" }
|
|
86
|
+
case "rerun":
|
|
87
|
+
return { type: "rerun", target: arg || "last" }
|
|
83
88
|
case "auth":
|
|
84
89
|
case "signin": {
|
|
85
90
|
const [first, ...rest] = arg.split(/\s+/).filter(Boolean)
|
package/src/config.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { systemLocale } from "./locale.ts"
|
|
|
15
15
|
import { currencyCode, currencyForLocale } from "./money.ts"
|
|
16
16
|
import { listPrice, pricesDate, type DeploymentType, type ListPrice } from "./prices.ts"
|
|
17
17
|
import { ragConfig, type RagConfig } from "./cases/rag.ts"
|
|
18
|
+
import { budgetConfig, type BudgetConfig } from "./tokenomics/budget.ts"
|
|
18
19
|
|
|
19
20
|
export type BrowserProvider = "local" | "browserstack" | "testmu"
|
|
20
21
|
export type RemoteBrowserProvider = Exclude<BrowserProvider, "local">
|
|
@@ -72,6 +73,9 @@ export type MemoryConfig = {
|
|
|
72
73
|
replay: ReplayMode
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/** registry: in forgeqe.yaml (docs/COST_ARCHITECTURE.md). on by default; off: every request takes the agent's path. */
|
|
77
|
+
export type RegistryConfig = { mode: "off" | "shadow" | "on" }
|
|
78
|
+
|
|
75
79
|
function runtimeBin(value: unknown, base: string): string | undefined {
|
|
76
80
|
if (!value) return undefined
|
|
77
81
|
const s = String(value)
|
|
@@ -373,6 +377,8 @@ export type AppConfig = {
|
|
|
373
377
|
defects: DefectsConfig
|
|
374
378
|
export: { outDir: string; format: string }
|
|
375
379
|
memory: MemoryConfig
|
|
380
|
+
/** Unset: on. */
|
|
381
|
+
registry?: RegistryConfig
|
|
376
382
|
code: CodeConfig
|
|
377
383
|
sources: SourcesConfig
|
|
378
384
|
suite: SuiteConfig
|
|
@@ -388,6 +394,8 @@ export type AppConfig = {
|
|
|
388
394
|
rag?: RagConfig
|
|
389
395
|
/** Flaky tests: retries in one run, and what to do with tests that keep flip-flopping (see src/code/history.ts). */
|
|
390
396
|
flaky?: FlakyConfig
|
|
397
|
+
/** Regression packs: the pipelines that run one (packs.pipeline in forgeqe.yaml). */
|
|
398
|
+
packs?: PacksConfig
|
|
391
399
|
/** Thresholds of the release-readiness verdict (release: in forgeqe.yaml). */
|
|
392
400
|
release?: ReleaseThresholds
|
|
393
401
|
/** API and contract tests (api: in forgeqe.yaml): see src/api/. */
|
|
@@ -400,6 +408,8 @@ export type AppConfig = {
|
|
|
400
408
|
domain?: DomainConfig
|
|
401
409
|
/** Teams and email notifications for suites, release readiness, and evaluations (notify: in forgeqe.yaml): see src/notify/. */
|
|
402
410
|
notify?: NotifyConfig
|
|
411
|
+
/** Model spending budgets per run, day and month (budget: in forgeqe.yaml); they only warn. See src/tokenomics/budget.ts. */
|
|
412
|
+
budget?: BudgetConfig
|
|
403
413
|
}
|
|
404
414
|
|
|
405
415
|
export type DomainConfig = {
|
|
@@ -427,6 +437,13 @@ export function replayMode(raw: unknown): ReplayMode {
|
|
|
427
437
|
return "verify"
|
|
428
438
|
}
|
|
429
439
|
|
|
440
|
+
export function registryModeOf(raw: unknown): RegistryConfig["mode"] {
|
|
441
|
+
const mode = String(raw ?? "on").toLowerCase()
|
|
442
|
+
if (mode === "off" || mode === "shadow" || mode === "on") return mode
|
|
443
|
+
if (raw === false) return "off"
|
|
444
|
+
throw new Error(`Unknown registry mode "${String(raw)}". Use off, shadow or on.`)
|
|
445
|
+
}
|
|
446
|
+
|
|
430
447
|
const MOCK_PROFILE: LlmProfile = { provider: "mock", model: "mock", temperature: 0 }
|
|
431
448
|
const NO_PROFILE: LlmProfile = { provider: "none", model: "" }
|
|
432
449
|
|
|
@@ -868,6 +885,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
|
|
|
868
885
|
: path.join(forgeqeHome(), "memory"),
|
|
869
886
|
replay: replayMode(memory.replay),
|
|
870
887
|
},
|
|
888
|
+
registry: { mode: registryModeOf(asRecord(yaml.registry).mode) },
|
|
871
889
|
code: {
|
|
872
890
|
repo: code.repo ? path.resolve(base, String(code.repo)) : undefined,
|
|
873
891
|
maxAttempts: Number(code.max_attempts || 3),
|
|
@@ -888,7 +906,9 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
|
|
|
888
906
|
sources: sourcesConfig(sources),
|
|
889
907
|
...(ragConfig(yaml.rag) ? { rag: ragConfig(yaml.rag) } : {}),
|
|
890
908
|
flaky: flakyConfig(asRecord(yaml.flaky)),
|
|
909
|
+
packs: packsConfig(asRecord(yaml.packs)),
|
|
891
910
|
release: releaseThresholds(yaml.release),
|
|
911
|
+
...(budgetConfig(yaml.budget) ? { budget: budgetConfig(yaml.budget) } : {}),
|
|
892
912
|
api: apiConfig(asRecord(yaml.api)),
|
|
893
913
|
bi: biConfig(asRecord(yaml.bi), base, outDir, configPath),
|
|
894
914
|
quality: withBaselineDir(qualityConfig(yaml.quality), base, configPath),
|
|
@@ -1100,6 +1120,32 @@ function githubConfig(raw: unknown): SourcesConfig["github"] {
|
|
|
1100
1120
|
}
|
|
1101
1121
|
}
|
|
1102
1122
|
|
|
1123
|
+
/** Where `forgeqe pack run --on pipeline` runs a pack: a GitHub Actions workflow, an Azure DevOps pipeline, or both. */
|
|
1124
|
+
export type PacksConfig = {
|
|
1125
|
+
pipeline: {
|
|
1126
|
+
default?: "github" | "azure_devops"
|
|
1127
|
+
github?: { repo: string; workflow: string; ref: string }
|
|
1128
|
+
azureDevops?: { project: string; pipelineId: number; ref: string }
|
|
1129
|
+
/** Longest wait for a pipeline run, in minutes. */
|
|
1130
|
+
timeoutMinutes: number
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function packsConfig(p: Record<string, unknown>): PacksConfig {
|
|
1135
|
+
const pipe = asRecord(p.pipeline)
|
|
1136
|
+
const gh = asRecord(pipe.github)
|
|
1137
|
+
const ado = asRecord(pipe.azure_devops)
|
|
1138
|
+
const def = String(pipe.default || "")
|
|
1139
|
+
return {
|
|
1140
|
+
pipeline: {
|
|
1141
|
+
...(def === "github" || def === "azure_devops" ? { default: def } : {}),
|
|
1142
|
+
...(gh.repo ? { github: { repo: String(gh.repo), workflow: String(gh.workflow || "forgeqe-pack.yml"), ref: String(gh.ref || "main") } } : {}),
|
|
1143
|
+
...(ado.project && Number(ado.pipeline_id) > 0 ? { azureDevops: { project: String(ado.project), pipelineId: Number(ado.pipeline_id), ref: String(ado.ref || "refs/heads/main") } } : {}),
|
|
1144
|
+
timeoutMinutes: Number(pipe.timeout_minutes) > 0 ? Number(pipe.timeout_minutes) : 60,
|
|
1145
|
+
},
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1103
1149
|
function flakyConfig(f: Record<string, unknown>): FlakyConfig {
|
|
1104
1150
|
const q = String(f.quarantine ?? "suggest")
|
|
1105
1151
|
return {
|