diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index 0cf14b85..75e9d129 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -257,6 +257,43 @@ handed_function(ce, m) :- hof_boundary_site(ce), !collection_store_site(ce), exp value_branch(a, x), method_value(x, m). +// …AND A PROXY HANDLER'S TRAPS. `new Proxy(target, handler)` and `Proxy.revocable(target, handler)` hand the runtime +// an object whose `get` / `set` / `has` … the runtime calls on every touch of the proxy. No call site names a trap, so +// what the traps call was reached by nothing: a change to the draft logic of a Proxy-based library selected none of the +// tests that create drafts. The creating site hands each trap the handler defines, the way `xs.map(cb)` hands `cb`. +// The handler is followed through the holders it is read from (a const, a reassigned `let`, a parameter it was passed +// as: holder_value), to an object literal's methods or the methods of the class a `new C()` builds, its bases included. +proxy_site(ce) :- call_site("client", "CONSTRUCTOR_CALL", "Proxy", _, _, ce, _), !type_decl("client", "Proxy", _, _, _, _, _). +proxy_site(ce) :- call_site("client", _, "revocable", "IDENTIFIER", recv, ce, _), expr_name("client", "Proxy", recv), + !type_decl("client", "Proxy", _, _, _, _, _). +proxy_handler_value(ce, a) :- proxy_site(ce), expr_child("client", ce, "ARGUMENT", "1", a). +proxy_handler_value(ce, v) :- proxy_handler_value(ce, e), expr_referenced("client", k, h, e), holder_ref_kind(k), + holder_value(h, v). +proxy_handler_value(ce, x) :- proxy_handler_value(ce, e), value_branch_of(e, x). +handed_function(ce, m) :- proxy_handler_value(ce, o), objlit_member_fn(o, name, m), proxy_trap_name(name). +handed_function(ce, m) :- proxy_handler_value(ce, n), expr_kind("client", "NEW_EXPRESSION", _, n), expr_type(n, _, t), + proxy_handler_class(t, c), method_owner("client", c, m), method_decl("client", name, _, _, m), proxy_trap_name(name). +proxy_handler_class(t, t) :- type_decl("client", _, _, _, _, _, t). +proxy_handler_class(t, a) :- type_decl("client", _, _, _, _, _, t), type_ancestor(t, a). +// a conditional or a cast between the holder and the site: `isArray ? arrayTraps : objectTraps`, `h as ProxyHandler` +value_branch_of(e, x) :- expr_kind("client", "TERNARY_EXPRESSION", _, e), expr_child("client", e, r, _, x), ternary_value_role(r). +value_branch_of(e, x) :- expr_kind("client", k, _, e), expr_kind_is_transparent(k), expr_child("client", e, _, _, x). +ternary_value_role("TERNARY_THEN"). +ternary_value_role("TERNARY_ELSE"). +proxy_trap_name("get"). +proxy_trap_name("set"). +proxy_trap_name("has"). +proxy_trap_name("deleteProperty"). +proxy_trap_name("ownKeys"). +proxy_trap_name("getOwnPropertyDescriptor"). +proxy_trap_name("defineProperty"). +proxy_trap_name("getPrototypeOf"). +proxy_trap_name("setPrototypeOf"). +proxy_trap_name("isExtensible"). +proxy_trap_name("preventExtensions"). +proxy_trap_name("apply"). +proxy_trap_name("construct"). + // …AND WHAT A LIBRARY CALL WRAPPED, KEPT IN A HOLDER. `export const plugin = fp(async (app) => …)`, then // `app.register(plugin)`: the holder keeps what a call with no client body RETURNED, so no holder rule above // sees a function in it, and the registration reached nothing although the same literal handed to it bare diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 3119a141..628971e0 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -220,6 +220,12 @@ .decl collection_receiver(c0:symbol) .decl keyed_collection_ref(c0:symbol) .decl handed_function(c0:symbol,c1:symbol) +.decl proxy_site(c0:symbol) +.decl proxy_handler_value(c0:symbol,c1:symbol) +.decl proxy_handler_class(c0:symbol,c1:symbol) +.decl value_branch_of(c0:symbol,c1:symbol) +.decl ternary_value_role(c0:symbol) +.decl proxy_trap_name(c0:symbol) .decl holder_wraps_handed_function(c0:symbol,c1:symbol) // (holder, fn): the holder keeps what a library call returned, and fn was handed to that call .decl call_returns_data(c0:symbol) // the call's result is typed, and the type has no call signature .decl call_result_callable(c0:symbol) diff --git a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts index 9f312e3c..d35d7582 100644 --- a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts @@ -504,7 +504,12 @@ function parseDiagnosticsOf(sourceFile: ts.SourceFile): readonly ts.Diagnostic[] } function scriptKindFor(filePath: string): ts.ScriptKind { - return filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX; + // a JavaScript file joins a TypeScript program when it imports it (a .js test of .ts source): + // parsed as JavaScript, where JSX is allowed and type syntax is not + if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX; + if (/\.[cm]?js$/.test(filePath)) return ts.ScriptKind.JS; + return ts.ScriptKind.TS; } /** diff --git a/parser/src/test/discovery-tests.ts b/parser/src/test/discovery-tests.ts index e061a957..af8ea224 100644 --- a/parser/src/test/discovery-tests.ts +++ b/parser/src/test/discovery-tests.ts @@ -285,6 +285,71 @@ const CHECKS: Check[] = [ return null; }, }, + { + name: 'a-test-the-build-tsconfig-excludes-is-analysed-with-the-program-it-imports', + proves: 'a spec file and its helper, excluded by the root tsconfig (`exclude: ["test/**"]`) and ' + + 'claimed by no other config, are analysed because they import the program; an unclaimed ' + + 'file that imports nothing of the program is still reported as NO_PROGRAM_CLAIMS_FILE', + rulesOut: 'analysing only what a config claims plus what claimed files import: a build config ' + + 'that leaves its tests to the runner made every test file an orphan, so test selection ' + + 'reached none of them', + run: async (tmp) => { + const root = build(tmp, 'excluded-tests', { + 'tsconfig.json': '{"compilerOptions":{"rootDir":"./src","strict":true},"include":["src"],"exclude":["test/**"]}', + 'src/lib.ts': 'export function area(w: number, h: number): number { return w * h; }\n', + 'test/helper.ts': 'import { area } from "../src/lib";\nexport const unit = () => area(1, 1);\n', + 'test/lib.spec.ts': 'import { area } from "../src/lib";\nimport { unit } from "./helper";\nexport const ok = area(2, 3) === 6 && unit() === 1;\n', + 'tools/standalone.ts': 'export function alone(): number { return 1; }\n', + }); + const out = path.join(tmp, 'excluded-tests-out'); + const silence = console.log; + console.log = () => {}; + try { + await extractProject({ projectPath: root, versionLink: 'v1', outputDir: out, layout: 'per-language' }); + } finally { + console.log = silence; + } + const modules = fs.readFileSync(path.join(out, 'typescript', 'all-typescript-modules.csv'), 'utf-8'); + const skipped = fs.readFileSync(path.join(out, 'typescript', 'skipped-typescript-files.csv'), 'utf-8'); + if (!modules.includes('src/lib.ts')) return 'control: the claimed source file src/lib.ts is not analysed'; + const missing = ['test/lib.spec.ts', 'test/helper.ts'].filter((f) => !modules.includes(f)); + if (missing.length > 0) return `not analysed though they import the program: ${missing.join(', ')}`; + if (modules.includes('tools/standalone.ts')) return 'tools/standalone.ts imports nothing of the program, yet joined it'; + if (!/tools\/standalone\.ts\t[^\n]*NO_PROGRAM_CLAIMS_FILE/.test(skipped)) return 'tools/standalone.ts is no longer reported as NO_PROGRAM_CLAIMS_FILE'; + return null; + }, + }, + { + name: 'a-javascript-test-of-typescript-source-joins-the-typescript-program', + proves: 'a .js test that imports the TypeScript source (ESM import, and require) is analysed in the ' + + 'TypeScript program, so its calls land in the graph that holds the source; a .js file that ' + + 'imports nothing of the program is not', + rulesOut: 'leaving every .js test of .ts source to the JavaScript graph, which holds no edge into the ' + + 'TypeScript one: test selection named none of the tests that break when the source does', + run: async (tmp) => { + const root = build(tmp, 'js-tests-of-ts', { + 'tsconfig.json': '{"compilerOptions":{"strict":true},"include":["src"]}', + 'src/lib.ts': 'export function area(w: number, h: number): number { return w * h; }\n', + '__tests__/lib.js': 'import { area } from "../src/lib";\nexport const ok = area(2, 3) === 6;\n', + '__tests__/req.js': 'const lib = require("../src/lib");\nmodule.exports = lib.area(1, 1);\n', + 'scripts/alone.js': 'module.exports = function alone() { return 1; };\n', + }); + const out = path.join(tmp, 'js-tests-of-ts-out'); + const silence = console.log; + console.log = () => {}; + try { + await extractProject({ projectPath: root, versionLink: 'v1', outputDir: out, layout: 'per-language' }); + } finally { + console.log = silence; + } + const modules = fs.readFileSync(path.join(out, 'typescript', 'all-typescript-modules.csv'), 'utf-8'); + if (!modules.includes('src/lib.ts')) return 'control: the claimed source file src/lib.ts is not analysed'; + const missing = ['__tests__/lib.js', '__tests__/req.js'].filter((f) => !modules.includes(f)); + if (missing.length > 0) return `not in the TypeScript program though they import it: ${missing.join(', ')}`; + if (modules.includes('scripts/alone.js')) return 'scripts/alone.js imports nothing of the program, yet joined it'; + return null; + }, + }, { name: 'per-language-keeps-the-javascript-of-a-dist-shipping-package', proves: 'extractProject in per-language layout writes javascript/ for a package whose only ' diff --git a/parser/src/workflows/typescript/typescript-project-analyzer.ts b/parser/src/workflows/typescript/typescript-project-analyzer.ts index f47f68d6..0ded7e17 100644 --- a/parser/src/workflows/typescript/typescript-project-analyzer.ts +++ b/parser/src/workflows/typescript/typescript-project-analyzer.ts @@ -757,37 +757,76 @@ function filesOfRootProgram( const texts = new Map(); const included = new Set(claimed.map((f) => path.normalize(f))); const available = new Map(unclaimed.map((f) => [path.normalize(f), f])); - const queue = [...claimed]; - while (queue.length > 0) { - const current = queue.pop()!; + // What each file imports, resolved and normalised; read once, for both walks below. + const importsOf = new Map(); + const resolvedImports = (file: string): string[] => { + const known = importsOf.get(file); + if (known !== undefined) { + return known; + } + const keys: string[] = []; + importsOf.set(file, keys); let text: string; try { - text = fs.readFileSync(current, 'utf-8'); + text = fs.readFileSync(file, 'utf-8'); } catch { - continue; + return keys; } - texts.set(current, text); + texts.set(file, text); // No parent pointers and no type nodes needed: this pass only reads // specifiers, so the cheapest possible parse is the right one. - const script = scriptTextOf(current, text); - const sf = ts.createSourceFile(current, script.text, ts.ScriptTarget.Latest, false, - script.scriptKind ?? (current.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS)); + const script = scriptTextOf(file, text); + const sf = ts.createSourceFile(file, script.text, ts.ScriptTarget.Latest, false, + script.scriptKind ?? (file.endsWith('.tsx') ? ts.ScriptKind.TSX : JS_JOINER.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS)); for (const specifier of importSpecifiersOf(sf)) { - const resolved = ts.resolveModuleName(specifier, current, rootOptions, ts.sys, resolutionCache) - .resolvedModule?.resolvedFileName ?? resolveVueSpecifier(specifier, current); - if (resolved === undefined) { - continue; + const resolved = ts.resolveModuleName(specifier, file, rootOptions, ts.sys, resolutionCache) + .resolvedModule?.resolvedFileName ?? resolveVueSpecifier(specifier, file); + if (resolved !== undefined) { + keys.push(path.normalize(resolved)); } - const key = path.normalize(resolved); - if (included.has(key) || !available.has(key)) { - continue; + } + return keys; + }; + const queue = [...claimed]; + const closeOverImports = (): void => { + while (queue.length > 0) { + for (const key of resolvedImports(queue.pop()!)) { + if (included.has(key) || !available.has(key)) { + continue; + } + included.add(key); + queue.push(available.get(key)!); } - included.add(key); - queue.push(available.get(key)!); } + }; + closeOverImports(); + // ...and the files that import the program. A build config routinely excludes its + // tests (`exclude: ["test/**", "**/*.spec.ts"]`) and leaves them to the test runner, + // so no config claims them and nothing claimed imports them: they arrived nowhere, + // and every test the change reaches was invisible to test selection. A file no other + // config owns that imports a file of this program is part of what the program runs + // under; it joins, together with the unclaimed files it pulls in. + // The same holds for a JavaScript file that imports the program — a `.js` test of `.ts` source, + // which the JavaScript graph holds with no edge into this one. It is a candidate here only: a + // TypeScript file that imports JavaScript still leaves it to the JavaScript analyzer. + const joiners = new Map(collectTypeScriptFiles(rootDir, new Set(TS_SKIP_DIRECTORIES), JS_JOINER) + .filter((f) => configResolver.resolve(f).configPath === '' || rootConfigs.has(path.resolve(configResolver.resolve(f).configPath))) + .map((f) => [path.normalize(f), f])); + for (let grew = true; grew;) { + grew = false; + for (const pool of [available, joiners]) { + for (const [key, file] of pool) { + if (!included.has(key) && resolvedImports(file).some((k) => included.has(k))) { + included.add(key); + queue.push(file); + grew = true; + } + } + } + closeOverImports(); } - const files = [...included].map((f) => available.get(f) ?? f); + const files = [...included].map((f) => available.get(f) ?? joiners.get(f) ?? f); const pulled = new Set(files.map((f) => path.normalize(f))); // A file no config claims and no claimed file imports belongs to no program at // all; it is reported, not dropped (NO_PROGRAM_CLAIMS_FILE). @@ -839,7 +878,8 @@ function importSpecifiersOf(sf: ts.SourceFile): string[] { && ts.isStringLiteral(node.moduleReference.expression)) { out.push(node.moduleReference.expression.text); } else if (ts.isCallExpression(node) - && node.expression.kind === ts.SyntaxKind.ImportKeyword + && (node.expression.kind === ts.SyntaxKind.ImportKeyword + || (ts.isIdentifier(node.expression) && node.expression.text === 'require')) && node.arguments.length > 0 && ts.isStringLiteral(node.arguments[0]!)) { out.push((node.arguments[0] as ts.StringLiteral).text); @@ -891,7 +931,10 @@ function verifyRelationFile(temporaryPath: string, outputPath: string): void { } } -function collectTypeScriptFiles(dir: string, excludes: ReadonlySet): string[] { +/** A JavaScript file that may join a TypeScript program by importing it (see filesOfRootProgram). */ +const JS_JOINER = /\.(?:[cm]?js|jsx)$/; + +function collectTypeScriptFiles(dir: string, excludes: ReadonlySet, only?: RegExp): string[] { const out: string[] = []; const walk = (current: string): void => { let entries: fs.Dirent[]; @@ -911,8 +954,8 @@ function collectTypeScriptFiles(dir: string, excludes: ReadonlySet): str } continue; } - if (TS_SOURCE_EXTENSIONS.some((extension) => entry.name.endsWith(extension)) - || (isVueFile(entry.name) && isTypeScriptVueComponent(full))) { + if (only ? only.test(entry.name) : (TS_SOURCE_EXTENSIONS.some((extension) => entry.name.endsWith(extension)) + || (isVueFile(entry.name) && isTypeScriptVueComponent(full)))) { out.push(full); } } diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py index 34c4a44e..6fc1541c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -258,7 +258,7 @@ def main(argv): lines = render(verb, doc, repo) if r.returncode in (0, 1) or doc.get('called_undeclared') else None if lines is None: # a refusal or an answer with no place in it: the verb's own words are the answer - print('\n'.join(doc.get('prose') or []) or r.stdout.strip()); return r.returncode + print('\n'.join(doc.get('prose') or []) or doc.get('refusal') or r.stdout.strip()); return r.returncode print('\n'.join(lines)) return 0 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py index 3f8c49a1..1c094212 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py @@ -133,8 +133,34 @@ def main(argv): print(json.dumps(base, indent=1) if not isinstance(base, str) else base, end='' if isinstance(base, str) else '\n') sys.stderr.write(''.join(n[4] for n in answered) + notes) return 0 + def _is_json(out): + try: json.loads(out); return True + except ValueError: return False + if '--json' in args and any(_is_json(n[3]) for n in named): + # no graph answered, but one answered IN JSON (path found no chain: a document with its leads, status 1): + # still ONE document. Printed as the text answer's per-graph headers instead, a machine reader got + # `══ typescript graph ══` and nothing it could parse; each other graph's refusal is kept as data and the exit + # status is the main one's. Refusals that are all text (a name no graph declares) stay text: the pager that + # asks impact for --json passes a refusal through as written + objs = [] + for lang, is_main, rc, out, err in named: + try: objs.append((lang, json.loads(out))) + except ValueError: objs.append((lang, {'refusal': out.strip()})) + base_lang, base = objs[0] + if not isinstance(base, dict): base = {'answer': base} + base = dict(base, language=base_lang) + if len(objs) > 1: base['other_languages'] = {l: o for l, o in objs[1:]} + if text: base['text'] = text + print(json.dumps(base, indent=1)) + sys.stderr.write(''.join(n[4] for n in named) + notes) + return named[0][2] show = answered or named + if not answered and len({(n[3], n[4]) for n in named}) == 1 and '--json' in args and not _is_json(named[0][3]): + # the same text refusal from every graph, asked for --json: one document carrying it, not a header and prose + print(json.dumps(dict(refusal=named[0][3].strip(), language=named[0][0], languages=[n[0] for n in named], + **({'text': text} if text else {})), indent=1)) + sys.stderr.write(named[0][4]); return named[0][2] if not answered and len({(n[3], n[4]) for n in named}) == 1: # the same refusal from every graph: once print(f"══ {', '.join(n[0] for n in named)} graph{'s' if len(named) > 1 else ''} ══"); sys.stdout.write(named[0][3]); sys.stderr.write(named[0][4]) if text: sys.stdout.write('\n' + text) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 1ff8a709..9dae7ca8 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -235,6 +235,27 @@ def type_kind(cat): TESTRE = re.compile(r'(^|/)(tests?|testing|__tests__|test-data|fixtures?)(/|$)|(^|/)test_|conftest\.py$|/src/test/|Tests?\.java$|\.(spec|test)\.[cm]?[tj]sx?$' r'|(^|/)[^/]*Tests?(\.[^/]+)*(/|$)|(^|/)Tests?\.cs$') +# A JavaScript / TypeScript test file named only for what it is: `test.ts` beside the module it tests +# (`src/isToday/index.ts` + `src/isToday/test.ts`), or a package's root `test.js`. Neither the name nor a directory +# says test, so every test of such a tree was a plain function: 0 test files, and test selection named none of the +# tests a change breaks. The bare name alone is not enough -- a library ships `testing/test.ts` exporting its test +# utilities, and a vendored `spec.ts` holds a specification's types -- so the file must also register a test. +BARE_TEST_NAME = re.compile(r'(^|/)(tests?|specs?)\.[cm]?[tj]sx?$') +REGISTERS_TEST = re.compile(r'^\s*(describe|it|test|suite)(\.\w+)*\s*\(', re.M) +_test_file = {} +def is_test_file(fp): + if not fp: return False + if fp not in _test_file: + hit = bool(TESTRE.search(fp)) + if not hit and BARE_TEST_NAME.search(fp): + try: + with open(fp if os.path.isabs(fp) else os.path.join(REPO, fp), encoding='utf-8', errors='replace') as fh: + hit = bool(REGISTERS_TEST.search(fh.read())) + except OSError: + hit = False + _test_file[fp] = hit + return _test_file[fp] + def rows(name): p = os.path.join(IR, name) if not HAVE_IR or not os.path.exists(p): return iter(()) @@ -482,7 +503,7 @@ for t in types.values(): d = tdisplay(t['id']); fp = rel(t['file_path']) if t['id'] in cls_bound: bound_at.add((fp, t['start_line'], tname(t))) sym.append((t['id'], tname(t), d, type_kind(t['category'] or ''), t['qualified_name'], None, fp, t['start_line'], t['end_line'], - tdisplay(outer_of[t['id']]) if t['id'] in outer_of else cls_bound.get(t['id'], (None, None))[1], 1 if fp and TESTRE.search(fp) else 0, None, t['id'])) + tdisplay(outer_of[t['id']]) if t['id'] in outer_of else cls_bound.get(t['id'], (None, None))[1], 1 if is_test_file(fp) else 0, None, t['id'])) # ── methods ─────────────────────────────────────────────────────────────────────────────────────── # a member whose key is an expression (`[Symbol.iterator]() {…}`, `[KEY] = 1`) has no name in the IR, only a link to the @@ -523,9 +544,9 @@ for m in c.execute("SELECT id, name, qualified_name, signature, kind, owner_type if k_: od = f"{od}.{k_}" if m['kind'] == 'MODULE_INITIALIZER': fp = rel(m['file_path']); stem = os.path.splitext(os.path.basename(fp or ''))[0] - sym.append((m['id'], '', f"{stem}.", 'module', m['qualified_name'], m['signature'], fp, m['start_line'], m['end_line'], None, 1 if fp and TESTRE.search(fp) else 0, m['id'], None)); continue + sym.append((m['id'], '', f"{stem}.", 'module', m['qualified_name'], m['signature'], fp, m['start_line'], m['end_line'], None, 1 if is_test_file(fp) else 0, m['id'], None)); continue fp = rel(m['file_path']) - sym.append((m['id'], name, (od + '.' if od else '') + name, method_kind(m['kind'], od), m['qualified_name'], m['signature'], fp, m['start_line'], m['end_line'], od, 1 if fp and TESTRE.search(fp) else 0, m['id'], None)) + sym.append((m['id'], name, (od + '.' if od else '') + name, method_kind(m['kind'], od), m['qualified_name'], m['signature'], fp, m['start_line'], m['end_line'], od, 1 if is_test_file(fp) else 0, m['id'], None)) # ── members an annotation processor DECLARES, which no source line spells ───────────────────────── # Java (Lombok, a record) and C# (an auto-property) synthesise members in the engine: `methods` carries them with @@ -559,7 +580,7 @@ for m in c.execute("SELECT id, name, qualified_name, signature, kind, owner_type if inner and od: od = od + '.' + inner if not nm: continue sym.append((m['id'], nm, (od + '.' if od else '') + nm, 'method', m['qualified_name'], m['signature'], - fp, t['start_line'], t['end_line'], od, 1 if fp and TESTRE.search(fp) else 0, m['id'], None)) + fp, t['start_line'], t['end_line'], od, 1 if is_test_file(fp) else 0, m['id'], None)) # ── other declarations from the IR: fields, constants, enum members ─────────────────────────────── modules = {} @@ -626,7 +647,7 @@ for d in A['decls']: if (fp, ln, name) in bound_at: continue # `const foo = () => …` is already the function foo qn = ((ot['qualified_name'] + '.' + name) if ot and ot.get('qualified_name') else None) or r.get('qualifiedName') or \ (((r.get(d['owner'], '') + '.') if d.get('owner') else (fp + '#')) + name) - sym.append((r.get(d['id']) if d.get('id') else None, name, (od + '.' if od else '') + name, d['kind'](r), qn, None, fp, ln, en, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) + sym.append((r.get(d['id']) if d.get('id') else None, name, (od + '.' if od else '') + name, d['kind'](r), qn, None, fp, ln, en, od, 1 if is_test_file(fp) else 0, None, None)) # the data keys of module-level const objects (collected with the member names): a field of the object, owned by the key # chain it is written under, so `TOPICS.CREATED` and `LIMITS.nested.depth` are names `impact` takes for name, od, v, k in data_keys: @@ -634,7 +655,7 @@ for name, od, v, k in data_keys: ln = int(k.get('startLine') or 0) vq = v.get('qualifiedName') or v.get('potentialQualifiedName') qn = f"{vq}{od[len(v['name']):]}.{name}" if vq and od.startswith(v['name']) else f"{fp}#{od}.{name}" - sym.append((None, name, f"{od}.{name}", 'field', qn, None, fp, ln, ln, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) + sym.append((None, name, f"{od}.{name}", 'field', qn, None, fp, ln, ln, od, 1 if is_test_file(fp) else 0, None, None)) c.executemany("INSERT INTO symbols VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", sym) # ── references, literals, comments ─────────────────────────────────────────────────────────────── diff --git a/tests/cases/typescript/a-file-named-test-is-a-test/case.json b/tests/cases/typescript/a-file-named-test-is-a-test/case.json new file mode 100644 index 00000000..3604a60c --- /dev/null +++ b/tests/cases/typescript/a-file-named-test-is-a-test/case.json @@ -0,0 +1,33 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a test file named only test.ts, beside the module it tests, registers tests with describe/it: its callbacks are tests, so the test that calls the change is selected", + "run": [ + "impact", + "area", + "--tests" + ], + "want": [ + "src/area/test.ts" + ], + "avoid": [ + "0 of 0 test method(s)" + ] + }, + { + "why": "CONTROL: a file named spec.ts that registers no test is code, not a test, even though it calls the change: of the two callers only the test file's callback is test code", + "run": [ + "impact", + "area" + ], + "want": [ + "2 resolved; 1 of them test" + ], + "avoid": [ + "2 of them test" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/typescript/a-file-named-test-is-a-test/src/area/index.ts b/tests/cases/typescript/a-file-named-test-is-a-test/src/area/index.ts new file mode 100644 index 00000000..a28758ea --- /dev/null +++ b/tests/cases/typescript/a-file-named-test-is-a-test/src/area/index.ts @@ -0,0 +1,3 @@ +export function area(w: number, h: number): number { + return w * h; +} diff --git a/tests/cases/typescript/a-file-named-test-is-a-test/src/area/test.ts b/tests/cases/typescript/a-file-named-test-is-a-test/src/area/test.ts new file mode 100644 index 00000000..ee481251 --- /dev/null +++ b/tests/cases/typescript/a-file-named-test-is-a-test/src/area/test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; +import { area } from './index'; + +describe('area', () => { + it('multiplies the sides', () => { + expect(area(2, 3)).toBe(6); + }); +}); diff --git a/tests/cases/typescript/a-file-named-test-is-a-test/src/schema/spec.ts b/tests/cases/typescript/a-file-named-test-is-a-test/src/schema/spec.ts new file mode 100644 index 00000000..9a432ade --- /dev/null +++ b/tests/cases/typescript/a-file-named-test-is-a-test/src/schema/spec.ts @@ -0,0 +1,5 @@ +import { area } from '../area/index'; + +// a specification's shapes, named spec.ts, registering no test +export interface Rect { w: number; h: number } +export const unitArea = (r: Rect): number => area(r.w, r.h); diff --git a/tests/cases/typescript/proxy-handler-traps-are-handed-over/case.json b/tests/cases/typescript/proxy-handler-traps-are-handed-over/case.json new file mode 100644 index 00000000..18c528f2 --- /dev/null +++ b/tests/cases/typescript/proxy-handler-traps-are-handed-over/case.json @@ -0,0 +1,50 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "new Proxy(base, traps) hands the runtime the handler's traps: whoever creates the draft reaches what get and set call", + "run": [ + "path", + "createDraft", + "readThrough" + ], + "want": [ + "readThrough", + "callback_registered" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "a handler passed in as a parameter, to Proxy.revocable, is followed to the object literal handed to it", + "run": [ + "path", + "createAudited", + "auditValue" + ], + "want": [ + "auditValue" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "CONTROL: an object with a get method handed to a built-in that is not Proxy is not a proxy handler", + "run": [ + "path", + "frozenTable", + "computeEntry" + ], + "want": [ + "no chain of resolved calls" + ], + "avoid": [ + "callback_registered" + ], + "expect_error": true + } + ] +} \ No newline at end of file diff --git a/tests/cases/typescript/proxy-handler-traps-are-handed-over/src/draft.ts b/tests/cases/typescript/proxy-handler-traps-are-handed-over/src/draft.ts new file mode 100644 index 00000000..3141c12a --- /dev/null +++ b/tests/cases/typescript/proxy-handler-traps-are-handed-over/src/draft.ts @@ -0,0 +1,53 @@ +export function readThrough(target: Record, key: string): unknown { + return target[key]; +} + +export function writeThrough(target: Record, key: string, value: unknown): boolean { + target[key] = value; + return true; +} + +const traps: ProxyHandler> = { + get(target, key) { + return readThrough(target, String(key)); + }, + set(target, key, value) { + return writeThrough(target, String(key), value); + }, +}; + +export function createDraft(base: Record): Record { + return new Proxy(base, traps); +} + +export function wrapWith(base: object, handler: ProxyHandler): object { + return Proxy.revocable(base, handler).proxy; +} + +export function auditValue(key: string): string { + return key.toUpperCase(); +} + +const auditTraps: ProxyHandler = { + get(_target, key) { + return auditValue(String(key)); + }, +}; + +export function createAudited(base: object): object { + return wrapWith(base, auditTraps); +} + +export function computeEntry(key: string): number { + return key.length; +} + +const lookupTable = { + get(key: string): number { + return computeEntry(key); + }, +}; + +export function frozenTable(): object { + return Object.freeze(lookupTable); +} diff --git a/tests/multi_language.py b/tests/multi_language.py index bd94f231..29ebe1d1 100644 --- a/tests/multi_language.py +++ b/tests/multi_language.py @@ -255,6 +255,14 @@ def check(ok, why, detail=''): except ValueError: d = {} check('direct' in d and 'other_languages' not in d and any('shape.ts' in (e.get('at') or '') for e in d.get('direct', [])), 'compatible: --json from one graph is that graph\'s object, unchanged in shape', js.stdout[-800:] + js.stderr) + # no graph answers (no chain connects the two): --json is still ONE document, not the text answer's per-graph + # headers a machine reader cannot parse; the exit status still says no chain was found + nj = sh(repo, AX, 'path', 'onlyInTs', 'square', '.', '--json', env=quiet) + try: d = json.loads(nj.stdout) + except ValueError: d = None + check(isinstance(d, dict) and nj.returncode != 0 and d.get('language') == 'typescript' and '══' not in nj.stdout, + 'compatible: --json when no graph answers is one document, keeping each graph\'s refusal and a failing status', + f"rc={nj.returncode}\n{nj.stdout[-800:]}{nj.stderr[-400:]}") # ── changed ─────────────────────────────────────────────────────────────────────────────────────────── calc = os.path.join(repo, 'tools/pkg/calc.py'); util = os.path.join(repo, 'src/util.ts')