gaia-framework 1.127.3 → 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.
Files changed (33) hide show
  1. package/CLAUDE.md +3 -3
  2. package/_gaia/_config/global.yaml +1 -1
  3. package/_gaia/core/bridge/adapters/flutter-adapter.js +530 -0
  4. package/_gaia/core/bridge/adapters/go-adapter.js +600 -0
  5. package/_gaia/core/bridge/adapters/index.js +218 -0
  6. package/_gaia/core/bridge/adapters/java-adapter.js +589 -0
  7. package/_gaia/core/bridge/adapters/js-adapter.js +729 -0
  8. package/_gaia/core/bridge/adapters/python-adapter.js +534 -0
  9. package/_gaia/core/bridge/bridge-orchestrator.js +152 -0
  10. package/_gaia/core/bridge/bridge-post-flip-checks.js +242 -0
  11. package/_gaia/core/bridge/bridge-scope-guard.js +169 -0
  12. package/_gaia/core/bridge/bridge-toggle.js +313 -0
  13. package/_gaia/core/bridge/layer-0-environment-check.js +91 -0
  14. package/_gaia/core/bridge/layer-1-test-runner-discovery.js +89 -0
  15. package/_gaia/core/bridge/layer-2-ci-execution.js +363 -0
  16. package/_gaia/core/bridge/layer-2-local-execution.js +251 -0
  17. package/_gaia/core/bridge/layer-2-tier-selection.js +252 -0
  18. package/_gaia/core/bridge/layer-3-result-parsing.js +177 -0
  19. package/_gaia/core/bridge/review-gate-tier-mapping.js +215 -0
  20. package/_gaia/core/bridge/runner-compatibility-guard.js +192 -0
  21. package/_gaia/core/workflows/bridge-toggle/checklist.md +3 -0
  22. package/_gaia/core/workflows/bridge-toggle/instructions.xml +15 -10
  23. package/_gaia/lifecycle/workflows/4-implementation/dev-story/instructions.xml +1 -1
  24. package/gaia-install.sh +22 -0
  25. package/package.json +2 -1
  26. package/src/brownfield/browser-matrix-detector.js +274 -0
  27. package/src/brownfield/ci-test-detector.js +231 -0
  28. package/src/brownfield/design-extractor.js +523 -0
  29. package/src/brownfield/docker-test-detector.js +252 -0
  30. package/src/brownfield/test-environment-generator.js +416 -0
  31. package/src/brownfield/test-runner-detector.js +259 -0
  32. package/src/design-lifecycle/delta-sync.js +127 -0
  33. package/src/design-lifecycle/design-state.js +266 -0
package/CLAUDE.md CHANGED
@@ -155,7 +155,7 @@ If any review fails, the story returns to `in-progress`. The Review Gate table i
155
155
 
156
156
  ### Review Gate-to-Tier Mapping (E17-S12, FR-195)
157
157
 
158
- When the Test Execution Bridge (ADR-028) is enabled, each review gate is linked to a set of test tiers (from the E17-S11 three-tier model) whose evidence is required to produce a PASSED verdict. The canonical mapping lives in `Gaia-framework/src/bridge/review-gate-tier-mapping.js` (`DEFAULT_GATE_TIER_MAPPING`) and can be overridden per-project via the `tiers.gate_mapping` block in `test-environment.yaml`.
158
+ When the Test Execution Bridge (ADR-028) is enabled, each review gate is linked to a set of test tiers (from the E17-S11 three-tier model) whose evidence is required to produce a PASSED verdict. The canonical mapping lives in `Gaia-framework/_gaia/core/bridge/review-gate-tier-mapping.js` (`DEFAULT_GATE_TIER_MAPPING`) and can be overridden per-project via the `tiers.gate_mapping` block in `test-environment.yaml`.
159
159
 
160
160
  | Review Gate | Required Tiers |
161
161
  |---|---|
