gaia-framework 1.83.2 → 1.105.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/.claude/commands/gaia-ci-edit.md +17 -0
- package/CLAUDE.md +10 -0
- package/_gaia/_config/environment-presets.yaml +140 -0
- package/_gaia/_config/global.yaml +1 -1
- package/_gaia/_config/lifecycle-sequence.yaml +9 -0
- package/_gaia/_config/workflow-manifest.csv +1 -0
- package/_gaia/core/engine/workflow.xml +2 -0
- package/_gaia/core/validators/ci-edit-audit.js +181 -0
- package/_gaia/core/validators/ci-edit-test-env-scan.js +89 -0
- package/_gaia/core/validators/dev-story-security-controls.js +264 -0
- package/_gaia/core/validators/promotion-chain-env-resolver.js +140 -0
- package/_gaia/core/validators/test-environment-validator.js +292 -0
- package/_gaia/dev/agents/_base-dev.md +6 -1
- package/_gaia/dev/skills/_skill-index.yaml +12 -6
- package/_gaia/dev/skills/figma-integration.md +203 -1
- package/_gaia/lifecycle/knowledge/brownfield/test-execution-scan.md +56 -9
- package/_gaia/lifecycle/templates/story-template.md +7 -0
- package/_gaia/lifecycle/workflows/2-planning/create-ux-design/instructions.xml +48 -4
- package/_gaia/lifecycle/workflows/4-implementation/code-review/instructions.xml +10 -0
- package/_gaia/lifecycle/workflows/4-implementation/create-story/instructions.xml +4 -0
- package/_gaia/lifecycle/workflows/4-implementation/dev-story/checklist.md +2 -0
- package/_gaia/lifecycle/workflows/4-implementation/dev-story/instructions.xml +104 -3
- package/_gaia/testing/workflows/ci-edit/checklist.md +41 -0
- package/_gaia/testing/workflows/ci-edit/instructions.xml +132 -0
- package/_gaia/testing/workflows/ci-edit/workflow.yaml +30 -0
- package/_gaia/testing/workflows/ci-setup/instructions.xml +75 -7
- package/_gaia/testing/workflows/test-gap-analysis/checklist.md +12 -0
- package/_gaia/testing/workflows/test-gap-analysis/instructions.xml +61 -2
- package/_gaia/testing/workflows/test-gap-analysis/workflow.yaml +2 -0
- package/gaia-install.sh +72 -76
- package/lib/copy-lib.sh +81 -0
- package/package.json +2 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-Story Security Controls (E20-S13)
|
|
3
|
+
*
|
|
4
|
+
* Pure, deterministic invariants that harden the dev-story CI integration
|
|
5
|
+
* against the threats identified in Threat Model v1.3.0. These functions are
|
|
6
|
+
* the single source of truth for the four security controls — Steps 13-16 of
|
|
7
|
+
* the dev-story workflow MUST delegate to these helpers rather than inlining
|
|
8
|
+
* their own check logic. This keeps the bypass-attempt test matrix in one
|
|
9
|
+
* place and prevents drift between the workflow engine and the security
|
|
10
|
+
* contract.
|
|
11
|
+
*
|
|
12
|
+
* Threats mitigated:
|
|
13
|
+
* - T25 (High) — PR target manipulation → resolvePrTargetBase
|
|
14
|
+
* - T26 (Med/H) — Unexpected CI check bypass → classifyCiCheck / evaluateMergeGate
|
|
15
|
+
* - T27 (High) — Force-merge attack → evaluateMergeGate
|
|
16
|
+
* - TB-10 — Credential leakage via CLI args/env → verifyAuthHygiene
|
|
17
|
+
*
|
|
18
|
+
* Architecture references:
|
|
19
|
+
* - ADR-033: Multi-Environment Promotion Chain
|
|
20
|
+
* - architecture §10.24.5 Steps 14 (Create PR), 15 (Wait for CI), 16 (Merge PR)
|
|
21
|
+
* - FR-249 (wait-for-ci allowlist), FR-250 (merge-gate enforcement)
|
|
22
|
+
*
|
|
23
|
+
* Design principles:
|
|
24
|
+
* - Pure functions: no I/O, no filesystem, no network. Inputs in, decision out.
|
|
25
|
+
* - No silent defaults — missing config throws, never falls back to `main`.
|
|
26
|
+
* - Bypass flags are NEVER honored at the merge gate. YOLO mode is an
|
|
27
|
+
* explicit carve-out: it accelerates user interaction but MUST NOT
|
|
28
|
+
* weaken security invariants (see E20-S13 story Dev Notes).
|
|
29
|
+
* - Halt messages are verbatim contracts — negative-path tests assert the
|
|
30
|
+
* exact wording so downstream tooling (audit log parsers, run-book docs)
|
|
31
|
+
* can rely on it.
|
|
32
|
+
*
|
|
33
|
+
* @module dev-story-security-controls
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// ─── T25 — PR target pinning ─────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the `--base` branch for `gh pr create` by reading ONLY from the
|
|
40
|
+
* workflow-loaded global.yaml `ci_cd.promotion_chain[0].branch`. Any runtime
|
|
41
|
+
* source (env vars, CLI flags, user input) is ignored by construction —
|
|
42
|
+
* the `runtime` argument is accepted for API symmetry and is deliberately
|
|
43
|
+
* not consulted beyond preserving the call site signature.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} globalConfig — Parsed global.yaml.
|
|
46
|
+
* @param {object} [_runtime] — Runtime context (env, cliFlags, userInput). Ignored by design.
|
|
47
|
+
* @returns {string} The pinned base branch name.
|
|
48
|
+
* @throws If `promotion_chain[0]` or its `branch` field is missing.
|
|
49
|
+
*/
|
|
50
|
+
// T25 — see E20-S13 AC1: base branch is bound once from promotion_chain[0].branch.
|
|
51
|
+
export function resolvePrTargetBase(globalConfig, _runtime) {
|
|
52
|
+
const chain = globalConfig?.ci_cd?.promotion_chain;
|
|
53
|
+
if (!Array.isArray(chain) || chain.length === 0) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"T25: promotion_chain[0] is not defined in global.yaml. " +
|
|
56
|
+
"Dev-story Step 14 requires a pinned base branch — no default to main is allowed.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const first = chain[0];
|
|
60
|
+
const branch = first && first.branch;
|
|
61
|
+
if (!branch || typeof branch !== "string") {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"T25: promotion_chain[0].branch is missing or not a string. " +
|
|
64
|
+
"Cannot construct a safe --base for gh pr create.",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return branch;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── T26 — CI check name allowlist ───────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Classify a CI check name as expected (in the allowlist) or unexpected.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} name
|
|
76
|
+
* @param {string[]} allowlist — The resolved `promotion_chain[0].ci_checks`.
|
|
77
|
+
* @returns {"expected"|"unexpected"}
|
|
78
|
+
*/
|
|
79
|
+
// T26 — see E20-S13 AC2: unexpected checks never count toward PASS.
|
|
80
|
+
export function classifyCiCheck(name, allowlist) {
|
|
81
|
+
if (!Array.isArray(allowlist)) return "unexpected";
|
|
82
|
+
return allowlist.includes(name) ? "expected" : "unexpected";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── T27 — Merge gate enforcement ────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const FAILED_STATES = new Set(["failure", "cancelled", "timed_out"]);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Evaluate whether a merge is allowed given the current CI check states.
|
|
91
|
+
*
|
|
92
|
+
* Rules (AC2 + AC3):
|
|
93
|
+
* 1. Build an index of returned checks. Any check whose name is NOT in
|
|
94
|
+
* `requiredChecks` is classified unexpected — WARNING emitted, never
|
|
95
|
+
* counted toward PASS.
|
|
96
|
+
* 2. For each name in `requiredChecks`, look up its status. Status must
|
|
97
|
+
* be "success". `failure`, `cancelled`, `timed_out`, and missing are
|
|
98
|
+
* all treated as NOT PASSED.
|
|
99
|
+
* 3. If any required check is not passed, `halt = true` and `haltMessage`
|
|
100
|
+
* contains the canonical verbatim text. Bypass flags (`force`, `yolo`,
|
|
101
|
+
* `env.GAIA_FORCE_MERGE`, `userConfirmation`) are NEVER honored — if
|
|
102
|
+
* they are set while the gate is failing, the halt still fires.
|
|
103
|
+
*
|
|
104
|
+
* @param {{name:string, status:string}[]} checks — Live states from `gh pr checks`.
|
|
105
|
+
* @param {string[]} requiredChecks — Allowlist from promotion_chain[0].ci_checks.
|
|
106
|
+
* @param {object} [bypassAttempt] — Hostile inputs: force, yolo, env, userConfirmation. All ignored.
|
|
107
|
+
* @returns {{
|
|
108
|
+
* allRequiredPassed: boolean,
|
|
109
|
+
* halt: boolean,
|
|
110
|
+
* haltMessage: string|null,
|
|
111
|
+
* warnings: string[],
|
|
112
|
+
* unexpected: string[],
|
|
113
|
+
* }}
|
|
114
|
+
*/
|
|
115
|
+
// T27 — see E20-S13 AC3: merge is refused on any failing required check.
|
|
116
|
+
// Bypass flags (force, yolo, env, userConfirmation) are deliberately not
|
|
117
|
+
// consulted — they are accepted for API symmetry so callers can't silently
|
|
118
|
+
// drop them and believe a "stricter" call path exists.
|
|
119
|
+
export function evaluateMergeGate(checks, requiredChecks, bypassAttempt = {}) {
|
|
120
|
+
void bypassAttempt; // explicit: bypass inputs are accepted and ignored (E20-S13 AC3)
|
|
121
|
+
|
|
122
|
+
const warnings = [];
|
|
123
|
+
const unexpected = [];
|
|
124
|
+
const checkIndex = new Map();
|
|
125
|
+
const requiredList = Array.isArray(requiredChecks) ? requiredChecks : [];
|
|
126
|
+
|
|
127
|
+
// Index returned checks and flag unexpected ones.
|
|
128
|
+
for (const c of checks || []) {
|
|
129
|
+
if (!c || typeof c.name !== "string") continue;
|
|
130
|
+
const classification = classifyCiCheck(c.name, requiredList);
|
|
131
|
+
if (classification === "unexpected") {
|
|
132
|
+
if (!unexpected.includes(c.name)) {
|
|
133
|
+
unexpected.push(c.name);
|
|
134
|
+
warnings.push(
|
|
135
|
+
`WARNING [dev-story/step-15]: unexpected CI check '${c.name}' ` +
|
|
136
|
+
`returned by gh pr checks but not declared in ` +
|
|
137
|
+
`promotion_chain[0].ci_checks. Excluded from merge-gate PASS calculation.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
continue; // never counts toward PASS
|
|
141
|
+
}
|
|
142
|
+
// Expected — record status (last write wins on duplicates).
|
|
143
|
+
checkIndex.set(c.name, c.status);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Evaluate each required check.
|
|
147
|
+
for (const name of requiredList) {
|
|
148
|
+
const status = checkIndex.get(name);
|
|
149
|
+
if (status === "success") continue;
|
|
150
|
+
|
|
151
|
+
const effective = status === undefined ? "missing" : status;
|
|
152
|
+
// Either a failing state or simply absent — both refuse the merge.
|
|
153
|
+
if (effective === "missing" || FAILED_STATES.has(effective) || status !== "success") {
|
|
154
|
+
return {
|
|
155
|
+
allRequiredPassed: false,
|
|
156
|
+
halt: true,
|
|
157
|
+
haltMessage:
|
|
158
|
+
`Merge refused: required CI check ${name} is in state ${effective}. ` +
|
|
159
|
+
`No bypass is supported.`,
|
|
160
|
+
warnings,
|
|
161
|
+
unexpected,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
allRequiredPassed: true,
|
|
168
|
+
halt: false,
|
|
169
|
+
haltMessage: null,
|
|
170
|
+
warnings,
|
|
171
|
+
unexpected,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── TB-10 — Credential hygiene ──────────────────────────────────
|
|
176
|
+
|
|
177
|
+
// Matches GitHub personal access tokens and fine-grained tokens.
|
|
178
|
+
const GITHUB_TOKEN_PATTERN = /\b(ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9_]{16,}\b/;
|
|
179
|
+
// Matches any "Authorization:" header value on an argv position.
|
|
180
|
+
const AUTH_HEADER_PATTERN = /Authorization\s*:\s*(Bearer|token)\s+\S+/i;
|
|
181
|
+
// Matches a URL with userinfo credentials (user:pass@host).
|
|
182
|
+
const URL_USERINFO_PATTERN = /\bhttps?:\/\/[^/\s@]*:[^/\s@]+@/i;
|
|
183
|
+
// Environment variables that, if set, imply a token is sitting on the
|
|
184
|
+
// process table and may leak. `gh` uses its own keyring — GH_TOKEN bypasses
|
|
185
|
+
// that and is disallowed for dev-story CI calls.
|
|
186
|
+
const FORBIDDEN_ENV_KEYS = new Set([
|
|
187
|
+
"GH_TOKEN",
|
|
188
|
+
"GITHUB_TOKEN",
|
|
189
|
+
"GH_ENTERPRISE_TOKEN",
|
|
190
|
+
"GITHUB_ENTERPRISE_TOKEN",
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Verify a planned shell invocation does not leak credentials via argv,
|
|
195
|
+
* URLs, or the process environment. Returns `{ ok: true }` on pass.
|
|
196
|
+
*
|
|
197
|
+
* Rules (AC4):
|
|
198
|
+
* 1. The `curl` / `wget` / `http` / `https` binaries are disallowed for
|
|
199
|
+
* GitHub interactions — use `gh` or `gh api` instead.
|
|
200
|
+
* 2. No argv position may contain a recognizable GitHub token.
|
|
201
|
+
* 3. No argv position may contain an Authorization header.
|
|
202
|
+
* 4. No argv position may be a URL with `user:pass@host` userinfo.
|
|
203
|
+
* 5. No forbidden env key (GH_TOKEN, GITHUB_TOKEN, etc.) may be present.
|
|
204
|
+
*
|
|
205
|
+
* @param {{command:string, args:string[], env:Record<string,string>}} invocation
|
|
206
|
+
* @returns {{ok:boolean, violation:(string|null)}}
|
|
207
|
+
*/
|
|
208
|
+
// TB-10 — see E20-S13 AC4: credentials must never appear in argv or env.
|
|
209
|
+
export function verifyAuthHygiene(invocation) {
|
|
210
|
+
const { command, args, env } = invocation || {};
|
|
211
|
+
const argList = Array.isArray(args) ? args : [];
|
|
212
|
+
const envMap = env && typeof env === "object" ? env : {};
|
|
213
|
+
|
|
214
|
+
// Rule 1: disallowed commands for GitHub API interactions.
|
|
215
|
+
if (command === "curl" || command === "wget") {
|
|
216
|
+
// Check if the invocation targets GitHub.
|
|
217
|
+
const joined = argList.join(" ");
|
|
218
|
+
if (
|
|
219
|
+
/github\.com/i.test(joined) ||
|
|
220
|
+
/api\.github\.com/i.test(joined) ||
|
|
221
|
+
AUTH_HEADER_PATTERN.test(joined)
|
|
222
|
+
) {
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
violation: `curl/wget may not be used for GitHub API calls — use 'gh' or 'gh api'. Command: ${command}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Rules 2-4: scan every argv position.
|
|
231
|
+
for (const arg of argList) {
|
|
232
|
+
if (typeof arg !== "string") continue;
|
|
233
|
+
if (GITHUB_TOKEN_PATTERN.test(arg)) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
violation: `token-like value present in command arguments: <redacted>`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (AUTH_HEADER_PATTERN.test(arg)) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
violation: `Authorization header present in command arguments — move to gh auth keyring`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (URL_USERINFO_PATTERN.test(arg)) {
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
violation: `URL embeds userinfo credentials — use gh auth keyring instead`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Rule 5: forbidden env vars.
|
|
254
|
+
for (const key of Object.keys(envMap)) {
|
|
255
|
+
if (FORBIDDEN_ENV_KEYS.has(key)) {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
violation: `credential env var ${key} set — gh CLI must use its keyring, not process env`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { ok: true, violation: null };
|
|
264
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promotion Chain Environment Resolver (E20-S10)
|
|
3
|
+
*
|
|
4
|
+
* Cross-references `test-environment.yaml` tier entries with
|
|
5
|
+
* `ci_cd.promotion_chain[]` entries declared in `global.yaml`, enriching the
|
|
6
|
+
* Test Execution Bridge context with CI provider, branch, and ci_checks when
|
|
7
|
+
* a valid mapping exists.
|
|
8
|
+
*
|
|
9
|
+
* Architecture references:
|
|
10
|
+
* - ADR-028: Test Execution Bridge Protocol
|
|
11
|
+
* - ADR-033: Multi-Environment Promotion Chain
|
|
12
|
+
* - FR-244 / MPC-39: E17 Tier Mapping — Test Environment Integration
|
|
13
|
+
* - NFR-045: Backward compatibility (no ci_cd block → silent ignore)
|
|
14
|
+
*
|
|
15
|
+
* Design principles:
|
|
16
|
+
* - Opt-in at both layers: the caller must provide `global.ci_cd.promotion_chain`
|
|
17
|
+
* AND a tier entry must set `promotion_chain_env_id`. Missing either layer
|
|
18
|
+
* falls back to the pre-E20 tier-local resolution path.
|
|
19
|
+
* - WARNING-level diagnostics — never HALTs. Orphaned references are
|
|
20
|
+
* ignored and the bridge falls back to tier-local config.
|
|
21
|
+
* - Silent backward compat: when `ci_cd` is absent from global.yaml, all
|
|
22
|
+
* `promotion_chain_env_id` fields are ignored with no warning.
|
|
23
|
+
*
|
|
24
|
+
* @module promotion-chain-env-resolver
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Look up a promotion chain entry by id.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} envId — The id to search for (from tier entry).
|
|
31
|
+
* @param {object} globalConfig — Parsed global.yaml content.
|
|
32
|
+
* @returns {object|null} The matching chain entry, or null if not found.
|
|
33
|
+
*/
|
|
34
|
+
export function resolvePromotionChainEnv(envId, globalConfig) {
|
|
35
|
+
if (!envId || !globalConfig) return null;
|
|
36
|
+
const chain = globalConfig?.ci_cd?.promotion_chain;
|
|
37
|
+
if (!Array.isArray(chain) || chain.length === 0) return null;
|
|
38
|
+
return chain.find((entry) => entry && entry.id === envId) || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the bridge execution context for a tier entry, optionally enriched
|
|
43
|
+
* from the promotion chain.
|
|
44
|
+
*
|
|
45
|
+
* Behavior matrix:
|
|
46
|
+
* 1. No `promotion_chain_env_id` on the tier entry → pre-E20 fallback,
|
|
47
|
+
* no warnings, no enrichment.
|
|
48
|
+
* 2. `promotion_chain_env_id` set but `global.ci_cd` absent → silent ignore
|
|
49
|
+
* (NFR-045 backward compat), id recorded on context for audit.
|
|
50
|
+
* 3. `promotion_chain_env_id` set and matches a chain entry → enrich the
|
|
51
|
+
* context with ci_provider, branch, and ci_checks from the chain entry.
|
|
52
|
+
* 4. `promotion_chain_env_id` set but does NOT match any chain entry
|
|
53
|
+
* (orphan) → emit WARNING naming the tier, orphaned id, and known ids;
|
|
54
|
+
* fall back to tier-local config.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} tierEntry — A single runner/tier definition from test-environment.yaml.
|
|
57
|
+
* @param {object} globalConfig — Parsed global.yaml content.
|
|
58
|
+
* @returns {{
|
|
59
|
+
* promotion_chain_env_id: (string|null),
|
|
60
|
+
* ci_provider: (string|undefined),
|
|
61
|
+
* branch: (string|undefined),
|
|
62
|
+
* ci_checks: (string[]|undefined),
|
|
63
|
+
* warnings: string[]
|
|
64
|
+
* }}
|
|
65
|
+
*/
|
|
66
|
+
export function resolveBridgeContext(tierEntry, globalConfig) {
|
|
67
|
+
const context = {
|
|
68
|
+
promotion_chain_env_id: null,
|
|
69
|
+
warnings: [],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (!tierEntry || typeof tierEntry !== "object") {
|
|
73
|
+
return context;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const envId = tierEntry.promotion_chain_env_id;
|
|
77
|
+
|
|
78
|
+
// Case 1: field not set / null — pre-E20 fallback, no warnings
|
|
79
|
+
if (envId === undefined || envId === null || envId === "") {
|
|
80
|
+
return context;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Record the id on context regardless of resolution outcome — used by
|
|
84
|
+
// evidence JSON for audit (even orphaned references are surfaced).
|
|
85
|
+
context.promotion_chain_env_id = envId;
|
|
86
|
+
|
|
87
|
+
// Case 2: ci_cd absent → silent ignore (NFR-045)
|
|
88
|
+
const ciCd = globalConfig && globalConfig.ci_cd;
|
|
89
|
+
if (!ciCd) {
|
|
90
|
+
return context;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const chain = ciCd.promotion_chain;
|
|
94
|
+
|
|
95
|
+
// Case 2b: ci_cd present but promotion_chain not defined → treat like
|
|
96
|
+
// no ci_cd (silent). This guards partial ci_cd blocks.
|
|
97
|
+
if (chain === undefined || chain === null) {
|
|
98
|
+
return context;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Case 4: empty chain or no match → orphan WARNING
|
|
102
|
+
if (!Array.isArray(chain) || chain.length === 0) {
|
|
103
|
+
context.warnings.push(formatOrphanWarning(tierEntry, envId, []));
|
|
104
|
+
return context;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const match = chain.find((entry) => entry && entry.id === envId);
|
|
108
|
+
|
|
109
|
+
if (!match) {
|
|
110
|
+
const knownIds = chain.map((e) => (e && e.id) || "?").filter(Boolean);
|
|
111
|
+
context.warnings.push(formatOrphanWarning(tierEntry, envId, knownIds));
|
|
112
|
+
return context;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Case 3: valid mapping — enrich context
|
|
116
|
+
context.ci_provider = match.ci_provider;
|
|
117
|
+
context.branch = match.branch;
|
|
118
|
+
context.ci_checks = Array.isArray(match.ci_checks) ? [...match.ci_checks] : undefined;
|
|
119
|
+
|
|
120
|
+
return context;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Format a human-readable orphaned-id warning.
|
|
125
|
+
*
|
|
126
|
+
* @param {object} tierEntry — Tier entry (for tier name).
|
|
127
|
+
* @param {string} orphanId — The orphaned promotion_chain_env_id value.
|
|
128
|
+
* @param {string[]} knownIds — The list of ids present in the chain.
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
function formatOrphanWarning(tierEntry, orphanId, knownIds) {
|
|
132
|
+
const tierName = (tierEntry && tierEntry.name) || `tier-${tierEntry?.tier ?? "?"}`;
|
|
133
|
+
const knownList = knownIds.length > 0 ? `[${knownIds.join(", ")}]` : "[]";
|
|
134
|
+
return (
|
|
135
|
+
`WARNING [test-environment.yaml]: tier '${tierName}' references ` +
|
|
136
|
+
`promotion_chain_env_id '${orphanId}' which does not exist in ` +
|
|
137
|
+
`ci_cd.promotion_chain (known ids: ${knownList}). ` +
|
|
138
|
+
`Mapping ignored. Falling back to tier-local config.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test-environment.yaml Schema Validator
|
|
3
|
+
*
|
|
4
|
+
* Validates the test-environment.yaml manifest against the schema
|
|
5
|
+
* defined in architecture section 10.20.5 (ADR-028, FR-196).
|
|
6
|
+
*
|
|
7
|
+
* Design:
|
|
8
|
+
* - Emits WARNINGs on schema violations — never throws errors.
|
|
9
|
+
* - Missing or empty file is treated as valid (auto-discovery fallback).
|
|
10
|
+
* - Includes a minimal YAML parser sufficient for the manifest schema.
|
|
11
|
+
*
|
|
12
|
+
* @module test-environment-validator
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ─── Minimal YAML Parser ────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a scalar YAML value into its JS equivalent.
|
|
19
|
+
* Handles booleans, null, integers, floats, and quoted strings.
|
|
20
|
+
*/
|
|
21
|
+
function parseScalar(raw) {
|
|
22
|
+
if (raw === "true") return true;
|
|
23
|
+
if (raw === "false") return false;
|
|
24
|
+
if (raw === "null" || raw === "~") return null;
|
|
25
|
+
if (/^\d+$/.test(raw)) return parseInt(raw, 10);
|
|
26
|
+
if (/^\d+\.\d+$/.test(raw)) return parseFloat(raw);
|
|
27
|
+
// Strip surrounding quotes
|
|
28
|
+
if (
|
|
29
|
+
(raw.startsWith('"') && raw.endsWith('"')) ||
|
|
30
|
+
(raw.startsWith("'") && raw.endsWith("'"))
|
|
31
|
+
) {
|
|
32
|
+
return raw.slice(1, -1);
|
|
33
|
+
}
|
|
34
|
+
return raw;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse an inline YAML flow sequence like `[a, b, c]` into an array of strings.
|
|
39
|
+
*/
|
|
40
|
+
function parseFlowSequence(raw) {
|
|
41
|
+
return raw
|
|
42
|
+
.slice(1, -1)
|
|
43
|
+
.split(",")
|
|
44
|
+
.map((s) => s.trim());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Flush the current nested structure (list or map) into the result object.
|
|
49
|
+
*/
|
|
50
|
+
function flushNested(result, key, isList, list, isMap, map) {
|
|
51
|
+
if (isList && key) result[key] = list;
|
|
52
|
+
else if (isMap && key) result[key] = map;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse a simple YAML string into a JS object.
|
|
57
|
+
*
|
|
58
|
+
* Supports: top-level scalars, lists of scalars, lists of maps (one level),
|
|
59
|
+
* nested maps (two levels), and inline flow sequences `[a, b]`.
|
|
60
|
+
*
|
|
61
|
+
* Limitations: does not support multi-line strings, anchors, aliases, or
|
|
62
|
+
* deeply nested structures. This is intentional — the manifest schema is flat.
|
|
63
|
+
*/
|
|
64
|
+
function parseSimpleYaml(text) {
|
|
65
|
+
const result = {};
|
|
66
|
+
const lines = text.split("\n");
|
|
67
|
+
let currentKey = null;
|
|
68
|
+
let currentList = null;
|
|
69
|
+
let currentMap = null;
|
|
70
|
+
let currentMapKey = null;
|
|
71
|
+
let inList = false;
|
|
72
|
+
let inMap = false;
|
|
73
|
+
|
|
74
|
+
for (const rawLine of lines) {
|
|
75
|
+
const line = rawLine.replace(/#.*$/, "").trimEnd();
|
|
76
|
+
if (line.trim() === "") continue;
|
|
77
|
+
|
|
78
|
+
const indent = line.length - line.trimStart().length;
|
|
79
|
+
|
|
80
|
+
// ── Top-level key ──────────────────────────────────────────
|
|
81
|
+
if (indent === 0 && line.includes(":")) {
|
|
82
|
+
flushNested(result, currentKey, inList, currentList, inMap, currentMap);
|
|
83
|
+
inList = false;
|
|
84
|
+
inMap = false;
|
|
85
|
+
currentList = null;
|
|
86
|
+
currentMap = null;
|
|
87
|
+
currentMapKey = null;
|
|
88
|
+
|
|
89
|
+
const colonIdx = line.indexOf(":");
|
|
90
|
+
const key = line.substring(0, colonIdx).trim();
|
|
91
|
+
const val = line.substring(colonIdx + 1).trim();
|
|
92
|
+
currentKey = key;
|
|
93
|
+
|
|
94
|
+
if (val === "") continue; // value on subsequent lines
|
|
95
|
+
result[key] = parseScalar(val);
|
|
96
|
+
currentKey = null;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── List item (dash prefix) ────────────────────────────────
|
|
101
|
+
if (line.trimStart().startsWith("- ") && currentKey) {
|
|
102
|
+
if (!inList && !inMap) {
|
|
103
|
+
inList = true;
|
|
104
|
+
currentList = [];
|
|
105
|
+
}
|
|
106
|
+
if (!inList) continue;
|
|
107
|
+
|
|
108
|
+
const itemText = line.trimStart().substring(2).trim();
|
|
109
|
+
if (itemText.includes(":")) {
|
|
110
|
+
const colonIdx = itemText.indexOf(":");
|
|
111
|
+
const k = itemText.substring(0, colonIdx).trim();
|
|
112
|
+
const v = itemText.substring(colonIdx + 1).trim();
|
|
113
|
+
currentList.push({ [k]: parseScalar(v) });
|
|
114
|
+
} else {
|
|
115
|
+
currentList.push(parseScalar(itemText));
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Indented key: value ────────────────────────────────────
|
|
121
|
+
if (indent > 0 && line.includes(":") && currentKey) {
|
|
122
|
+
const trimmed = line.trimStart();
|
|
123
|
+
const colonIdx = trimmed.indexOf(":");
|
|
124
|
+
const k = trimmed.substring(0, colonIdx).trim();
|
|
125
|
+
const v = trimmed.substring(colonIdx + 1).trim();
|
|
126
|
+
|
|
127
|
+
// Inside a list entry — append properties to the last entry
|
|
128
|
+
if (inList && currentList && currentList.length > 0) {
|
|
129
|
+
const lastEntry = currentList[currentList.length - 1];
|
|
130
|
+
if (typeof lastEntry === "object" && lastEntry !== null) {
|
|
131
|
+
lastEntry[k] =
|
|
132
|
+
v.startsWith("[") && v.endsWith("]")
|
|
133
|
+
? parseFlowSequence(v)
|
|
134
|
+
: v === ""
|
|
135
|
+
? {}
|
|
136
|
+
: parseScalar(v);
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Nested map
|
|
142
|
+
if (!inMap) {
|
|
143
|
+
inMap = true;
|
|
144
|
+
currentMap = {};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (v === "" || v.startsWith("{")) {
|
|
148
|
+
// Sub-map key (e.g., tiers.1)
|
|
149
|
+
currentMapKey = k;
|
|
150
|
+
if (!currentMap[k]) currentMap[k] = {};
|
|
151
|
+
} else if (v.startsWith("[") && v.endsWith("]")) {
|
|
152
|
+
const target =
|
|
153
|
+
currentMapKey && currentMap[currentMapKey]
|
|
154
|
+
? currentMap[currentMapKey]
|
|
155
|
+
: currentMap;
|
|
156
|
+
target[k] = parseFlowSequence(v);
|
|
157
|
+
} else {
|
|
158
|
+
const target =
|
|
159
|
+
currentMapKey && currentMap[currentMapKey]
|
|
160
|
+
? currentMap[currentMapKey]
|
|
161
|
+
: currentMap;
|
|
162
|
+
target[k] = parseScalar(v);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
flushNested(result, currentKey, inList, currentList, inMap, currentMap);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Runner Validation ──────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
const RUNNER_REQUIRED_FIELDS = ["name", "command", "tier"];
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Validate a single runner entry and return any warning messages.
|
|
177
|
+
* @param {object} runner — Parsed runner object
|
|
178
|
+
* @param {number} index — Position in the runners array (for error messages)
|
|
179
|
+
* @returns {string[]} Warning messages (empty if valid)
|
|
180
|
+
*/
|
|
181
|
+
function validateRunnerEntry(runner, index) {
|
|
182
|
+
const warnings = [];
|
|
183
|
+
if (typeof runner !== "object" || runner === null) {
|
|
184
|
+
warnings.push(`Runner entry [${index}] is not a valid map.`);
|
|
185
|
+
return warnings;
|
|
186
|
+
}
|
|
187
|
+
for (const field of RUNNER_REQUIRED_FIELDS) {
|
|
188
|
+
if (runner[field] === undefined || runner[field] === null || runner[field] === "") {
|
|
189
|
+
warnings.push(
|
|
190
|
+
`Runner entry [${index}] is missing required field: '${field}'.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return warnings;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Public API ─────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @typedef {Object} ValidationResult
|
|
201
|
+
* @property {boolean} valid — true if no warnings (or file absent/empty)
|
|
202
|
+
* @property {string[]} warnings — list of WARNING-level schema violations
|
|
203
|
+
* @property {string} [info] — informational message (e.g., auto-discovery fallback)
|
|
204
|
+
*/
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Validate test-environment.yaml content against the manifest schema.
|
|
208
|
+
*
|
|
209
|
+
* @param {string|null} content — Raw YAML content, null if file absent, or empty string.
|
|
210
|
+
* @param {object} [options] — Optional cross-validation context.
|
|
211
|
+
* @param {object} [options.globalConfig] — Parsed global.yaml content. When provided
|
|
212
|
+
* and `ci_cd.promotion_chain` exists, the validator cross-checks every
|
|
213
|
+
* `promotion_chain_env_id` against chain ids and emits an orphan warning
|
|
214
|
+
* if the id is not present (E20-S10, AC4).
|
|
215
|
+
* @returns {ValidationResult}
|
|
216
|
+
*/
|
|
217
|
+
export function validateTestEnvironment(content, options = {}) {
|
|
218
|
+
// AC5: Missing file is not an error — fallback to auto-discovery
|
|
219
|
+
if (content === null || content === undefined || content.trim() === "") {
|
|
220
|
+
return {
|
|
221
|
+
valid: true,
|
|
222
|
+
warnings: [],
|
|
223
|
+
info: "No test-environment.yaml found — using auto-discovery fallback (FR-196).",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const warnings = [];
|
|
228
|
+
|
|
229
|
+
let parsed;
|
|
230
|
+
try {
|
|
231
|
+
parsed = parseSimpleYaml(content);
|
|
232
|
+
} catch {
|
|
233
|
+
return {
|
|
234
|
+
valid: false,
|
|
235
|
+
warnings: [
|
|
236
|
+
"Failed to parse test-environment.yaml — invalid YAML syntax.",
|
|
237
|
+
],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// AC2: Required field — version
|
|
242
|
+
if (parsed.version === undefined || parsed.version === null) {
|
|
243
|
+
warnings.push(
|
|
244
|
+
"Missing required field: 'version'. Expected an integer (e.g., version: 1)."
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// AC2: Required field — runners (list of maps with name, command, tier)
|
|
249
|
+
if (!parsed.runners || !Array.isArray(parsed.runners)) {
|
|
250
|
+
warnings.push(
|
|
251
|
+
"Missing required field: 'runners'. Expected a list of runner entries with name, command, and tier."
|
|
252
|
+
);
|
|
253
|
+
} else {
|
|
254
|
+
for (let i = 0; i < parsed.runners.length; i++) {
|
|
255
|
+
warnings.push(...validateRunnerEntry(parsed.runners[i], i));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// E20-S10 AC4: Cross-validate promotion_chain_env_id references against
|
|
260
|
+
// the promotion chain when the caller provides a globalConfig. When
|
|
261
|
+
// `ci_cd` is absent (AC6 backward compat), this check is skipped entirely
|
|
262
|
+
// — promotion_chain_env_id fields are silently ignored.
|
|
263
|
+
const globalConfig = options && options.globalConfig;
|
|
264
|
+
const chain = globalConfig?.ci_cd?.promotion_chain;
|
|
265
|
+
|
|
266
|
+
if (Array.isArray(chain) && Array.isArray(parsed.runners)) {
|
|
267
|
+
const knownIds = chain
|
|
268
|
+
.map((entry) => (entry && entry.id ? entry.id : null))
|
|
269
|
+
.filter((id) => id !== null);
|
|
270
|
+
|
|
271
|
+
for (const runner of parsed.runners) {
|
|
272
|
+
if (!runner || typeof runner !== "object") continue;
|
|
273
|
+
const envId = runner.promotion_chain_env_id;
|
|
274
|
+
if (envId === undefined || envId === null || envId === "" || envId === "null") {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!knownIds.includes(envId)) {
|
|
278
|
+
const tierName = runner.name || `tier-${runner.tier ?? "?"}`;
|
|
279
|
+
warnings.push(
|
|
280
|
+
`WARNING [test-environment.yaml]: tier '${tierName}' references ` +
|
|
281
|
+
`promotion_chain_env_id '${envId}' which does not exist in ` +
|
|
282
|
+
`ci_cd.promotion_chain (known ids: [${knownIds.join(", ")}]).`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
valid: warnings.length === 0,
|
|
290
|
+
warnings,
|
|
291
|
+
};
|
|
292
|
+
}
|