@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.
- package/README.md +5 -3
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/deliver.js +3 -3
- package/dist/cli/deliver.js.map +1 -1
- package/dist/delivery/applier.d.ts +6 -0
- package/dist/delivery/applier.d.ts.map +1 -1
- package/dist/delivery/applier.js +11 -3
- package/dist/delivery/applier.js.map +1 -1
- package/dist/delivery/convergence-loop.d.ts +1 -1
- package/dist/delivery/convergence-loop.d.ts.map +1 -1
- package/dist/delivery/convergence-loop.js +44 -11
- package/dist/delivery/convergence-loop.js.map +1 -1
- package/dist/delivery/index.d.ts +2 -0
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +2 -0
- package/dist/delivery/index.js.map +1 -1
- package/dist/delivery/integrity.d.ts +44 -0
- package/dist/delivery/integrity.d.ts.map +1 -0
- package/dist/delivery/integrity.js +120 -0
- package/dist/delivery/integrity.js.map +1 -0
- package/dist/delivery/spec-imports.d.ts +78 -0
- package/dist/delivery/spec-imports.d.ts.map +1 -0
- package/dist/delivery/spec-imports.js +445 -0
- package/dist/delivery/spec-imports.js.map +1 -0
- package/package.json +1 -1
|
@@ -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
|