gaia-framework 1.127.2 → 1.127.6
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/CLAUDE.md +3 -3
- package/_gaia/_config/global.yaml +1 -1
- package/_gaia/core/bridge/adapters/flutter-adapter.js +530 -0
- package/_gaia/core/bridge/adapters/go-adapter.js +600 -0
- package/_gaia/core/bridge/adapters/index.js +218 -0
- package/_gaia/core/bridge/adapters/java-adapter.js +589 -0
- package/_gaia/core/bridge/adapters/js-adapter.js +729 -0
- package/_gaia/core/bridge/adapters/python-adapter.js +534 -0
- package/_gaia/core/bridge/bridge-orchestrator.js +152 -0
- package/_gaia/core/bridge/bridge-post-flip-checks.js +242 -0
- package/_gaia/core/bridge/bridge-scope-guard.js +169 -0
- package/_gaia/core/bridge/bridge-toggle.js +313 -0
- package/_gaia/core/bridge/layer-0-environment-check.js +91 -0
- package/_gaia/core/bridge/layer-1-test-runner-discovery.js +89 -0
- package/_gaia/core/bridge/layer-2-ci-execution.js +363 -0
- package/_gaia/core/bridge/layer-2-local-execution.js +251 -0
- package/_gaia/core/bridge/layer-2-tier-selection.js +252 -0
- package/_gaia/core/bridge/layer-3-result-parsing.js +177 -0
- package/_gaia/core/bridge/review-gate-tier-mapping.js +215 -0
- package/_gaia/core/bridge/runner-compatibility-guard.js +192 -0
- package/_gaia/core/workflows/bridge-toggle/checklist.md +3 -0
- package/_gaia/core/workflows/bridge-toggle/instructions.xml +18 -15
- package/_gaia/lifecycle/workflows/4-implementation/dev-story/instructions.xml +1 -1
- package/gaia-install.sh +22 -0
- package/package.json +2 -1
- package/src/brownfield/browser-matrix-detector.js +274 -0
- package/src/brownfield/ci-test-detector.js +231 -0
- package/src/brownfield/design-extractor.js +523 -0
- package/src/brownfield/docker-test-detector.js +252 -0
- package/src/brownfield/test-environment-generator.js +416 -0
- package/src/brownfield/test-runner-detector.js +259 -0
- package/src/design-lifecycle/delta-sync.js +127 -0
- package/src/design-lifecycle/design-state.js +266 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E25-S3: Go Stack Adapter
|
|
3
|
+
*
|
|
4
|
+
* Plugs into the E25-S5 adapter registry and satisfies the StackAdapter
|
|
5
|
+
* contract (architecture §10.20.11.1). Unlike the Python and Java adapters
|
|
6
|
+
* (which read JUnit XML files from disk), the Go adapter consumes
|
|
7
|
+
* `go test -json` — a line-delimited JSON event stream emitted on stdout.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities:
|
|
10
|
+
* - Layer 0: readinessCheck — detect `go` on PATH + `go.mod` at project root
|
|
11
|
+
* - Layer 1: discoverRunners — `go list ./...` for single-module,
|
|
12
|
+
* `go list -m all` for nested-module monorepos
|
|
13
|
+
* - Layer 3: parseOutput — streaming JSON event parser correlating events
|
|
14
|
+
* by (Package, Test) keys; tolerant of panic truncation
|
|
15
|
+
*
|
|
16
|
+
* Detection semantics: AND over `go.mod` (single-file detection).
|
|
17
|
+
* No new runtime dependencies — line-delimited JSON.parse only.
|
|
18
|
+
*
|
|
19
|
+
* Traces to: FR-307, FR-310, NFR-047, ADR-028, ADR-038, architecture §10.20.11
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
23
|
+
import { join, relative } from "path";
|
|
24
|
+
import { execFileSync as realExecFileSync } from "child_process";
|
|
25
|
+
|
|
26
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const DETECTION_PATTERNS = ["go.mod"];
|
|
29
|
+
|
|
30
|
+
const DEFAULT_COMMAND = "go test -json ./...";
|
|
31
|
+
const STDERR_SNIPPET_MAX = 2048;
|
|
32
|
+
const RAW_OUTPUT_SNIPPET_MAX = 2048;
|
|
33
|
+
|
|
34
|
+
const DEFAULT_BUILD_TAGS = {
|
|
35
|
+
integration: "integration",
|
|
36
|
+
e2e: "e2e",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const REMEDIATION = {
|
|
40
|
+
missingGoToolchain: "Go not found — install from https://go.dev/dl/",
|
|
41
|
+
missingGoMod:
|
|
42
|
+
"No go.mod found at the project root. Run `go mod init <module>` to initialize a Go module.",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ─── Layer 0 helpers ────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function goAvailable(execFile) {
|
|
48
|
+
try {
|
|
49
|
+
execFile("go", ["version"], { stdio: "ignore" });
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasGoMod(projectPath) {
|
|
57
|
+
return existsSync(join(projectPath, "go.mod"));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Layer 0: readinessCheck ────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Readiness check for Go projects (AC2).
|
|
64
|
+
*
|
|
65
|
+
* @param {string} projectPath
|
|
66
|
+
* @param {object} [options]
|
|
67
|
+
* @param {function} [options._execFile] - execFileSync override (tests only)
|
|
68
|
+
* @returns {object}
|
|
69
|
+
*/
|
|
70
|
+
function readinessCheck(projectPath, options = {}) {
|
|
71
|
+
const started = Date.now();
|
|
72
|
+
const execFile = options._execFile || realExecFileSync;
|
|
73
|
+
|
|
74
|
+
if (!projectPath || typeof projectPath !== "string") {
|
|
75
|
+
throw new TypeError("readinessCheck: projectPath is required");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// NFR-035 bridge_enabled guard — parity with js/python/java adapters.
|
|
79
|
+
if (options?.test_execution_bridge?.bridge_enabled === false) {
|
|
80
|
+
return {
|
|
81
|
+
passed: true,
|
|
82
|
+
remediation: null,
|
|
83
|
+
ready: true,
|
|
84
|
+
skipped: true,
|
|
85
|
+
checks: [],
|
|
86
|
+
remediations: [],
|
|
87
|
+
report: "",
|
|
88
|
+
elapsedMs: Date.now() - started,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const checks = [];
|
|
93
|
+
|
|
94
|
+
const toolchainOk = goAvailable(execFile);
|
|
95
|
+
checks.push({
|
|
96
|
+
name: "go-toolchain",
|
|
97
|
+
passed: toolchainOk,
|
|
98
|
+
detected: toolchainOk ? "go" : null,
|
|
99
|
+
remediation: toolchainOk ? null : REMEDIATION.missingGoToolchain,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const goModOk = hasGoMod(projectPath);
|
|
103
|
+
checks.push({
|
|
104
|
+
name: "go-mod",
|
|
105
|
+
passed: goModOk,
|
|
106
|
+
remediation: goModOk ? null : REMEDIATION.missingGoMod,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Priority: missing toolchain is the most actionable failure.
|
|
110
|
+
let remediation = null;
|
|
111
|
+
if (!toolchainOk) remediation = REMEDIATION.missingGoToolchain;
|
|
112
|
+
else if (!goModOk) remediation = REMEDIATION.missingGoMod;
|
|
113
|
+
|
|
114
|
+
const passed = toolchainOk && goModOk;
|
|
115
|
+
const elapsedMs = Date.now() - started;
|
|
116
|
+
const remediations = checks.filter((c) => !c.passed && c.remediation).map((c) => c.remediation);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
passed,
|
|
120
|
+
remediation,
|
|
121
|
+
ready: passed,
|
|
122
|
+
skipped: false,
|
|
123
|
+
checks,
|
|
124
|
+
remediations,
|
|
125
|
+
report: buildReport(checks, passed, elapsedMs),
|
|
126
|
+
elapsedMs,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildReport(checks, ready, elapsedMs) {
|
|
131
|
+
const rows = checks.map((c) => {
|
|
132
|
+
const status = c.passed ? "PASS" : "FAIL";
|
|
133
|
+
const detail = c.detected || "";
|
|
134
|
+
return ` ${status.padEnd(4)} ${c.name.padEnd(24)} ${detail}`;
|
|
135
|
+
});
|
|
136
|
+
return (
|
|
137
|
+
"Bridge Layer 0 — Go Readiness\n" +
|
|
138
|
+
"──────────────────────────────────────\n" +
|
|
139
|
+
rows.join("\n") +
|
|
140
|
+
`\n──────────────────────────────────────\n Overall: ${
|
|
141
|
+
ready ? "READY" : "NOT READY"
|
|
142
|
+
} (${elapsedMs}ms)`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Monorepo detection (Layer 1 helper) ────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Walk the project tree looking for nested `go.mod` files (excluding the
|
|
150
|
+
* root `go.mod`). Returns the list of module-root-relative directories.
|
|
151
|
+
* Stops at common vendor / build / node_modules directories to keep the
|
|
152
|
+
* scan cheap.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} projectPath
|
|
155
|
+
* @returns {string[]} nested module directories (relative), excluding root
|
|
156
|
+
*/
|
|
157
|
+
function findNestedModules(projectPath) {
|
|
158
|
+
const skipDirs = new Set([
|
|
159
|
+
"node_modules",
|
|
160
|
+
"vendor",
|
|
161
|
+
".git",
|
|
162
|
+
"build",
|
|
163
|
+
"dist",
|
|
164
|
+
"target",
|
|
165
|
+
".idea",
|
|
166
|
+
".vscode",
|
|
167
|
+
]);
|
|
168
|
+
const results = [];
|
|
169
|
+
|
|
170
|
+
function walk(dir, depth) {
|
|
171
|
+
if (depth > 6) return; // depth guard
|
|
172
|
+
let entries;
|
|
173
|
+
try {
|
|
174
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
175
|
+
} catch {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
if (!entry.isDirectory()) continue;
|
|
180
|
+
if (skipDirs.has(entry.name)) continue;
|
|
181
|
+
if (entry.name.startsWith(".")) continue;
|
|
182
|
+
const sub = join(dir, entry.name);
|
|
183
|
+
if (existsSync(join(sub, "go.mod"))) {
|
|
184
|
+
results.push(relative(projectPath, sub));
|
|
185
|
+
}
|
|
186
|
+
walk(sub, depth + 1);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
walk(projectPath, 0);
|
|
191
|
+
return results;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── Layer 1: discoverRunners ───────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Discover Go runners (AC3, AC7).
|
|
198
|
+
*
|
|
199
|
+
* Single-module projects emit one `go test -json ./...` runner.
|
|
200
|
+
* Monorepos (nested go.mod detected) emit one runner per module plus
|
|
201
|
+
* the root runner when the root itself is a module.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} projectPath
|
|
204
|
+
* @param {object} [manifest]
|
|
205
|
+
* @returns {Promise<object>}
|
|
206
|
+
*/
|
|
207
|
+
async function discoverRunners(projectPath /*, manifest */) {
|
|
208
|
+
if (!projectPath || typeof projectPath !== "string") {
|
|
209
|
+
throw new TypeError("discoverRunners: projectPath is required");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!hasGoMod(projectPath)) {
|
|
213
|
+
return {
|
|
214
|
+
status: "error",
|
|
215
|
+
message: "No go.mod found at the project root.",
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const nested = findNestedModules(projectPath);
|
|
220
|
+
|
|
221
|
+
// Single-module project — flat runner manifest.
|
|
222
|
+
if (nested.length === 0) {
|
|
223
|
+
const primary = {
|
|
224
|
+
runner_name: "go-test",
|
|
225
|
+
command: DEFAULT_COMMAND,
|
|
226
|
+
source: "go.mod",
|
|
227
|
+
tier_mapping: { tier: "unit", gates: [] },
|
|
228
|
+
tier: "unit",
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
status: "ok",
|
|
232
|
+
primary,
|
|
233
|
+
manifest: {
|
|
234
|
+
mode: "single-module",
|
|
235
|
+
primary_runner: primary,
|
|
236
|
+
runners: [primary],
|
|
237
|
+
tiers: { unit: { description: "Go unit tests (go test -json ./...)" } },
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Monorepo — one runner per module (root + nested).
|
|
243
|
+
const modules = ["."].concat(nested);
|
|
244
|
+
const runners = modules.map((modPath) => {
|
|
245
|
+
const label = modPath === "." ? "root" : modPath;
|
|
246
|
+
return {
|
|
247
|
+
runner_name: `go-test:${label}`,
|
|
248
|
+
command: DEFAULT_COMMAND,
|
|
249
|
+
source: join(modPath === "." ? "" : modPath, "go.mod"),
|
|
250
|
+
module: modPath,
|
|
251
|
+
cwd: modPath === "." ? "." : modPath,
|
|
252
|
+
tier_mapping: { tier: "unit", gates: [] },
|
|
253
|
+
tier: "unit",
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
const primary = runners[0];
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
status: "ok",
|
|
260
|
+
primary,
|
|
261
|
+
manifest: {
|
|
262
|
+
mode: "multi-module",
|
|
263
|
+
modules,
|
|
264
|
+
primary_runner: primary,
|
|
265
|
+
runners,
|
|
266
|
+
tiers: { unit: { description: "Go unit tests per-module" } },
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ─── Build tag scanner (Layer 1 helper) ─────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
const buildTagCache = new Map(); // projectPath → Map<filePath, tags[]>
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Extract `//go:build <expr>` tags from the top of a `_test.go` file.
|
|
277
|
+
* Only scans the first 20 non-empty lines (build tags must appear before
|
|
278
|
+
* the package clause). Returns an array of individual tag tokens.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} filePath
|
|
281
|
+
* @returns {string[]}
|
|
282
|
+
*/
|
|
283
|
+
function scanFileBuildTags(filePath) {
|
|
284
|
+
let text;
|
|
285
|
+
try {
|
|
286
|
+
text = readFileSync(filePath, "utf8");
|
|
287
|
+
} catch {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
const lines = text.split(/\r?\n/).slice(0, 40);
|
|
291
|
+
const tags = [];
|
|
292
|
+
for (const raw of lines) {
|
|
293
|
+
const line = raw.trim();
|
|
294
|
+
if (line === "") continue;
|
|
295
|
+
if (line.startsWith("package ")) break;
|
|
296
|
+
const m = /^\/\/go:build\s+(.+)$/.exec(line);
|
|
297
|
+
if (m) {
|
|
298
|
+
// Split on logical operators to extract tag identifiers.
|
|
299
|
+
const tokens = m[1].split(/\s+|\|\||&&|!|\(|\)/).filter(Boolean);
|
|
300
|
+
tags.push(...tokens);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
// Legacy `// +build` syntax — supported defensively.
|
|
304
|
+
const legacy = /^\/\/\s*\+build\s+(.+)$/.exec(line);
|
|
305
|
+
if (legacy) {
|
|
306
|
+
const tokens = legacy[1].split(/\s+|,/).filter(Boolean);
|
|
307
|
+
tags.push(...tokens);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return Array.from(new Set(tags));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Scan a project for all `*_test.go` files and their build tags.
|
|
315
|
+
* Results are cached per project path.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} projectPath
|
|
318
|
+
* @returns {Map<string, string[]>} file path → tags[]
|
|
319
|
+
*/
|
|
320
|
+
function scanProjectBuildTags(projectPath) {
|
|
321
|
+
if (buildTagCache.has(projectPath)) return buildTagCache.get(projectPath);
|
|
322
|
+
const out = new Map();
|
|
323
|
+
const skipDirs = new Set([
|
|
324
|
+
"node_modules",
|
|
325
|
+
"vendor",
|
|
326
|
+
".git",
|
|
327
|
+
"build",
|
|
328
|
+
"dist",
|
|
329
|
+
"target",
|
|
330
|
+
".idea",
|
|
331
|
+
".vscode",
|
|
332
|
+
]);
|
|
333
|
+
|
|
334
|
+
function walk(dir, depth) {
|
|
335
|
+
if (depth > 8) return;
|
|
336
|
+
let entries;
|
|
337
|
+
try {
|
|
338
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
339
|
+
} catch {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
for (const entry of entries) {
|
|
343
|
+
const sub = join(dir, entry.name);
|
|
344
|
+
if (entry.isDirectory()) {
|
|
345
|
+
if (skipDirs.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
346
|
+
walk(sub, depth + 1);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (entry.isFile() && entry.name.endsWith("_test.go")) {
|
|
350
|
+
out.set(sub, scanFileBuildTags(sub));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
walk(projectPath, 0);
|
|
356
|
+
buildTagCache.set(projectPath, out);
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Map a `*_test.go` file to a tier based on its build tags.
|
|
362
|
+
*
|
|
363
|
+
* @param {string[]} tags
|
|
364
|
+
* @param {object} [tagMapping] - { integration: string, e2e: string } override
|
|
365
|
+
* @returns {"unit"|"integration"|"e2e"}
|
|
366
|
+
*/
|
|
367
|
+
function mapTagsToTier(tags, tagMapping = DEFAULT_BUILD_TAGS) {
|
|
368
|
+
if (!tags || tags.length === 0) return "unit";
|
|
369
|
+
if (tags.includes(tagMapping.e2e)) return "e2e";
|
|
370
|
+
if (tags.includes(tagMapping.integration)) return "integration";
|
|
371
|
+
return "unit";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Resolve build-tag tier mapping for a project. Exposed for AC6 testing
|
|
376
|
+
* and for downstream consumers (E25-S6 per-stack tier mapping).
|
|
377
|
+
*
|
|
378
|
+
* @param {string} projectPath
|
|
379
|
+
* @param {object} [options]
|
|
380
|
+
* @param {object} [options.stackHints] - test-environment.yaml tiers.stack_hints.go_build_tags override
|
|
381
|
+
* @returns {{ tierByFile: Map<string,string>, mapping: object }}
|
|
382
|
+
*/
|
|
383
|
+
function resolveTierMapping(projectPath, options = {}) {
|
|
384
|
+
// E25-S6: `stackHints` accepts either a tag-mapping object (legacy shape
|
|
385
|
+
// used internally by this adapter — { integration: "integration", e2e: "e2e" })
|
|
386
|
+
// or the array form from test-environment.yaml
|
|
387
|
+
// (`tiers.stack_hints.go_build_tags: ["integration", "e2e"]`). When the
|
|
388
|
+
// array form is supplied, the first entry is treated as the integration
|
|
389
|
+
// tag and the second as the e2e tag, matching Dev Notes semantics.
|
|
390
|
+
let mapping;
|
|
391
|
+
let tierSource;
|
|
392
|
+
if (Array.isArray(options.stackHints)) {
|
|
393
|
+
const hints = options.stackHints;
|
|
394
|
+
mapping = {
|
|
395
|
+
integration: hints[0] || DEFAULT_BUILD_TAGS.integration,
|
|
396
|
+
e2e: hints[1] || DEFAULT_BUILD_TAGS.e2e,
|
|
397
|
+
};
|
|
398
|
+
tierSource = "stack_hints";
|
|
399
|
+
} else if (options.stackHints && typeof options.stackHints === "object") {
|
|
400
|
+
mapping = { ...DEFAULT_BUILD_TAGS, ...options.stackHints };
|
|
401
|
+
tierSource = "stack_hints";
|
|
402
|
+
} else {
|
|
403
|
+
mapping = DEFAULT_BUILD_TAGS;
|
|
404
|
+
tierSource = "adapter_default";
|
|
405
|
+
}
|
|
406
|
+
const tagged = scanProjectBuildTags(projectPath);
|
|
407
|
+
const tierByFile = new Map();
|
|
408
|
+
for (const [file, tags] of tagged.entries()) {
|
|
409
|
+
tierByFile.set(file, mapTagsToTier(tags, mapping));
|
|
410
|
+
}
|
|
411
|
+
// E25-S6 evidence entries — one per tier with tier_source recorded so
|
|
412
|
+
// downstream evidence files can record whether the resolution came from a
|
|
413
|
+
// project hint or the adapter default (ADR-038 §10.20.11).
|
|
414
|
+
const entries = [
|
|
415
|
+
{ tier: "unit", tag: null, tier_source: tierSource },
|
|
416
|
+
{ tier: "integration", tag: mapping.integration, tier_source: tierSource },
|
|
417
|
+
{ tier: "e2e", tag: mapping.e2e, tier_source: tierSource },
|
|
418
|
+
];
|
|
419
|
+
return { tierByFile, mapping, entries };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ─── Layer 3: streaming JSON parser ─────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Split a `go test -json` stdout buffer into parsed event objects.
|
|
426
|
+
* Silently skips non-JSON lines (Go occasionally emits plain text).
|
|
427
|
+
*
|
|
428
|
+
* @param {string} stdout
|
|
429
|
+
* @returns {Array<object>}
|
|
430
|
+
*/
|
|
431
|
+
function parseJsonEventStream(stdout) {
|
|
432
|
+
if (!stdout || typeof stdout !== "string") return [];
|
|
433
|
+
const events = [];
|
|
434
|
+
const lines = stdout.split(/\r?\n/);
|
|
435
|
+
for (const line of lines) {
|
|
436
|
+
const trimmed = line.trim();
|
|
437
|
+
if (!trimmed || trimmed.charAt(0) !== "{") continue;
|
|
438
|
+
try {
|
|
439
|
+
const obj = JSON.parse(trimmed);
|
|
440
|
+
if (obj && typeof obj === "object") events.push(obj);
|
|
441
|
+
} catch {
|
|
442
|
+
// Skip malformed event — common with truncated panic output.
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return events;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Correlate `go test -json` events into per-test records keyed by
|
|
450
|
+
* (Package, Test). Package-level events (no Test field) contribute to
|
|
451
|
+
* package summaries but are not emitted as test records.
|
|
452
|
+
*
|
|
453
|
+
* @param {Array<object>} events
|
|
454
|
+
* @returns {{ tests: Array<object>, packageSummaries: Map<string, object> }}
|
|
455
|
+
*/
|
|
456
|
+
function correlateEvents(events) {
|
|
457
|
+
const byKey = new Map();
|
|
458
|
+
const pkgSummary = new Map();
|
|
459
|
+
|
|
460
|
+
for (const ev of events) {
|
|
461
|
+
const pkg = ev.Package || "";
|
|
462
|
+
const action = ev.Action || "";
|
|
463
|
+
|
|
464
|
+
if (!ev.Test) {
|
|
465
|
+
// Package-level event.
|
|
466
|
+
if (!pkgSummary.has(pkg)) {
|
|
467
|
+
pkgSummary.set(pkg, { status: null, elapsed: 0 });
|
|
468
|
+
}
|
|
469
|
+
if (["pass", "fail", "skip"].includes(action)) {
|
|
470
|
+
const entry = pkgSummary.get(pkg);
|
|
471
|
+
entry.status = action;
|
|
472
|
+
if (typeof ev.Elapsed === "number") entry.elapsed = ev.Elapsed;
|
|
473
|
+
}
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const key = `${pkg}\u0000${ev.Test}`;
|
|
478
|
+
let rec = byKey.get(key);
|
|
479
|
+
if (!rec) {
|
|
480
|
+
rec = {
|
|
481
|
+
package: pkg,
|
|
482
|
+
test: ev.Test,
|
|
483
|
+
name: `${pkg}.${ev.Test}`,
|
|
484
|
+
status: "running",
|
|
485
|
+
duration_ms: 0,
|
|
486
|
+
output: [],
|
|
487
|
+
};
|
|
488
|
+
byKey.set(key, rec);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (action === "output" && typeof ev.Output === "string") {
|
|
492
|
+
rec.output.push(ev.Output);
|
|
493
|
+
} else if (action === "pass") {
|
|
494
|
+
rec.status = "passed";
|
|
495
|
+
if (typeof ev.Elapsed === "number") {
|
|
496
|
+
rec.duration_ms = Math.round(ev.Elapsed * 1000);
|
|
497
|
+
}
|
|
498
|
+
} else if (action === "fail") {
|
|
499
|
+
rec.status = "failed";
|
|
500
|
+
if (typeof ev.Elapsed === "number") {
|
|
501
|
+
rec.duration_ms = Math.round(ev.Elapsed * 1000);
|
|
502
|
+
}
|
|
503
|
+
} else if (action === "skip") {
|
|
504
|
+
rec.status = "skipped";
|
|
505
|
+
if (typeof ev.Elapsed === "number") {
|
|
506
|
+
rec.duration_ms = Math.round(ev.Elapsed * 1000);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const tests = [];
|
|
512
|
+
for (const rec of byKey.values()) {
|
|
513
|
+
const entry = {
|
|
514
|
+
package: rec.package,
|
|
515
|
+
name: rec.name,
|
|
516
|
+
status: rec.status === "running" ? "error" : rec.status,
|
|
517
|
+
duration_ms: rec.duration_ms,
|
|
518
|
+
};
|
|
519
|
+
if (entry.status === "failed" || entry.status === "error") {
|
|
520
|
+
const joined = rec.output.join("");
|
|
521
|
+
if (joined) {
|
|
522
|
+
entry.failure_message =
|
|
523
|
+
joined.length > RAW_OUTPUT_SNIPPET_MAX ? joined.slice(0, RAW_OUTPUT_SNIPPET_MAX) : joined;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
tests.push(entry);
|
|
527
|
+
}
|
|
528
|
+
return { tests, packageSummaries: pkgSummary };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Parse `go test -json` output (AC4, AC5).
|
|
533
|
+
*
|
|
534
|
+
* @param {string} stdout
|
|
535
|
+
* @param {string} stderr
|
|
536
|
+
* @param {number} exitCode
|
|
537
|
+
* @param {object} [options]
|
|
538
|
+
* @returns {object}
|
|
539
|
+
*/
|
|
540
|
+
function parseOutput(stdout, stderr, exitCode /*, options = {} */) {
|
|
541
|
+
const stdoutStr = typeof stdout === "string" ? stdout : "";
|
|
542
|
+
const stderrStr = typeof stderr === "string" ? stderr : "";
|
|
543
|
+
|
|
544
|
+
const events = parseJsonEventStream(stdoutStr);
|
|
545
|
+
|
|
546
|
+
// No parseable events at all — fall back to parse_error record.
|
|
547
|
+
if (events.length === 0) {
|
|
548
|
+
return {
|
|
549
|
+
parse_error: true,
|
|
550
|
+
stderr_snippet: stderrStr.slice(0, STDERR_SNIPPET_MAX),
|
|
551
|
+
summary: { total: 0, passed: 0, failed: 0, skipped: 0 },
|
|
552
|
+
tests: [],
|
|
553
|
+
exit_code: exitCode,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const { tests } = correlateEvents(events);
|
|
558
|
+
const summary = {
|
|
559
|
+
total: tests.length,
|
|
560
|
+
passed: tests.filter((t) => t.status === "passed").length,
|
|
561
|
+
failed: tests.filter((t) => t.status === "failed").length,
|
|
562
|
+
skipped: tests.filter((t) => t.status === "skipped").length,
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// AC5: partial / panic-truncated stream handling.
|
|
566
|
+
// Non-zero exit with at least one running/error record OR a stderr panic:
|
|
567
|
+
// emit partial evidence record with raw_output_snippet.
|
|
568
|
+
const hasRunning = tests.some((t) => t.status === "error");
|
|
569
|
+
const stderrHasPanic = /\bpanic:/i.test(stderrStr);
|
|
570
|
+
if (exitCode !== 0 && (hasRunning || stderrHasPanic)) {
|
|
571
|
+
return {
|
|
572
|
+
parse_error: false,
|
|
573
|
+
status: "error",
|
|
574
|
+
raw_output_snippet: stderrStr.slice(0, RAW_OUTPUT_SNIPPET_MAX),
|
|
575
|
+
summary,
|
|
576
|
+
tests,
|
|
577
|
+
exit_code: exitCode,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return { summary, tests, exit_code: exitCode };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ─── Export ─────────────────────────────────────────────────────────────────
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* @type {import('./index.js').StackAdapter}
|
|
588
|
+
*/
|
|
589
|
+
const goAdapter = {
|
|
590
|
+
name: "go",
|
|
591
|
+
detectionPatterns: DETECTION_PATTERNS,
|
|
592
|
+
// Single required file — default ALL semantics are fine.
|
|
593
|
+
readinessCheck,
|
|
594
|
+
discoverRunners,
|
|
595
|
+
parseOutput,
|
|
596
|
+
// Exposed for E25-S6 per-stack tier mapping consumers.
|
|
597
|
+
resolveTierMapping,
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
export default goAdapter;
|