@oscharko-dev/keiko-workflows 0.2.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/dist/.tsbuildinfo +1 -0
- package/dist/bug-investigation/context.d.ts +7 -0
- package/dist/bug-investigation/context.d.ts.map +1 -0
- package/dist/bug-investigation/context.js +119 -0
- package/dist/bug-investigation/descriptor.d.ts +4 -0
- package/dist/bug-investigation/descriptor.d.ts.map +1 -0
- package/dist/bug-investigation/descriptor.js +46 -0
- package/dist/bug-investigation/emit.d.ts +13 -0
- package/dist/bug-investigation/emit.d.ts.map +1 -0
- package/dist/bug-investigation/emit.js +35 -0
- package/dist/bug-investigation/events.d.ts +2 -0
- package/dist/bug-investigation/events.d.ts.map +1 -0
- package/dist/bug-investigation/events.js +6 -0
- package/dist/bug-investigation/failure-parse.d.ts +4 -0
- package/dist/bug-investigation/failure-parse.d.ts.map +1 -0
- package/dist/bug-investigation/failure-parse.js +154 -0
- package/dist/bug-investigation/guard.d.ts +3 -0
- package/dist/bug-investigation/guard.d.ts.map +1 -0
- package/dist/bug-investigation/guard.js +69 -0
- package/dist/bug-investigation/index.d.ts +8 -0
- package/dist/bug-investigation/index.d.ts.map +1 -0
- package/dist/bug-investigation/index.js +13 -0
- package/dist/bug-investigation/internal.d.ts +39 -0
- package/dist/bug-investigation/internal.d.ts.map +1 -0
- package/dist/bug-investigation/internal.js +65 -0
- package/dist/bug-investigation/memory.d.ts +5 -0
- package/dist/bug-investigation/memory.d.ts.map +1 -0
- package/dist/bug-investigation/memory.js +91 -0
- package/dist/bug-investigation/model-loop.d.ts +5 -0
- package/dist/bug-investigation/model-loop.d.ts.map +1 -0
- package/dist/bug-investigation/model-loop.js +225 -0
- package/dist/bug-investigation/parse.d.ts +4 -0
- package/dist/bug-investigation/parse.d.ts.map +1 -0
- package/dist/bug-investigation/parse.js +125 -0
- package/dist/bug-investigation/prompt.d.ts +5 -0
- package/dist/bug-investigation/prompt.d.ts.map +1 -0
- package/dist/bug-investigation/prompt.js +122 -0
- package/dist/bug-investigation/report.d.ts +24 -0
- package/dist/bug-investigation/report.d.ts.map +1 -0
- package/dist/bug-investigation/report.js +151 -0
- package/dist/bug-investigation/stages.d.ts +14 -0
- package/dist/bug-investigation/stages.d.ts.map +1 -0
- package/dist/bug-investigation/stages.js +247 -0
- package/dist/bug-investigation/types.d.ts +88 -0
- package/dist/bug-investigation/types.d.ts.map +1 -0
- package/dist/bug-investigation/types.js +6 -0
- package/dist/bug-investigation/verify-stage.d.ts +11 -0
- package/dist/bug-investigation/verify-stage.d.ts.map +1 -0
- package/dist/bug-investigation/verify-stage.js +91 -0
- package/dist/bug-investigation/workflow.d.ts +3 -0
- package/dist/bug-investigation/workflow.d.ts.map +1 -0
- package/dist/bug-investigation/workflow.js +85 -0
- package/dist/contextpack/assemble.d.ts +35 -0
- package/dist/contextpack/assemble.d.ts.map +1 -0
- package/dist/contextpack/assemble.js +431 -0
- package/dist/contextpack/compaction.d.ts +23 -0
- package/dist/contextpack/compaction.d.ts.map +1 -0
- package/dist/contextpack/compaction.js +68 -0
- package/dist/contextpack/index.d.ts +9 -0
- package/dist/contextpack/index.d.ts.map +1 -0
- package/dist/contextpack/index.js +8 -0
- package/dist/contextpack/microIndex.d.ts +29 -0
- package/dist/contextpack/microIndex.d.ts.map +1 -0
- package/dist/contextpack/microIndex.js +98 -0
- package/dist/contextpack/reranker.d.ts +15 -0
- package/dist/contextpack/reranker.d.ts.map +1 -0
- package/dist/contextpack/reranker.js +31 -0
- package/dist/descriptor.d.ts +2 -0
- package/dist/descriptor.d.ts.map +1 -0
- package/dist/descriptor.js +1 -0
- package/dist/governed-handoff.d.ts +6 -0
- package/dist/governed-handoff.d.ts.map +1 -0
- package/dist/governed-handoff.js +86 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/planner/anchors.d.ts +17 -0
- package/dist/planner/anchors.d.ts.map +1 -0
- package/dist/planner/anchors.js +291 -0
- package/dist/planner/explorationPlanner.d.ts +9 -0
- package/dist/planner/explorationPlanner.d.ts.map +1 -0
- package/dist/planner/explorationPlanner.js +15 -0
- package/dist/planner/governor.d.ts +16 -0
- package/dist/planner/governor.d.ts.map +1 -0
- package/dist/planner/governor.js +106 -0
- package/dist/planner/index.d.ts +11 -0
- package/dist/planner/index.d.ts.map +1 -0
- package/dist/planner/index.js +8 -0
- package/dist/planner/intent.d.ts +8 -0
- package/dist/planner/intent.d.ts.map +1 -0
- package/dist/planner/intent.js +140 -0
- package/dist/planner/plan.d.ts +43 -0
- package/dist/planner/plan.d.ts.map +1 -0
- package/dist/planner/plan.js +237 -0
- package/dist/promptEnhancer/index.d.ts +23 -0
- package/dist/promptEnhancer/index.d.ts.map +1 -0
- package/dist/promptEnhancer/index.js +282 -0
- package/dist/qualityIntelligence/__tests__/fixtures/runEntryFixtures.d.ts +30 -0
- package/dist/qualityIntelligence/__tests__/fixtures/runEntryFixtures.d.ts.map +1 -0
- package/dist/qualityIntelligence/__tests__/fixtures/runEntryFixtures.js +114 -0
- package/dist/qualityIntelligence/cancellation.d.ts +20 -0
- package/dist/qualityIntelligence/cancellation.d.ts.map +1 -0
- package/dist/qualityIntelligence/cancellation.js +55 -0
- package/dist/qualityIntelligence/descriptors.d.ts +41 -0
- package/dist/qualityIntelligence/descriptors.d.ts.map +1 -0
- package/dist/qualityIntelligence/descriptors.js +105 -0
- package/dist/qualityIntelligence/index.d.ts +11 -0
- package/dist/qualityIntelligence/index.d.ts.map +1 -0
- package/dist/qualityIntelligence/index.js +11 -0
- package/dist/qualityIntelligence/modelRoutedTestDesign.d.ts +100 -0
- package/dist/qualityIntelligence/modelRoutedTestDesign.d.ts.map +1 -0
- package/dist/qualityIntelligence/modelRoutedTestDesign.js +620 -0
- package/dist/qualityIntelligence/runEntries.d.ts +60 -0
- package/dist/qualityIntelligence/runEntries.d.ts.map +1 -0
- package/dist/qualityIntelligence/runEntries.js +243 -0
- package/dist/qualityIntelligence/runtimeCommon.d.ts +106 -0
- package/dist/qualityIntelligence/runtimeCommon.d.ts.map +1 -0
- package/dist/qualityIntelligence/runtimeCommon.js +258 -0
- package/dist/qualityIntelligence/scopedRegeneration.d.ts +26 -0
- package/dist/qualityIntelligence/scopedRegeneration.d.ts.map +1 -0
- package/dist/qualityIntelligence/scopedRegeneration.js +35 -0
- package/dist/ranking/filter.d.ts +20 -0
- package/dist/ranking/filter.d.ts.map +1 -0
- package/dist/ranking/filter.js +99 -0
- package/dist/ranking/index.d.ts +9 -0
- package/dist/ranking/index.d.ts.map +1 -0
- package/dist/ranking/index.js +8 -0
- package/dist/ranking/rank.d.ts +21 -0
- package/dist/ranking/rank.d.ts.map +1 -0
- package/dist/ranking/rank.js +160 -0
- package/dist/ranking/scoring.d.ts +13 -0
- package/dist/ranking/scoring.d.ts.map +1 -0
- package/dist/ranking/scoring.js +39 -0
- package/dist/ranking/signals.d.ts +20 -0
- package/dist/ranking/signals.d.ts.map +1 -0
- package/dist/ranking/signals.js +145 -0
- package/dist/unit-tests/context.d.ts +7 -0
- package/dist/unit-tests/context.d.ts.map +1 -0
- package/dist/unit-tests/context.js +129 -0
- package/dist/unit-tests/conventions.d.ts +5 -0
- package/dist/unit-tests/conventions.d.ts.map +1 -0
- package/dist/unit-tests/conventions.js +87 -0
- package/dist/unit-tests/descriptor.d.ts +5 -0
- package/dist/unit-tests/descriptor.d.ts.map +1 -0
- package/dist/unit-tests/descriptor.js +43 -0
- package/dist/unit-tests/emit.d.ts +13 -0
- package/dist/unit-tests/emit.d.ts.map +1 -0
- package/dist/unit-tests/emit.js +35 -0
- package/dist/unit-tests/events.d.ts +2 -0
- package/dist/unit-tests/events.d.ts.map +1 -0
- package/dist/unit-tests/events.js +6 -0
- package/dist/unit-tests/frontend.d.ts +42 -0
- package/dist/unit-tests/frontend.d.ts.map +1 -0
- package/dist/unit-tests/frontend.js +281 -0
- package/dist/unit-tests/index.d.ts +9 -0
- package/dist/unit-tests/index.d.ts.map +1 -0
- package/dist/unit-tests/index.js +15 -0
- package/dist/unit-tests/internal.d.ts +36 -0
- package/dist/unit-tests/internal.d.ts.map +1 -0
- package/dist/unit-tests/internal.js +43 -0
- package/dist/unit-tests/model-loop.d.ts +6 -0
- package/dist/unit-tests/model-loop.d.ts.map +1 -0
- package/dist/unit-tests/model-loop.js +98 -0
- package/dist/unit-tests/parse.d.ts +7 -0
- package/dist/unit-tests/parse.d.ts.map +1 -0
- package/dist/unit-tests/parse.js +68 -0
- package/dist/unit-tests/prompt.d.ts +6 -0
- package/dist/unit-tests/prompt.d.ts.map +1 -0
- package/dist/unit-tests/prompt.js +139 -0
- package/dist/unit-tests/report.d.ts +26 -0
- package/dist/unit-tests/report.d.ts.map +1 -0
- package/dist/unit-tests/report.js +104 -0
- package/dist/unit-tests/stages.d.ts +12 -0
- package/dist/unit-tests/stages.d.ts.map +1 -0
- package/dist/unit-tests/stages.js +202 -0
- package/dist/unit-tests/strategy.d.ts +6 -0
- package/dist/unit-tests/strategy.d.ts.map +1 -0
- package/dist/unit-tests/strategy.js +36 -0
- package/dist/unit-tests/target-guard.d.ts +5 -0
- package/dist/unit-tests/target-guard.d.ts.map +1 -0
- package/dist/unit-tests/target-guard.js +29 -0
- package/dist/unit-tests/types.d.ts +74 -0
- package/dist/unit-tests/types.d.ts.map +1 -0
- package/dist/unit-tests/types.js +6 -0
- package/dist/unit-tests/verify-stage.d.ts +10 -0
- package/dist/unit-tests/verify-stage.d.ts.map +1 -0
- package/dist/unit-tests/verify-stage.js +56 -0
- package/dist/unit-tests/workflow.d.ts +3 -0
- package/dist/unit-tests/workflow.d.ts.map +1 -0
- package/dist/unit-tests/workflow.js +69 -0
- package/package.json +38 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Frontend test-stack detection and convention-driven test-style selection (Issue #1203, Epic #1189,
|
|
2
|
+
// ADR-0042 D7). Extends the deterministic convention detection (conventions.ts) from "which base test
|
|
3
|
+
// framework" to "which frontend test STYLE the target warrants" — unit, component, interaction,
|
|
4
|
+
// accessibility smoke, or browser smoke — grounded in the project's declared test tooling and the
|
|
5
|
+
// target file's own shape.
|
|
6
|
+
//
|
|
7
|
+
// PURE except for the narrow filesystem seam used to read local package manifests and detect framework
|
|
8
|
+
// config (no clock, no RNG, no network). ALL path and content predicates use plain string ops
|
|
9
|
+
// (split/startsWith/endsWith/includes) — zero regex — so there is no ReDoS surface (CodeQL
|
|
10
|
+
// js/polynomial-redos), mirroring conventions.ts.
|
|
11
|
+
//
|
|
12
|
+
// "Prefer convention-driven generation over user prompt guessing" (Engineering Notes): the style is
|
|
13
|
+
// derived from the detected stack plus the target path/shape, never from a user-supplied flag. When the
|
|
14
|
+
// target is a frontend component but the project declares no supported frontend test stack, the
|
|
15
|
+
// selector returns `unsupported` so the workflow surfaces a clear, reviewable limitation instead of
|
|
16
|
+
// fabricating a browser/component test it cannot ground (Acceptance Criterion 5).
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
export const EMPTY_FRONTEND_TEST_STACK = {
|
|
19
|
+
componentFramework: "none",
|
|
20
|
+
hasReactTestingLibrary: false,
|
|
21
|
+
hasUserEvent: false,
|
|
22
|
+
hasJestDom: false,
|
|
23
|
+
hasAccessibilityMatchers: false,
|
|
24
|
+
hasDomEnvironment: false,
|
|
25
|
+
hasPlaywright: false,
|
|
26
|
+
};
|
|
27
|
+
export const TEST_STYLES = [
|
|
28
|
+
"unit",
|
|
29
|
+
"component",
|
|
30
|
+
"interaction",
|
|
31
|
+
"accessibility-smoke",
|
|
32
|
+
"browser-smoke",
|
|
33
|
+
"unsupported",
|
|
34
|
+
];
|
|
35
|
+
// ─── Manifest parsing (pure) ─────────────────────────────────────────────────────────────────────
|
|
36
|
+
const DEPENDENCY_FIELDS = [
|
|
37
|
+
"dependencies",
|
|
38
|
+
"devDependencies",
|
|
39
|
+
"peerDependencies",
|
|
40
|
+
"optionalDependencies",
|
|
41
|
+
];
|
|
42
|
+
function isRecord(value) {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
// The union of dependency names declared across every dependency field of one package manifest.
|
|
46
|
+
// Throw-free: an unparseable or non-object manifest yields an empty set.
|
|
47
|
+
export function manifestDependencyNames(manifestJson) {
|
|
48
|
+
const names = new Set();
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(manifestJson);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return names;
|
|
55
|
+
}
|
|
56
|
+
if (!isRecord(parsed)) {
|
|
57
|
+
return names;
|
|
58
|
+
}
|
|
59
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
60
|
+
const block = parsed[field];
|
|
61
|
+
if (isRecord(block)) {
|
|
62
|
+
for (const name of Object.keys(block)) {
|
|
63
|
+
names.add(name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return names;
|
|
68
|
+
}
|
|
69
|
+
// ─── Stack detection (pure over a dependency set) ──────────────────────────────────────────────────
|
|
70
|
+
const REACT = "react";
|
|
71
|
+
const RTL = "@testing-library/react";
|
|
72
|
+
const USER_EVENT = "@testing-library/user-event";
|
|
73
|
+
const JEST_DOM = "@testing-library/jest-dom";
|
|
74
|
+
const PLAYWRIGHT_DEPS = ["@playwright/test", "playwright"];
|
|
75
|
+
const DOM_ENV_DEPS = ["jsdom", "happy-dom"];
|
|
76
|
+
const AXE_DEPS = [
|
|
77
|
+
"jest-axe",
|
|
78
|
+
"vitest-axe",
|
|
79
|
+
"@axe-core/react",
|
|
80
|
+
"@axe-core/playwright",
|
|
81
|
+
"axe-core",
|
|
82
|
+
];
|
|
83
|
+
function hasAny(names, candidates) {
|
|
84
|
+
return candidates.some((candidate) => names.has(candidate));
|
|
85
|
+
}
|
|
86
|
+
// Derives the frontend test stack from the union of declared dependency names plus framework-config
|
|
87
|
+
// signals. Pure and total.
|
|
88
|
+
export function detectFrontendStackFromDependencies(names, signals) {
|
|
89
|
+
return {
|
|
90
|
+
componentFramework: names.has(REACT) ? "react" : "none",
|
|
91
|
+
hasReactTestingLibrary: names.has(RTL),
|
|
92
|
+
hasUserEvent: names.has(USER_EVENT),
|
|
93
|
+
hasJestDom: names.has(JEST_DOM),
|
|
94
|
+
hasAccessibilityMatchers: hasAny(names, AXE_DEPS),
|
|
95
|
+
hasDomEnvironment: hasAny(names, DOM_ENV_DEPS),
|
|
96
|
+
hasPlaywright: hasAny(names, PLAYWRIGHT_DEPS) || signals.hasPlaywrightConfig,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const MANIFEST = "package.json";
|
|
100
|
+
const PLAYWRIGHT_CONFIG_BASENAMES = [
|
|
101
|
+
"playwright.config.ts",
|
|
102
|
+
"playwright.config.js",
|
|
103
|
+
"playwright.config.mjs",
|
|
104
|
+
"playwright.config.cjs",
|
|
105
|
+
"playwright.config.mts",
|
|
106
|
+
"playwright.config.cts",
|
|
107
|
+
];
|
|
108
|
+
// A hard ceiling on the manifest walk-up so a pathological start directory cannot loop unboundedly.
|
|
109
|
+
const MAX_WALK_DEPTH = 16;
|
|
110
|
+
function readManifestNames(fs, dir) {
|
|
111
|
+
const path = join(dir, MANIFEST);
|
|
112
|
+
if (!fs.exists(path)) {
|
|
113
|
+
return new Set();
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return manifestDependencyNames(fs.readFileUtf8(path));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return new Set();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function hasPlaywrightConfigIn(fs, dir) {
|
|
123
|
+
return PLAYWRIGHT_CONFIG_BASENAMES.some((basename) => fs.exists(join(dir, basename)));
|
|
124
|
+
}
|
|
125
|
+
// The directories from the anchor's directory up to (and including) the workspace root, nearest first,
|
|
126
|
+
// never escaping the root. `anchorRelPath` is a workspace-relative target path; absent → just the root.
|
|
127
|
+
function manifestSearchDirs(root, anchorRelPath) {
|
|
128
|
+
const resolvedRoot = resolve(root);
|
|
129
|
+
if (anchorRelPath === undefined || anchorRelPath.length === 0) {
|
|
130
|
+
return [resolvedRoot];
|
|
131
|
+
}
|
|
132
|
+
const dirs = [];
|
|
133
|
+
let current = dirname(resolve(resolvedRoot, anchorRelPath));
|
|
134
|
+
for (let depth = 0; depth < MAX_WALK_DEPTH; depth += 1) {
|
|
135
|
+
if (current === resolvedRoot || !current.startsWith(`${resolvedRoot}/`)) {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
dirs.push(current);
|
|
139
|
+
current = dirname(current);
|
|
140
|
+
}
|
|
141
|
+
dirs.push(resolvedRoot);
|
|
142
|
+
return dirs;
|
|
143
|
+
}
|
|
144
|
+
// Reads the project's declared frontend test tooling by unioning the dependency names of the nearest
|
|
145
|
+
// package manifests (target package up to the workspace root) and detecting a Playwright config. Bounded
|
|
146
|
+
// and throw-free; an unreadable manifest contributes nothing.
|
|
147
|
+
export function detectFrontendStack(workspace, fs, anchorRelPath) {
|
|
148
|
+
const names = new Set();
|
|
149
|
+
let hasPlaywrightConfig = false;
|
|
150
|
+
for (const dir of manifestSearchDirs(workspace.root, anchorRelPath)) {
|
|
151
|
+
for (const name of readManifestNames(fs, dir)) {
|
|
152
|
+
names.add(name);
|
|
153
|
+
}
|
|
154
|
+
if (hasPlaywrightConfigIn(fs, dir)) {
|
|
155
|
+
hasPlaywrightConfig = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return detectFrontendStackFromDependencies(names, { hasPlaywrightConfig });
|
|
159
|
+
}
|
|
160
|
+
// ─── Target classification (pure) ──────────────────────────────────────────────────────────────────
|
|
161
|
+
function toPosix(path) {
|
|
162
|
+
return path.split("\\").join("/");
|
|
163
|
+
}
|
|
164
|
+
function lastSegment(path) {
|
|
165
|
+
const posix = toPosix(path);
|
|
166
|
+
const idx = posix.lastIndexOf("/");
|
|
167
|
+
return idx === -1 ? posix : posix.slice(idx + 1);
|
|
168
|
+
}
|
|
169
|
+
function endsWithAny(value, suffixes) {
|
|
170
|
+
return suffixes.some((suffix) => value.endsWith(suffix));
|
|
171
|
+
}
|
|
172
|
+
const COMPONENT_EXTENSIONS = [".tsx", ".jsx"];
|
|
173
|
+
// A path that, by extension, is a React component module (`.tsx`/`.jsx`).
|
|
174
|
+
export function isReactComponentPath(path) {
|
|
175
|
+
return endsWithAny(toPosix(path), COMPONENT_EXTENSIONS);
|
|
176
|
+
}
|
|
177
|
+
// A best-effort, regex-free check that a `.ts`/`.js` module's source looks like a React component:
|
|
178
|
+
// it imports React and either returns JSX or calls React.createElement. Used only as a secondary signal
|
|
179
|
+
// for non-`.tsx` files; the extension is the primary signal.
|
|
180
|
+
export function looksLikeReactComponentSource(source) {
|
|
181
|
+
if (source === undefined || source.length === 0) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const importsReact = source.includes('from "react"') ||
|
|
185
|
+
source.includes("from 'react'") ||
|
|
186
|
+
source.includes('require("react")');
|
|
187
|
+
if (!importsReact) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
return source.includes("React.createElement") || source.includes("/>") || source.includes("</");
|
|
191
|
+
}
|
|
192
|
+
const PLAYWRIGHT_ENTRY_DIRS = [
|
|
193
|
+
"e2e/",
|
|
194
|
+
"tests/e2e/",
|
|
195
|
+
"playwright/",
|
|
196
|
+
"app/",
|
|
197
|
+
"pages/",
|
|
198
|
+
];
|
|
199
|
+
const NEXT_ROUTE_BASENAMES = [
|
|
200
|
+
"page.tsx",
|
|
201
|
+
"page.jsx",
|
|
202
|
+
"layout.tsx",
|
|
203
|
+
"layout.jsx",
|
|
204
|
+
];
|
|
205
|
+
// Whether a path is an application entry point a Playwright browser smoke can meaningfully target:
|
|
206
|
+
// a Next.js route file (`page`/`layout`), a `*.page.*` module, or a file under a recognised end-to-end
|
|
207
|
+
// or app/route directory. Pure string predicates only.
|
|
208
|
+
export function isPlaywrightEntryPath(path) {
|
|
209
|
+
const posix = toPosix(path);
|
|
210
|
+
const base = lastSegment(posix);
|
|
211
|
+
if (NEXT_ROUTE_BASENAMES.includes(base)) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
if (base.includes(".page.")) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
return PLAYWRIGHT_ENTRY_DIRS.some((dir) => posix.startsWith(dir) || posix.includes(`/${dir}`));
|
|
218
|
+
}
|
|
219
|
+
// Identifies when a Playwright browser smoke is appropriate (AC: "identify when Playwright smoke is
|
|
220
|
+
// appropriate and when it is not"): only when the project declares Playwright AND the target is an
|
|
221
|
+
// application entry point. A plain library or in-page component is never an appropriate Playwright
|
|
222
|
+
// target, even when the project ships Playwright.
|
|
223
|
+
export function isPlaywrightSmokeAppropriate(path, stack) {
|
|
224
|
+
return stack.hasPlaywright && isPlaywrightEntryPath(path);
|
|
225
|
+
}
|
|
226
|
+
function verificationForFramework(framework) {
|
|
227
|
+
if (framework === "vitest" || framework === "jest" || framework === "mocha") {
|
|
228
|
+
return framework;
|
|
229
|
+
}
|
|
230
|
+
return "none";
|
|
231
|
+
}
|
|
232
|
+
function isComponentTarget(input) {
|
|
233
|
+
return (isReactComponentPath(input.targetPath) || looksLikeReactComponentSource(input.targetSource));
|
|
234
|
+
}
|
|
235
|
+
const UNIT_REASON = "Selected unit-test style: the target is a plain TypeScript/JavaScript module, so a Playwright " +
|
|
236
|
+
"browser smoke is not appropriate and a standard unit test is generated.";
|
|
237
|
+
const BROWSER_REASON = "Selected Playwright browser-smoke style: the target is an application entry point and the project " +
|
|
238
|
+
"declares Playwright.";
|
|
239
|
+
const COMPONENT_REASON = "Selected component-test style: the target is a React component and the project declares " +
|
|
240
|
+
"@testing-library/react.";
|
|
241
|
+
const INTERACTION_REASON = "Selected interaction-test style: the target is a React component and the project declares " +
|
|
242
|
+
"@testing-library/react with @testing-library/user-event, so user interactions are exercised.";
|
|
243
|
+
const ACCESSIBILITY_REASON = "Selected accessibility-smoke style: the target is a React component and the project declares " +
|
|
244
|
+
"@testing-library/react with an accessibility matcher, so an accessibility assertion is included.";
|
|
245
|
+
const UNSUPPORTED_REASON = "Unsupported stack: the target is a frontend component but the project does not declare a supported " +
|
|
246
|
+
"React component-testing stack (@testing-library/react). A reviewable limitation is reported instead " +
|
|
247
|
+
"of a fabricated browser or component test.";
|
|
248
|
+
function componentStrategy(input) {
|
|
249
|
+
const verification = verificationForFramework(input.framework);
|
|
250
|
+
if (input.stack.hasAccessibilityMatchers) {
|
|
251
|
+
return { style: "accessibility-smoke", reason: ACCESSIBILITY_REASON, verification };
|
|
252
|
+
}
|
|
253
|
+
if (input.stack.hasUserEvent) {
|
|
254
|
+
return { style: "interaction", reason: INTERACTION_REASON, verification };
|
|
255
|
+
}
|
|
256
|
+
return { style: "component", reason: COMPONENT_REASON, verification };
|
|
257
|
+
}
|
|
258
|
+
// Selects the convention-driven test strategy for a target. Total over its input: every target maps to
|
|
259
|
+
// exactly one style with a stated reason and a verification runner.
|
|
260
|
+
//
|
|
261
|
+
// 1. An application entry point in a Playwright project → browser smoke.
|
|
262
|
+
// 2. A React component with a supported React testing stack → accessibility-smoke / interaction /
|
|
263
|
+
// component (richest applicable).
|
|
264
|
+
// 3. A React component WITHOUT a supported stack → unsupported (a clear limitation, AC5).
|
|
265
|
+
// 4. Any other module → unit (a Playwright smoke is explicitly not appropriate here, AC3).
|
|
266
|
+
export function selectTestStrategy(input) {
|
|
267
|
+
if (isPlaywrightSmokeAppropriate(input.targetPath, input.stack)) {
|
|
268
|
+
return { style: "browser-smoke", reason: BROWSER_REASON, verification: "playwright" };
|
|
269
|
+
}
|
|
270
|
+
if (isComponentTarget(input)) {
|
|
271
|
+
if (input.stack.componentFramework !== "react" || !input.stack.hasReactTestingLibrary) {
|
|
272
|
+
return { style: "unsupported", reason: UNSUPPORTED_REASON, verification: "none" };
|
|
273
|
+
}
|
|
274
|
+
return componentStrategy(input);
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
style: "unit",
|
|
278
|
+
reason: UNIT_REASON,
|
|
279
|
+
verification: verificationForFramework(input.framework),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { generateUnitTests } from "./workflow.js";
|
|
2
|
+
export { assembleReport, renderMarkdownReport, type ReportParts } from "./report.js";
|
|
3
|
+
export { UNIT_TEST_WORKFLOW_DESCRIPTOR, type WorkflowDescriptor, type WorkflowInputSpec, } from "./descriptor.js";
|
|
4
|
+
export { detectConventions, isTestPath } from "./conventions.js";
|
|
5
|
+
export { detectFrontendStack, detectFrontendStackFromDependencies, manifestDependencyNames, selectTestStrategy, EMPTY_FRONTEND_TEST_STACK, TEST_STYLES, type ComponentFramework, type FrontendStackSignals, type FrontendTestStack, type ManifestReaderFs, type StrategyInput, type TestStrategy, type TestStyle, type TestVerification, } from "./frontend.js";
|
|
6
|
+
export { resolveTestStrategy, strategyAnchorPath } from "./strategy.js";
|
|
7
|
+
export { DEFAULT_WORKFLOW_LIMITS, type AddedTestFile, type FileNamingStyle, type TestConventions, type UnitTestTarget, type UnitTestWorkflowDeps, type UnitTestWorkflowInput, type UnitTestWorkflowReport, type WorkflowLimits, type WorkflowStatus, } from "./types.js";
|
|
8
|
+
export type { ConventionsDetectedEvent, ContextSelectedEvent, ModelCallCompletedEvent, ModelCallStartedEvent, PatchAppliedEvent, PatchValidatedEvent, VerificationResultEvent, WorkflowCompletedEvent, WorkflowEvent, WorkflowEventSink, WorkflowFailedEvent, WorkflowStartedEvent, } from "./events.js";
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/unit-tests/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,oBAAoB,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAErF,OAAO,EACL,6BAA6B,EAC7B,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,GACvB,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAKjE,OAAO,EACL,mBAAmB,EACnB,mCAAmC,EACnC,uBAAuB,EACvB,kBAAkB,EAClB,yBAAyB,EACzB,WAAW,EACX,KAAK,kBAAkB,EACvB,KAAK,oBAAoB,EACzB,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,gBAAgB,GACtB,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAExE,OAAO,EACL,uBAAuB,EACvB,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,YAAY,CAAC;AAEpB,YAAY,EACV,wBAAwB,EACxB,oBAAoB,EACpB,uBAAuB,EACvB,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,uBAAuB,EACvB,sBAAsB,EACtB,aAAa,EACb,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Public barrel for the unit-test generation workflow (ADR-0008 D1). Re-exports the single entry
|
|
2
|
+
// (generateUnitTests), the static UI descriptor, the Markdown renderer, the WorkflowEvent family,
|
|
3
|
+
// and all public types. Internal pipeline modules (internal, model-loop, verify-stage, stages,
|
|
4
|
+
// emit, parse, context, prompt) are NOT re-exported — they are implementation detail. Explicit
|
|
5
|
+
// named re-exports, `type` keyword for type-only, double quotes, `.js`.
|
|
6
|
+
export { generateUnitTests } from "./workflow.js";
|
|
7
|
+
export { assembleReport, renderMarkdownReport } from "./report.js";
|
|
8
|
+
export { UNIT_TEST_WORKFLOW_DESCRIPTOR, } from "./descriptor.js";
|
|
9
|
+
export { detectConventions, isTestPath } from "./conventions.js";
|
|
10
|
+
// Convention-driven frontend test-style selection (Issue #1203). The detector reads local manifests /
|
|
11
|
+
// framework config; the selector maps a target to its test style; resolveTestStrategy composes both
|
|
12
|
+
// over a workflow target and ContextPack.
|
|
13
|
+
export { detectFrontendStack, detectFrontendStackFromDependencies, manifestDependencyNames, selectTestStrategy, EMPTY_FRONTEND_TEST_STACK, TEST_STYLES, } from "./frontend.js";
|
|
14
|
+
export { resolveTestStrategy, strategyAnchorPath } from "./strategy.js";
|
|
15
|
+
export { DEFAULT_WORKFLOW_LIMITS, } from "./types.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PatchValidation } from "@oscharko-dev/keiko-tools";
|
|
2
|
+
import { type EventEmitter } from "./emit.js";
|
|
3
|
+
import { type UnitTestWorkflowDeps, type UnitTestWorkflowInput, type WorkflowLimits } from "./types.js";
|
|
4
|
+
import type { WorkflowEventSink } from "./events.js";
|
|
5
|
+
export declare const NO_OP_SINK: WorkflowEventSink;
|
|
6
|
+
export interface WorkflowProgress {
|
|
7
|
+
modelCallCount: number;
|
|
8
|
+
patchRetryCount: number;
|
|
9
|
+
}
|
|
10
|
+
export interface RunState {
|
|
11
|
+
readonly input: UnitTestWorkflowInput;
|
|
12
|
+
readonly deps: UnitTestWorkflowDeps;
|
|
13
|
+
readonly limits: WorkflowLimits;
|
|
14
|
+
readonly signal: AbortSignal;
|
|
15
|
+
readonly now: () => number;
|
|
16
|
+
readonly emitter: EventEmitter;
|
|
17
|
+
readonly startedAt: number;
|
|
18
|
+
readonly progress: WorkflowProgress;
|
|
19
|
+
}
|
|
20
|
+
export interface AcceptedPatch {
|
|
21
|
+
readonly diff: string;
|
|
22
|
+
readonly validation: PatchValidation;
|
|
23
|
+
readonly coveredBehavior: string | undefined;
|
|
24
|
+
readonly knownGaps: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
export interface ModelLoopResult {
|
|
27
|
+
readonly accepted: AcceptedPatch | undefined;
|
|
28
|
+
readonly modelCallCount: number;
|
|
29
|
+
readonly patchRetryCount: number;
|
|
30
|
+
readonly lastRejectionCode: string | undefined;
|
|
31
|
+
}
|
|
32
|
+
export declare const EMPTY_LOOP: ModelLoopResult;
|
|
33
|
+
export declare function resolveLimits(input: UnitTestWorkflowInput): WorkflowLimits;
|
|
34
|
+
export declare function buildRunState(input: UnitTestWorkflowInput, deps: UnitTestWorkflowDeps, fingerprint: string): RunState;
|
|
35
|
+
export declare function nextActionsFor(applied: boolean, files: readonly string[]): readonly string[];
|
|
36
|
+
//# sourceMappingURL=internal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["../../src/unit-tests/internal.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,EAAsB,KAAK,YAAY,EAAE,MAAM,WAAW,CAAC;AAClE,OAAO,EAEL,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,cAAc,EACpB,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAGrD,eAAO,MAAM,UAAU,EAAE,iBAAmD,CAAC;AAE7E,MAAM,WAAW,gBAAgB;IAC/B,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;CACzB;AAGD,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,KAAK,EAAE,qBAAqB,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,CAAC;CACrC;AAGD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAC;IACrC,QAAQ,CAAC,eAAe,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7C,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,QAAQ,EAAE,aAAa,GAAG,SAAS,CAAC;IAC7C,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,GAAG,SAAS,CAAC;CAChD;AAGD,eAAO,MAAM,UAAU,EAAE,eAKxB,CAAC;AAEF,wBAAgB,aAAa,CAAC,KAAK,EAAE,qBAAqB,GAAG,cAAc,CAE1E;AAED,wBAAgB,aAAa,CAC3B,KAAK,EAAE,qBAAqB,EAC5B,IAAI,EAAE,oBAAoB,EAC1B,WAAW,EAAE,MAAM,GAClB,QAAQ,CAaV;AAGD,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,MAAM,EAAE,CAS5F"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Shared internal types and small pure helpers used across the workflow pipeline stages (the
|
|
2
|
+
// model loop, the verify stage, and the report stages). Kept private to the module — none of these
|
|
3
|
+
// are re-exported from index.ts. Splitting them out keeps each pipeline file under the LOC limit
|
|
4
|
+
// while leaving a single source of truth for the resolved RunState and the loop result shape.
|
|
5
|
+
import { createEventEmitter } from "./emit.js";
|
|
6
|
+
import { DEFAULT_WORKFLOW_LIMITS, } from "./types.js";
|
|
7
|
+
// A no-op sink used when the caller injects none. emit is synchronous (ADR-0004 EventSink contract).
|
|
8
|
+
export const NO_OP_SINK = { emit: () => undefined };
|
|
9
|
+
// The zero-progress loop used to assemble a cancelled/failed report before the model loop ran.
|
|
10
|
+
export const EMPTY_LOOP = {
|
|
11
|
+
accepted: undefined,
|
|
12
|
+
modelCallCount: 0,
|
|
13
|
+
patchRetryCount: 0,
|
|
14
|
+
lastRejectionCode: undefined,
|
|
15
|
+
};
|
|
16
|
+
export function resolveLimits(input) {
|
|
17
|
+
return { ...DEFAULT_WORKFLOW_LIMITS, ...input.limits };
|
|
18
|
+
}
|
|
19
|
+
export function buildRunState(input, deps, fingerprint) {
|
|
20
|
+
const now = deps.now ?? Date.now;
|
|
21
|
+
const idSource = deps.idSource ?? (() => crypto.randomUUID());
|
|
22
|
+
return {
|
|
23
|
+
input,
|
|
24
|
+
deps,
|
|
25
|
+
limits: resolveLimits(input),
|
|
26
|
+
signal: deps.signal ?? new AbortController().signal,
|
|
27
|
+
now,
|
|
28
|
+
emitter: createEventEmitter(deps.sink ?? NO_OP_SINK, idSource(), fingerprint, now),
|
|
29
|
+
startedAt: now(),
|
|
30
|
+
progress: { modelCallCount: 0, patchRetryCount: 0 },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// UI-renderable next actions for the report. Pure.
|
|
34
|
+
export function nextActionsFor(applied, files) {
|
|
35
|
+
const first = files[0] ?? "the generated test file";
|
|
36
|
+
if (applied) {
|
|
37
|
+
return [`Review the generated tests in ${first}`, "Run `keiko verify` to confirm they pass"];
|
|
38
|
+
}
|
|
39
|
+
return [
|
|
40
|
+
`Review the proposed tests for ${first}`,
|
|
41
|
+
"Re-run with --apply to write the tests and verify",
|
|
42
|
+
];
|
|
43
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ContextPack, WorkspaceInfo } from "@oscharko-dev/keiko-workspace";
|
|
2
|
+
import type { ModelLoopResult, RunState } from "./internal.js";
|
|
3
|
+
import type { TestStrategy } from "./frontend.js";
|
|
4
|
+
import type { TestConventions } from "./types.js";
|
|
5
|
+
export declare function runModelLoop(state: RunState, workspace: WorkspaceInfo, conventions: TestConventions, strategy: TestStrategy, pack: ContextPack): Promise<ModelLoopResult>;
|
|
6
|
+
//# sourceMappingURL=model-loop.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"model-loop.d.ts","sourceRoot":"","sources":["../../src/unit-tests/model-loop.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAMhF,OAAO,KAAK,EAAiB,eAAe,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC9E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AA+FlD,wBAAsB,YAAY,CAChC,KAAK,EAAE,QAAQ,EACf,SAAS,EAAE,aAAa,EACxB,WAAW,EAAE,eAAe,EAC5B,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,WAAW,GAChB,OAAO,CAAC,eAAe,CAAC,CAoC1B"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The bounded model/validate/production-guard retry loop (ADR-0008 D6/D8). Each attempt builds the
|
|
2
|
+
// prompt, calls the injected ModelPort, parses the output, validates the diff through #6, and
|
|
3
|
+
// applies the production-code guard. The loop stops on the first accepted patch, after maxRetries
|
|
4
|
+
// rejections, or when model calls reach the maxModelCalls hard ceiling — whichever comes first. The
|
|
5
|
+
// model call is the one IO boundary here; its failure propagates to the workflow catch boundary.
|
|
6
|
+
import { nodeWorkspaceFs } from "@oscharko-dev/keiko-workspace/internal/fs";
|
|
7
|
+
import { validatePatch } from "@oscharko-dev/keiko-tools";
|
|
8
|
+
import { governedPatchRejectionCode } from "../governed-handoff.js";
|
|
9
|
+
import { isTestPath } from "./conventions.js";
|
|
10
|
+
import { parseModelOutput } from "./parse.js";
|
|
11
|
+
import { buildPrompt } from "./prompt.js";
|
|
12
|
+
// The production-code guard (D6): every changed path must satisfy isTestPath. Returns "out-of-scope"
|
|
13
|
+
// when any path is a non-test/traversal path, or undefined when all pass. The guard runs on
|
|
14
|
+
// validation.files[].path — the SAME string #6 resolves and would write.
|
|
15
|
+
function productionGuard(workspace, validation) {
|
|
16
|
+
const offending = validation.files.some((file) => !isTestPath(workspace, file.path));
|
|
17
|
+
return offending ? "out-of-scope" : undefined;
|
|
18
|
+
}
|
|
19
|
+
function emitValidation(state, validation, code) {
|
|
20
|
+
const ok = code === undefined && validation.ok;
|
|
21
|
+
state.emitter.emit({
|
|
22
|
+
type: "patch:validated",
|
|
23
|
+
ok,
|
|
24
|
+
patchBytes: validation.totalBytes,
|
|
25
|
+
filesChanged: validation.files.length,
|
|
26
|
+
...(ok ? {} : { rejectionCode: code ?? validation.reasons[0]?.code }),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function emptyPatchRejection(parsedDiff, validation) {
|
|
30
|
+
return parsedDiff.trim().length === 0 || validation.files.length === 0 ? "empty" : undefined;
|
|
31
|
+
}
|
|
32
|
+
async function callModel(state, messages, attempt, contextBytes) {
|
|
33
|
+
state.progress.modelCallCount = Math.max(state.progress.modelCallCount, attempt);
|
|
34
|
+
state.emitter.emit({ type: "workflow:model:call:started", attempt, contextBytes });
|
|
35
|
+
const response = await state.deps.model.call({ modelId: state.input.modelId, messages }, state.signal);
|
|
36
|
+
state.emitter.emit({
|
|
37
|
+
type: "workflow:model:call:completed",
|
|
38
|
+
attempt,
|
|
39
|
+
finishReason: response.finishReason,
|
|
40
|
+
promptTokens: response.usage.promptTokens,
|
|
41
|
+
completionTokens: response.usage.completionTokens,
|
|
42
|
+
latencyMs: response.usage.latencyMs,
|
|
43
|
+
});
|
|
44
|
+
return response.content;
|
|
45
|
+
}
|
|
46
|
+
// One attempt: prompt -> model -> parse -> validate -> guard.
|
|
47
|
+
async function attemptOnce(state, workspace, conventions, strategy, pack, attempt, rejectionReason) {
|
|
48
|
+
const messages = buildPrompt(state.input, conventions, strategy, pack, rejectionReason);
|
|
49
|
+
const content = await callModel(state, messages, attempt, pack.usedBytes);
|
|
50
|
+
const parsed = parseModelOutput(content);
|
|
51
|
+
const validation = validatePatch(workspace, parsed.diff, {
|
|
52
|
+
fs: state.deps.fs ?? nodeWorkspaceFs,
|
|
53
|
+
});
|
|
54
|
+
const effectiveDiff = validation.normalizedDiff ?? parsed.diff;
|
|
55
|
+
const guardCode = validation.ok
|
|
56
|
+
? (emptyPatchRejection(effectiveDiff, validation) ??
|
|
57
|
+
productionGuard(workspace, validation) ??
|
|
58
|
+
governedPatchRejectionCode(state.deps.workflowHandoff, validation))
|
|
59
|
+
: validation.reasons[0]?.code;
|
|
60
|
+
emitValidation(state, validation, guardCode);
|
|
61
|
+
if (validation.ok && guardCode === undefined) {
|
|
62
|
+
const accepted = {
|
|
63
|
+
diff: effectiveDiff,
|
|
64
|
+
validation,
|
|
65
|
+
coveredBehavior: parsed.coveredBehavior,
|
|
66
|
+
knownGaps: parsed.knownGaps,
|
|
67
|
+
};
|
|
68
|
+
return { accepted, rejectionCode: undefined };
|
|
69
|
+
}
|
|
70
|
+
return { accepted: undefined, rejectionCode: guardCode ?? "malformed" };
|
|
71
|
+
}
|
|
72
|
+
export async function runModelLoop(state, workspace, conventions, strategy, pack) {
|
|
73
|
+
let modelCallCount = 0;
|
|
74
|
+
let patchRetryCount = 0;
|
|
75
|
+
let rejectionReason;
|
|
76
|
+
while (modelCallCount < state.limits.maxModelCalls &&
|
|
77
|
+
patchRetryCount <= state.limits.maxRetries) {
|
|
78
|
+
modelCallCount += 1;
|
|
79
|
+
const result = await attemptOnce(state, workspace, conventions, strategy, pack, modelCallCount, rejectionReason);
|
|
80
|
+
if (result.accepted !== undefined) {
|
|
81
|
+
return {
|
|
82
|
+
accepted: result.accepted,
|
|
83
|
+
modelCallCount,
|
|
84
|
+
patchRetryCount,
|
|
85
|
+
lastRejectionCode: undefined,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
patchRetryCount += 1;
|
|
89
|
+
state.progress.patchRetryCount = patchRetryCount;
|
|
90
|
+
rejectionReason = result.rejectionCode;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
accepted: undefined,
|
|
94
|
+
modelCallCount,
|
|
95
|
+
patchRetryCount,
|
|
96
|
+
lastRejectionCode: rejectionReason,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ParsedModelOutput {
|
|
2
|
+
readonly diff: string;
|
|
3
|
+
readonly coveredBehavior: string | undefined;
|
|
4
|
+
readonly knownGaps: string | undefined;
|
|
5
|
+
}
|
|
6
|
+
export declare function parseModelOutput(content: string): ParsedModelOutput;
|
|
7
|
+
//# sourceMappingURL=parse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/unit-tests/parse.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,iBAAiB;IAEhC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB,QAAQ,CAAC,eAAe,EAAE,MAAM,GAAG,SAAS,CAAC;IAE7C,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC;AA8DD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,iBAAiB,CASnE"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Defensive parser for the model-output contract (ADR-0008 steering note B). The model is
|
|
2
|
+
// instructed (see prompt.ts) to emit the unified diff inside a fenced ```diff ... ``` block,
|
|
3
|
+
// optionally followed by labeled prose sections (## Covered behavior, ## Known gaps). This
|
|
4
|
+
// parser extracts those parts WITHOUT trusting the model to comply: if no fence is present the
|
|
5
|
+
// whole content is treated as the diff; prose sections are extracted only when their headings
|
|
6
|
+
// appear. All extraction uses plain string ops (line splitting, startsWith, trim) — zero regex,
|
|
7
|
+
// so there is no ReDoS surface (steering note F). Redaction happens at the workflow boundary,
|
|
8
|
+
// not here: this module is pure string parsing.
|
|
9
|
+
const FENCE = "```";
|
|
10
|
+
const COVERED_HEADING = "## covered behavior";
|
|
11
|
+
const GAPS_HEADING = "## known gaps";
|
|
12
|
+
// Returns the contents of the FIRST fenced block (```diff ... ``` or a bare ``` ... ```) and the
|
|
13
|
+
// text that follows the closing fence. When no fenced block is found, returns the whole input as
|
|
14
|
+
// the diff and an empty remainder (the no-fence fallback).
|
|
15
|
+
function extractFencedDiff(content) {
|
|
16
|
+
const lines = content.split("\n");
|
|
17
|
+
const openIndex = lines.findIndex((line) => line.trimStart().startsWith(FENCE));
|
|
18
|
+
if (openIndex === -1) {
|
|
19
|
+
return { diff: content.trim(), rest: "" };
|
|
20
|
+
}
|
|
21
|
+
const closeIndex = lines.findIndex((line, idx) => idx > openIndex && line.trimStart().startsWith(FENCE));
|
|
22
|
+
if (closeIndex === -1) {
|
|
23
|
+
// An opening fence with no close: treat everything after the opener as the diff.
|
|
24
|
+
return {
|
|
25
|
+
diff: lines
|
|
26
|
+
.slice(openIndex + 1)
|
|
27
|
+
.join("\n")
|
|
28
|
+
.trim(),
|
|
29
|
+
rest: "",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
diff: lines
|
|
34
|
+
.slice(openIndex + 1, closeIndex)
|
|
35
|
+
.join("\n")
|
|
36
|
+
.trim(),
|
|
37
|
+
rest: lines.slice(closeIndex + 1).join("\n"),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// Extracts the body of a labeled section: every line after the matching `## heading` up to the
|
|
41
|
+
// next `## ` heading (or end of input). Returns undefined when the heading is absent. Returns
|
|
42
|
+
// undefined when the section body is empty after trimming.
|
|
43
|
+
function extractSection(text, heading) {
|
|
44
|
+
const lines = text.split("\n");
|
|
45
|
+
const start = lines.findIndex((line) => line.trim().toLowerCase() === heading);
|
|
46
|
+
if (start === -1) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const body = [];
|
|
50
|
+
for (const line of lines.slice(start + 1)) {
|
|
51
|
+
if (line.trim().startsWith("## ")) {
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
body.push(line);
|
|
55
|
+
}
|
|
56
|
+
const joined = body.join("\n").trim();
|
|
57
|
+
return joined.length === 0 ? undefined : joined;
|
|
58
|
+
}
|
|
59
|
+
export function parseModelOutput(content) {
|
|
60
|
+
const { diff, rest } = extractFencedDiff(content);
|
|
61
|
+
// Prose may follow the fenced diff; when there is no fence the whole content is the diff and
|
|
62
|
+
// there is no prose to scan, so `rest` is empty and both sections resolve to undefined.
|
|
63
|
+
return {
|
|
64
|
+
diff,
|
|
65
|
+
coveredBehavior: extractSection(rest, COVERED_HEADING),
|
|
66
|
+
knownGaps: extractSection(rest, GAPS_HEADING),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ChatMessage } from "@oscharko-dev/keiko-model-gateway";
|
|
2
|
+
import type { ContextPack } from "@oscharko-dev/keiko-workspace";
|
|
3
|
+
import type { TestStrategy } from "./frontend.js";
|
|
4
|
+
import type { TestConventions, UnitTestWorkflowInput } from "./types.js";
|
|
5
|
+
export declare function buildPrompt(input: UnitTestWorkflowInput, conventions: TestConventions, strategy: TestStrategy, pack: ContextPack, rejectionReason?: string): readonly ChatMessage[];
|
|
6
|
+
//# sourceMappingURL=prompt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../../src/unit-tests/prompt.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mCAAmC,CAAC;AACrE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AACjE,OAAO,KAAK,EAAE,YAAY,EAAa,MAAM,eAAe,CAAC;AAC7D,OAAO,KAAK,EAAE,eAAe,EAAkB,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAoJzF,wBAAgB,WAAW,CACzB,KAAK,EAAE,qBAAqB,EAC5B,WAAW,EAAE,eAAe,EAC5B,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,WAAW,EACjB,eAAe,CAAC,EAAE,MAAM,GACvB,SAAS,WAAW,EAAE,CAKxB"}
|