yam-flow 2.8.3 → 2.8.5

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.
@@ -0,0 +1,348 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ const digest = (x: string | Buffer) => `sha256:${createHash('sha256').update(x).digest('hex')}`;
7
+ const stable = (x: any): string => Array.isArray(x) ? `[${x.map(stable).join(',')}]` : x && typeof x === 'object'
8
+ ? `{${Object.keys(x).sort().map(k => `${JSON.stringify(k)}:${stable(x[k])}`).join(',')}}` : JSON.stringify(x);
9
+ const seal = (x: any) => digest(stable(x));
10
+ const ID = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,159}$/;
11
+ const OID = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/;
12
+ const MAX = 4 * 1024 * 1024;
13
+ const STAGES = ['complete', 'commit', 'push', 'ci', 'deploy'];
14
+ export const TEST_REQUIREMENTS = {
15
+ docs: ['inspection'], logic: ['success', 'invalid', 'boundary'], api: ['success', 'invalid', 'contract'],
16
+ db: ['success', 'denied', 'invalid', 'readback', 'rollback', 'migration_upgrade', 'migration_fresh'],
17
+ auth: ['success', 'denied', 'cross_scope', 'revoked'], ui: ['success', 'error'],
18
+ release: ['artifact', 'integrity']
19
+ } as const;
20
+ type Risk = keyof typeof TEST_REQUIREMENTS;
21
+ type Check = { id: string; target: string; scenario: string; explanation: string; evidence: 'static' | 'unit' | 'integration' | 'e2e'; command: string[]; environment?: string };
22
+ type Surface = { id: string; paths: string[]; risks: Risk[]; targets: string[]; checks: Check[] };
23
+ type Policy = { schema: 'yam.test-policy.v1'; surfaces: Surface[] };
24
+ type Measurement = { check_id: string; command_digest: string; exit_code: number; output_digest: string; observation: any; errors: string[] };
25
+ const POLICY = 'testing/policy.json';
26
+ function policyPath(root: string, supplied?: string) { return supplied || (fs.existsSync(path.join(root, POLICY)) || git(root, ['ls-files', '--', POLICY]).trim() ? POLICY : '.yam/testing/policy.json'); }
27
+ const unsafe = (v: unknown) => /[\x00-\x1f\x7f<>]|gh[pousr]_[A-Za-z0-9]{20}|AKIA[A-Z0-9]{16}|-----BEGIN.*PRIVATE KEY|(?:token|password|api[_-]?key|secret)\s*[:=]\s*\S{8,}/i.test(String(v));
28
+ function fail(code: string): never { throw new Error(code); }
29
+ function exact(x: any, keys: string[]) {
30
+ if (!x || typeof x !== 'object' || Array.isArray(x) || Object.keys(x).some(k => !keys.includes(k))) fail('invalid_fields');
31
+ }
32
+ function rel(x: string) {
33
+ if (typeof x !== 'string' || !x || path.isAbsolute(x) || /[\\\x00-\x1f]/.test(x) || x.split('/').some(p => ['..', '.', ''].includes(p))) fail('unsafe_path');
34
+ return x;
35
+ }
36
+ function regular(file: string, max = MAX): Buffer {
37
+ let p = path.parse(path.resolve(file)).root;
38
+ for (const s of path.resolve(file).slice(p.length).split(path.sep).filter(Boolean)) {
39
+ p = path.join(p, s); if (fs.lstatSync(p).isSymbolicLink()) fail('symlink_rejected');
40
+ }
41
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
42
+ try { const s = fs.fstatSync(fd); if (!s.isFile() || s.size > max || s.nlink !== 1) fail('unsafe_file'); return fs.readFileSync(fd); }
43
+ finally { fs.closeSync(fd); }
44
+ }
45
+ function git(root: string, args: string[]): string {
46
+ const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', timeout: 15000, maxBuffer: 16 * MAX, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } });
47
+ if (r.status !== 0 || r.error) fail('git_unavailable'); return r.stdout;
48
+ }
49
+ function gitBytes(root: string, args: string[]) {
50
+ const r = spawnSync('git', ['-C', root, ...args], { timeout: 15000, maxBuffer: 32 * MAX });
51
+ if (r.status !== 0 || r.error) fail('git_unavailable'); return r.stdout;
52
+ }
53
+ function rootOf(cwd: string) { return fs.realpathSync(git(cwd, ['rev-parse', '--show-toplevel']).trim()); }
54
+ const excluded = (f: string) => /^(?:\.git|\.yam|\.codex|\.agents|node_modules|dist|coverage|build|out|\.cache|\.next)(?:\/|$)/.test(f) || /\.(?:tgz|log)$/.test(f);
55
+ const match = (f: string, p: string) => p.endsWith('/**') ? f.startsWith(p.slice(0, -2)) : f === p;
56
+ function snapshot(root: string, ref?: string) {
57
+ const files: Record<string, string> = Object.create(null); let total = 0;
58
+ if (ref) {
59
+ if (ref !== 'INDEX' && (!OID.test(ref) || git(root, ['cat-file', '-t', ref]).trim() !== 'commit')) fail('exact_revision_required');
60
+ const rows = ref === 'INDEX' ? git(root, ['ls-files', '--stage', '-z']).split('\0').filter(Boolean).map(row => {
61
+ const tab = row.indexOf('\t'), [mode, oid, stage] = row.slice(0,tab).split(' ');
62
+ if (stage !== '0') fail('unmerged_index'); return `${mode} blob ${oid}\t${row.slice(tab+1)}`;
63
+ }) : git(root, ['ls-tree', '-rz', ref]).split('\0').filter(Boolean);
64
+ for (const row of rows) {
65
+ const tab = row.indexOf('\t'), f = row.slice(tab + 1); if (excluded(f)) continue; rel(f);
66
+ if (!/^(100644|100755) blob /.test(row)) fail('unsupported_source_entry');
67
+ const oid = row.slice(0, tab).split(' ')[2];
68
+ const bytes = gitBytes(root, ['cat-file', 'blob', oid]); total += bytes.length;
69
+ if (total > 128 * MAX) fail('source_limit'); files[f] = seal([row.slice(0, 6), digest(bytes)]);
70
+ }
71
+ } else {
72
+ for (const f of [...new Set(git(root, ['ls-files', '-co', '--exclude-standard', '-z']).split('\0').filter(Boolean))].sort()) {
73
+ if (excluded(f)) continue; rel(f); const file = path.join(root, f);
74
+ try { fs.lstatSync(file); } catch (e: any) { if (e.code === 'ENOENT') continue; throw e; }
75
+ const bytes = regular(file, 32 * MAX); total += bytes.length; if (total > 128 * MAX) fail('source_limit');
76
+ files[f] = seal([(fs.statSync(file).mode & 0o111) ? '100755' : '100644', digest(bytes)]);
77
+ }
78
+ }
79
+ if (Object.keys(files).length > 10000) fail('source_limit');
80
+ return { files, digest: seal(files) };
81
+ }
82
+ function readPolicy(root: string, file: string, pin?: string): { policy: Policy; digest: string } {
83
+ const bytes = regular(path.join(root, rel(file))), value = JSON.parse(bytes.toString());
84
+ if (pin && (!/^sha256:[a-f0-9]{64}$/.test(pin) || digest(bytes) !== pin)) fail('policy_pin_mismatch');
85
+ exact(value, ['schema', 'surfaces']);
86
+ if (value.schema !== 'yam.test-policy.v1' || !Array.isArray(value.surfaces) || !value.surfaces.length || value.surfaces.length > 128) fail('invalid_policy');
87
+ if (value.surfaces.reduce((n,s) => n + (Array.isArray(s?.checks) ? s.checks.length : 0), 0) > 256) fail('policy_limit');
88
+ const ids = new Set<string>();
89
+ for (const s of value.surfaces) {
90
+ exact(s, ['id', 'paths', 'risks', 'targets', 'checks']);
91
+ if (typeof s.id !== 'string' || !ID.test(s.id) || ids.has(s.id)) fail('duplicate_surface'); ids.add(s.id);
92
+ if (!Array.isArray(s.paths) || !s.paths.length || s.paths.length > 128) fail('invalid_paths');
93
+ for (const p of s.paths) { rel(p); if (p.replace(/\/\*\*$/, '').includes('*') || excluded(p)) fail('invalid_paths'); }
94
+ if (!Array.isArray(s.risks) || !s.risks.length || s.risks.length > 7 || s.risks.some(r => !Object.hasOwn(TEST_REQUIREMENTS, r))) fail('invalid_risks');
95
+ if (!Array.isArray(s.targets) || !s.targets.length || s.targets.length > 128 || new Set(s.targets).size !== s.targets.length || s.targets.some(t => typeof t !== 'string' || !ID.test(t))) fail('invalid_targets');
96
+ if (!Array.isArray(s.checks) || s.checks.length > 256) fail('invalid_checks');
97
+ for (const c of s.checks) {
98
+ exact(c, ['id', 'target', 'scenario', 'explanation', 'evidence', 'command', 'environment']);
99
+ if (typeof c.id !== 'string' || !ID.test(c.id) || ids.has(c.id)) fail('duplicate_check'); ids.add(c.id);
100
+ if (!s.targets.includes(c.target) || typeof c.scenario !== 'string' || !ID.test(c.scenario) || typeof c.explanation !== 'string' || !c.explanation.trim() || c.explanation.length > 500 || unsafe(c.explanation)) fail('invalid_check');
101
+ if (!['static', 'unit', 'integration', 'e2e'].includes(c.evidence)) fail('invalid_evidence');
102
+ if (!Array.isArray(c.command) || !c.command.length || c.command.length > 64 || c.command.some(a => typeof a !== 'string' || !a || a.length > 2048 || unsafe(a))) fail('invalid_command');
103
+ if (c.environment) rel(c.environment);
104
+ }
105
+ }
106
+ return { policy: value, digest: digest(bytes) };
107
+ }
108
+ function infer(file: string): Risk[] {
109
+ if (/\.sql$|(?:^|\/)(?:migrations|database|db)(?:\/|\.)/i.test(file)) return ['db'];
110
+ const risks: Risk[] = [];
111
+ if (/(?:^|\/)(?:auth|permissions?|sessions?|rls)(?:[./-]|$)/i.test(file) && !/\.md$/.test(file)) risks.push('auth');
112
+ if (/^(?:\.github\/workflows\/)|^(?:package(?:-lock)?\.json|yam\.manifest\.json)$/.test(file)) risks.push('release');
113
+ if (/(?:^|\/)(?:api|routes)(?:\/|\.)/.test(file)) risks.push('api');
114
+ if (/\.(?:tsx|jsx|vue|svelte)$/.test(file)) risks.push('ui');
115
+ return risks.length ? risks : /\.(?:md|txt|rst)$/.test(file) ? ['docs'] : ['logic'];
116
+ }
117
+ export function planTestCheck(root: string, policy: Policy, base: string) {
118
+ const now = snapshot(root), old = snapshot(root, base);
119
+ const changed = [...new Set([...Object.keys(now.files), ...Object.keys(old.files)])].filter(f => now.files[f] !== old.files[f]).sort();
120
+ const errors: string[] = [], selected = new Map<string, Check>();
121
+ const surfaces = policy.surfaces.filter(s => changed.some(f => s.paths.some(p => match(f, p))));
122
+ for (const file of changed) {
123
+ const owners = surfaces.filter(s => s.paths.some(p => match(file, p)));
124
+ if (!owners.length) { errors.push('unmapped_changed_file'); continue; }
125
+ for (const risk of infer(file)) {
126
+ if (!owners.some(s => s.risks.includes(risk))) errors.push(`missing_risk:${risk}`);
127
+ }
128
+ // Bounded SQL discovery is supplementary; dynamic SQL and overloaded signatures need explicit review.
129
+ if (/\.sql$/i.test(file)) {
130
+ let sql = ''; try { sql = regular(path.join(root, file)).toString(); } catch { /* deleted objects are covered by baseline below */ }
131
+ try { sql += '\n' + git(root, ['show', `${base}:${file}`]); } catch { /* new file */ }
132
+ const targets = [...sql.matchAll(/\b(?:CREATE\s+(?:OR\s+REPLACE\s+)?|ALTER\s+|DROP\s+)(FUNCTION|PROCEDURE|POLICY|TRIGGER)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?([a-zA-Z_][\w.]*)/gi)].map(m => `${m[1].toLowerCase()}:${m[2].toLowerCase()}`);
133
+ for (const target of new Set(targets)) if (!owners.some(s => s.risks.includes('db') && s.targets.includes(target))) errors.push('unmapped_sql_object');
134
+ if (/\bEXECUTE\s+(?!ON\b)|"/i.test(sql)) errors.push('sql_manual_review_required');
135
+ }
136
+ }
137
+ for (const s of surfaces) {
138
+ const required = [...new Set(s.risks.flatMap(r => [...TEST_REQUIREMENTS[r]]))];
139
+ for (const target of s.targets) for (const scenario of required) {
140
+ const candidates = s.checks.filter(c => c.target === target && c.scenario === scenario);
141
+ if (!candidates.length) errors.push(`missing_scenario:${scenario}`);
142
+ candidates.forEach(c => selected.set(c.id, c));
143
+ }
144
+ for (const c of s.checks) selected.set(c.id, c);
145
+ if (s.risks.some(r => ['db', 'auth', 'api', 'ui'].includes(r))) for (const c of s.checks) {
146
+ if (['static', 'unit'].includes(c.evidence)) errors.push(`real_evidence_required:${c.id}`);
147
+ if (s.risks.includes('db') && !c.environment) errors.push(`db_environment_required:${c.id}`);
148
+ }
149
+ }
150
+ return { changed, source_digest: now.digest, checks: [...selected.values()], errors: [...new Set(errors)] };
151
+ }
152
+ function environment(root: string, c: Check) {
153
+ if (!c.environment) return null;
154
+ const e = JSON.parse(regular(path.join(root, c.environment)).toString());
155
+ if (!e || e.schema !== 'yam.test-environment.v1' || typeof e.engine !== 'string' || !e.engine.trim()
156
+ || typeof e.engine_version !== 'string' || !e.engine_version.trim() || e.source_digest !== snapshot(root).digest
157
+ || !/^sha256:[a-f0-9]{64}$/.test(e.configuration_digest) || !/^sha256:[a-f0-9]{64}$/.test(e.roles_digest)
158
+ || !/^sha256:[a-f0-9]{64}$/.test(e.fixtures_digest) || !Array.isArray(e.migrations)) fail('invalid_environment');
159
+ const files = [...new Set(git(root, ['ls-files', '-co', '--exclude-standard', '-z']).split('\0').filter(f => f && !excluded(f)
160
+ && /(?:^|\/)migrations\//i.test(f) && fs.existsSync(path.join(root,f))))].sort();
161
+ if (files.length !== e.migrations.length || files.some((f,i) => e.migrations[i]?.file !== f
162
+ || e.migrations[i]?.sha256 !== digest(regular(path.join(root,rel(f)))))) fail('migration_inventory_mismatch');
163
+ return e;
164
+ }
165
+ function validateObservation(c: Check, m: Measurement, expected: any) {
166
+ const errors = Array.isArray(m.errors) && m.errors.every(e => typeof e === 'string' && /^[a-z_]+(?::[a-zA-Z0-9_.:-]+)?$/.test(e)) ? [...m.errors] : ['invalid_measurement']; const o = m.observation;
167
+ if (m.check_id !== c.id || m.command_digest !== seal(c.command) || m.exit_code !== 0) errors.push(`command_failed:${c.id}`);
168
+ if (!o || o.id !== c.id || o.target !== c.target || o.scenario !== c.scenario || o.evidence !== c.evidence || o.status !== 'passed'
169
+ || !Number.isSafeInteger(o.tests) || o.tests < 1 || !Number.isSafeInteger(o.assertions) || o.assertions < 1 || o.failed !== 0 || o.skipped !== 0) errors.push(`invalid_result:${c.id}`);
170
+ if (['integration', 'e2e'].includes(c.evidence) && o?.mocked !== false) errors.push(`mock_substitution:${c.id}`);
171
+ if (c.environment && (!expected || seal(o?.environment ?? null) !== seal(expected))) errors.push(`environment_mismatch:${c.id}`);
172
+ return errors;
173
+ }
174
+ function evaluate(checks: Check[], measurements: Measurement[], expected: Record<string, any>) {
175
+ const errors: string[] = [];
176
+ if (!Array.isArray(measurements) || measurements.length !== checks.length) return ['measurement_inventory_mismatch'];
177
+ for (const c of checks) {
178
+ const matches = measurements.filter(m => m.check_id === c.id);
179
+ if (matches.length !== 1) errors.push(`measurement_missing:${c.id}`);
180
+ else errors.push(...validateObservation(c, matches[0], expected[c.id]));
181
+ }
182
+ return [...new Set(errors)];
183
+ }
184
+
185
+ function deployment(root: string, artifact?: string, configuration?: string) {
186
+ if (!artifact || !configuration) fail('deployment_files_required');
187
+ return { artifact: rel(artifact), artifact_digest: digest(regular(path.join(root,rel(artifact)), 256 * MAX)),
188
+ configuration: rel(configuration), configuration_digest: digest(regular(path.join(root,rel(configuration)))) };
189
+ }
190
+
191
+ function privateDir(root: string) {
192
+ for (const part of ['.yam', '.yam/testing', '.yam/testing/runs']) {
193
+ const dir = path.join(root, part); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { mode: 0o700 });
194
+ const s = fs.lstatSync(dir); if (!s.isDirectory() || s.isSymbolicLink()) fail('unsafe_state_directory');
195
+ }
196
+ }
197
+ function writeNew(file: string, value: string) {
198
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
199
+ try { fs.writeFileSync(fd, value); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
200
+ }
201
+ function persist(root: string, r: any, checks: Check[]) {
202
+ privateDir(root); const lock = path.join(root, '.yam/testing/lock'); writeNew(lock, r.run_id);
203
+ try {
204
+ const note = path.join(root, '.yam/testing/notes.md');
205
+ if (fs.existsSync(note)) regular(note, 64 * MAX);
206
+ const lines = ['\n-- Test check --', `Run: ${r.run_id}; Time: ${r.time}`, `Stage: ${r.stage}`, `Policy: ${r.policy_digest || 'unavailable'}`, `Source: ${r.source_digest || 'unavailable'}`, ''];
207
+ checks.forEach((c, i) => { const m = r.measurements.find(x => x.check_id === c.id); lines.push(`${i + 1}. ${c.id} : "${c.explanation}" : ${!m ? '미검증' : m.errors.length ? '점검 실패' : '점검 완료 - 이상 없음'}`); });
208
+ lines.push(`Result: ${r.result}`, `Blocking: ${r.errors.join(', ') || 'none'}`, 'Evidence: adapter-reported assertions; process exit and source identity measured by yam', '-- End Test check --', '');
209
+ const fd = fs.openSync(note, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0), 0o600);
210
+ try { fs.writeFileSync(fd, lines.join('\n')); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
211
+ r.note_digest = digest(regular(note, 64 * MAX)); r.digest = seal(r);
212
+ writeNew(path.join(root, `.yam/testing/runs/${r.run_id}.json`), JSON.stringify(r, null, 2) + '\n');
213
+ const latest = path.join(root, '.yam/testing/latest.json'); if (fs.existsSync(latest)) regular(latest);
214
+ const temp = path.join(root, `.yam/testing/${randomUUID()}.tmp`);
215
+ try { writeNew(temp, JSON.stringify(r, null, 2) + '\n'); fs.renameSync(temp, latest); } finally { fs.rmSync(temp, { force: true }); }
216
+ } finally { fs.unlinkSync(lock); }
217
+ }
218
+ export function runTestCheck(cwd: string, options: { policy?: string; pin?: string; base?: string; stage?: string; source?: string; artifact?: string; configuration?: string } = {}) {
219
+ let root: string; try { root = rootOf(cwd); } catch { return { ok: false, result: 'BLOCKED', errors: ['git_unavailable'] }; }
220
+ const r: any = { schema: 'yam.test-check.v1', run_id: `test-${randomUUID()}`, time: new Date().toISOString(), stage: STAGES.includes(options.stage || 'complete') ? options.stage || 'complete' : 'unavailable', result: 'BLOCKED', errors: [], measurements: [], policy_digest: '', source_digest: '', base: '', expected_environments: {} };
221
+ let checks: Check[] = [];
222
+ try {
223
+ if (!['complete', 'commit', 'push', 'ci', 'deploy'].includes(r.stage)) fail('invalid_stage');
224
+ if (['ci', 'deploy'].includes(r.stage) && (!options.pin || !/^sha256:[a-f0-9]{64}$/.test(options.pin))) fail('trusted_policy_pin_required');
225
+ if (r.stage === 'deploy') r.deployment = deployment(root, options.artifact, options.configuration);
226
+ const p = readPolicy(root, policyPath(root, options.policy), options.pin); r.policy_digest = p.digest;
227
+ if (typeof options.base !== 'string' || !OID.test(options.base)) fail('exact_base_required'); r.base = options.base;
228
+ const plan = planTestCheck(root, p.policy, r.base); checks = plan.checks; r.source_digest = plan.source_digest; r.changed = plan.changed;
229
+ r.errors.push(...plan.errors); if (!plan.changed.length) r.errors.push('empty_change_scope');
230
+ for (const c of checks) r.expected_environments[c.id] = environment(root, c);
231
+ const executions = new Map<string, any>();
232
+ if (!r.errors.length) for (const c of checks) {
233
+ const key = seal(c.command); let execution = executions.get(key);
234
+ if (!execution) {
235
+ const command = c.command[0] === '$NODE' ? process.execPath : c.command[0];
236
+ const x = spawnSync(command, c.command.slice(1), { cwd: root, encoding: 'utf8', timeout: 180000, maxBuffer: MAX, shell: false,
237
+ env: { ...process.env, YAM_TEST_CHECK: '1', YAM_TEST_ARTIFACT: r.deployment?.artifact || '', YAM_TEST_CONFIGURATION: r.deployment?.configuration || '' } });
238
+ let body: any = null; try { body = JSON.parse(x.stdout); } catch { /* fail closed without recording raw output */ }
239
+ execution = { exit_code: x.error ? -1 : x.status ?? -1, output_digest: digest((x.stdout || '') + (x.stderr || '')),
240
+ tests: body?.schema === 'yam.test-observation.v1' && Array.isArray(body.tests) ? body.tests : [] };
241
+ executions.set(key, execution);
242
+ }
243
+ const matches = execution.tests.filter(o => o.id === c.id);
244
+ const o = matches.length === 1 ? matches[0] : null;
245
+ // Store only allowlisted facts. Never persist subprocess text, environment values or arbitrary report fields.
246
+ const observation = o ? { id: c.id, target: o.target === c.target ? c.target : '', scenario: o.scenario === c.scenario ? c.scenario : '',
247
+ evidence: o.evidence === c.evidence ? c.evidence : '', status: o.status === 'passed' ? 'passed' : 'failed',
248
+ tests: Number.isSafeInteger(o.tests) ? o.tests : 0, assertions: Number.isSafeInteger(o.assertions) ? o.assertions : 0,
249
+ failed: Number.isSafeInteger(o.failed) ? o.failed : -1, skipped: Number.isSafeInteger(o.skipped) ? o.skipped : -1, mocked: o.mocked === false ? false : true,
250
+ environment: o.environment == null ? null : seal(o.environment) } : null;
251
+ const expected = r.expected_environments[c.id];
252
+ // Persist environment hashes only, while comparing actual structured measurements before redaction.
253
+ const measurement: Measurement = { check_id: c.id, command_digest: key, exit_code: execution.exit_code, output_digest: execution.output_digest, observation: o, errors: [] };
254
+ measurement.errors = validateObservation(c, measurement, expected); measurement.observation = observation;
255
+ r.measurements.push(measurement);
256
+ }
257
+ r.expected_environments = Object.fromEntries(Object.entries(r.expected_environments).map(([k,v]) => [k, v == null ? null : seal(v)]));
258
+ r.errors.push(...r.measurements.flatMap(m => m.errors));
259
+ if (r.measurements.length !== checks.length) r.errors.push('measurement_inventory_mismatch');
260
+ if (snapshot(root).digest !== r.source_digest) r.errors.push('source_changed_during_tests');
261
+ if (readPolicy(root, policyPath(root, options.policy), options.pin).digest !== r.policy_digest) r.errors.push('policy_changed_during_tests');
262
+ for (const c of checks) if ((environment(root, c) == null ? null : seal(environment(root,c))) !== r.expected_environments[c.id]) r.errors.push('environment_changed_during_tests');
263
+ if (r.stage === 'commit' && snapshot(root, 'INDEX').digest !== r.source_digest) r.errors.push('staged_source_mismatch');
264
+ if (['push', 'ci', 'deploy'].includes(r.stage)) {
265
+ if (!options.source || !OID.test(options.source) || snapshot(root, options.source).digest !== r.source_digest) r.errors.push('source_revision_mismatch');
266
+ if (['ci', 'deploy'].includes(r.stage) && !options.pin) r.errors.push('trusted_policy_pin_required');
267
+ }
268
+ if (r.stage === 'deploy' && seal(deployment(root, options.artifact, options.configuration)) !== seal(r.deployment)) r.errors.push('deployment_changed_during_tests');
269
+ r.errors = [...new Set(r.errors)]; r.result = r.errors.length ? 'BLOCKED' : 'PASS';
270
+ } catch (error: any) { r.errors.push('test_check_setup_failed'); if (/^[a-z_]{1,80}$/.test(error?.message || '')) r.errors.push(error.message); }
271
+ // Environment values from an early setup failure must not escape into receipts.
272
+ r.expected_environments = Object.fromEntries(Object.entries(r.expected_environments).map(([k,v]) => [k, typeof v === 'string' && /^sha256:/.test(v) ? v : null]));
273
+ try { persist(root, r, checks); } catch { return { ok: false, result: 'BLOCKED', errors: [...r.errors, 'test_note_persistence_failed'] }; }
274
+ return { ok: r.result === 'PASS', result: r.result, errors: r.errors, run: r.run_id, note: '.yam/testing/notes.md', receipt: `.yam/testing/runs/${r.run_id}.json`, changed_files: r.changed?.length || 0, checks: checks.length };
275
+ }
276
+
277
+ export function checkTestCheck(cwd: string, options: { policy?: string; pin?: string; stage?: string; source?: string; receipt?: string; artifact?: string; configuration?: string; base?: string } = {}) {
278
+ try {
279
+ const root = rootOf(cwd), { policy, digest: policyDigest } = readPolicy(root, policyPath(root, options.policy), options.pin);
280
+ const r = JSON.parse(regular(path.join(root, rel(options.receipt || '.yam/testing/latest.json'))).toString());
281
+ const { digest: storedDigest, ...body } = r;
282
+ const errors: string[] = [];
283
+ if (r.schema !== 'yam.test-check.v1' || seal(body) !== storedDigest || r.result !== 'PASS' || !Array.isArray(r.errors) || r.errors.length) errors.push('invalid_receipt');
284
+ if (r.policy_digest !== policyDigest) errors.push('policy_changed');
285
+ if (options.base !== undefined && (typeof options.base !== 'string' || !OID.test(options.base) || r.base !== options.base)) errors.push('baseline_mismatch');
286
+ if (!Number.isFinite(Date.parse(r.time)) || Date.now() - Date.parse(r.time) < 0 || Date.now() - Date.parse(r.time) > 24 * 3600000) errors.push('receipt_expired');
287
+ if (snapshot(root).digest !== r.source_digest) errors.push('source_changed');
288
+ const plan = planTestCheck(root, policy, r.base); errors.push(...plan.errors); if (!plan.changed.length) errors.push('empty_change_scope');
289
+ const expected: any = {};
290
+ for (const c of plan.checks) { const e = environment(root,c); expected[c.id] = e == null ? null : seal(e); }
291
+ if (seal(expected) !== seal(r.expected_environments)) errors.push('environment_changed');
292
+ // Observations store only measured environment hashes, not raw values.
293
+ const checks = plan.checks.map(c => ({ ...c, environment: undefined }));
294
+ errors.push(...evaluate(checks, r.measurements, {}));
295
+ for (const c of plan.checks) if (c.environment && r.measurements.find(m => m.check_id === c.id)?.observation?.environment !== expected[c.id]) errors.push('environment_mismatch');
296
+ const note = regular(path.join(root, '.yam/testing/notes.md'), 64 * MAX).toString();
297
+ if (digest(Buffer.from(note)) !== r.note_digest || !note.includes(`Run: ${r.run_id};`) || !note.includes('-- End Test check --')) errors.push('note_missing');
298
+ const stage = options.stage || 'complete';
299
+ if (stage === 'deploy' && (r.stage !== 'deploy' || seal(deployment(root, options.artifact, options.configuration)) !== seal(r.deployment))) errors.push('deployment_identity_mismatch');
300
+ if (!['complete', 'commit', 'push', 'ci', 'deploy'].includes(stage)) errors.push('invalid_stage');
301
+ if (stage === 'commit' && snapshot(root, 'INDEX').digest !== r.source_digest) errors.push('staged_source_mismatch');
302
+ if (['push', 'ci', 'deploy'].includes(stage)) {
303
+ if (!options.source || !OID.test(options.source) || snapshot(root, options.source).digest !== r.source_digest) errors.push('source_revision_mismatch');
304
+ if (['ci', 'deploy'].includes(stage) && !options.pin) errors.push('trusted_policy_pin_required');
305
+ }
306
+ return { ok: errors.length === 0, result: errors.length ? 'BLOCKED' : 'PASS', errors: [...new Set(errors)], run: r.run_id, checks: plan.checks.length };
307
+ } catch { return { ok: false, result: 'BLOCKED', errors: ['test_check_unavailable'] }; }
308
+ }
309
+
310
+ export function testCheckConfigured(cwd: string) {
311
+ try { const root = rootOf(cwd); return fs.existsSync(path.join(root, policyPath(root))) || !!git(root, ['ls-files', '--', POLICY]).trim() || fs.existsSync(path.join(root, '.yam/testing/latest.json')); }
312
+ catch {
313
+ // Git discovery failure must not disable an already configured project's Stop gate.
314
+ let dir = path.resolve(cwd);
315
+ while (true) {
316
+ if ([POLICY, '.yam/testing/policy.json', '.yam/testing/latest.json'].some(f => fs.existsSync(path.join(dir,f)))) return true;
317
+ const parent = path.dirname(dir); if (parent === dir) return false; dir = parent;
318
+ }
319
+ }
320
+ }
321
+ export function testCheckStop(cwd: string, changed: boolean, message: string) {
322
+ if (!changed || !testCheckConfigured(cwd)) return null;
323
+ const result = checkTestCheck(cwd);
324
+ if (result.ok) return null;
325
+ if (/^Test check: BLOCKED(?:\s*[;:—–-]\s*[^\r\n]+)?[ \t]*$/m.test(message)) return { continue: true, systemMessage: 'Test check remains BLOCKED; this is an incomplete-status handoff, not approval of completion, commit or deployment.' };
326
+ return { decision: 'block', reason: 'yam Test check BLOCKED. Run yam test-check run --base <exact-base> --json, then yam test-check check --json. Repair required scenarios/evidence; if unable, report Test check: BLOCKED and the verification limitation. Do not claim complete.' };
327
+ }
328
+
329
+ export function runTestCheckCommand(args: string[], cwd = process.cwd()) {
330
+ const action = args[0]; const options: any = {};
331
+ const names = { '--policy': 'policy', '--policy-digest': 'pin', '--base': 'base', '--stage': 'stage', '--source': 'source', '--receipt': 'receipt', '--artifact': 'artifact', '--configuration': 'configuration' };
332
+ try {
333
+ for (let i=1; i<args.length; i++) { if (args[i] === '--json') continue; const key = names[args[i]]; if (!key || options[key] || !args[i+1] || args[i+1].startsWith('--')) fail('invalid_option'); options[key] = args[++i]; }
334
+ if (action === 'plan') {
335
+ const root = rootOf(cwd); if (!options.base || !OID.test(options.base)) fail('exact_base_required');
336
+ const { policy, digest: policy_digest } = readPolicy(root, policyPath(root, options.policy), options.pin);
337
+ const plan = planTestCheck(root, policy, options.base);
338
+ return { ok: plan.errors.length === 0, ...plan, checks: plan.checks.map(c => ({id:c.id, target:c.target, scenario:c.scenario, explanation:c.explanation, evidence:c.evidence})), policy_digest };
339
+ }
340
+ if (action === 'run') return runTestCheck(cwd, options);
341
+ if (action === 'check') return checkTestCheck(cwd, options);
342
+ if (action === 'status') return { ok: true, configured: testCheckConfigured(cwd), ...checkTestCheck(cwd, options) };
343
+ return { ok: false, result: 'BLOCKED', errors: ['supported_commands_run_check_status'] };
344
+ } catch {
345
+ if (action === 'run') return runTestCheck(cwd, { stage: 'invalid' });
346
+ return { ok: false, result: 'BLOCKED', errors: ['invalid_test_check_command'] };
347
+ }
348
+ }
@@ -0,0 +1,53 @@
1
+ {
2
+ "schema": "yam.test-policy.v1",
3
+ "surfaces": [
4
+ {
5
+ "id": "feature",
6
+ "paths": [
7
+ "src/**",
8
+ "tests/**"
9
+ ],
10
+ "risks": [
11
+ "logic"
12
+ ],
13
+ "targets": [
14
+ "feature"
15
+ ],
16
+ "checks": [
17
+ {
18
+ "id": "success",
19
+ "target": "feature",
20
+ "scenario": "success",
21
+ "explanation": "정상 입력에서 기대하는 결과 확인",
22
+ "evidence": "unit",
23
+ "command": [
24
+ "$NODE",
25
+ "tests/yam-adapter.mjs"
26
+ ]
27
+ },
28
+ {
29
+ "id": "invalid",
30
+ "target": "feature",
31
+ "scenario": "invalid",
32
+ "explanation": "Invalid input is rejected without incorrect side effects",
33
+ "evidence": "unit",
34
+ "command": [
35
+ "$NODE",
36
+ "tests/yam-adapter.mjs"
37
+ ]
38
+ },
39
+ {
40
+ "id": "boundary",
41
+ "target": "feature",
42
+ "scenario": "boundary",
43
+ "explanation": "Boundary and regression cases",
44
+ "evidence": "unit",
45
+ "command": [
46
+ "$NODE",
47
+ "tests/yam-adapter.mjs"
48
+ ]
49
+ }
50
+ ]
51
+ }
52
+ ]
53
+ }
package/yam.manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yam",
3
- "version": "2.8.3",
3
+ "version": "2.8.5",
4
4
  "principles": [
5
5
  "Direction before execution.",
6
6
  "Keep the current branch; new/switched branches, worktrees and PRs require explicit user direction or applicable project rules. Protection blockers require reporting, not silent workflow changes or bypass.",