@@ -189,7 +189,7 @@ The Test Execution Bridge (ADR-028, architecture §10.20) orchestrates test runs
189
189
 
190
190
  **Supported stacks (built-in adapters, architecture §10.20.11):**
191
191
 
192
- The bridge ships with five static-import stack adapters, selected automatically by `getAdapter()` in `Gaia-framework/src/bridge/adapters/index.js`. Priority order is deterministic: `javascript → python → java → go → flutter`.
192
+ The bridge ships with five static-import stack adapters, selected automatically by `getAdapter()` in `Gaia-framework/_gaia/core/bridge/adapters/index.js`. Priority order is deterministic: `javascript → python → java → go → flutter`.
193
193
 
194
194
  | Stack | Representative runner command | Detection pattern |
195
195
  |---|---|---|
@@ -218,7 +218,7 @@ Adding a new stack adapter is documented in `docs/architecture/bridge-adapter-co
218
218
  - Trigger any GitHub Actions workflow other than the `ci_workflow` declared in `test-environment.yaml`
219
219
 
220
220
  **Enforcement points:**
221
- - `Gaia-framework/src/bridge/bridge-scope-guard.js` — shared scope guard module exporting `assertInScope`, `assertCommandAllowed`, `assertCiWorkflowAllowed`
221
+ - `Gaia-framework/_gaia/core/bridge/bridge-scope-guard.js` — shared scope guard module exporting `assertInScope`, `assertCommandAllowed`, `assertCiWorkflowAllowed`
222
222
  - Layer 2 local execution (`layer-2-local-execution.js`) calls all three guards before `spawn`
223
223
  - Layer 2 CI execution (`layer-2-ci-execution.js`) calls the shell-operator guard on the runner command and the CI workflow allowlist guard before `gh workflow run`
224
224
 
@@ -3,7 +3,7 @@
3
3
  # After modifying this file, run /gaia-build-configs to regenerate resolved configs.
4
4
 
5
5
  framework_name: "GAIA"
6
- framework_version: "1.127.3"
6
+ framework_version: "1.127.6"
7
7
 
8
8
  # User settings
9
9
  user_name: "jlouage"
@@ -0,0 +1,530 @@
1
+ /**
2
+ * E25-S4: Flutter and Dart Stack Adapter
3
+ *
4
+ * Plugs into the E25-S5 adapter registry and satisfies the StackAdapter
5
+ * contract (architecture §10.20.11.1). A single adapter handles both Flutter
6
+ * and pure Dart projects because Flutter's `flutter test --machine` wraps
7
+ * `package:test` and emits the same line-delimited JSON event schema as
8
+ * `dart test --reporter json`. The `discoverRunners` layer branches between
9
+ * the two commands based on whether `pubspec.yaml` declares a top-level
10
+ * `flutter:` section.
11
+ *
12
+ * Responsibilities:
13
+ * - Layer 0: readinessCheck — detect flutter or dart on PATH + pubspec.yaml
14
+ * - Layer 1: discoverRunners — parse pubspec.yaml, branch flutter vs dart,
15
+ * emit Tier 1 (test/) and Tier 3 (integration_test/) when present,
16
+ * else a single all-tier fallback
17
+ * - Layer 3: parseOutput — streaming JSON event parser correlating testStart
18
+ * and testDone by integer testID; tolerant of truncated streams
19
+ *
20
+ * Detection semantics: AND over `pubspec.yaml` (single-file detection).
21
+ * No new runtime dependencies — js-yaml is already in devDependencies.
22
+ *
23
+ * Traces to: FR-311, NFR-047, ADR-028, ADR-038, architecture §10.20.11
24
+ */
25
+
26
+ import { existsSync, readFileSync, statSync } from "fs";
27
+ import { join } from "path";
28
+ import { execFileSync as realExecFileSync } from "child_process";
29
+ import yaml from "js-yaml";
30
+
31
+ // ─── Constants ──────────────────────────────────────────────────────────────
32
+
33
+ const DETECTION_PATTERNS = ["pubspec.yaml"];
34
+
35
+ const FLUTTER_COMMAND = "flutter test --machine";
36
+ const DART_COMMAND = "dart test --reporter json";
37
+
38
+ const STDERR_SNIPPET_MAX = 2048;
39
+ const RAW_OUTPUT_SNIPPET_MAX = 2048;
40
+
41
+ const REMEDIATION = {
42
+ missingPubspec: "pubspec.yaml not found at project root",
43
+ missingFlutter: "flutter CLI not found on PATH — install Flutter SDK from https://flutter.dev",
44
+ missingDart: "dart CLI not found on PATH — install Dart SDK from https://dart.dev/get-dart",
45
+ };
46
+
47
+ // ─── Layer 0 helpers ────────────────────────────────────────────────────────
48
+
49
+ function cliAvailable(execFile, bin) {
50
+ try {
51
+ execFile(bin, ["--version"], { stdio: "ignore" });
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function hasPubspec(projectPath) {
59
+ return existsSync(join(projectPath, "pubspec.yaml"));
60
+ }
61
+
62
+ function readPubspec(projectPath) {
63
+ try {
64
+ const raw = readFileSync(join(projectPath, "pubspec.yaml"), "utf8");
65
+ return yaml.load(raw) || {};
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Is this project a Flutter project (top-level `flutter:` section in pubspec.yaml)?
73
+ * Returns false for pure Dart libraries that only declare `dependencies.flutter` etc.
74
+ */
75
+ function isFlutterProject(pubspec) {
76
+ if (!pubspec || typeof pubspec !== "object") return false;
77
+ return Object.prototype.hasOwnProperty.call(pubspec, "flutter");
78
+ }
79
+
80
+ function hasDir(projectPath, name) {
81
+ try {
82
+ return statSync(join(projectPath, name)).isDirectory();
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ // ─── Layer 0: readinessCheck ────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Readiness check for Flutter/Dart projects (AC2).
92
+ *
93
+ * @param {string} projectPath
94
+ * @param {object} [options]
95
+ * @param {function} [options._execFile] - execFileSync override (tests only)
96
+ * @returns {object}
97
+ */
98
+ function readinessCheck(projectPath, options = {}) {
99
+ const started = Date.now();
100
+ const execFile = options._execFile || realExecFileSync;
101
+
102
+ if (!projectPath || typeof projectPath !== "string") {
103
+ throw new TypeError("readinessCheck: projectPath is required");
104
+ }
105
+
106
+ // NFR-035 bridge_enabled guard — parity with other adapters.
107
+ if (options?.test_execution_bridge?.bridge_enabled === false) {
108
+ return {
109
+ passed: true,
110
+ remediation: null,
111
+ ready: true,
112
+ skipped: true,
113
+ checks: [],
114
+ remediations: [],
115
+ report: "",
116
+ elapsedMs: Date.now() - started,
117
+ };
118
+ }
119
+
120
+ const checks = [];
121
+
122
+ const pubspecOk = hasPubspec(projectPath);
123
+ checks.push({
124
+ name: "pubspec",
125
+ passed: pubspecOk,
126
+ remediation: pubspecOk ? null : REMEDIATION.missingPubspec,
127
+ });
128
+
129
+ // If pubspec exists, determine whether this is a Flutter or pure Dart project.
130
+ // Then check for the appropriate toolchain binary.
131
+ let pubspec = null;
132
+ let flutterProject = false;
133
+ if (pubspecOk) {
134
+ pubspec = readPubspec(projectPath);
135
+ flutterProject = isFlutterProject(pubspec);
136
+ }
137
+
138
+ const flutterOk = flutterProject ? cliAvailable(execFile, "flutter") : null;
139
+ const dartOk = !flutterProject ? cliAvailable(execFile, "dart") : null;
140
+
141
+ if (flutterProject) {
142
+ checks.push({
143
+ name: "flutter-toolchain",
144
+ passed: flutterOk === true,
145
+ detected: flutterOk ? "flutter" : null,
146
+ remediation: flutterOk ? null : REMEDIATION.missingFlutter,
147
+ });
148
+ } else if (pubspecOk) {
149
+ checks.push({
150
+ name: "dart-toolchain",
151
+ passed: dartOk === true,
152
+ detected: dartOk ? "dart" : null,
153
+ remediation: dartOk ? null : REMEDIATION.missingDart,
154
+ });
155
+ }
156
+
157
+ // Priority: missing pubspec is most actionable, then toolchain.
158
+ let remediation = null;
159
+ if (!pubspecOk) {
160
+ remediation = REMEDIATION.missingPubspec;
161
+ } else if (flutterProject && !flutterOk) {
162
+ remediation = REMEDIATION.missingFlutter;
163
+ } else if (!flutterProject && !dartOk) {
164
+ remediation = REMEDIATION.missingDart;
165
+ }
166
+
167
+ const passed = checks.every((c) => c.passed);
168
+ const elapsedMs = Date.now() - started;
169
+ const remediations = checks.filter((c) => !c.passed && c.remediation).map((c) => c.remediation);
170
+
171
+ return {
172
+ passed,
173
+ remediation,
174
+ ready: passed,
175
+ skipped: false,
176
+ checks,
177
+ remediations,
178
+ report: buildReport(checks, passed, elapsedMs),
179
+ elapsedMs,
180
+ };
181
+ }
182
+
183
+ function buildReport(checks, ready, elapsedMs) {
184
+ const rows = checks.map((c) => {
185
+ const status = c.passed ? "PASS" : "FAIL";
186
+ const detail = c.detected || "";
187
+ return ` ${status.padEnd(4)} ${c.name.padEnd(24)} ${detail}`;
188
+ });
189
+ return (
190
+ "Bridge Layer 0 — Flutter/Dart Readiness\n" +
191
+ "──────────────────────────────────────\n" +
192
+ rows.join("\n") +
193
+ `\n──────────────────────────────────────\n Overall: ${
194
+ ready ? "READY" : "NOT READY"
195
+ } (${elapsedMs}ms)`
196
+ );
197
+ }
198
+
199
+ // ─── Layer 1: discoverRunners ───────────────────────────────────────────────
200
+
201
+ /**
202
+ * Discover Flutter/Dart runners (AC3, AC6, AC7).
203
+ *
204
+ * Flutter projects use `flutter test --machine`; pure Dart libraries use
205
+ * `dart test --reporter json`. Tier emission depends on project layout:
206
+ *
207
+ * - Flutter project with `integration_test/` alongside `test/`:
208
+ * Tier 1 (unit, `flutter test test/`) + Tier 3 (e2e, `flutter test integration_test/`).
209
+ * Flutter does not emit a Tier 2 entry — the testing model lacks a
210
+ * standard unit-vs-integration distinction (AC6).
211
+ * - Otherwise: single `all` tier runner with a fallback log message (AC7).
212
+ *
213
+ * @param {string} projectPath
214
+ * @param {object} [manifest]
215
+ * @returns {Promise<object>}
216
+ */
217
+ async function discoverRunners(projectPath /*, manifest */) {
218
+ if (!projectPath || typeof projectPath !== "string") {
219
+ throw new TypeError("discoverRunners: projectPath is required");
220
+ }
221
+
222
+ if (!hasPubspec(projectPath)) {
223
+ return {
224
+ status: "error",
225
+ message: "No pubspec.yaml found at the project root.",
226
+ };
227
+ }
228
+
229
+ const pubspec = readPubspec(projectPath);
230
+ if (pubspec === null) {
231
+ return {
232
+ status: "error",
233
+ message: "Failed to parse pubspec.yaml.",
234
+ };
235
+ }
236
+
237
+ const flutterProject = isFlutterProject(pubspec);
238
+ const hasTestDir = hasDir(projectPath, "test");
239
+ const hasIntegrationDir = hasDir(projectPath, "integration_test");
240
+
241
+ // Flutter with integration_test/ — emit Tier 1 + Tier 3.
242
+ if (flutterProject && hasTestDir && hasIntegrationDir) {
243
+ const tier1 = {
244
+ runner_name: "flutter-test",
245
+ command: `${FLUTTER_COMMAND} test/`,
246
+ source: "pubspec.yaml",
247
+ tier_mapping: { tier: "unit", gates: [] },
248
+ tier: "unit",
249
+ };
250
+ const tier3 = {
251
+ runner_name: "flutter-test-integration",
252
+ command: `${FLUTTER_COMMAND} integration_test/`,
253
+ source: "pubspec.yaml",
254
+ tier_mapping: { tier: "e2e", gates: [] },
255
+ tier: "e2e",
256
+ };
257
+ return {
258
+ status: "ok",
259
+ primary: tier1,
260
+ manifest: {
261
+ mode: "flutter-tiered",
262
+ primary_runner: tier1,
263
+ runners: [tier1, tier3],
264
+ tiers: {
265
+ unit: { description: "Flutter widget/unit tests (test/)" },
266
+ e2e: { description: "Flutter integration tests (integration_test/)" },
267
+ },
268
+ },
269
+ };
270
+ }
271
+
272
+ // Pure Dart library OR Flutter without integration_test/ — single all-tier runner.
273
+ const command = flutterProject ? FLUTTER_COMMAND : DART_COMMAND;
274
+ const runnerName = flutterProject ? "flutter-test" : "dart-test";
275
+ const logMessage =
276
+ "no standard unit/integration/e2e convention for Flutter/Dart — using all-tier fallback";
277
+
278
+ const primary = {
279
+ runner_name: runnerName,
280
+ command,
281
+ source: "pubspec.yaml",
282
+ tier_mapping: { tier: "all", gates: [] },
283
+ tier: "all",
284
+ };
285
+ return {
286
+ status: "ok",
287
+ primary,
288
+ manifest: {
289
+ mode: flutterProject ? "flutter-all" : "dart-all",
290
+ primary_runner: primary,
291
+ runners: [primary],
292
+ tiers: {
293
+ all: {
294
+ description: flutterProject
295
+ ? "Flutter widget/unit tests (all)"
296
+ : "Dart package tests (all)",
297
+ },
298
+ },
299
+ log: logMessage,
300
+ },
301
+ };
302
+ }
303
+
304
+ // ─── Layer 3: streaming JSON parser ─────────────────────────────────────────
305
+
306
+ /**
307
+ * Split a package:test JSON stdout buffer into parsed event objects.
308
+ * Silently skips non-JSON lines and the final partial line after a truncation.
309
+ *
310
+ * @param {string} stdout
311
+ * @returns {Array<object>}
312
+ */
313
+ function parseJsonEventStream(stdout) {
314
+ if (!stdout || typeof stdout !== "string") return [];
315
+ const events = [];
316
+ const lines = stdout.split(/\r?\n/);
317
+ for (const line of lines) {
318
+ const trimmed = line.trim();
319
+ if (!trimmed || trimmed.charAt(0) !== "{") continue;
320
+ try {
321
+ const obj = JSON.parse(trimmed);
322
+ if (obj && typeof obj === "object") events.push(obj);
323
+ } catch {
324
+ // Skip malformed event — common with truncated / SIGTERM'd output.
325
+ }
326
+ }
327
+ return events;
328
+ }
329
+
330
+ /**
331
+ * Correlate testStart and testDone events into per-test records keyed by
332
+ * integer testID. error events are folded into the matching record's
333
+ * failure_message field. print events are ignored for summary but do not
334
+ * break correlation.
335
+ *
336
+ * @param {Array<object>} events
337
+ * @returns {{ tests: Array<object>, sawDone: boolean }}
338
+ */
339
+ function correlateEvents(events) {
340
+ const byId = new Map(); // testID → record
341
+ let sawDone = false;
342
+
343
+ for (const ev of events) {
344
+ const type = ev.type || "";
345
+
346
+ if (type === "done") {
347
+ sawDone = true;
348
+ continue;
349
+ }
350
+
351
+ if (type === "testStart" && ev.test && typeof ev.test.id === "number") {
352
+ const t = ev.test;
353
+ byId.set(t.id, {
354
+ id: t.id,
355
+ name: t.name || `test#${t.id}`,
356
+ status: "incomplete",
357
+ duration_ms: 0,
358
+ startMs: typeof ev.time === "number" ? ev.time : 0,
359
+ failure_message: null,
360
+ });
361
+ continue;
362
+ }
363
+
364
+ if (type === "testDone" && typeof ev.testID === "number") {
365
+ const rec = byId.get(ev.testID);
366
+ if (!rec) continue;
367
+ // package:test result values: "success" | "failure" | "error".
368
+ const result = ev.result || "";
369
+ if (ev.skipped === true) {
370
+ rec.status = "skipped";
371
+ } else if (result === "success") {
372
+ rec.status = "passed";
373
+ } else if (result === "failure" || result === "error") {
374
+ rec.status = "failed";
375
+ }
376
+ if (typeof ev.time === "number" && typeof rec.startMs === "number") {
377
+ rec.duration_ms = Math.max(0, ev.time - rec.startMs);
378
+ }
379
+ continue;
380
+ }
381
+
382
+ if (type === "error" && typeof ev.testID === "number") {
383
+ const rec = byId.get(ev.testID);
384
+ if (!rec) continue;
385
+ const err = ev.error || "";
386
+ const stack = ev.stackTrace || "";
387
+ const msg = (err + (stack ? "\n" + stack : "")).trim();
388
+ if (msg) {
389
+ rec.failure_message =
390
+ msg.length > RAW_OUTPUT_SNIPPET_MAX ? msg.slice(0, RAW_OUTPUT_SNIPPET_MAX) : msg;
391
+ }
392
+ // Ensure status is failed if an error event arrives.
393
+ if (rec.status !== "failed" && rec.status !== "skipped") {
394
+ rec.status = "failed";
395
+ }
396
+ continue;
397
+ }
398
+
399
+ // print, suite, group, allSuites, start — ignored for correlation.
400
+ }
401
+
402
+ const tests = [];
403
+ for (const rec of byId.values()) {
404
+ const entry = {
405
+ id: rec.id,
406
+ name: rec.name,
407
+ status: rec.status,
408
+ duration_ms: rec.duration_ms,
409
+ };
410
+ if (rec.failure_message) entry.failure_message = rec.failure_message;
411
+ tests.push(entry);
412
+ }
413
+ return { tests, sawDone };
414
+ }
415
+
416
+ /**
417
+ * Parse `flutter test --machine` / `dart test --reporter json` output
418
+ * (AC4, AC5).
419
+ *
420
+ * @param {string} stdout
421
+ * @param {string} stderr
422
+ * @param {number} exitCode
423
+ * @param {object} [options] - { event?: "timeout" } to signal Layer-2 SIGTERM
424
+ * @returns {object}
425
+ */
426
+ function parseOutput(stdout, stderr, exitCode, options = {}) {
427
+ const stdoutStr = typeof stdout === "string" ? stdout : "";
428
+ const stderrStr = typeof stderr === "string" ? stderr : "";
429
+
430
+ const events = parseJsonEventStream(stdoutStr);
431
+
432
+ // No parseable events at all — fall back to parse_error record.
433
+ if (events.length === 0) {
434
+ return {
435
+ parse_error: true,
436
+ stderr_snippet: stderrStr.slice(0, STDERR_SNIPPET_MAX),
437
+ summary: { total: 0, passed: 0, failed: 0, skipped: 0, incomplete: 0 },
438
+ tests: [],
439
+ exit_code: exitCode,
440
+ };
441
+ }
442
+
443
+ const { tests, sawDone } = correlateEvents(events);
444
+
445
+ const summary = {
446
+ total: tests.length,
447
+ passed: tests.filter((t) => t.status === "passed").length,
448
+ failed: tests.filter((t) => t.status === "failed").length,
449
+ skipped: tests.filter((t) => t.status === "skipped").length,
450
+ incomplete: tests.filter((t) => t.status === "incomplete").length,
451
+ };
452
+
453
+ // Layer 2 can explicitly tag the run as a timeout via options.event.
454
+ // Otherwise, infer timeout from: no `done` event received + at least one
455
+ // incomplete test + non-zero exit code.
456
+ const inferredTimeout = !sawDone && summary.incomplete > 0 && exitCode !== 0;
457
+ const isTimeout = options.event === "timeout" || inferredTimeout;
458
+
459
+ const result = {
460
+ parse_error: false,
461
+ summary,
462
+ tests,
463
+ exit_code: exitCode,
464
+ };
465
+ if (isTimeout) {
466
+ result.event = "timeout";
467
+ result.raw_output_snippet = stderrStr.slice(0, RAW_OUTPUT_SNIPPET_MAX);
468
+ }
469
+ return result;
470
+ }
471
+
472
+ // ─── E25-S6: resolveTierMapping ─────────────────────────────────────────────
473
+
474
+ const DEFAULT_FLUTTER_SUITES = Object.freeze({
475
+ unit: "test/",
476
+ integration: "integration_test/",
477
+ e2e: "integration_test/",
478
+ });
479
+
480
+ /**
481
+ * Resolve per-tier Flutter suite directory mapping. Honours the optional
482
+ * `stackHints.flutter_suites` override from test-environment.yaml
483
+ * tiers.stack_hints. Partial hint blocks are merged on top of the defaults
484
+ * so unset tiers fall back to the directory convention. Each resulting
485
+ * entry records `tier_source: "stack_hints"` when the hint supplied that
486
+ * tier, `"adapter_default"` otherwise (E25-S6 FR-312).
487
+ *
488
+ * @param {string} _projectPath
489
+ * @param {object} [options]
490
+ * @param {{unit?: string, integration?: string, e2e?: string}} [options.stackHints]
491
+ * @returns {{ mapping: { unit: string, integration: string, e2e: string },
492
+ * entries: Array<{ tier: string, suite: string, tier_source: "stack_hints"|"adapter_default" }> }}
493
+ */
494
+ function resolveTierMapping(_projectPath, options = {}) {
495
+ const hints =
496
+ options.stackHints && typeof options.stackHints === "object" ? options.stackHints : {};
497
+ const mapping = { ...DEFAULT_FLUTTER_SUITES };
498
+ const entries = [];
499
+ for (const tier of ["unit", "integration", "e2e"]) {
500
+ const hint = hints[tier];
501
+ if (typeof hint === "string" && hint.length > 0) {
502
+ mapping[tier] = hint;
503
+ entries.push({ tier, suite: hint, tier_source: "stack_hints" });
504
+ } else {
505
+ entries.push({
506
+ tier,
507
+ suite: DEFAULT_FLUTTER_SUITES[tier],
508
+ tier_source: "adapter_default",
509
+ });
510
+ }
511
+ }
512
+ return { mapping, entries };
513
+ }
514
+
515
+ // ─── Export ─────────────────────────────────────────────────────────────────
516
+
517
+ /**
518
+ * @type {import('./index.js').StackAdapter}
519
+ */
520
+ const flutterAdapter = {
521
+ name: "flutter",
522
+ detectionPatterns: DETECTION_PATTERNS,
523
+ readinessCheck,
524
+ discoverRunners,
525
+ parseOutput,
526
+ // E25-S6 — exposed for per-stack tier mapping consumers (FR-312).
527
+ resolveTierMapping,
528
+ };
529
+
530
+ export default flutterAdapter;