Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions graph/typescript/engine/resolution/value-flow.dl
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`
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
Expand Down
6 changes: 6 additions & 0 deletions graph/typescript/souffle/decls_all.dl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
65 changes: 65 additions & 0 deletions parser/src/test/discovery-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '
Expand Down
89 changes: 66 additions & 23 deletions parser/src/workflows/typescript/typescript-project-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,37 +757,76 @@ function filesOfRootProgram(
const texts = new Map<string, string>();
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<string, string[]>();
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).
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -891,7 +931,10 @@ function verifyRelationFile(temporaryPath: string, outputPath: string): void {
}
}

function collectTypeScriptFiles(dir: string, excludes: ReadonlySet<string>): 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<string>, only?: RegExp): string[] {
const out: string[] = [];
const walk = (current: string): void => {
let entries: fs.Dirent[];
Expand All @@ -911,8 +954,8 @@ function collectTypeScriptFiles(dir: string, excludes: ReadonlySet<string>): 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);
}
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading