@miller-tech/uap 1.33.0 → 1.35.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.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Gate Integrity Guard — runtime tamper detection for protected files
3
+ *
4
+ * The applier's protectedFiles filter only constrains what the MODEL writes
5
+ * through file blocks. Gate execution runs project code — including test
6
+ * files the model just created — and that code can `writeFileSync` over a
7
+ * protected spec or helper at runtime, defeating the static filter.
8
+ *
9
+ * This guard snapshots the bytes of every protected file after the baseline
10
+ * run, then re-verifies after each gate run: any mutated protected file is
11
+ * restored from the snapshot and the run's gate result is discarded as a
12
+ * GATE INTEGRITY VIOLATION, with feedback telling the model exactly why.
13
+ * Protected paths that did not exist at snapshot time ("reserved" oracle
14
+ * paths) are restored to absence — a runtime-fabricated golden is removed.
15
+ */
16
+ import { createHash } from 'crypto';
17
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
18
+ import { dirname, resolve } from 'path';
19
+ /** Per-file restoration budget; larger files are hash-verified only. */
20
+ const MAX_RESTORE_BYTES = 1_000_000;
21
+ /** Total bytes of restoration content kept in memory. */
22
+ const MAX_TOTAL_RESTORE_BYTES = 32_000_000;
23
+ function sha256(buf) {
24
+ return createHash('sha256').update(buf).digest('hex');
25
+ }
26
+ /**
27
+ * Capture the on-disk state of every protected file (original-case relative
28
+ * paths). Absent paths are recorded as reserved. Fail-soft per file.
29
+ */
30
+ export function captureIntegrity(projectRoot, files) {
31
+ const root = resolve(projectRoot);
32
+ const snapshot = new Map();
33
+ let totalBytes = 0;
34
+ for (const rel of files) {
35
+ const abs = resolve(root, rel);
36
+ try {
37
+ if (!existsSync(abs)) {
38
+ snapshot.set(rel, { hash: null, content: null });
39
+ continue;
40
+ }
41
+ const bytes = readFileSync(abs);
42
+ const keep = bytes.length <= MAX_RESTORE_BYTES && totalBytes + bytes.length <= MAX_TOTAL_RESTORE_BYTES;
43
+ if (keep)
44
+ totalBytes += bytes.length;
45
+ snapshot.set(rel, { hash: sha256(bytes), content: keep ? bytes : null });
46
+ }
47
+ catch {
48
+ // Unreadable — skip; the applier-level protection still applies.
49
+ }
50
+ }
51
+ return snapshot;
52
+ }
53
+ /**
54
+ * Verify every captured file against its snapshot; restore what changed.
55
+ * Returns what was tampered with so the caller can discard gate results.
56
+ */
57
+ export function verifyAndRestore(projectRoot, snapshot) {
58
+ const root = resolve(projectRoot);
59
+ const check = { tampered: [], restored: [], unrecoverable: [] };
60
+ for (const [rel, record] of snapshot) {
61
+ const abs = resolve(root, rel);
62
+ try {
63
+ const existsNow = existsSync(abs);
64
+ if (record.hash === null) {
65
+ // Reserved path: must stay absent. A runtime-fabricated oracle is removed.
66
+ if (existsNow) {
67
+ check.tampered.push(rel);
68
+ rmSync(abs, { force: true });
69
+ check.restored.push(rel);
70
+ }
71
+ continue;
72
+ }
73
+ if (!existsNow) {
74
+ check.tampered.push(rel);
75
+ if (record.content) {
76
+ mkdirSync(dirname(abs), { recursive: true });
77
+ writeFileSync(abs, record.content);
78
+ check.restored.push(rel);
79
+ }
80
+ else {
81
+ check.unrecoverable.push(rel);
82
+ }
83
+ continue;
84
+ }
85
+ const bytes = readFileSync(abs);
86
+ if (sha256(bytes) !== record.hash) {
87
+ check.tampered.push(rel);
88
+ if (record.content) {
89
+ writeFileSync(abs, record.content);
90
+ check.restored.push(rel);
91
+ }
92
+ else {
93
+ check.unrecoverable.push(rel);
94
+ }
95
+ }
96
+ }
97
+ catch {
98
+ check.unrecoverable.push(rel);
99
+ if (!check.tampered.includes(rel))
100
+ check.tampered.push(rel);
101
+ }
102
+ }
103
+ return check;
104
+ }
105
+ /** Render the violation feedback prepended to discarded gate results. */
106
+ export function integrityViolationFeedback(check) {
107
+ const lines = [
108
+ `GATE INTEGRITY VIOLATION: test execution modified protected file(s): ${check.tampered.join(', ')}.`,
109
+ 'Gate results for this turn are DISCARDED. Protected test/oracle files must never be written —',
110
+ 'not via file blocks and not at runtime from test code. Implement the source instead.',
111
+ ];
112
+ if (check.unrecoverable.length > 0) {
113
+ lines.push(`Could not restore: ${check.unrecoverable.join(', ')} — manual attention needed.`);
114
+ }
115
+ else {
116
+ lines.push('All modified files were restored to their original state.');
117
+ }
118
+ return lines.join('\n');
119
+ }
120
+ //# sourceMappingURL=integrity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"integrity.js","sourceRoot":"","sources":["../../src/delivery/integrity.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Spec Transitive-Import Protection
3
+ *
4
+ * A protected spec is only as trustworthy as its oracle material: helpers
5
+ * that build expectations, fixtures/data files holding expected output, and
6
+ * mocks shaping the environment. If those live OUTSIDE test directories the
7
+ * applier's test-file protection misses them, and a model can satisfy the
8
+ * spec by rewriting what it asserts against.
9
+ *
10
+ * This module walks each spec's import graph and protects what specs import
11
+ * as oracle material:
12
+ *
13
+ * - relative imports resolving into fixture/helper/mock-conventional
14
+ * locations (directory segments or basename markers)
15
+ * - data files (.json/.yaml/.txt/.csv/.snap/…) — referenced via imports OR
16
+ * quoted string literals (readFileSync paths), since a data file imported
17
+ * by a spec is expected-output material, never the unit under test
18
+ * - the transitive imports of anything protected above (helper chains)
19
+ *
20
+ * Deliberately NOT protected: plain code the spec imports (../src/duration)
21
+ * — that is the unit under test, the very thing the model must write.
22
+ * Distinguishing "helper" from "implementation" beyond naming conventions is
23
+ * undecidable here; the conventional set keeps brownfield delivery possible
24
+ * while closing the fixture/helper channel. All analysis is fail-soft.
25
+ */
26
+ export interface ProtectionSnapshot {
27
+ /** Lower-cased relative paths for the applier membership check */
28
+ protectedFiles: Set<string>;
29
+ /** Original-case paths for prompts/diagnostics */
30
+ display: string[];
31
+ }
32
+ /** True when `rel` ('/'-separated) is oracle material by location or name. */
33
+ export declare function isOraclePath(rel: string): boolean;
34
+ /** Resolve a './' or '../' import specifier. See resolveFromBase. */
35
+ export declare function resolveRelativeImport(fromFileAbs: string, specifier: string, projectRootAbs: string): string[];
36
+ export interface TsconfigAliases {
37
+ /** Absolute baseUrl dir — null when the project never declared one */
38
+ baseUrlAbs: string | null;
39
+ /** paths patterns; baseAbs is where this entry's targets resolve from
40
+ * (the effective baseUrl, else the declaring config's directory per
41
+ * TS 4.1+ semantics) */
42
+ paths: Array<{
43
+ pattern: string;
44
+ targets: string[];
45
+ baseAbs: string;
46
+ }>;
47
+ }
48
+ /**
49
+ * JSONC → JSON with a string-aware scanner: comments and trailing commas are
50
+ * stripped only OUTSIDE string literals, so wildcard targets like
51
+ * `packages/{star}/src` survive — a naive regex would treat the star-slash
52
+ * inside the string as a comment terminator and splice the config.
53
+ */
54
+ export declare function jsoncToJson(text: string): string;
55
+ /**
56
+ * Load baseUrl/paths from <projectRoot>/tsconfig.json, following relative
57
+ * `extends` chains (child wins). Returns null when there is no usable
58
+ * tsconfig — alias resolution is then skipped entirely.
59
+ */
60
+ export declare function loadTsconfigAliases(projectRoot: string): TsconfigAliases | null;
61
+ /**
62
+ * Resolve a non-relative specifier through tsconfig paths/baseUrl. Returns
63
+ * every existing root-relative candidate (protection over-approximates).
64
+ */
65
+ export declare function resolveAliasImport(specifier: string, aliases: TsconfigAliases, projectRootAbs: string): string[];
66
+ /**
67
+ * Expand a set of spec files (original-case, root-relative) to the oracle
68
+ * material they transitively reference. Returns original-case paths,
69
+ * EXCLUDING the seeds themselves. Bounded and fail-soft.
70
+ */
71
+ export declare function expandSpecImports(projectRoot: string, specFiles: string[]): string[];
72
+ /**
73
+ * Full gate-integrity snapshot: pre-existing test files plus the oracle
74
+ * material their import graphs reference. The membership set is lower-cased
75
+ * (case-insensitive matching); `display` keeps original casing for prompts.
76
+ */
77
+ export declare function snapshotProtection(projectRoot: string): ProtectionSnapshot;
78
+ //# sourceMappingURL=spec-imports.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spec-imports.d.ts","sourceRoot":"","sources":["../../src/delivery/spec-imports.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAsDH,MAAM,WAAW,kBAAkB;IACjC,kEAAkE;IAClE,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC5B,kDAAkD;IAClD,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,8EAA8E;AAC9E,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAOjD;AAqCD,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,GACrB,MAAM,EAAE,CAGV;AAUD,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;;4BAEwB;IACxB,KAAK,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACvE;AAKD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA8ChD;AAUD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAkE/E;AAaD;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,eAAe,EACxB,cAAc,EAAE,MAAM,GACrB,MAAM,EAAE,CAyBV;AAyCD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CA8DpF;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,kBAAkB,CAkB1E"}
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Spec Transitive-Import Protection
3
+ *
4
+ * A protected spec is only as trustworthy as its oracle material: helpers
5
+ * that build expectations, fixtures/data files holding expected output, and
6
+ * mocks shaping the environment. If those live OUTSIDE test directories the
7
+ * applier's test-file protection misses them, and a model can satisfy the
8
+ * spec by rewriting what it asserts against.
9
+ *
10
+ * This module walks each spec's import graph and protects what specs import
11
+ * as oracle material:
12
+ *
13
+ * - relative imports resolving into fixture/helper/mock-conventional
14
+ * locations (directory segments or basename markers)
15
+ * - data files (.json/.yaml/.txt/.csv/.snap/…) — referenced via imports OR
16
+ * quoted string literals (readFileSync paths), since a data file imported
17
+ * by a spec is expected-output material, never the unit under test
18
+ * - the transitive imports of anything protected above (helper chains)
19
+ *
20
+ * Deliberately NOT protected: plain code the spec imports (../src/duration)
21
+ * — that is the unit under test, the very thing the model must write.
22
+ * Distinguishing "helper" from "implementation" beyond naming conventions is
23
+ * undecidable here; the conventional set keeps brownfield delivery possible
24
+ * while closing the fixture/helper channel. All analysis is fail-soft.
25
+ */
26
+ import { readFileSync, statSync } from 'fs';
27
+ import { dirname, isAbsolute, relative, resolve, sep } from 'path';
28
+ import { isTestFilePath, listTestFiles } from './applier.js';
29
+ /** Directory names whose contents are oracle material when a spec imports them. */
30
+ const ORACLE_DIR_SEGMENTS = new Set([
31
+ 'fixtures',
32
+ 'fixture',
33
+ '__fixtures__',
34
+ 'helpers',
35
+ 'helper',
36
+ 'mocks',
37
+ 'mock',
38
+ '__mocks__',
39
+ 'stubs',
40
+ 'stub',
41
+ 'testdata',
42
+ 'test-data',
43
+ 'test-utils',
44
+ 'testutils',
45
+ '__snapshots__',
46
+ 'snapshots',
47
+ 'golden',
48
+ 'goldens',
49
+ ]);
50
+ /** Basename markers for oracle files outside conventional directories.
51
+ * `setup`/`matchers` cover runner bootstrap files (vitest.setup.ts,
52
+ * custom-matcher modules) that shape what "passing" means. */
53
+ const ORACLE_BASENAME_RE = /\.(fixture|fixtures|mock|mocks|stub|stubs|helper|helpers|setup|matchers?)\.[^.]+$/;
54
+ const DATA_EXTS = 'json|jsonc|json5|ya?ml|txt|csv|tsv|xml|html|snap|golden';
55
+ /** Data extensions: imported/referenced by a spec ⇒ expected-output material. */
56
+ const DATA_EXT_RE = new RegExp(`\\.(${DATA_EXTS})$`, 'i');
57
+ /** import/export-from/dynamic-import/require/side-effect-import specifiers.
58
+ * Matches inside comments/strings too — over-protection only, accepted.
59
+ * The clause length cap bounds regex cost on minified/pathological lines. */
60
+ const IMPORT_RE = /(?:import|export)\s[^'"`;]{0,2048}?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)|require\s*\(\s*['"]([^'"]+)['"]\s*\)|import\s+['"]([^'"]+)['"]/g;
61
+ /** Quoted relative-ish paths with data extensions (readFileSync etc.). */
62
+ const DATA_LITERAL_RE = new RegExp(`['"]([^'"\\n]{1,300}?\\.(?:${DATA_EXTS}))['"]`, 'gi');
63
+ const CODE_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
64
+ const MAX_SPEC_BYTES = 2_000_000;
65
+ const MAX_GRAPH_FILES = 500;
66
+ const MAX_GRAPH_DEPTH = 8;
67
+ const MAX_GRAPH_BYTES = 20_000_000;
68
+ /** True when `rel` ('/'-separated) is oracle material by location or name. */
69
+ export function isOraclePath(rel) {
70
+ const segments = rel.split('/');
71
+ const base = segments[segments.length - 1].toLowerCase();
72
+ if (base === 'conftest.py')
73
+ return true;
74
+ if (segments.slice(0, -1).some((s) => ORACLE_DIR_SEGMENTS.has(s.toLowerCase())))
75
+ return true;
76
+ if (ORACLE_BASENAME_RE.test(base))
77
+ return true;
78
+ return DATA_EXT_RE.test(base);
79
+ }
80
+ function isFile(abs) {
81
+ try {
82
+ return statSync(abs).isFile();
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ /**
89
+ * Expand a resolved base path the way bundlers/loaders do — exact file,
90
+ * TS-style .js→.ts swap, appended extensions, directory index — returning
91
+ * EVERY existing root-relative candidate ('/'-separated). This is
92
+ * protection, not module resolution, so when both x.js and x.ts exist we
93
+ * protect both rather than guess which one the runner loads.
94
+ */
95
+ function resolveFromBase(baseAbs, projectRootAbs) {
96
+ const candidates = [baseAbs];
97
+ // TS sources import emitted-name './x.js' while the file on disk is x.ts
98
+ const jsSwap = baseAbs.match(/^(.*)\.([mc]?)js$/);
99
+ if (jsSwap) {
100
+ candidates.push(`${jsSwap[1]}.${jsSwap[2]}ts`, `${jsSwap[1]}.tsx`);
101
+ }
102
+ for (const ext of CODE_EXTS)
103
+ candidates.push(baseAbs + ext);
104
+ for (const ext of CODE_EXTS)
105
+ candidates.push(resolve(baseAbs, `index${ext}`));
106
+ const found = [];
107
+ for (const candidate of candidates) {
108
+ if (!isFile(candidate))
109
+ continue;
110
+ const rel = relative(projectRootAbs, candidate);
111
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
112
+ continue;
113
+ found.push(rel.split(sep).join('/'));
114
+ }
115
+ return found;
116
+ }
117
+ /** Resolve a './' or '../' import specifier. See resolveFromBase. */
118
+ export function resolveRelativeImport(fromFileAbs, specifier, projectRootAbs) {
119
+ if (!specifier.startsWith('./') && !specifier.startsWith('../'))
120
+ return [];
121
+ return resolveFromBase(resolve(dirname(fromFileAbs), specifier), projectRootAbs);
122
+ }
123
+ const MAX_TSCONFIG_EXTENDS = 4;
124
+ const MAX_TSCONFIG_BYTES = 1_000_000;
125
+ /**
126
+ * JSONC → JSON with a string-aware scanner: comments and trailing commas are
127
+ * stripped only OUTSIDE string literals, so wildcard targets like
128
+ * `packages/{star}/src` survive — a naive regex would treat the star-slash
129
+ * inside the string as a comment terminator and splice the config.
130
+ */
131
+ export function jsoncToJson(text) {
132
+ let out = '';
133
+ let i = 0;
134
+ let inString = false;
135
+ while (i < text.length) {
136
+ const c = text[i];
137
+ if (inString) {
138
+ out += c;
139
+ if (c === '\\') {
140
+ out += text[i + 1] ?? '';
141
+ i += 2;
142
+ continue;
143
+ }
144
+ if (c === '"')
145
+ inString = false;
146
+ i++;
147
+ continue;
148
+ }
149
+ if (c === '"') {
150
+ inString = true;
151
+ out += c;
152
+ i++;
153
+ continue;
154
+ }
155
+ if (c === '/' && text[i + 1] === '/') {
156
+ while (i < text.length && text[i] !== '\n')
157
+ i++;
158
+ continue;
159
+ }
160
+ if (c === '/' && text[i + 1] === '*') {
161
+ i += 2;
162
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
163
+ i++;
164
+ i += 2;
165
+ continue;
166
+ }
167
+ if (c === ',') {
168
+ // Trailing comma: skip when the next non-whitespace closes a scope
169
+ let j = i + 1;
170
+ while (j < text.length && /\s/.test(text[j]))
171
+ j++;
172
+ if (text[j] === '}' || text[j] === ']') {
173
+ i++;
174
+ continue;
175
+ }
176
+ }
177
+ out += c;
178
+ i++;
179
+ }
180
+ return out;
181
+ }
182
+ function parseJsonc(text) {
183
+ try {
184
+ return JSON.parse(text);
185
+ }
186
+ catch {
187
+ return JSON.parse(jsoncToJson(text));
188
+ }
189
+ }
190
+ /**
191
+ * Load baseUrl/paths from <projectRoot>/tsconfig.json, following relative
192
+ * `extends` chains (child wins). Returns null when there is no usable
193
+ * tsconfig — alias resolution is then skipped entirely.
194
+ */
195
+ export function loadTsconfigAliases(projectRoot) {
196
+ const rootAbs = resolve(projectRoot);
197
+ const merged = [];
198
+ const seen = new Set();
199
+ // Child first; `extends` parents appended after (string or TS 5 array form,
200
+ // relative entries only). The reverse walk below applies base → child so
201
+ // child settings win.
202
+ const pending = [resolve(rootAbs, 'tsconfig.json')];
203
+ while (pending.length > 0 && merged.length < MAX_TSCONFIG_EXTENDS) {
204
+ const configPath = pending.shift();
205
+ if (seen.has(configPath))
206
+ continue;
207
+ seen.add(configPath);
208
+ let parsed;
209
+ try {
210
+ const st = statSync(configPath);
211
+ if (!st.isFile() || st.size > MAX_TSCONFIG_BYTES)
212
+ continue;
213
+ parsed = parseJsonc(readFileSync(configPath, 'utf-8'));
214
+ }
215
+ catch {
216
+ continue;
217
+ }
218
+ const co = (parsed.compilerOptions ?? {});
219
+ merged.push({ dir: dirname(configPath), compilerOptions: co });
220
+ const ext = parsed.extends;
221
+ const extEntries = typeof ext === 'string' ? [ext] : Array.isArray(ext) ? ext : [];
222
+ for (const entry of extEntries) {
223
+ // npm-package extends ("@tsconfig/node20") are skipped — bare names.
224
+ if (typeof entry !== 'string' || (!entry.startsWith('./') && !entry.startsWith('../'))) {
225
+ continue;
226
+ }
227
+ pending.push(resolve(dirname(configPath), entry.endsWith('.json') ? entry : `${entry}.json`));
228
+ }
229
+ }
230
+ if (merged.length === 0)
231
+ return null;
232
+ // Effective baseUrl is the child-most declaration (merged[0] is the child).
233
+ let baseUrlAbs = null;
234
+ for (const { dir, compilerOptions } of merged) {
235
+ if (typeof compilerOptions.baseUrl === 'string') {
236
+ baseUrlAbs = resolve(dir, compilerOptions.baseUrl);
237
+ break;
238
+ }
239
+ }
240
+ // Per-key merge, child wins; each entry resolves its targets from the
241
+ // effective baseUrl when declared, else from the declaring config's dir
242
+ // (TS 4.1+ semantics). Keeping parent keys a child's paths object would
243
+ // have replaced wholesale is deliberate over-protection.
244
+ const paths = new Map();
245
+ for (const { dir, compilerOptions } of [...merged].reverse()) {
246
+ if (!compilerOptions.paths || typeof compilerOptions.paths !== 'object')
247
+ continue;
248
+ for (const [key, value] of Object.entries(compilerOptions.paths)) {
249
+ if (!Array.isArray(value))
250
+ continue;
251
+ const targets = value.filter((v) => typeof v === 'string');
252
+ if (targets.length === 0)
253
+ continue;
254
+ paths.set(key, { targets, baseAbs: baseUrlAbs ?? dir });
255
+ }
256
+ }
257
+ const patternList = [...paths.entries()].map(([pattern, { targets, baseAbs }]) => ({
258
+ pattern,
259
+ targets,
260
+ baseAbs,
261
+ }));
262
+ if (!baseUrlAbs && patternList.length === 0)
263
+ return null;
264
+ return { baseUrlAbs, paths: patternList };
265
+ }
266
+ /** Match `specifier` against one tsconfig paths pattern; null when no match. */
267
+ function matchAliasPattern(specifier, pattern) {
268
+ const starIdx = pattern.indexOf('*');
269
+ if (starIdx === -1)
270
+ return specifier === pattern ? '' : null;
271
+ const prefix = pattern.slice(0, starIdx);
272
+ const suffix = pattern.slice(starIdx + 1);
273
+ if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix))
274
+ return null;
275
+ if (specifier.length < prefix.length + suffix.length)
276
+ return null;
277
+ return specifier.slice(prefix.length, specifier.length - suffix.length);
278
+ }
279
+ /**
280
+ * Resolve a non-relative specifier through tsconfig paths/baseUrl. Returns
281
+ * every existing root-relative candidate (protection over-approximates).
282
+ */
283
+ export function resolveAliasImport(specifier, aliases, projectRootAbs) {
284
+ if (specifier.startsWith('./') || specifier.startsWith('../'))
285
+ return [];
286
+ if (specifier.startsWith('node:') || isAbsolute(specifier))
287
+ return [];
288
+ const found = new Set();
289
+ for (const { pattern, targets, baseAbs } of aliases.paths) {
290
+ const wildcard = matchAliasPattern(specifier, pattern);
291
+ if (wildcard === null)
292
+ continue;
293
+ for (const target of targets) {
294
+ const substituted = target.includes('*') ? target.replace('*', wildcard) : target;
295
+ for (const rel of resolveFromBase(resolve(baseAbs, substituted), projectRootAbs)) {
296
+ found.add(rel);
297
+ }
298
+ }
299
+ }
300
+ // ONLY an explicitly declared baseUrl makes bare specifiers resolvable
301
+ // from it (TS semantics) — without the gate every `import 'react'` in
302
+ // every spec would cost ~18 stat calls against the project root.
303
+ if (found.size === 0 && aliases.baseUrlAbs !== null) {
304
+ for (const rel of resolveFromBase(resolve(aliases.baseUrlAbs, specifier), projectRootAbs)) {
305
+ found.add(rel);
306
+ }
307
+ }
308
+ return [...found];
309
+ }
310
+ function extractImportSpecifiers(source) {
311
+ const specs = [];
312
+ for (const match of source.matchAll(IMPORT_RE)) {
313
+ const spec = match[1] ?? match[2] ?? match[3] ?? match[4];
314
+ if (spec)
315
+ specs.push(spec);
316
+ }
317
+ return specs;
318
+ }
319
+ /**
320
+ * Quoted data-file references (readFileSync paths), resolved against BOTH
321
+ * the spec's dir and the root — runtimes read relative to cwd, authors write
322
+ * relative to the spec, and protecting both is fail-safe. A literal under an
323
+ * oracle-conventional directory is protected even when the file does NOT yet
324
+ * exist ("reserved"): otherwise a spec reading a missing goldens/output.json
325
+ * invites the model to fabricate the golden instead of the implementation.
326
+ */
327
+ function extractDataLiterals(source, fromFileAbs, rootAbs) {
328
+ const found = [];
329
+ for (const match of source.matchAll(DATA_LITERAL_RE)) {
330
+ const literal = match[1];
331
+ if (literal.includes('://') || literal.startsWith('/'))
332
+ continue;
333
+ for (const baseDir of [dirname(fromFileAbs), rootAbs]) {
334
+ const abs = resolve(baseDir, literal);
335
+ const rel = relative(rootAbs, abs);
336
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
337
+ continue;
338
+ const relPosix = rel.split(sep).join('/');
339
+ const segments = relPosix.split('/');
340
+ const inOracleDir = segments
341
+ .slice(0, -1)
342
+ .some((seg) => ORACLE_DIR_SEGMENTS.has(seg.toLowerCase()));
343
+ if (isFile(abs) || inOracleDir) {
344
+ found.push(relPosix);
345
+ }
346
+ }
347
+ }
348
+ return found;
349
+ }
350
+ /**
351
+ * Expand a set of spec files (original-case, root-relative) to the oracle
352
+ * material they transitively reference. Returns original-case paths,
353
+ * EXCLUDING the seeds themselves. Bounded and fail-soft.
354
+ */
355
+ export function expandSpecImports(projectRoot, specFiles) {
356
+ const rootAbs = resolve(projectRoot);
357
+ const protectedExtra = new Set();
358
+ const visited = new Set();
359
+ let bytesRead = 0;
360
+ let aliases = null;
361
+ try {
362
+ aliases = loadTsconfigAliases(projectRoot);
363
+ }
364
+ catch {
365
+ aliases = null;
366
+ }
367
+ // Queue entries: [relPath, depth] — we recurse through specs and oracle
368
+ // files, never through plain implementation code.
369
+ const queue = specFiles.map((f) => [f, 0]);
370
+ while (queue.length > 0 && visited.size < MAX_GRAPH_FILES && bytesRead < MAX_GRAPH_BYTES) {
371
+ const [rel, depth] = queue.shift();
372
+ if (depth > MAX_GRAPH_DEPTH || visited.has(rel))
373
+ continue;
374
+ visited.add(rel);
375
+ // Data files are protected as leaves, never scanned: their contents are
376
+ // arbitrary data, and code regexes over fixture JSON manufacture
377
+ // second-order false positives.
378
+ if (DATA_EXT_RE.test(rel))
379
+ continue;
380
+ const abs = resolve(rootAbs, rel);
381
+ let source;
382
+ try {
383
+ const st = statSync(abs);
384
+ if (!st.isFile() || st.size > MAX_SPEC_BYTES)
385
+ continue;
386
+ bytesRead += st.size;
387
+ source = readFileSync(abs, 'utf-8');
388
+ }
389
+ catch {
390
+ continue;
391
+ }
392
+ const referenced = new Set();
393
+ for (const spec of extractImportSpecifiers(source)) {
394
+ for (const resolved of resolveRelativeImport(abs, spec, rootAbs)) {
395
+ referenced.add(resolved);
396
+ }
397
+ if (aliases) {
398
+ for (const resolved of resolveAliasImport(spec, aliases, rootAbs)) {
399
+ referenced.add(resolved);
400
+ }
401
+ }
402
+ }
403
+ for (const dataRel of extractDataLiterals(source, abs, rootAbs)) {
404
+ referenced.add(dataRel);
405
+ }
406
+ for (const ref of referenced) {
407
+ if (!isOraclePath(ref) && !isTestFilePath(ref))
408
+ continue; // unit under test stays writable
409
+ // Added unconditionally: a test-path ref may live where the directory
410
+ // walk cannot see it (hidden dir, skipped segment, beyond depth), so
411
+ // relying on listTestFiles to have found it would leave a gap.
412
+ protectedExtra.add(ref);
413
+ // Helper chains: a protected file's own oracle imports are protected
414
+ queue.push([ref, depth + 1]);
415
+ }
416
+ }
417
+ return [...protectedExtra];
418
+ }
419
+ /**
420
+ * Full gate-integrity snapshot: pre-existing test files plus the oracle
421
+ * material their import graphs reference. The membership set is lower-cased
422
+ * (case-insensitive matching); `display` keeps original casing for prompts.
423
+ */
424
+ export function snapshotProtection(projectRoot) {
425
+ let tests = [];
426
+ try {
427
+ tests = listTestFiles(projectRoot);
428
+ }
429
+ catch {
430
+ tests = [];
431
+ }
432
+ let extra = [];
433
+ try {
434
+ extra = expandSpecImports(projectRoot, tests);
435
+ }
436
+ catch {
437
+ extra = [];
438
+ }
439
+ const display = [...new Set([...tests, ...extra])].sort();
440
+ return {
441
+ protectedFiles: new Set(display.map((f) => f.toLowerCase())),
442
+ display,
443
+ };
444
+ }
445
+ //# sourceMappingURL=spec-imports.js.map