diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index ebf4d9da3..bbf662324 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -79,7 +79,7 @@ PER_QUERY = {'target', 'textuse', 'importuse', 'inside_target', 'nonsource', 'qu # `ref` is not here either: the alongside layer reads it to tell a sibling that touches the same field from one that does # not, for EVERY target kind. What is left is the layer only a field or type target can reach. _REF_LAYER = {'qualifier', 'typeref', 'typeref_file', 'literal', 'dec_literal', 'field', 'accessor', 'faccess', 'gen_table', - 'base_name', 'field_type', 'reexport', 'switch_over'} + 'base_name', 'field_type', 'reexport', 'reexport_from', 'switch_over'} _CONFIG = {'config', 'config_key_known', 'config_site'} # `reexport` is NOT out of a method's reach: rules 396 and 398 both start at target(q,"method",m,_) — the barrel # that names it, and the `export *` that re-exports the whole module it is declared in. Skipping it handed Soufflé @@ -91,9 +91,9 @@ _CONFIG = {'config', 'config_key_known', 'config_site'} # silently — the one failure mode this table can have. _KEY_LAYER = {'literal', 'dec_literal'} KIND_SKIPS = { # relations a query of this kind can never reach - 'method': (_REF_LAYER - {'reexport'} - _KEY_LAYER) | _CONFIG, - 'param': (_REF_LAYER - {'reexport'} - _KEY_LAYER) | _CONFIG, - 'var': (_REF_LAYER - {'reexport'} - _KEY_LAYER) | _CONFIG, + 'method': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER) | _CONFIG, + 'param': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER) | _CONFIG, + 'var': (_REF_LAYER - {'reexport', 'reexport_from'} - _KEY_LAYER) | _CONFIG, 'decoration': (_REF_LAYER - _KEY_LAYER) | _CONFIG, 'config': _REF_LAYER - _KEY_LAYER, 'newconst': (_REF_LAYER - {'switch_over'} - _KEY_LAYER) | _CONFIG, @@ -675,7 +675,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '27' # 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '28' # 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -965,18 +965,12 @@ class Impact: sites.append((ckey(k), c, d, f, l)) W('config_site', sorted(set(sites))) # a barrel: `export { alpha } from './core.js'`. The parser records no reference for that line, so the file - # that has to change in the same commit as a rename was invisible. Read from the source, per module - rex = [] - for f, mid in sorted(mod_of.items()): - if not re.search(r'\.(ts|tsx|js|jsx|mjs|cjs)$', f): continue - for i, line in enumerate(self.code(f), 1): - m = re.match(r'\s*export\s*(type\s*)?\{([^}]*)\}\s*from\s', line) - if m: - for part in m.group(2).split(','): - w = re.findall(r'[A-Za-z_$][\w$]*', part) - if w: rex.append((mid, w[0], f, i)) - elif re.match(r'\s*export\s*\*\s*from\s', line): rex.append((mid, '*', f, i)) - W('reexport', sorted(set(rex))) + # that has to change in the same commit as a rename was invisible. Read from the source, per module, by the + # scanner the SQL port shares; `reexport_from` is the file each `export *` line names, when it is one here + import graph_sql + rex, star = graph_sql.barrel_lines(mod_of, self.code, self.lines, {s['file'] for s in g.sym.values() if s.get('file')}) + W('reexport', rex) + W('reexport_from', star) decs = collections.defaultdict(set) for r in (g.q("SELECT owner_id, name FROM decorations") if g.has('decorations') else []): decs[r[0]].add(r[1].split('.')[-1]) def named_test(i, s): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 573c57a0c..d7f708168 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -67,6 +67,7 @@ // decorated_name(m, w) the name m denotes what its decorator returned: calling m runs w .decl decorated_name(m:symbol, w:symbol) .input decorated_name .decl reexport(c:symbol, n:symbol, f:symbol, l:number) .input reexport +.decl reexport_from(f:symbol, l:number, src:symbol) .input reexport_from .decl field(fl:symbol, t:symbol, n:symbol, f:symbol, l:number) .input field .decl accessor(fl:symbol, n:symbol, rw:symbol) .input accessor .decl faccess(c:symbol, fl:symbol, acc:symbol, tier:symbol, f:symbol, l:number) .input faccess @@ -604,7 +605,12 @@ direct_edge(q, c, m) :- target(q, "typeparam", m, _), calls(c, m, _, _, _). direct(q, c, "uses", "re-exports it (a barrel: this line must change with a rename)", "text", f, l) :- target(q, "method", m, _), named(n, m), reexport(c, n, f, l). direct(q, c, "uses", "re-exports it (a barrel: this line must change with a rename)", "text", f, l) :- target(q, "field", fl, _), field(fl, _, n, _, _), reexport(c, n, f, l). direct(q, c, "uses", "re-exports it (a barrel: this line must change with a rename)", "text", f, l) :- target(q, "type", t, _), typ(t, n, _), reexport(c, n, f, l). -direct(q, c, "uses", "re-exports the whole module it is declared in (export * — a rename changes what this file exports)", "text", f, l) :- target(q, "method", m, _), decl_file(m, df), reexport(c, "*", f, l), f != df. +// `export *` only from the module it is declared in: the line's own source file, or a file a further `export *` of +// that one reaches. Every `export *` in the repository used to qualify, whatever it pointed at. +.decl star_reach(f:symbol, l:number, df:symbol) +star_reach(f, l, s) :- reexport_from(f, l, s). +star_reach(f, l, d) :- star_reach(f, l, s), reexport_from(s, _, d). +direct(q, c, "uses", "re-exports the whole module it is declared in (export * — a rename changes what this file exports)", "text", f, l) :- target(q, "method", m, _), decl_file(m, df), reexport(c, "*", f, l), star_reach(f, l, df), f != df. // ── a STRING target: a topic, a queue, a route, a bean qualifier — a name in a namespace that is not the code's ── direct(q, c, "uses", "names it in a string literal", "text", f, l) :- target(q, "string", _, v), literal(c, v, f, l). diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 38b534a16..cca5d6058 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -801,7 +801,8 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): rows += _bean_definition_consumers(q, ids) # a barrel that re-exports it, or the whole module it is declared in (rules 396 and 399) rows += reexport_rows(q, names, code, rel or (lambda x: x), - {f for (f,) in q(f"SELECT file FROM symbols WHERE id IN ({ph}) AND file IS NOT NULL", *ids)}) + {f for (f,) in q(f"SELECT file FROM symbols WHERE id IN ({ph}) AND file IS NOT NULL", *ids)}, + lines) # alongside: siblings of the target's own type, then the other types declared in the same file owners = {r[0] for r in q(f"SELECT owner FROM symbols WHERE id IN ({ph})", *ids) if r[0]} memb, owner_disp, tfile, _tid, _by_tid = _members(q) @@ -2199,17 +2200,121 @@ def _alongside_for_type(q, tids, inside, target_fields=(), at=None): _REEXPORTS = {} REEXPORT_NAMED = re.compile(r'\s*export\s*(type\s*)?\{([^}]*)\}\s*from\s') REEXPORT_STAR = re.compile(r'\s*export\s*\*\s*from\s') +REEXPORT_STAR_SPEC = re.compile(r'\s*export\s*\*\s*from\s*([\'"])([^\'"]+)\1') MODULE_EXT = re.compile(r'\.(ts|tsx|js|jsx|mjs|cjs)$') +SPEC_EXT = ('', '.ts', '.tsx', '.d.ts', '.js', '.jsx', '.mjs', '.cjs', + '/index.ts', '/index.tsx', '/index.d.ts', '/index.js', '/index.jsx', '/index.mjs', '/index.cjs') + + +def _jsonc(text): + """a tsconfig as JSON: comments and trailing commas dropped, outside strings only (`"@/*"` is not a comment).""" + out = []; i = 0; n = len(text) + while i < n: + ch = text[i] + if ch == '"': + j = i + 1 + while j < n and text[j] != '"': j += 2 if text[j] == '\\' else 1 + out.append(text[i:j + 1]); i = j + 1 + elif text.startswith('//', i): + while i < n and text[i] != '\n': i += 1 + elif text.startswith('/*', i): + e = text.find('*/', i + 2); i = n if e < 0 else e + 2 + else: + out.append(ch); i += 1 + try: return json.loads(re.sub(r',(\s*[}\]])', r'\1', ''.join(out))) + except ValueError: return {} + + +def _ts_paths(f, lines, cache): + """the nearest tsconfig.json above file f -> (its directory, baseUrl, paths); `extends` is not followed.""" + d = os.path.dirname(f) + while True: + if d not in cache: + cfg = os.path.join(d, 'tsconfig.json') if d else 'tsconfig.json' + text = '\n'.join(lines(cfg) or []) + cache[d] = (_jsonc(text).get('compilerOptions') or {}) if text.strip() else None + opts = cache[d] + if opts is not None: + return d, opts.get('baseUrl'), opts.get('paths') or {} + if not d: return '', None, {} + d = os.path.dirname(d) + + +def resolve_module(f, spec, files, lines, cache): + """the repository file a module specifier written in f names, or None (a package, or nothing indexed). + Relative first; otherwise the nearest tsconfig's `paths` and `baseUrl`. A TypeScript ESM import spells the + compiled name (`./x.js` for x.ts), so that extension is also tried off.""" + j = lambda *p: os.path.normpath(os.path.join(*p)).replace(os.sep, '/') + cands = [] + if spec.startswith('.'): + cands.append(j(os.path.dirname(f) or '.', spec)) + elif lines is not None: + cdir, base, paths = _ts_paths(f, lines, cache) + root = j(cdir or '.', base) if base else (cdir or '.') + for pat, subs in paths.items(): + if not isinstance(subs, list): continue + if '*' in pat: + pre, _, post = pat.partition('*') + if not (spec.startswith(pre) and spec.endswith(post) and len(spec) >= len(pre) + len(post)): continue + star = spec[len(pre):len(spec) - len(post)] + elif spec != pat: continue + else: star = '' + cands += [j(root, s.replace('*', star)) for s in subs if isinstance(s, str)] + if base: cands.append(j(root, spec)) + for c in cands: + stems = [c] + ([c.rsplit('.', 1)[0]] if re.search(r'\.(js|jsx|mjs|cjs)$', c) else []) + for s in stems: + for e in SPEC_EXT: + if s + e in files: return s + e + return None + + +def barrel_lines(mod_of, code, lines=None, files=()): + """the barrel lines of every module -> (reexport rows (c,n,f,l), export-* sources (f,l,src_file)). + Shared by the exporter and the SQL port so the two read a barrel the same way. The specifier is read off + the raw line (`code` blanks string literals); a `export *` whose source is not a file of this repository + (a package, an alias no tsconfig maps) has no source row.""" + rex = []; star = []; files = set(files); cache = {} + for f, mid in sorted(mod_of.items()): + if not MODULE_EXT.search(f): continue + raw = lines(f) if lines is not None else [] + for i, line in enumerate(code(f), 1): + m = REEXPORT_NAMED.match(line) + if m: + for part in m.group(2).split(','): + w = re.findall(r'[A-Za-z_$][\w$]*', part) + if w: rex.append((mid, w[0], f, i)) + elif REEXPORT_STAR.match(line): + rex.append((mid, '*', f, i)) + s = REEXPORT_STAR_SPEC.match(raw[i - 1]) if i <= len(raw) else None + src = s and resolve_module(f, s.group(2), files, lines, cache) + if src: star.append((f, i, src)) + return sorted(set(rex)), sorted(set(star)) + + +def star_reach(star): + """star_reach(f,l,df) — the files an `export *` at f:l re-exports, through every further `export *` on the way.""" + nxt = collections.defaultdict(set) + for f, _l, s in star: nxt[f].add(s) + out = {} + for f, l, s in star: + seen = {s}; todo = [s] + while todo: + for d in nxt.get(todo.pop(), ()): + if d not in seen: seen.add(d); todo.append(d) + out[(f, l)] = seen + return out -def reexports(q, code): - """`reexport(c,n,f,l)` — a barrel: `export { alpha } from './core.js'`, and `export * from …` as the name `*`. +def reexports(q, code, lines=None): + """`reexport(c,n,f,l)` — a barrel: `export { alpha } from './core.js'`, and `export * from …` as the name `*`; + with it `star_reach`, the modules each `export *` line re-exports (keyed (f,l)). The parser records no reference for that line, so the file that has to change in the same commit as a rename was invisible to every other relation. Read from the source per module, exactly as the exporter reads it — this was the second reason a bundle with any JS or TS module in it declined here. """ - key = id(q) + key = (id(q), lines is not None) if key in _REEXPORTS: return _REEXPORTS[key] # `mod_of` the way the exporter builds it, which is not "every module row". It walks `g.sym`, and that is # {r['id']: dict(r) for r in SELECT * WHERE method_id IS NOT NULL OR type_id IS NOT NULL} @@ -2223,37 +2328,29 @@ def reexports(q, code): mod_of = {} for i, (f, kind) in one.items(): if kind == 'module' and f: mod_of.setdefault(f, i) - rex = [] - for f, mid in sorted(mod_of.items()): - if not MODULE_EXT.search(f): continue - for i, line in enumerate(code(f), 1): - m = REEXPORT_NAMED.match(line) - if m: - for part in m.group(2).split(','): - w = re.findall(r'[A-Za-z_$][\w$]*', part) - if w: rex.append((mid, w[0], f, i)) - elif REEXPORT_STAR.match(line): - rex.append((mid, '*', f, i)) - return _REEXPORTS.setdefault(key, sorted(set(rex))) + rex, star = barrel_lines(mod_of, code, lines, {f for f, _k in one.values() if f}) + return _REEXPORTS.setdefault(key, (rex, star_reach(star))) -def reexport_rows(q, names, code, rel, decl_files=()): +def reexport_rows(q, names, code, rel, decl_files=(), lines=None): """Rules 396-399 — the barrel lines that must change with a rename of the target. `decl_files` is given for a METHOD target only: rule 399 says that re-exporting the whole module a method is - declared in is a dependency too, but only from a DIFFERENT file than the one that declares it. + declared in is a dependency too, but only from a DIFFERENT file than the one that declares it, and only when + that `export *` reaches the declaring file (star_reach) — not every `export *` in the repository. """ if code is None: return [] rows = [] - for c, n, f, l in reexports(q, code): + rex, reach = reexports(q, code, lines) + for c, n, f, l in rex: if n in names: rows.append((c, 'uses', 're-exports it (a barrel: this line must change with a rename)', 'text', rel(f) if f else '', l or 0)) - # `… decl_file(m,df), reexport(c,"*",f,l), f != df` — m ranges over EVERY declaration the query resolved - # to, so the row exists when SOME target is declared outside this file, not when all of them are. A target - # that resolves to many declarations (an anonymous callable) has one in the barrel's own file often - # enough for the two readings to differ. - elif n == '*' and (decl_files - {f}): + # `… decl_file(m,df), reexport(c,"*",f,l), star_reach(f,l,df), f != df` — m ranges over EVERY declaration + # the query resolved to, so the row exists when SOME target is declared in a file this line re-exports, not + # when all of them are. A target that resolves to many declarations (an anonymous callable) has one in the + # barrel's own file often enough for the two readings to differ. + elif n == '*' and ((set(decl_files) - {f}) & reach.get((f, l), set())): rows.append((c, 'uses', 're-exports the whole module it is declared in (export * — a rename changes ' 'what this file exports)', 'text', rel(f) if f else '', l or 0)) return rows diff --git a/tests/cases/typescript/export-star-scoped-to-its-source/case.json b/tests/cases/typescript/export-star-scoped-to-its-source/case.json new file mode 100644 index 000000000..8682eafbe --- /dev/null +++ b/tests/cases/typescript/export-star-scoped-to-its-source/case.json @@ -0,0 +1,6 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "an export * is a dependent of a method only when it re-exports the module the method is declared in, directly or through a further export *", "run": ["impact", "slugify"], "want": ["src/util/index.ts:1 — re-exports", "src/index.ts:1 — re-exports", "re-exports the whole module"], "avoid": ["src/util/index.ts:2 — re-exports", "src/index.ts:2 — re-exports", "src/aliased.ts:1 — re-exports", "src/testing.ts:1 — re-exports", "(2 sites)"]}, + {"why": "a method no export * reaches gets none of them: every export * in the repository used to be listed", "run": ["impact", "score"], "want": ["src/index.ts:2 — re-exports", "re-exports the whole module"], "avoid": ["src/index.ts:1 — re-exports", "src/util/index.ts:1 — re-exports", "src/util/index.ts:2 — re-exports", "src/aliased.ts:1 — re-exports", "src/testing.ts:1 — re-exports", "(2 sites)"]}, + {"why": "a TypeScript ESM specifier names the compiled file (./extra.js for extra.ts), and a tsconfig paths alias names a repository file", "run": ["impact", "trim", "clamp"], "want": ["src/util/index.ts:2 — re-exports", "src/index.ts:1 — re-exports", "src/aliased.ts:1 — re-exports"], "avoid": ["src/util/index.ts:1 — re-exports", "src/index.ts:2 — re-exports", "src/testing.ts:1 — re-exports", "(2 sites)"]}, + {"why": "control: the barrel's own declaration is not re-exported by the barrel's export * lines, but is by the export * that points at the barrel", "run": ["impact", "helper"], "want": ["src/index.ts:1 — re-exports", "re-exports the whole module"], "avoid": ["src/util/index.ts:1 — re-exports", "src/util/index.ts:2 — re-exports", "src/index.ts:2 — re-exports", "src/aliased.ts:1 — re-exports", "src/testing.ts:1 — re-exports", "(2 sites)"]}]} diff --git a/tests/cases/typescript/export-star-scoped-to-its-source/src/aliased.ts b/tests/cases/typescript/export-star-scoped-to-its-source/src/aliased.ts new file mode 100644 index 000000000..1888f4b19 --- /dev/null +++ b/tests/cases/typescript/export-star-scoped-to-its-source/src/aliased.ts @@ -0,0 +1 @@ +export * from '@util/numbers'; diff --git a/tests/cases/typescript/export-star-scoped-to-its-source/src/app.ts b/tests/cases/typescript/export-star-scoped-to-its-source/src/app.ts new file mode 100644 index 000000000..f16b8f378 --- /dev/null +++ b/tests/cases/typescript/export-star-scoped-to-its-source/src/app.ts @@ -0,0 +1,4 @@ +import { slugify, trim, helper, score } from './index'; +import { clamp as bound } from './aliased'; + +export function run(): string { return slugify('A') + trim(' b') + helper() + score(1) + bound(2); } diff --git a/tests/cases/typescript/export-star-scoped-to-its-source/src/feature.ts b/tests/cases/typescript/export-star-scoped-to-its-source/src/feature.ts new file mode 100644 index 000000000..ca7e089a2 --- /dev/null +++ b/tests/cases/typescript/export-star-scoped-to-its-source/src/feature.ts @@ -0,0 +1,3 @@ +import { clamp } from './util/numbers'; + +export function score(n: number): number { return clamp(n); } diff --git a/tests/cases/typescript/export-star-scoped-to-its-source/src/index.ts b/tests/cases/typescript/export-star-scoped-to-its-source/src/index.ts new file mode 100644 index 000000000..42c14f7d8 --- /dev/null +++ b/tests/cases/typescript/export-star-scoped-to-its-source/src/index.ts @@ -0,0 +1,2 @@ +export * from './util'; +export * from './feature'; diff --git a/tests/cases/typescript/export-star-scoped-to-its-source/src/testing.ts b/tests/cases/typescript/export-star-scoped-to-its-source/src/testing.ts new file mode 100644 index 000000000..f7cc5f51d --- /dev/null +++ b/tests/cases/typescript/export-star-scoped-to-its-source/src/testing.ts @@ -0,0 +1 @@ +export * from 'some-package'; diff --git a/tests/cases/typescript/export-star-scoped-to-its-source/src/util/extra.ts b/tests/cases/typescript/export-star-scoped-to-its-source/src/util/extra.ts new file mode 100644 index 000000000..36c983145 --- /dev/null +++ b/tests/cases/typescript/export-star-scoped-to-its-source/src/util/extra.ts @@ -0,0 +1 @@ +export function trim(s: string): string { return s.trim(); } diff --git a/tests/cases/typescript/export-star-scoped-to-its-source/src/util/index.ts b/tests/cases/typescript/export-star-scoped-to-its-source/src/util/index.ts new file mode 100644 index 000000000..44310ebdc --- /dev/null +++ b/tests/cases/typescript/export-star-scoped-to-its-source/src/util/index.ts @@ -0,0 +1,3 @@ +export * from './strings'; +export * from './extra.js'; +export function helper(): number { return 1; } diff --git a/tests/cases/typescript/export-star-scoped-to-its-source/src/util/numbers.ts b/tests/cases/typescript/export-star-scoped-to-its-source/src/util/numbers.ts new file mode 100644 index 000000000..72c06111e --- /dev/null +++ b/tests/cases/typescript/export-star-scoped-to-its-source/src/util/numbers.ts @@ -0,0 +1 @@ +export function clamp(n: number): number { return Math.max(0, n); } diff --git a/tests/cases/typescript/export-star-scoped-to-its-source/src/util/strings.ts b/tests/cases/typescript/export-star-scoped-to-its-source/src/util/strings.ts new file mode 100644 index 000000000..13fd1296d --- /dev/null +++ b/tests/cases/typescript/export-star-scoped-to-its-source/src/util/strings.ts @@ -0,0 +1 @@ +export function slugify(s: string): string { return s.toLowerCase(); } diff --git a/tests/cases/typescript/export-star-scoped-to-its-source/tsconfig.json b/tests/cases/typescript/export-star-scoped-to-its-source/tsconfig.json new file mode 100644 index 000000000..9a3e4b33b --- /dev/null +++ b/tests/cases/typescript/export-star-scoped-to-its-source/tsconfig.json @@ -0,0 +1,7 @@ +{ + // the alias the aliased barrel is written with; "@util/*" is not a comment + "compilerOptions": { + "baseUrl": ".", + "paths": { "@util/*": ["src/util/*"], }, + }, +}